opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
@@ -275,15 +275,12 @@ class TestCliEnsureRuntimeCredentialsCallable:
|
||||
|
||||
def test_callable_predicate_present_in_cli_runtime_validation(self):
|
||||
from pathlib import Path
|
||||
# ``_ensure_runtime_credentials`` was extracted from cli.py into the
|
||||
# ``CLIAgentSetupMixin`` (god-file decomposition Phase 4). Read the
|
||||
# module the method actually lives in now.
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
/ "hermes_cli" / "cli_agent_setup_mixin.py").read_text()
|
||||
/ "cli.py").read_text()
|
||||
# The fix introduces ``_is_callable_provider`` which gates the
|
||||
# string-only check so callable token providers survive.
|
||||
assert "_is_callable_provider = callable(api_key)" in src, (
|
||||
"_ensure_runtime_credentials must preserve a callable "
|
||||
"cli.py:_ensure_runtime_credentials must preserve a callable "
|
||||
"api_key (Entra ID bearer provider). Without the guard, the "
|
||||
"callable is stringified to 'no-key-required' and Azure 401s."
|
||||
)
|
||||
|
||||
@@ -84,56 +84,6 @@ class TestRunConversationCodexPath:
|
||||
assert result["codex_thread_id"] == "thread-stub-1"
|
||||
assert result["codex_turn_id"] == "turn-stub-1"
|
||||
|
||||
def test_codex_app_server_token_usage_updates_session_accounting(self, monkeypatch):
|
||||
def fake_run_turn(self, user_input: str, **kwargs):
|
||||
return TurnResult(
|
||||
final_text="done",
|
||||
projected_messages=[{"role": "assistant", "content": "done"}],
|
||||
turn_id="turn-usage-1",
|
||||
thread_id="thread-usage-1",
|
||||
token_usage_last={
|
||||
"totalTokens": 130,
|
||||
"inputTokens": 80,
|
||||
"cachedInputTokens": 20,
|
||||
"outputTokens": 25,
|
||||
"reasoningOutputTokens": 5,
|
||||
},
|
||||
model_context_window=200000,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
|
||||
monkeypatch.setattr(
|
||||
CodexAppServerSession, "ensure_started", lambda self: "thread-usage-1"
|
||||
)
|
||||
agent = _make_codex_agent()
|
||||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||||
result = agent.run_conversation("hello")
|
||||
|
||||
assert result["api_calls"] == 1
|
||||
assert result["prompt_tokens"] == 100
|
||||
assert result["completion_tokens"] == 25
|
||||
assert result["total_tokens"] == 130
|
||||
assert result["input_tokens"] == 80
|
||||
assert result["output_tokens"] == 25
|
||||
assert result["cache_read_tokens"] == 20
|
||||
assert result["cache_write_tokens"] == 0
|
||||
assert result["reasoning_tokens"] == 5
|
||||
assert result["last_prompt_tokens"] == 100
|
||||
|
||||
assert agent.session_api_calls == 1
|
||||
assert agent.session_prompt_tokens == 100
|
||||
assert agent.session_completion_tokens == 25
|
||||
assert agent.session_total_tokens == 130
|
||||
assert agent.session_input_tokens == 80
|
||||
assert agent.session_output_tokens == 25
|
||||
assert agent.session_cache_read_tokens == 20
|
||||
assert agent.session_cache_write_tokens == 0
|
||||
assert agent.session_reasoning_tokens == 5
|
||||
assert agent.context_compressor.last_prompt_tokens == 100
|
||||
assert agent.context_compressor.last_completion_tokens == 25
|
||||
assert agent.context_compressor.last_total_tokens == 130
|
||||
assert agent.context_compressor.context_length == 200000
|
||||
|
||||
def test_projected_messages_are_spliced(self, fake_session):
|
||||
agent = _make_codex_agent()
|
||||
with patch.object(agent, "_spawn_background_review", return_value=None):
|
||||
|
||||
@@ -136,101 +136,6 @@ class TestPartialStreamStubFinishReason:
|
||||
assert "write_file" in content
|
||||
|
||||
|
||||
# ── Clean stream-end mid-tool-call (no exception, no finish_reason) ─────────
|
||||
|
||||
class TestCleanStreamEndMidToolCall:
|
||||
"""The upstream closes the SSE stream cleanly after delivering a tool
|
||||
name + the opening '{' of its arguments — NO exception, NO finish_reason,
|
||||
NO [DONE]. Observed live on NVIDIA Nemotron Ultra via the Nous dedicated
|
||||
endpoint: it stalls/drops during large tool-arg generation.
|
||||
|
||||
The mock-builder must NOT stamp this as finish_reason='length' (which
|
||||
routes it through the max_tokens-boost truncation path and finally
|
||||
reports the misleading 'Response truncated due to output length limit').
|
||||
It must route through the partial-stream-stub path so the loop reports
|
||||
an honest mid-tool-call drop and asks the model to chunk its output.
|
||||
"""
|
||||
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_no_finish_reason_partial_tool_args_routes_to_stub(
|
||||
self, _mock_close, mock_create, monkeypatch,
|
||||
):
|
||||
def _clean_ending_stream():
|
||||
# Reasoning + tool name + the lone opening brace, then the
|
||||
# generator simply RETURNS (StopIteration) — no raise, no
|
||||
# finish_reason chunk, no [DONE].
|
||||
yield _make_stream_chunk(content="\n")
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, tc_id="call_x", name="execute_code"),
|
||||
])
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, arguments="{"),
|
||||
])
|
||||
# falls off the end — clean close, no terminator
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = (
|
||||
lambda *a, **kw: _clean_ending_stream()
|
||||
)
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = _make_agent()
|
||||
agent._fire_stream_delta = lambda text: None
|
||||
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
|
||||
assert response.id == PARTIAL_STREAM_STUB_ID, (
|
||||
"A clean stream-end mid tool-call (no finish_reason) must be "
|
||||
"tagged as a partial-stream stub, not a 'stream-<uuid>' "
|
||||
"truncation — otherwise the loop reports the false 'output "
|
||||
"length limit' error."
|
||||
)
|
||||
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
|
||||
assert response.choices[0].message.tool_calls is None, (
|
||||
"Incomplete tool args must never auto-execute."
|
||||
)
|
||||
assert getattr(response, "_dropped_tool_names", None) == ["execute_code"]
|
||||
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_real_length_truncation_still_uses_uuid_id(
|
||||
self, _mock_close, mock_create, monkeypatch,
|
||||
):
|
||||
"""Control: when the provider DOES send finish_reason='length' with
|
||||
partial tool args, it is a genuine output cap — keep the existing
|
||||
non-stub behaviour (boost max_tokens and retry)."""
|
||||
|
||||
def _capped_stream():
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, tc_id="call_y", name="execute_code"),
|
||||
])
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, arguments="{"),
|
||||
])
|
||||
# Provider explicitly reports the output cap.
|
||||
yield _make_stream_chunk(finish_reason="length")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = (
|
||||
lambda *a, **kw: _capped_stream()
|
||||
)
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = _make_agent()
|
||||
agent._fire_stream_delta = lambda text: None
|
||||
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
|
||||
assert response.id != PARTIAL_STREAM_STUB_ID, (
|
||||
"A provider-reported finish_reason='length' is a real output cap "
|
||||
"and must keep the existing truncation path, not the stream-drop "
|
||||
"stub path."
|
||||
)
|
||||
assert response.id.startswith("stream-")
|
||||
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
|
||||
|
||||
|
||||
# ── Length-continuation prompt branching ──────────────────────────────────
|
||||
|
||||
class TestLengthContinuationPromptBranching:
|
||||
|
||||
@@ -81,12 +81,10 @@ class TestSourceLinesAreClamped:
|
||||
return f.read()
|
||||
|
||||
def test_gateway_run_clamped(self):
|
||||
# The /usage stats handler was extracted from gateway/run.py into
|
||||
# gateway/slash_commands.py (god-file decomposition Phase 3b).
|
||||
src = self._read_file("gateway/slash_commands.py")
|
||||
src = self._read_file("gateway/run.py")
|
||||
# Check that the stats handler has min(100, ...)
|
||||
assert "min(100, ctx.last_prompt_tokens" in src, (
|
||||
"gateway/slash_commands.py stats pct is not clamped with min(100, ...)"
|
||||
"gateway/run.py stats pct is not clamped with min(100, ...)"
|
||||
)
|
||||
|
||||
def test_cli_clamped(self):
|
||||
|
||||
@@ -2402,20 +2402,15 @@ class TestConcurrentToolExecution:
|
||||
|
||||
def test_concurrent_handles_tool_error(self, agent):
|
||||
"""If one tool raises, others should still complete."""
|
||||
# Distinguish the two calls by their arguments so the error is tied to
|
||||
# a SPECIFIC tool call rather than invocation order. Concurrent
|
||||
# execution gives no guarantee that c1's handler runs before c2's, so
|
||||
# keying the raise on a call-order counter is racy: under thread-pool
|
||||
# scheduling c2 could be invoked first, take the "first call raises"
|
||||
# branch, and the error would land in messages[1] instead of
|
||||
# messages[0]. Keying on args makes the assertion deterministic.
|
||||
tc1 = _mock_tool_call(name="web_search", arguments='{"q": "boom"}', call_id="c1")
|
||||
tc2 = _mock_tool_call(name="web_search", arguments='{"q": "ok"}', call_id="c2")
|
||||
tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
|
||||
tc2 = _mock_tool_call(name="web_search", arguments='{}', call_id="c2")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
|
||||
messages = []
|
||||
|
||||
call_count = [0]
|
||||
def fake_handle(name, args, task_id, **kwargs):
|
||||
if args.get("q") == "boom":
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise RuntimeError("boom")
|
||||
return "success"
|
||||
|
||||
@@ -2423,11 +2418,9 @@ class TestConcurrentToolExecution:
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
|
||||
assert len(messages) == 2
|
||||
# Results are ordered by tool_call_id; c1 raised, c2 succeeded.
|
||||
assert messages[0]["tool_call_id"] == "c1"
|
||||
# First tool should have error
|
||||
assert "Error" in messages[0]["content"] or "boom" in messages[0]["content"]
|
||||
# Second tool should succeed
|
||||
assert messages[1]["tool_call_id"] == "c2"
|
||||
assert "success" in messages[1]["content"]
|
||||
|
||||
def test_concurrent_interrupt_before_start(self, agent):
|
||||
@@ -5063,41 +5056,6 @@ class TestMaxTokensParam:
|
||||
result = agent._max_tokens_param(4096)
|
||||
assert result == {"max_completion_tokens": 4096}
|
||||
|
||||
# ── Model-name fallback for non-openai.com endpoints serving newer families ──
|
||||
|
||||
def test_returns_max_completion_tokens_for_gpt5_on_custom_endpoint(self, agent):
|
||||
"""Custom OpenAI-compatible endpoint serving gpt-5.x must also use
|
||||
max_completion_tokens — otherwise the server 400s on max_tokens."""
|
||||
agent.base_url = "https://my-gateway.example.com/v1"
|
||||
agent.model = "gpt-5.4"
|
||||
result = agent._max_tokens_param(4096)
|
||||
assert result == {"max_completion_tokens": 4096}
|
||||
|
||||
def test_returns_max_completion_tokens_for_gpt4o_on_openrouter(self, agent):
|
||||
agent.base_url = "https://openrouter.ai/api/v1"
|
||||
agent.model = "openai/gpt-4o-mini"
|
||||
result = agent._max_tokens_param(4096)
|
||||
assert result == {"max_completion_tokens": 4096}
|
||||
|
||||
def test_returns_max_completion_tokens_for_o1_on_custom_endpoint(self, agent):
|
||||
agent.base_url = "https://custom.example.com/v1"
|
||||
agent.model = "o1-preview"
|
||||
result = agent._max_tokens_param(4096)
|
||||
assert result == {"max_completion_tokens": 4096}
|
||||
|
||||
def test_returns_max_tokens_for_classic_gpt4_on_openrouter(self, agent):
|
||||
"""Classic gpt-4 (non-omni) still uses max_tokens. Don't over-match."""
|
||||
agent.base_url = "https://openrouter.ai/api/v1"
|
||||
agent.model = "openai/gpt-4-turbo"
|
||||
result = agent._max_tokens_param(4096)
|
||||
assert result == {"max_tokens": 4096}
|
||||
|
||||
def test_returns_max_tokens_for_llama_on_local(self, agent):
|
||||
agent.base_url = "http://localhost:11434/v1"
|
||||
agent.model = "llama3"
|
||||
result = agent._max_tokens_param(4096)
|
||||
assert result == {"max_tokens": 4096}
|
||||
|
||||
|
||||
class TestGpt5ApiModeRouting:
|
||||
"""Verify provider-specific GPT-5 API-mode routing."""
|
||||
@@ -5830,15 +5788,7 @@ class TestStreamingApiCall:
|
||||
assert tc[0].function.name == "search"
|
||||
assert tc[1].function.name == "read"
|
||||
|
||||
def test_truncated_tool_call_args_no_finish_reason_routes_to_stub(self, agent):
|
||||
# Stream delivers a tool call with incomplete JSON args and then ENDS
|
||||
# with no finish_reason (the SSE just stops — no terminator, no
|
||||
# [DONE]). This is an upstream mid-tool-call drop, NOT an output cap.
|
||||
# The builder must route it through the partial-stream-stub path
|
||||
# (id=PARTIAL_STREAM_STUB_ID, tool_calls=None so it can't execute,
|
||||
# finish_reason=length so the loop's continuation machinery fires with
|
||||
# chunking guidance) rather than stamping a normal 'length' truncation.
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
def test_truncated_tool_call_args_upgrade_finish_reason_to_length(self, agent):
|
||||
chunks = [
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_1", "write_file", '{"path":"x.txt","content":"hel')]),
|
||||
]
|
||||
@@ -5846,24 +5796,6 @@ class TestStreamingApiCall:
|
||||
|
||||
resp = agent._interruptible_streaming_api_call({"messages": []})
|
||||
|
||||
assert resp.id == PARTIAL_STREAM_STUB_ID
|
||||
assert resp.choices[0].finish_reason == "length"
|
||||
assert resp.choices[0].message.tool_calls is None
|
||||
assert getattr(resp, "_dropped_tool_names", None) == ["write_file"]
|
||||
|
||||
def test_truncated_tool_call_args_with_length_finish_reason_upgrades(self, agent):
|
||||
# Control: when the provider explicitly reports finish_reason='length'
|
||||
# alongside incomplete tool args, it IS a genuine output cap. Keep the
|
||||
# existing behaviour — tool_calls preserved, finish_reason 'length' —
|
||||
# so the max_tokens-boost truncation retry path still applies.
|
||||
chunks = [
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_1", "write_file", '{"path":"x.txt","content":"hel')]),
|
||||
_make_chunk(finish_reason="length"),
|
||||
]
|
||||
agent.client.chat.completions.create.return_value = iter(chunks)
|
||||
|
||||
resp = agent._interruptible_streaming_api_call({"messages": []})
|
||||
|
||||
tc = resp.choices[0].message.tool_calls
|
||||
assert len(tc) == 1
|
||||
assert tc[0].function.name == "write_file"
|
||||
|
||||
@@ -1573,87 +1573,3 @@ class TestCopilotACPStreamingDecision:
|
||||
_use_streaming = False
|
||||
|
||||
assert _use_streaming is True
|
||||
|
||||
|
||||
class TestBedrockIamStreamingFallback:
|
||||
"""bedrock_converse streaming branch: IAM denial of
|
||||
InvokeModelWithResponseStream falls back to converse() inline and sets
|
||||
_disable_streaming for the rest of the session."""
|
||||
|
||||
def _make_bedrock_agent(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "bedrock_converse"
|
||||
agent._interrupt_requested = False
|
||||
return agent
|
||||
|
||||
def test_iam_denial_falls_back_inline_and_disables_streaming(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock tests")
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
agent = self._make_bedrock_agent()
|
||||
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
client.converse.return_value = {
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"agent.bedrock_adapter._get_bedrock_runtime_client",
|
||||
return_value=client,
|
||||
):
|
||||
response = agent._interruptible_streaming_api_call(
|
||||
{"modelId": agent.model, "messages": []}
|
||||
)
|
||||
|
||||
client.converse.assert_called_once()
|
||||
assert response.choices[0].message.content == "hi"
|
||||
assert getattr(agent, "_disable_streaming", False) is True
|
||||
|
||||
def test_other_bedrock_errors_still_propagate(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock tests")
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
agent = self._make_bedrock_agent()
|
||||
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {"Code": "ThrottlingException", "Message": "slow down"}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.bedrock_adapter._get_bedrock_runtime_client",
|
||||
return_value=client,
|
||||
):
|
||||
with pytest.raises(ClientError):
|
||||
agent._interruptible_streaming_api_call(
|
||||
{"modelId": agent.model, "messages": []}
|
||||
)
|
||||
|
||||
client.converse.assert_not_called()
|
||||
assert getattr(agent, "_disable_streaming", False) is False
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Regression tests for the thinking-block signature recovery.
|
||||
|
||||
The recovery in ``agent/conversation_loop.py`` strips ``reasoning_details``
|
||||
from ``api_messages`` (the API-call-time list rebuilt on every retry) and
|
||||
leaves ``messages`` (the canonical store) untouched. The previous
|
||||
implementation popped from ``messages`` directly, which never reached
|
||||
``api_messages`` because each entry in ``api_messages`` was a shallow
|
||||
copy of the corresponding entry in ``messages``, and the mutation also
|
||||
landed in ``state.db`` on the next ``_persist_session`` call, corrupting
|
||||
the conversation.
|
||||
|
||||
These tests cover the surface that the recovery touches in isolation:
|
||||
shallow copies share inner field references; popping a key from one dict
|
||||
does not remove it from the other; and a list of shallow copies behaves
|
||||
the same way.
|
||||
"""
|
||||
|
||||
|
||||
def _shallow_copies(messages):
|
||||
return [m.copy() for m in messages]
|
||||
|
||||
|
||||
def test_pop_on_shallow_copy_does_not_affect_source():
|
||||
rd = [{"type": "thinking", "thinking": "r", "signature": "s"}]
|
||||
src = {"role": "assistant", "content": "x", "reasoning_details": rd}
|
||||
cp = src.copy()
|
||||
|
||||
cp.pop("reasoning_details", None)
|
||||
|
||||
assert "reasoning_details" not in cp
|
||||
assert "reasoning_details" in src
|
||||
assert src["reasoning_details"] is rd
|
||||
|
||||
|
||||
def test_strip_api_messages_leaves_canonical_messages_intact():
|
||||
"""Mirrors the recovery: pop reasoning_details from api_messages only.
|
||||
|
||||
The canonical ``messages`` list keeps its reasoning_details so future
|
||||
persists carry the original signed blocks.
|
||||
"""
|
||||
rd_one = [{"type": "thinking", "thinking": "one", "signature": "sig_one"}]
|
||||
rd_two = [{"type": "thinking", "thinking": "two", "signature": "sig_two"}]
|
||||
messages = [
|
||||
{"role": "user", "content": "q1"},
|
||||
{"role": "assistant", "content": "a1", "reasoning_details": rd_one},
|
||||
{"role": "user", "content": "q2"},
|
||||
{"role": "assistant", "content": "a2", "reasoning_details": rd_two},
|
||||
]
|
||||
api_messages = _shallow_copies(messages)
|
||||
|
||||
stripped = 0
|
||||
for m in api_messages:
|
||||
if isinstance(m, dict) and "reasoning_details" in m:
|
||||
m.pop("reasoning_details", None)
|
||||
stripped += 1
|
||||
|
||||
assert stripped == 2
|
||||
assert all("reasoning_details" not in m for m in api_messages)
|
||||
canonical_rd = [
|
||||
m.get("reasoning_details") for m in messages if m["role"] == "assistant"
|
||||
]
|
||||
assert canonical_rd == [rd_one, rd_two]
|
||||
|
||||
|
||||
def test_strip_is_idempotent_when_run_twice():
|
||||
"""A second strip is a no-op when reasoning_details has already been
|
||||
removed from api_messages. Guards against a duplicate firing path.
|
||||
"""
|
||||
api_messages = [
|
||||
{"role": "assistant", "content": "a", "reasoning_details": [{"x": 1}]},
|
||||
{"role": "user", "content": "q"},
|
||||
]
|
||||
for _ in range(2):
|
||||
for m in api_messages:
|
||||
if isinstance(m, dict) and "reasoning_details" in m:
|
||||
m.pop("reasoning_details", None)
|
||||
|
||||
assert all("reasoning_details" not in m for m in api_messages)
|
||||
|
||||
|
||||
def test_strip_skips_messages_without_reasoning_details():
|
||||
api_messages = [
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
{"role": "tool", "tool_call_id": "1", "content": "ok"},
|
||||
]
|
||||
snapshot = [dict(m) for m in api_messages]
|
||||
|
||||
for m in api_messages:
|
||||
if isinstance(m, dict) and "reasoning_details" in m:
|
||||
m.pop("reasoning_details", None)
|
||||
|
||||
assert api_messages == snapshot
|
||||
Reference in New Issue
Block a user