diff --git a/CHANGELOG.md b/CHANGELOG.md index e1fb662..8cddddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/ctxlens/parsers/openai_chat.py b/src/ctxlens/parsers/openai_chat.py index 5532f62..53fed21 100644 --- a/src/ctxlens/parsers/openai_chat.py +++ b/src/ctxlens/parsers/openai_chat.py @@ -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 @@ -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): @@ -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": diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 40ae2b9..06c806b 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import pytest from ctxlens.models import Segment @@ -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") @@ -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):