fix(agent): preserve Codex message items for replay
This commit is contained in:
@@ -716,6 +716,103 @@ class TestNormalizeCodexResponse:
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].function.name == "web_search"
|
||||
|
||||
def test_message_items_captured_with_id_and_phase(self, monkeypatch):
|
||||
"""Exact message items (with id/phase) must be captured for cache replay."""
|
||||
agent = self._make_codex_agent(monkeypatch)
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message", status="completed", id="msg_abc",
|
||||
phase="commentary",
|
||||
content=[SimpleNamespace(type="output_text", text="Thinking...")],
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="message", status="completed", id="msg_def",
|
||||
phase="final_answer",
|
||||
content=[SimpleNamespace(type="output_text", text="Done!")],
|
||||
),
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
msg, reason = _normalize_codex_response(response)
|
||||
assert msg.codex_message_items is not None
|
||||
assert len(msg.codex_message_items) == 2
|
||||
assert msg.codex_message_items[0]["id"] == "msg_abc"
|
||||
assert msg.codex_message_items[0]["phase"] == "commentary"
|
||||
assert msg.codex_message_items[0]["content"][0]["text"] == "Thinking..."
|
||||
assert msg.codex_message_items[1]["id"] == "msg_def"
|
||||
assert msg.codex_message_items[1]["phase"] == "final_answer"
|
||||
assert msg.codex_message_items[1]["content"][0]["text"] == "Done!"
|
||||
|
||||
def test_message_items_none_when_no_messages(self, monkeypatch):
|
||||
"""Only reasoning + tool calls should yield None codex_message_items."""
|
||||
agent = self._make_codex_agent(monkeypatch)
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(type="function_call", status="completed",
|
||||
call_id="call_1", name="web_search", arguments='{}', id="fc_1"),
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
msg, reason = _normalize_codex_response(response)
|
||||
assert msg.codex_message_items is None
|
||||
|
||||
|
||||
class TestChatMessagesToResponsesInputMessageItems:
|
||||
"""Verify codex_message_items are replayed verbatim instead of reconstructed."""
|
||||
|
||||
def test_replays_exact_message_items(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses",
|
||||
base_url="https://chatgpt.com/backend-api/codex")
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hello world",
|
||||
"codex_message_items": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"id": "msg_123",
|
||||
"phase": "final_answer",
|
||||
"content": [{"type": "output_text", "text": "Hello world"}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "follow up"},
|
||||
]
|
||||
items = _chat_messages_to_responses_input(messages)
|
||||
msg_items = [i for i in items if i.get("type") == "message"]
|
||||
assert len(msg_items) == 1
|
||||
assert msg_items[0]["id"] == "msg_123"
|
||||
assert msg_items[0]["phase"] == "final_answer"
|
||||
assert msg_items[0]["content"][0]["text"] == "Hello world"
|
||||
|
||||
def test_fallback_to_plain_when_no_message_items(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses",
|
||||
base_url="https://chatgpt.com/backend-api/codex")
|
||||
messages = [{"role": "assistant", "content": "Hello world"}]
|
||||
items = _chat_messages_to_responses_input(messages)
|
||||
assert items == [{"role": "assistant", "content": "Hello world"}]
|
||||
|
||||
def test_skips_invalid_message_items(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses",
|
||||
base_url="https://chatgpt.com/backend-api/codex")
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "fallback text",
|
||||
"codex_message_items": [
|
||||
{"type": "function_call", "role": "assistant"}, # wrong type
|
||||
{"type": "message", "role": "user"}, # wrong role
|
||||
{"type": "message", "role": "assistant", "content": "not a list"},
|
||||
],
|
||||
},
|
||||
]
|
||||
items = _chat_messages_to_responses_input(messages)
|
||||
# All invalid — falls back to plain text reconstruction
|
||||
assert items == [{"role": "assistant", "content": "fallback text"}]
|
||||
|
||||
|
||||
# ── Chat completions response handling (OpenRouter/Nous) ─────────────────────
|
||||
|
||||
|
||||
@@ -943,6 +943,33 @@ def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(mo
|
||||
assert "inspect the repository" in (assistant_message.content or "")
|
||||
|
||||
|
||||
def test_normalize_codex_response_preserves_message_status_for_replay(monkeypatch):
|
||||
"""Incomplete Codex output messages must not be replayed as completed."""
|
||||
agent = _build_agent(monkeypatch)
|
||||
from agent.codex_responses_adapter import _normalize_codex_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
id="msg_partial",
|
||||
phase="commentary",
|
||||
status="in_progress",
|
||||
content=[SimpleNamespace(type="output_text", text="Still working...")],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||
status="in_progress",
|
||||
model="gpt-5-codex",
|
||||
)
|
||||
|
||||
assistant_message, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "incomplete"
|
||||
assert assistant_message.codex_message_items[0]["id"] == "msg_partial"
|
||||
assert assistant_message.codex_message_items[0]["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_normalize_codex_response_detects_leaked_tool_call_text(monkeypatch):
|
||||
"""Harmony-style `to=functions.foo` leaked into assistant content with no
|
||||
structured function_call items must be treated as incomplete so the
|
||||
@@ -1403,6 +1430,44 @@ def test_chat_messages_to_responses_input_reasoning_only_has_following_item(monk
|
||||
assert following.get("role") == "assistant"
|
||||
|
||||
|
||||
def test_codex_message_item_status_survives_conversion_and_preflight(monkeypatch):
|
||||
"""Stored Codex assistant message statuses must survive replay normalization."""
|
||||
agent = _build_agent(monkeypatch)
|
||||
from agent.codex_responses_adapter import (
|
||||
_chat_messages_to_responses_input,
|
||||
_preflight_codex_input_items,
|
||||
)
|
||||
|
||||
items = _chat_messages_to_responses_input([
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "partial",
|
||||
"codex_message_items": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "incomplete",
|
||||
"id": "msg_incomplete",
|
||||
"phase": "commentary",
|
||||
"content": [{"type": "output_text", "text": "partial"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
])
|
||||
replay_item = next(item for item in items if item.get("type") == "message")
|
||||
assert replay_item["status"] == "incomplete"
|
||||
|
||||
normalized = _preflight_codex_input_items([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "in_progress",
|
||||
"content": [{"type": "output_text", "text": "working"}],
|
||||
}
|
||||
])
|
||||
assert normalized[0]["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch):
|
||||
"""Two consecutive reasoning-only responses with different encrypted content
|
||||
must NOT be treated as duplicates."""
|
||||
@@ -1453,6 +1518,58 @@ def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch
|
||||
assert "enc_second" in encrypted_contents
|
||||
|
||||
|
||||
def test_duplicate_detection_distinguishes_different_codex_message_items(monkeypatch):
|
||||
"""Incomplete turns with new message ids/phases/statuses must not be collapsed."""
|
||||
agent = _build_agent(monkeypatch)
|
||||
responses = [
|
||||
SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
id="msg_first",
|
||||
phase="commentary",
|
||||
status="in_progress",
|
||||
content=[SimpleNamespace(type="output_text", text="Still working...")],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=50, output_tokens=10, total_tokens=60),
|
||||
status="in_progress",
|
||||
model="gpt-5-codex",
|
||||
),
|
||||
SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
id="msg_second",
|
||||
phase="commentary",
|
||||
status="in_progress",
|
||||
content=[SimpleNamespace(type="output_text", text="Still working...")],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=50, output_tokens=10, total_tokens=60),
|
||||
status="in_progress",
|
||||
model="gpt-5-codex",
|
||||
),
|
||||
_codex_message_response("Final answer after progress updates."),
|
||||
]
|
||||
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0))
|
||||
|
||||
result = agent.run_conversation("keep going")
|
||||
|
||||
assert result["completed"] is True
|
||||
interim_msgs = [
|
||||
msg for msg in result["messages"]
|
||||
if msg.get("role") == "assistant"
|
||||
and msg.get("finish_reason") == "incomplete"
|
||||
]
|
||||
assert len(interim_msgs) == 2
|
||||
assert [msg["codex_message_items"][0]["id"] for msg in interim_msgs] == [
|
||||
"msg_first",
|
||||
"msg_second",
|
||||
]
|
||||
assert all(msg["codex_message_items"][0]["status"] == "in_progress" for msg in interim_msgs)
|
||||
|
||||
|
||||
def test_chat_messages_to_responses_input_deduplicates_reasoning_ids(monkeypatch):
|
||||
"""Duplicate reasoning item IDs across multi-turn incomplete responses
|
||||
must be deduplicated so the Responses API doesn't reject with HTTP 400."""
|
||||
|
||||
Reference in New Issue
Block a user