Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Attribute OpenAI chat `developer` messages to the system segment instead of
the user segment.

## [0.1.1] - 2026-08-06

### Changed
Expand Down
6 changes: 4 additions & 2 deletions src/ctxlens/parsers/openai_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

or an object ``{"messages": [...], "tools": [...]}`` where ``tools`` is the
function/tool schema list sent to the model.

Both ``system`` and ``developer`` messages contribute to the system segment.
"""

from __future__ import annotations
Expand Down Expand Up @@ -41,7 +43,7 @@ def sniff(cls, raw: str, path: Path | None = None) -> float:
return 0.0
roles = {m.get("role") for m in messages if isinstance(m, dict)}
score = 0.5
if roles & {"system", "user", "assistant", "tool"}:
if roles & {"system", "developer", "user", "assistant", "tool"}:
score += 0.3
# a bare array is the strongest generic signal
if isinstance(obj, list):
Expand Down Expand Up @@ -98,7 +100,7 @@ def parse(self, raw: str, path: Path | None = None) -> Session:
)

def _emit_message(self, entry, role, content, turn, out):
if role == "system":
if role in {"system", "developer"}:
out.append(Message(Segment.SYSTEM, stringify(content), turn, role=role))
return
if role == "tool":
Expand Down
41 changes: 40 additions & 1 deletion tests/test_parsers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

import pytest

from ctxlens.models import Segment
Expand Down Expand Up @@ -83,6 +85,41 @@ def test_parse_openai_array_roles(openai_array):
assert any(m.segment == Segment.ASSISTANT for m in s.messages)


@pytest.mark.parametrize("wrapped", [False, True], ids=["array", "object"])
def test_detect_openai_developer_only(wrapped):
def transcript(role):
messages = [{"role": role, "content": "Answer concisely."}]
return json.dumps({"messages": messages} if wrapped else messages)

raw = transcript("developer")
assert detect_parser(raw) is OpenAIChatParser
assert OpenAIChatParser.sniff(raw) == OpenAIChatParser.sniff(transcript("system"))


@pytest.mark.parametrize("wrapped", [False, True], ids=["array", "object"])
def test_parse_openai_developer_role(wrapped):
messages = [
{"role": "developer", "content": "Answer concisely."},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi!"},
{"role": "developer", "content": "Use bullet points now."},
]
raw = json.dumps({"messages": messages} if wrapped else messages)

session = parse_text(raw)

assert session.source_format == "openai-chat"
assert [m.segment for m in session.messages] == [
Segment.SYSTEM,
Segment.USER,
Segment.ASSISTANT,
Segment.SYSTEM,
]
assert [m.role for m in session.messages] == [m["role"] for m in messages]
assert [m.text for m in session.messages] == [m["content"] for m in messages]
assert [m.turn for m in session.messages] == [1, 1, 1, 2]


def test_force_format_override(openai_array):
raw = openai_array.read_text()
s = parse_text(raw, fmt="openai-chat")
Expand All @@ -96,7 +133,9 @@ def test_unknown_format_raises():

def test_bad_json_line_raises():
with pytest.raises(ParseError):
ClaudeCodeParser().parse('{"type":"user","message":{"role":"user","content":"hi"}}\n{bad json')
ClaudeCodeParser().parse(
'{"type":"user","message":{"role":"user","content":"hi"}}\n{bad json'
)


def test_turns_are_monotonic(claude_jsonl, codex_session, openai_chat):
Expand Down
Loading