diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 044c923..0d5f48a 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -58,6 +58,12 @@ self._current_assistant = None self._current_thinking = None + # tool_call_id -> tool arguments, so a later ``role=="tool"`` message can + # recover the args the assistant sent (the persisted tool message only + # carries the result, not the call args). Mirrors the live path where the + # tool_call WS event's meta already contains ``args``. + args_by_id: dict[str, dict] = {} + for m in messages: if not m.get("is_display", True): continue @@ -68,23 +74,45 @@ thinking = m.get("thinking") if thinking: self.items.append(ChatItem(kind="thinking_block", content=thinking)) + content = m.get("content") or "" + if m.get("is_plan"): + self.items.append(ChatItem(kind="plan_ready", content=content, meta={"is_subagent": False})) + elif content: + # Match the live stream: stream_delta (assistant text) arrives + # BEFORE tool_started, so the text bubble sits above the tool + # cards, not below them. Emitting tool_started first would strand + # the answer under its own tool calls on resume. + self.items.append(ChatItem(kind="assistant_message", content=content)) for tc in m.get("tool_calls") or []: + tcid = tc.get("id") + if tcid: + args_by_id[tcid] = tc.get("arguments") or {} self.items.append( ChatItem( kind="tool_started", meta={"tool": tc.get("name", ""), "args": tc.get("arguments") or {}}, ) ) - content = m.get("content") or "" - if m.get("is_plan"): - self.items.append(ChatItem(kind="plan_ready", content=content, meta={"is_subagent": False})) - elif content: - self.items.append(ChatItem(kind="assistant_message", content=content)) + # The whole-turn duration is stamped only on the assistant message + # that closed the run (intermediate tool-tour assistant messages get + # no elapsed_seconds), so this reproduces the single turn_meta line + # the live stream_end appends at the end of a turn. + elapsed = m.get("elapsed_seconds") + if elapsed is not None: + self.items.append(ChatItem(kind="turn_meta", meta={"elapsed_seconds": elapsed})) elif role == "tool": + tcid = m.get("tool_call_id") + args = args_by_id.pop(tcid, {}) if tcid else {} self.items.append( ChatItem( kind="tool_call", - meta={"tool": m.get("name") or "", "result": m.get("content") or "", "success": True}, + meta={ + "tool": m.get("name") or "", + "args": args, + "result": m.get("content") or "", + "success": True, + "metadata": m.get("metadata") or {}, + }, ) ) diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index e5e8aec..ad229f4 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -273,6 +273,104 @@ assert model.items[4].content == "X has a and b." +def test_chat_model_load_history_recovers_tool_args_and_metadata() -> None: + """Resume must reproduce the live tool_call payload: the tool message in + storage carries only the result, so load_history recovers the call args via + the assistant message's tool_call_id and forwards the stored metadata. + Without this the filesystem renderer sees args={} → action=None and falls + back to a plain (un-highlighted) result — the resume "no syntax highlighting" + symptom.""" + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg( + role="assistant", + content="", + tool_calls=[{"id": "call-7", "name": "filesystem", "arguments": {"action": "edit", "path": "/a.py"}}], + ), + _hist_msg( + role="tool", + name="filesystem", + tool_call_id="call-7", + content="replaced", + metadata={"diff": "- 2│ old\n+ 2│ new"}, + ), + ] + model = ChatModel() + model.load_history(messages) + + tool_call = [it for it in model.items if it.kind == "tool_call"][0] + assert tool_call.meta["tool"] == "filesystem" + assert tool_call.meta["args"] == {"action": "edit", "path": "/a.py"} + assert tool_call.meta["result"] == "replaced" + assert tool_call.meta["metadata"] == {"diff": "- 2│ old\n+ 2│ new"} + + +def test_chat_model_load_history_tool_args_fallback_when_id_missing() -> None: + """If the tool message has no tool_call_id (or it doesn't match a stored + call), args fall back to {} rather than raising — the renderer then degrades + to the plain result, which is acceptable for legacy/foreign sessions.""" + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg(role="tool", name="filesystem", content="listed"), + ] + model = ChatModel() + model.load_history(messages) + tool_call = [it for it in model.items if it.kind == "tool_call"][0] + assert tool_call.meta["args"] == {} + assert tool_call.meta["metadata"] == {} + + +def test_chat_model_load_history_emits_assistant_text_before_tool_started() -> None: + """When an assistant message carries BOTH text and tool_calls, the text + bubble must land ABOVE the tool cards — matching the live stream where + stream_delta precedes tool_started. The previous code emitted tool_started + first, stranding the answer under its own tool calls on resume.""" + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg( + role="assistant", + content="Let me check the file.", + tool_calls=[{"id": "1", "name": "filesystem", "arguments": {"action": "read"}}], + ), + _hist_msg(role="tool", name="filesystem", tool_call_id="1", content="ok"), + ] + model = ChatModel() + model.load_history(messages) + + kinds = [it.kind for it in model.items] + assert kinds == ["assistant_message", "tool_started", "tool_call"] + assert model.items[0].content == "Let me check the file." + + +def test_chat_model_load_history_appends_turn_meta_for_elapsed() -> None: + """The whole-turn duration is stamped on the assistant message that closed + the run; load_history reproduces the single turn_meta line stream_end + appends. Intermediate tool-tour assistant messages (no elapsed_seconds) do + not produce extra turn_meta items.""" + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg( + role="assistant", + content="", + tool_calls=[{"id": "1", "name": "filesystem", "arguments": {}}], + ), + _hist_msg(role="tool", name="filesystem", tool_call_id="1", content="ok"), + _hist_msg(role="assistant", content="done", elapsed_seconds=42.5), + ] + model = ChatModel() + model.load_history(messages) + + metas = [it for it in model.items if it.kind == "turn_meta"] + assert len(metas) == 1 + assert metas[0].meta["elapsed_seconds"] == 42.5 + # turn_meta sits below the final answer, as in the live stream. + assert model.items[-1].kind == "turn_meta" + + def test_chat_model_load_history_skips_non_display() -> None: """Context-only user message, summaries, compression events are not shown.""" from clients.terminal.tui.chat_model import ChatModel