Files
hermes-agent/tests/agent/test_responses_empty_tool_replay.py
kshitijk4poor 23200e75e1 fix(agent): keep user image parts through typed Responses preflight
Stamping type=message on user items routed them from the untyped role
validator to _preflight_message, whose new user branch accepted text parts
only. Every turn with an image in history then failed preflight with
"unsupported type 'input_image'" on all codex_responses routes.

_preflight_message now keeps its strict output_text path for replayed
assistant output (a content list carrying id/status/phase) and hands every
other typed message to _preflight_role_message, the single validator for
role items (string content, user image parts). Preflight is therefore a
no-op on the converter's own output: it no longer synthesizes a status the
converter never sent (plain assistant items on custom endpoints, typed
output_text items on the ChatGPT Codex backend). The content annotations
widened to Any go back to lists.

native_compaction's retained summary carrier gets the same explicit type.

The PR's nine new adapter tests collapse into one converter -> preflight
invariant, parametrized over the default and codex_backend issuers, with a
user image turn; it fails on the contributor head and on main. The empty
assistant-carrier guard in test_responses_empty_tool_replay matches any
empty assistant item again rather than one exact dict.
2026-10-01 16:18:23 +05:30

51 lines
2.4 KiB
Python

"""Responses replay must not invent a blank assistant turn before a tool call.
Regression tests for #103483 (Muse Spark degenerate finals on the Responses
wire) and #75202 (strict Responses-compatible providers reject an empty
``content`` string with 400).
"""
import json
from pathlib import Path
from agent.codex_responses_adapter import _chat_messages_to_responses_input
def test_reasoning_without_tool_keeps_nonempty_following_item():
"""Control: a lone reasoning item still needs a follower (else
``missing_following_item``), and it must be non-empty for strict providers."""
items = _chat_messages_to_responses_input([{
'role': 'assistant', 'content': '',
'codex_reasoning_items': [{'type': 'reasoning', 'id': 'rs_test',
'encrypted_content': 'synthetic-encrypted-fixture', 'summary': []}],
}])
assert items[-1] == {'type': 'message', 'role': 'assistant', 'content': ' '}
def test_failing_turn_fixture_emits_no_invented_carrier():
"""Real failing-turn shape from #103483 (fixture by @cristianbdev, see
https://gist.github.com/cristianbdev/6036f7aa3838935adfaeb9b6800f7450 —
3 reasoning+tool assistant rows, 5 tool calls/outputs, content redacted).
The adapter must invent no blank carrier, must keep every reasoning item
directly followed by a function_call, and must preserve all call/output
pairings.
"""
fixture_path = (
Path(__file__).resolve().parent.parent
/ "fixtures" / "spark_failing_turn_shape.json"
)
messages = json.loads(fixture_path.read_text(encoding="utf-8"))
items = _chat_messages_to_responses_input(messages)
assert not any(i.get("role") == "assistant" and i.get("content") == "" for i in items)
for i, item in enumerate(items):
if item.get("type") == "reasoning":
# Reasoning items may cluster; the next non-reasoning item after
# each of them must be the function_call that follows it — never
# an invented blank assistant message.
nxt = next(it for it in items[i + 1:] if it.get("type") != "reasoning")
assert nxt.get("type") == "function_call"
calls = [item["call_id"] for item in items if item.get("type") == "function_call"]
outputs = [item["call_id"] for item in items if item.get("type") == "function_call_output"]
assert len(calls) == 5
assert sorted(calls) == sorted(outputs)