diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b064076..47039e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Fixed reasoning summaries exposing Markdown delimiters and duplicate terminal rows after refocus, and redesigned agent progress (single `Agent` calls and parallel `RunAgents` fan-outs) as a compact, payload-free agent activity tree. +- Retain a non-streaming OpenAI Responses reasoning item's encrypted content when it carries no summary, matching the streaming path so the reasoning boundary stays replayable. - Reduce the shell prompt session to a compatibility façade over deep `prompting/` modules (config, keybindings, completion menus, narrow shell-facing methods), with Unicode cell-width coverage and documented module ownership in the architecture guide. - Render image/audio/video and unknown content parts as payload-free labels in the live view, roll nested subagent activity (up to 16 levels) under the correct root tool card, and stop provider-remapped tool results from starting replay user turns. - **Behavior change:** `!` shell commands now run through the detected configured shell (` -c`, PowerShell `-command` on Windows) instead of the implicit `/bin/sh`/`cmd.exe`, with separate 1 MiB stdout/stderr caps and cancellation cleanup; existing `cmd.exe`-syntax commands on Windows may need updating. diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_responses.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_responses.py index 7fb5b893..98214222 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_responses.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_responses.py @@ -475,6 +475,18 @@ def _responses_finish_reason(response: Response) -> str | None: return response.status +def _reasoning_summary_index(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def _responses_output_index(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + class OpenAIResponsesStreamedMessage: def __init__(self, response: Response | AsyncStream[ResponseStreamEvent]): if isinstance(response, Response): @@ -535,16 +547,30 @@ async def _convert_non_stream_response( ), ) elif item.type == "reasoning": - for summary in item.summary: + encrypted_content = getattr(item, "encrypted_content", None) + emitted_summary = False + for summary_index, summary in enumerate(getattr(item, "summary", ())): + emitted_summary = True yield ThinkPart( think=summary.text, - encrypted=item.encrypted_content, + encrypted=encrypted_content, + summary_index=summary_index, + ) + if not emitted_summary and encrypted_content is not None: + # Mirror the streaming `output_item.done` path: a reasoning item + # can carry encrypted_content with no summary parts, and dropping + # it here would make the reasoning boundary non-replayable. + yield ThinkPart( + think="", + encrypted=encrypted_content, + summary_index=None, ) async def _convert_stream_response( self, response: AsyncStream[ResponseStreamEvent] ) -> AsyncIterator[StreamedMessagePart]: """Convert streaming Responses events into message parts.""" + reasoning_summary_indices_by_output: dict[int, int | None] = {} try: async for chunk in response: if isinstance(chunk, ResponseCreatedEvent): @@ -565,16 +591,40 @@ async def _convert_stream_response( elif chunk.type == "response.output_item.done": item = chunk.item if item.type == "reasoning": - yield ThinkPart(think="", encrypted=item.encrypted_content) + output_index = _responses_output_index(getattr(chunk, "output_index", None)) + summary_index = ( + reasoning_summary_indices_by_output.pop(output_index, None) + if output_index is not None + else None + ) + yield ThinkPart( + think="", + encrypted=item.encrypted_content, + summary_index=summary_index, + ) elif isinstance(chunk, ResponseFunctionCallArgumentsDeltaEvent): yield ToolCallPart( arguments_part=chunk.delta, stream_index=chunk.output_index, ) elif chunk.type == "response.reasoning_summary_part.added": - yield ThinkPart(think="") + summary_index = _reasoning_summary_index(getattr(chunk, "summary_index", None)) + output_index = _responses_output_index(getattr(chunk, "output_index", None)) + if output_index is not None: + reasoning_summary_indices_by_output[output_index] = summary_index + yield ThinkPart( + think="", + summary_index=summary_index, + ) elif chunk.type == "response.reasoning_summary_text.delta": - yield ThinkPart(think=chunk.delta) + summary_index = _reasoning_summary_index(getattr(chunk, "summary_index", None)) + output_index = _responses_output_index(getattr(chunk, "output_index", None)) + if output_index is not None: + reasoning_summary_indices_by_output[output_index] = summary_index + yield ThinkPart( + think=getattr(chunk, "delta", ""), + summary_index=summary_index, + ) elif isinstance(chunk, ResponseErrorEvent): self._finish_reason = "failed" return diff --git a/packages/pythinker-core/src/pythinker_core/message.py b/packages/pythinker-core/src/pythinker_core/message.py index bfcfc415..f392b09f 100644 --- a/packages/pythinker-core/src/pythinker_core/message.py +++ b/packages/pythinker-core/src/pythinker_core/message.py @@ -98,11 +98,14 @@ class ThinkPart(ContentPart): think: str encrypted: str | None = None """Encrypted thinking content, or signature.""" + summary_index: int | None = Field(default=None, exclude_if=lambda value: value is None) @override def merge_in_place(self, other: Any) -> bool: if not isinstance(other, ThinkPart): return False + if self.summary_index != other.summary_index: + return False if self.encrypted: return False self.think += other.think diff --git a/packages/pythinker-core/tests/test_message.py b/packages/pythinker-core/tests/test_message.py index 8a51ae55..1070e6c4 100644 --- a/packages/pythinker-core/tests/test_message.py +++ b/packages/pythinker-core/tests/test_message.py @@ -301,6 +301,21 @@ def test_message_with_empty_list_content(): ) +def test_think_part_summary_index_round_trips_through_pydantic() -> None: + part = ThinkPart(think="Plan", summary_index=3) + dumped = part.model_dump() + + assert dumped == snapshot( + { + "type": "think", + "think": "Plan", + "encrypted": None, + "summary_index": 3, + } + ) + assert ThinkPart.model_validate(dumped) == part + + def test_message_extract_text(): message = Message( role="user", diff --git a/packages/pythinker-core/tests/test_stream_message_assembler.py b/packages/pythinker-core/tests/test_stream_message_assembler.py index db57a1a6..54ded56b 100644 --- a/packages/pythinker-core/tests/test_stream_message_assembler.py +++ b/packages/pythinker-core/tests/test_stream_message_assembler.py @@ -1,7 +1,7 @@ import pytest from pythinker_core.chat_provider import APIStreamProtocolError -from pythinker_core.message import TextPart, ToolCall, ToolCallPart +from pythinker_core.message import TextPart, ThinkPart, ToolCall, ToolCallPart from pythinker_core.stream_message_assembler import StreamMessageAssembler @@ -364,6 +364,71 @@ def test_terminal_failure_reasons_are_rejected(finish_reason: str, category: str assert "private" not in str(caught.value) +def test_same_summary_index_merges_adjacent_think_parts() -> None: + assembler = StreamMessageAssembler() + assembler.add(ThinkPart(think="Plan", summary_index=0)) + assembler.add(ThinkPart(think=" next", summary_index=0)) + assembler.add(ThinkPart(think="Check", summary_index=1)) + + message = assembler.finish(response_id=None, finish_reason="completed") + + assert message.content == [ + ThinkPart(think="Plan next", summary_index=0), + ThinkPart(think="Check", summary_index=1), + ] + + +def test_different_summary_indices_stay_separate() -> None: + assembler = StreamMessageAssembler() + assembler.add(ThinkPart(think="Plan", summary_index=0)) + assembler.add(ThinkPart(think="Check", summary_index=1)) + + message = assembler.finish(response_id=None, finish_reason="completed") + + assert message.content == [ + ThinkPart(think="Plan", summary_index=0), + ThinkPart(think="Check", summary_index=1), + ] + + +def test_non_adjacent_duplicate_summary_indices_stay_separate() -> None: + assembler = StreamMessageAssembler() + assembler.add(ThinkPart(think="Plan", summary_index=0)) + assembler.add(ThinkPart(think="Check", summary_index=1)) + assembler.add(ThinkPart(think="Finish", summary_index=0)) + + message = assembler.finish(response_id=None, finish_reason="completed") + + assert message.content == [ + ThinkPart(think="Plan", summary_index=0), + ThinkPart(think="Check", summary_index=1), + ThinkPart(think="Finish", summary_index=0), + ] + + +def test_unindexed_legacy_think_parts_still_merge() -> None: + assembler = StreamMessageAssembler() + assembler.add(ThinkPart(think="Plan")) + assembler.add(ThinkPart(think=" next")) + + message = assembler.finish(response_id=None, finish_reason="completed") + + assert message.content == [ThinkPart(think="Plan next")] + + +def test_indexed_and_unindexed_think_parts_do_not_merge() -> None: + assembler = StreamMessageAssembler() + assembler.add(ThinkPart(think="Plan", summary_index=0)) + assembler.add(ThinkPart(think=" legacy")) + + message = assembler.finish(response_id=None, finish_reason="completed") + + assert message.content == [ + ThinkPart(think="Plan", summary_index=0), + ThinkPart(think=" legacy"), + ] + + def test_content_parts_assemble_independently_from_tool_calls() -> None: assembler = StreamMessageAssembler() assembler.add(TextPart(text="hello ")) diff --git a/packages/pythinker-core/tests/test_stream_tool_call_metadata.py b/packages/pythinker-core/tests/test_stream_tool_call_metadata.py index 93c194ae..04e17394 100644 --- a/packages/pythinker-core/tests/test_stream_tool_call_metadata.py +++ b/packages/pythinker-core/tests/test_stream_tool_call_metadata.py @@ -1,4 +1,5 @@ from collections.abc import AsyncIterator, Sequence +from types import SimpleNamespace from typing import Literal, Self, cast import pytest @@ -23,7 +24,9 @@ ResponseFailedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionToolCall, + ResponseOutputItem, ResponseOutputItemAddedEvent, + ResponseReasoningItem, ResponseStreamEvent, ) from openai.types.responses.response import IncompleteDetails @@ -39,7 +42,8 @@ from pythinker_core.contrib.chat_provider.anthropic import AnthropicStreamedMessage from pythinker_core.contrib.chat_provider.openai_legacy import OpenAILegacyStreamedMessage from pythinker_core.contrib.chat_provider.openai_responses import OpenAIResponsesStreamedMessage -from pythinker_core.message import Message, ToolCall, ToolCallPart +from pythinker_core.message import Message, ThinkPart, ToolCall, ToolCallPart +from pythinker_core.stream_message_assembler import StreamMessageAssembler from pythinker_core.tooling import Tool @@ -71,13 +75,14 @@ def _response( response_id: str = "response_1", status: Literal["completed", "failed", "cancelled", "incomplete"] = "completed", incomplete_reason: Literal["max_output_tokens", "content_filter"] | None = None, + output: list[ResponseOutputItem] | None = None, ) -> Response: return Response( id=response_id, created_at=1, model="gpt-5", object="response", - output=[], + output=[] if output is None else output, parallel_tool_calls=True, tool_choice="auto", tools=[], @@ -92,6 +97,10 @@ async def _collect(stream: object) -> list[ToolCall | ToolCallPart]: return [part async for part in cast(AsyncIterator[ToolCall | ToolCallPart], stream)] +async def _collect_parts(stream: object) -> list[StreamedMessagePart]: + return [part async for part in cast(AsyncIterator[StreamedMessagePart], stream)] + + class _StaticStreamProvider: name = "static-stream" @@ -358,6 +367,320 @@ async def test_openai_responses_uses_output_index_and_semantic_call_id() -> None ] +async def test_openai_responses_preserves_reasoning_summary_order_and_indices() -> None: + events = _async_events( + cast( + ResponseStreamEvent, + SimpleNamespace(type="response.reasoning_summary_part.added", summary_index=0), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="Plan", + summary_index=0, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace(type="response.reasoning_summary_part.added", summary_index=2), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="Check", + summary_index=2, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="Evaluate", + summary_index=1, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="Finish", + summary_index=2, + ), + ), + ) + stream = OpenAIResponsesStreamedMessage(cast(AsyncStream[ResponseStreamEvent], events)) + + parts = [part for part in await _collect_parts(stream) if isinstance(part, ThinkPart)] + + assert [(part.think, part.summary_index) for part in parts] == [ + ("", 0), + ("Plan", 0), + ("", 2), + ("Check", 2), + ("Evaluate", 1), + ("Finish", 2), + ] + + +async def test_openai_responses_streamed_reasoning_done_encrypts_last_summary() -> None: + events = _async_events( + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_part.added", + item_id="reasoning_1", + output_index=0, + summary_index=0, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + item_id="reasoning_1", + output_index=0, + delta="Plan", + summary_index=0, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_part.added", + item_id="reasoning_1", + output_index=0, + summary_index=1, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + item_id="reasoning_1", + output_index=0, + delta="Check", + summary_index=1, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.output_item.done", + output_index=0, + item=SimpleNamespace( + type="reasoning", + id="reasoning_1", + encrypted_content="enc_last", + ), + ), + ), + ) + stream = OpenAIResponsesStreamedMessage(cast(AsyncStream[ResponseStreamEvent], events)) + assembler = StreamMessageAssembler() + + for part in await _collect_parts(stream): + assembler.add(part) + message = assembler.finish(response_id=None, finish_reason="completed") + + assert message.content == [ + ThinkPart(think="Plan", summary_index=0), + ThinkPart(think="Check", encrypted="enc_last", summary_index=1), + ] + + +async def test_openai_responses_done_without_summary_emits_encrypted_part() -> None: + events = _async_events( + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.output_item.done", + output_index=4, + item=SimpleNamespace( + type="reasoning", + id="reasoning_4", + encrypted_content="enc_orphan", + ), + ), + ), + ) + stream = OpenAIResponsesStreamedMessage(cast(AsyncStream[ResponseStreamEvent], events)) + + parts = [part for part in await _collect_parts(stream) if isinstance(part, ThinkPart)] + + assert parts == [ThinkPart(think="", encrypted="enc_orphan", summary_index=None)] + + +async def test_openai_responses_streamed_reasoning_done_indices_do_not_leak_between_items() -> None: + events = _async_events( + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + item_id="reasoning_1", + output_index=0, + delta="First", + summary_index=0, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + item_id="reasoning_2", + output_index=1, + delta="Second", + summary_index=2, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.output_item.done", + output_index=0, + item=SimpleNamespace( + type="reasoning", + id="reasoning_1", + encrypted_content="enc_first", + ), + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.output_item.done", + output_index="bad", + item=SimpleNamespace( + type="reasoning", + id=None, + encrypted_content="enc_untracked", + ), + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.output_item.done", + output_index=1, + item=SimpleNamespace( + type="reasoning", + id="reasoning_2", + encrypted_content="enc_second", + ), + ), + ), + ) + stream = OpenAIResponsesStreamedMessage(cast(AsyncStream[ResponseStreamEvent], events)) + + parts = [part for part in await _collect_parts(stream) if isinstance(part, ThinkPart)] + + assert parts == [ + ThinkPart(think="First", summary_index=0), + ThinkPart(think="Second", summary_index=2), + ThinkPart(think="", encrypted="enc_first", summary_index=0), + ThinkPart(think="", encrypted="enc_untracked"), + ThinkPart(think="", encrypted="enc_second", summary_index=2), + ] + + +async def test_openai_responses_reasoning_summary_invalid_indices_fallback_to_none() -> None: + events = _async_events( + cast( + ResponseStreamEvent, + SimpleNamespace(type="response.reasoning_summary_part.added", summary_index=True), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="bool", + summary_index=True, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="negative", + summary_index=-1, + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace( + type="response.reasoning_summary_text.delta", + delta="string", + summary_index="3", + ), + ), + cast( + ResponseStreamEvent, + SimpleNamespace(type="response.reasoning_summary_text.delta", delta="missing"), + ), + ) + stream = OpenAIResponsesStreamedMessage(cast(AsyncStream[ResponseStreamEvent], events)) + + parts = [part for part in await _collect_parts(stream) if isinstance(part, ThinkPart)] + + assert [(part.think, part.summary_index) for part in parts] == [ + ("", None), + ("bool", None), + ("negative", None), + ("string", None), + ("missing", None), + ] + + +async def test_openai_responses_completed_reasoning_summaries_keep_order_and_encryption() -> None: + response = _response( + output=[ + ResponseReasoningItem.model_validate( + { + "type": "reasoning", + "id": "reasoning_1", + "summary": [ + {"type": "summary_text", "text": "Plan"}, + {"type": "summary_text", "text": "Check"}, + ], + "encrypted_content": "enc_abc", + } + ) + ] + ) + stream = OpenAIResponsesStreamedMessage(response) + + parts = [part for part in await _collect_parts(stream) if isinstance(part, ThinkPart)] + + assert parts == [ + ThinkPart(think="Plan", encrypted="enc_abc", summary_index=0), + ThinkPart(think="Check", encrypted="enc_abc", summary_index=1), + ] + + +async def test_openai_responses_completed_reasoning_without_summary_keeps_encryption() -> None: + # A non-streaming reasoning item can return encrypted_content with an empty + # summary; it must still emit an encrypted ThinkPart so the boundary is + # replayable, matching the streaming `output_item.done` behavior. + response = _response( + output=[ + ResponseReasoningItem.model_validate( + { + "type": "reasoning", + "id": "reasoning_1", + "summary": [], + "encrypted_content": "enc_orphan", + } + ) + ] + ) + stream = OpenAIResponsesStreamedMessage(response) + + parts = [part for part in await _collect_parts(stream) if isinstance(part, ThinkPart)] + + assert parts == [ThinkPart(think="", encrypted="enc_orphan", summary_index=None)] + + async def test_openai_responses_empty_streamed_call_id_is_deterministic( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/src/pythinker_code/subagents/runner.py b/src/pythinker_code/subagents/runner.py index 48393309..1326c270 100644 --- a/src/pythinker_code/subagents/runner.py +++ b/src/pythinker_code/subagents/runner.py @@ -367,6 +367,7 @@ async def run(self, req: ForegroundRunRequest) -> ToolReturnValue: parent_tool_call_id=tool_call.id if tool_call is not None else None, agent_id=agent_id, subagent_type=actual_type, + description=req.description.strip() or None, output_writer=output_writer, ) @@ -536,6 +537,7 @@ def _make_ui_loop_fn( parent_tool_call_id: str | None, agent_id: str, subagent_type: str, + description: str | None, output_writer: SubagentOutputWriter, ): super_wire = get_wire_or_none() @@ -561,6 +563,7 @@ async def _ui_loop_fn(wire: Wire) -> None: parent_tool_call_id=parent_tool_call_id, agent_id=agent_id, subagent_type=subagent_type, + description=description, event=msg, ) ) diff --git a/src/pythinker_code/ui/shell/tool_renderers/agent.py b/src/pythinker_code/ui/shell/tool_renderers/agent.py index c0d2e88d..5531122d 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/agent.py +++ b/src/pythinker_code/ui/shell/tool_renderers/agent.py @@ -15,6 +15,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.key_hints import key_hint +from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, ToolRenderDefinition, @@ -406,61 +407,54 @@ def _plural(count: int, singular: str) -> str: return f"{count} {singular}" if count == 1 else f"{count} {singular}s" -def _run_agent_is_async(mode: str, status: str) -> bool: - return mode == "background" or status.lower() == "launched" - - -def _run_agent_is_resolved(status: str, *, is_async: bool) -> bool: +def _normalize_run_agents_status(status: str, *, mode: str) -> str: norm = normalize_agent_status(status) - if norm in {"completed", "failed", "timed out", "cancelled"}: - return True - return is_async and norm in { + if norm in {"completed", "success", "done"}: + return "completed" + if norm in {"failed", "error", "timed out", "cancelled"}: + return "failed" + if norm in {"deferred", "queued", "pending"}: + return "queued" + if norm in { + "created", "starting", "running", - "created", "launched", "awaiting approval", - } - - -def _run_agent_is_backgrounded(*, is_async: bool, is_resolved: bool, status: str) -> bool: - if not is_async or not is_resolved: - return False - return normalize_agent_status(status) not in {"completed", "failed", "timed out", "cancelled"} - - -def _run_agent_status_subline(entry: dict[str, str], *, is_resolved: bool) -> str: - if not is_resolved: - if normalize_agent_status(entry.get("status", "")) == "awaiting approval": - return "Awaiting approval…" - preview = entry.get("summary_preview") or entry.get("message") or entry.get("brief") - if preview: - return _compact_inline(preview, max_chars=72) - return "Initializing…" - return "Done" - - -def _render_grouped_agents_summary( - count: int, - *, - all_resolved: bool, - all_async: bool, - common_type: str | None, -) -> Text: + }: + return "running/background" + if mode == "background" and not norm: + return "running/background" + return "unknown" + + +def _run_agents_status_glyph(status: str) -> str: + if status == "completed": + return "✓" + if status == "failed": + return "✘" + if status == "queued": + return "○" + if status == "unknown": + return "?" + return "●" + + +def _run_agents_status_style(status: str) -> RichStyle: + if status == "completed": + return tui_rich_style("success") + if status == "failed": + return tui_rich_style("error") + return tui_rich_style("dim") + + +def _render_grouped_agents_summary(count: int, statuses: list[str]) -> Text: + uniform = bool(statuses) and all(status == statuses[0] for status in statuses) + label = statuses[0] if uniform else "mixed" text = Text() - if all_resolved: - if all_async: - text.append(str(count), style=RichStyle(bold=True)) - text.append(" background agents launched", style=tui_rich_style("dim")) - else: - text.append(str(count), style=RichStyle(bold=True)) - type_suffix = f" {common_type}" if common_type else "" - text.append(f"{type_suffix} agents finished", style=tui_rich_style("dim")) - else: - text.append("Running ", style=tui_rich_style("dim")) - text.append(str(count), style=RichStyle(bold=True)) - type_suffix = f" {common_type}" if common_type else "" - text.append(f"{type_suffix} agents…", style=tui_rich_style("dim")) + text.append(str(count), style=RichStyle(bold=True)) + word = "agent" if count == 1 else "agents" + text.append(f" {word} {label}", style=tui_rich_style("dim")) return text @@ -468,50 +462,50 @@ def _render_agent_progress_line( entry: dict[str, str], *, is_last: bool, - hide_type: bool, mode: str, -) -> Group: - status = entry["status"] - is_async = _run_agent_is_async(mode, status) - is_resolved = _run_agent_is_resolved(status, is_async=is_async) - is_backgrounded = _run_agent_is_backgrounded( - is_async=is_async, - is_resolved=is_resolved, - status=status, - ) - + width: int, +) -> Text: + status = _normalize_run_agents_status(entry["status"], mode=mode) + glyph = _run_agents_status_glyph(status) tree = _TREE_LAST if is_last else _TREE_BRANCH - gutter = _TREE_GUTTER_LAST if is_last else _TREE_GUTTER_MID subagent_type = entry["subagent_type"] - description = entry.get("name_extra") or None - label_style = tui_rich_style("tool_title") + RichStyle(bold=True) + description = entry.get("name_extra") or "" + suffix = "" + if status == "completed": + suffix = " · Done" + elif status == "failed": + suffix = " · Failed" + elif status == "unknown": + suffix = " · Unknown" + + content_width = max(1, width - cell_width(" ⎿ ")) + wide_status = f" {status} " + wide_fixed = f" {tree} {glyph}{wide_status}{subagent_type}{suffix}" + rendered_status = " " if cell_width(wide_fixed) > content_width else wide_status + + fixed_without_type = f" {tree} {glyph}{rendered_status}{suffix}" + type_budget = max(1, content_width - cell_width(fixed_without_type)) + rendered_type = truncate_to_width(subagent_type, type_budget) + rendered_fixed = f" {tree} {glyph}{rendered_status}{rendered_type}{suffix}" + description_budget = content_width - cell_width(rendered_fixed) - 1 row = Text() - row.append(" ", style="") + row.append(" ") row.append(f"{tree} ", style=tui_rich_style("dim")) - if hide_type: - label = description or subagent_type - row.append(label, style=label_style) - else: - row.append(subagent_type, style=label_style) - if description: - row.append(" (", style=tui_rich_style("dim")) - row.append(description, style=label_style) - row.append(")", style=tui_rich_style("dim")) - if not is_resolved: - row.stylize(tui_rich_style("dim"), 3, len(row)) - - children: list[RenderableType] = [row] - if not is_backgrounded: - sub = Text() - sub.append(" ", style="") - sub.append(gutter, style=tui_rich_style("dim")) - sub.append( - _run_agent_status_subline(entry, is_resolved=is_resolved), - style=tui_rich_style("dim"), + row.append(glyph, style=_run_agents_status_style(status)) + row.append(rendered_status, style=tui_rich_style("dim")) + row.append(rendered_type, style=tui_rich_style("tool_title") + RichStyle(bold=True)) + if description and description_budget > 0: + row.append(" ", style=tui_rich_style("dim")) + row.append( + truncate_to_width(description, description_budget), + style=tui_rich_style("tool_title"), ) - children.append(sub) - return Group(*children) + if suffix: + row.append(suffix, style=tui_rich_style("dim")) + row.no_wrap = True + row.overflow = "ellipsis" + return row def _run_agent_arg_summaries(args: dict[str, object]) -> list[tuple[str, str]] | None: @@ -525,11 +519,12 @@ def _run_agent_arg_summaries(args: dict[str, object]) -> list[tuple[str, str]] | summaries.append((f"agent-{index}", "coder")) continue raw_agent_dict = cast(dict[str, object], raw_agent) - name = as_str(raw_agent_dict.get("name")) or f"agent-{index}" + title = as_str(raw_agent_dict.get("title")) + name = title or as_str(raw_agent_dict.get("name")) or f"agent-{index}" subagent_type = as_str(raw_agent_dict.get("subagent_type")) or "coder" summaries.append( ( - _compact_inline(name, max_chars=24), + _compact_inline(name, max_chars=32), _compact_inline(subagent_type, max_chars=24), ) ) @@ -542,13 +537,20 @@ def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: mode = "foreground" if args.get("run_in_background") is False else "background" if agent_summaries is None: if ctx.has_result: - header = tool_call_header( - _RUN_AGENTS_TOOL_NAME, - missing_required_arg("agents"), - style_token="error", + summary = as_str(args.get("summary")) + if ctx.is_error: + return tool_call_header( + "Agents", + missing_required_arg("agents"), + style_token="error", + ) + return tool_call_header( + "Agents", + summary, + style_token="success", + summary_style_token="thinking_text", ) - return header - line = pending_tool_call_header(_RUN_AGENTS_TOOL_NAME) + line = pending_tool_call_header("Agents") return running_spinner( line, execution_started=ctx.execution_started, @@ -560,19 +562,20 @@ def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: summary = Text() if run_summary: summary.append_text(fg("thinking_text", run_summary)) + summary.append_text(fg("dim", f" · {mode}")) else: count = len(agent_summaries) summary.append_text(fg("border_accent", _plural(count, "agent"))) summary.append_text(fg("thinking_text", f" · {mode}")) style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" - header = tool_call_header(_RUN_AGENTS_TOOL_NAME, summary, style_token=style_token) + header = tool_call_header("Agents", summary, style_token=style_token) children: list[RenderableType] = [header] if ctx.has_result and run_summary is None: children.append(missing_required_arg("summary")) - rendered: RenderableType = Group(*children) if len(children) > 1 else header + rendered: RenderableType = Group(*children) return running_spinner( rendered, execution_started=ctx.execution_started, @@ -726,10 +729,18 @@ def _render_run_agents_text_result( if not text: return None ctx.state["__suppress_generic_expand_hint__"] = True + ctx.state["__has_expandable_payload__"] = True + if not ctx.expanded: + status = "failed" if result.is_error else "result unavailable" + detail = "raw details preserved" + return Group( + fg("error" if result.is_error else "muted", f"Agents {status}"), + key_hint("app.tools.expand", detail), + ) body, remaining = format_lines_block( text, - expanded=ctx.expanded, - collapsed_max_lines=_RUN_AGENTS_ERROR_COLLAPSED_LINES, + expanded=True, + collapsed_max_lines=10**9, style_token="error" if result.is_error else "tool_output", ) if remaining > 0: @@ -737,6 +748,28 @@ def _render_run_agents_text_result( return body +def _run_agents_requested_titles_by_name(args: dict[str, object]) -> dict[str, str]: + raw_agents_value = args.get("agents") + if not isinstance(raw_agents_value, list): + return {} + titles: dict[str, str] = {} + for raw_agent in cast("list[object]", raw_agents_value): + if not isinstance(raw_agent, dict): + continue + raw_agent_dict = cast(dict[str, object], raw_agent) + name = as_str(raw_agent_dict.get("name")) + title = as_str(raw_agent_dict.get("title")) + if name and title: + titles[name] = title + return titles + + +def _run_agents_row_description(agent: dict[str, str], title_by_name: dict[str, str]) -> str: + name = agent.get("name") or "" + description = agent.get("description") or title_by_name.get(name) or name + return _compact_inline(description, max_chars=80) if description else "" + + def _render_run_agents_result( ctx: ToolRenderContext, result: ToolResultPayload ) -> RenderableType | None: @@ -749,15 +782,15 @@ def _render_run_agents_result( if not agents: return _render_run_agents_text_result(ctx, result) + title_by_name = _run_agents_requested_titles_by_name(ctx.args or {}) entries: list[dict[str, str]] = [] - for index, agent in enumerate(agents): + for agent in agents: subagent_type = agent.get("subagent_type") or agent.get("actual_subagent_type") or "coder" - name = agent.get("name") or f"agent-{index + 1}" - extra = "" if name == subagent_type else name + extra = _run_agents_row_description(agent, title_by_name) entries.append( { "subagent_type": subagent_type, - "name_extra": extra, + "name_extra": "" if extra == subagent_type else extra, "status": agent.get("detail_status") or agent.get("status") or "unknown", "task_id": agent.get("task_id") or "", "summary_preview": agent.get("summary_preview") or "", @@ -767,38 +800,27 @@ def _render_run_agents_result( ) mode = top.get("mode") or "background" - all_resolved = all( - _run_agent_is_resolved( - entry["status"], - is_async=_run_agent_is_async(mode, entry["status"]), - ) - for entry in entries - ) - all_async = all(_run_agent_is_async(mode, entry["status"]) for entry in entries) - types = [entry["subagent_type"] for entry in entries] - all_same_type = bool(types) and all(t == types[0] for t in types) - common_type = types[0] if all_same_type and types[0] not in _RUN_AGENTS_GENERIC_TYPES else None - hide_type = all_same_type and common_type is not None + normalized_statuses = [ + _normalize_run_agents_status(entry["status"], mode=mode) for entry in entries + ] children: list[RenderableType] = [ - _render_grouped_agents_summary( - len(entries), - all_resolved=all_resolved, - all_async=all_async, - common_type=common_type, - ) + _render_grouped_agents_summary(len(entries), normalized_statuses) ] for index, entry in enumerate(entries): children.append( _render_agent_progress_line( entry, is_last=index == len(entries) - 1, - hide_type=hide_type, mode=mode, + width=ctx.width, ) ) - if not all_async and not ctx.expanded: + has_foreground_terminal = any( + status in {"completed", "failed"} for status in normalized_statuses + ) + if has_foreground_terminal and not ctx.expanded: ctx.state["__suppress_generic_expand_hint__"] = True children.append(key_hint("app.tools.expand", "to expand")) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 4bf46657..2a70a1de 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -12,11 +12,14 @@ import re import time from collections import Counter, deque +from dataclasses import dataclass from enum import Enum from typing import Any, Literal, NamedTuple, cast import streamingjson # type: ignore[reportMissingTypeStubs] from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult +from rich.measure import Measurement +from rich.segment import Segment from rich.style import Style from rich.text import Text @@ -31,6 +34,7 @@ markdown_commit_boundary, ) from pythinker_code.ui.shell.components.render_utils import ( + cell_width, render_message_response, sanitize_ansi, truncate_to_width, @@ -47,6 +51,7 @@ from pythinker_code.ui.shell.mcp_status import mcp_startup_header from pythinker_code.ui.shell.motion import ( ActivitySnapshot, + active_marker_frame, activity_status_line, append_streaming_caret, reduced_motion_enabled, @@ -126,6 +131,17 @@ def smooth_streaming_enabled() -> bool: _MAX_SUBAGENT_ROLLUP_TOOLS = 6 _MAX_SUBAGENT_CHANGED_FILES = 5 _MAX_FILE_ACTIVITY_ROWS = 5 +_MAX_RUN_AGENTS_ACTIVITY_ROWS = 6 +_SUBAGENT_ACTIVITY_LABELS: dict[str, str] = { + "Read": "reading…", + "ReadFile": "reading…", + "Grep": "searching…", + "Glob": "finding files…", + "Bash": "running command…", + "Shell": "running command…", + "WriteFile": "writing…", + "StrReplaceFile": "editing…", +} # Background-agent statuses that mean "still running" — the tool call result # has arrived but the spawned agent has not yet finished. Blocks with this @@ -145,6 +161,16 @@ def smooth_streaming_enabled() -> bool: ) +@dataclass(slots=True) +class _SubagentActivityState: + agent_id: str + subagent_type: str + description: str + observed_at: float + current_tool_name: str | None = None + state: Literal["waiting", "running", "completed", "failed"] = "waiting" + + def _parse_tool_result_top_fields(result_text: str) -> dict[str, str]: """Parse top-level ``key: value`` lines before nested agent/task sections.""" top: dict[str, str] = {} @@ -162,6 +188,21 @@ def _parse_tool_result_top_fields(result_text: str) -> dict[str, str]: return top +def _format_subagent_type(subagent_type: str) -> str: + cleaned = sanitize_ansi(subagent_type).strip().replace("_", "-") or "agent" + return " ".join(part.capitalize() for part in cleaned.split("-") if part) or "Agent" + + +def _normalize_subagent_description(description: str | None) -> str: + return " ".join(sanitize_ansi(description or "").split()) + + +def _semantic_subagent_activity(tool_name: str | None) -> str: + if not tool_name: + return "thinking…" + return _SUBAGENT_ACTIVITY_LABELS.get(tool_name, "thinking…") + + def _is_active_background_agent(tool_name: str, result_text: str) -> bool: """Return True when a tool card should stay in the Live area after its result arrives.""" if tool_name == "Agent": @@ -563,11 +604,32 @@ def _tail_lines(text: str, n: int) -> str: return text[pos + 1 :] +@dataclass(slots=True) +class _ThinkingSegment: + summary_index: int | None + text: str = "" + encrypted_boundary: bool = False + + +class _StyleOverrideRenderable: + def __init__(self, renderable: RenderableType, style: Style) -> None: + self._renderable = renderable + self._style = style + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + return Measurement.get(console, options, self._renderable) + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + for segment in console.render(self._renderable, options): + style = self._style if segment.style is None else segment.style + self._style + yield Segment(segment.text, style, segment.control) + + _COMPLETE_HTML_COMMENT_BLOCK_RE = re.compile(r"(?ms)^[ \t]*).)*?-->[ \t]*(?=\r?$)") -def _render_thinking_preview(preview: str) -> RenderableType | None: - """Bounded thinking preview as Markdown, top-level HTML comments stripped; None if empty.""" +def _clean_thinking_markdown(text: str) -> str: + """Strip complete top-level HTML comment lines outside fenced code.""" segments: list[str] = [] unfenced: list[str] = [] @@ -577,20 +639,28 @@ def flush_unfenced() -> None: segments.append(_COMPLETE_HTML_COMMENT_BLOCK_RE.sub("", "".join(unfenced))) unfenced.clear() - for line, inside_fence in iter_fence_aware_lines(preview): + for line, inside_fence in iter_fence_aware_lines(text): if inside_fence: flush_unfenced() segments.append(line) else: unfenced.append(line) flush_unfenced() + return "".join(segments) - cleaned = "".join(segments) + +def _render_thinking_markdown(text: str) -> RenderableType | None: + cleaned = _clean_thinking_markdown(text) if not cleaned.strip(): return None return render_agent_body(cleaned) +def _render_thinking_preview(preview: str) -> RenderableType | None: + """Bounded thinking preview as Markdown, top-level HTML comments stripped; None if empty.""" + return _render_thinking_markdown(preview) + + def _advance_by_display_cells(text: str, start: int, cell_budget: int) -> int: """Return a character offset advanced by roughly ``cell_budget`` terminal cells.""" from rich.cells import cell_len @@ -632,6 +702,7 @@ def __init__(self, is_think: bool, *, show_thinking_stream: bool = False, paced: # instead of all at once on each delta, for smooth streaming. self._paced = paced and not is_think self.raw_text = "" + self._thinking_segments: list[_ThinkingSegment] = [] # Accumulated float estimate — avoids per-chunk int truncation. self._token_count: float = 0.0 self._start_time = time.monotonic() @@ -692,9 +763,21 @@ def render_expanded(self) -> RenderableType: finally: self._report_update.set_expanded(was_expanded) - def append(self, content: str) -> None: + def append( + self, + content: str, + *, + summary_index: int | None = None, + encrypted: str | None = None, + ) -> None: self.raw_text += content self._token_count += _estimate_tokens(content) + if self.is_think and (content or encrypted): + self._append_thinking_segment( + content, + summary_index=summary_index, + encrypted=encrypted, + ) self._invalidate_preview_cache() if self._paced: # Reveal is paced by reveal_tick() for smooth streaming; just buffer @@ -825,15 +908,16 @@ def compose_final(self) -> RenderableType: """Render the remaining uncommitted content when the block ends.""" if self.is_think: if self._show_thinking_stream: - remaining = self._pending_text() - if not remaining: + thinking_style = tui_rich_style("thinking_text") + Style(italic=True) + rendered_segments = [ + _StyleOverrideRenderable(rendered, thinking_style) + for segment in self._thinking_segments + if (rendered := _render_thinking_markdown(segment.text)) is not None + ] + if not rendered_segments: return Text("") - thinking_style = tui_rich_style("thinking_text") - # Render reasoning as plain muted text — not themed Markdown — so - # it reads as uniform grey rather than picking up bright heading / - # purple emphasis colors. return BulletColumns( - Text(remaining, style=thinking_style + Style(italic=True)), + Group(*rendered_segments), bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=thinking_style), ) elapsed_str = format_elapsed(time.monotonic() - self._start_time) @@ -897,6 +981,31 @@ def take_committed_renderables(self) -> list[RenderableType]: # -- Private ------------------------------------------------------------- + def _append_thinking_segment( + self, + content: str, + *, + summary_index: int | None, + encrypted: str | None, + ) -> None: + encrypted_boundary = bool(encrypted) + if ( + self._thinking_segments + and self._thinking_segments[-1].summary_index == summary_index + and not self._thinking_segments[-1].encrypted_boundary + ): + segment = self._thinking_segments[-1] + segment.text += content + segment.encrypted_boundary = encrypted_boundary + return + self._thinking_segments.append( + _ThinkingSegment( + summary_index=summary_index, + text=content, + encrypted_boundary=encrypted_boundary, + ) + ) + def _pending_text(self) -> str: return self.raw_text[self._committed_len : self._revealed_len] @@ -1252,7 +1361,10 @@ def __init__(self, tool_call: ToolCall): self._result: ToolReturnValue | None = None self._subagent_id: str | None = None self._subagent_type: str | None = None + self._subagent_description: str | None = None + self._subagent_activities: dict[str, _SubagentActivityState] = {} + self._subagent_tool_call_owner: dict[str, str] = {} self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {} self._finished_subagent_tool_call_ids: set[str] = set() self._last_subagent_tool_call: ToolCall | None = None @@ -1318,7 +1430,13 @@ def is_background_pending(self) -> bool: def has_expandable_card(self) -> bool: return self._tui_card is not None and self._tui_card.can_expand + @property + def _owns_agent_activity(self) -> bool: + return self._tool_name in {"Agent", "RunAgents"} + def active_subagent_label(self) -> str | None: + if self._tool_name in {"Agent", "RunAgents"}: + return None if not self._ongoing_subagent_tool_calls: return None call = next(reversed(self._ongoing_subagent_tool_calls.values())) @@ -1399,14 +1517,30 @@ def finish(self, result: ToolReturnValue): self._is_background_pending = _is_active_background_agent(self._tool_name, result_text) self._renderable = self._compose() - def append_sub_tool_call(self, tool_call: ToolCall): + def append_sub_tool_call(self, tool_call: ToolCall, *, agent_id: str | None = None) -> None: if tool_call.id in self._finished_subagent_tool_call_ids: return + if self._owns_agent_activity: + owner = agent_id or self._subagent_id + if owner is not None: + self._subagent_tool_call_owner[tool_call.id] = owner + state = self._subagent_activities.get(owner) + if state is not None: + state.current_tool_name = tool_call.function.name + state.state = "waiting" + state.observed_at = time.monotonic() + self._renderable = self._compose() + return self._ongoing_subagent_tool_calls[tool_call.id] = tool_call self._last_subagent_tool_call = tool_call self._renderable = self._compose() - def append_sub_tool_call_part(self, tool_call_part: ToolCallPart): + def append_sub_tool_call_part( + self, tool_call_part: ToolCallPart, *, agent_id: str | None = None + ) -> None: + if self._owns_agent_activity: + # Agent-centric collapsed activity is semantic only; never append or render raw args. + return if self._last_subagent_tool_call is None: return if not tool_call_part.arguments_part: @@ -1417,9 +1551,23 @@ def append_sub_tool_call_part(self, tool_call_part: ToolCallPart): self._last_subagent_tool_call.function.arguments += tool_call_part.arguments_part self._renderable = self._compose() - def finish_sub_tool_call(self, tool_result: ToolResult): + def finish_sub_tool_call(self, tool_result: ToolResult, *, agent_id: str | None = None) -> None: if tool_result.tool_call_id in self._finished_subagent_tool_call_ids: return + if self._owns_agent_activity: + owner = agent_id or self._subagent_tool_call_owner.get(tool_result.tool_call_id) + if owner is not None and owner in self._subagent_activities: + state = self._subagent_activities[owner] + state.current_tool_name = None + state.state = "running" + state.observed_at = time.monotonic() + self._finished_subagent_tool_call_ids.add(tool_result.tool_call_id) + self._subagent_tool_call_owner.pop(tool_result.tool_call_id, None) + self._subagent_output_parts.pop(tool_result.tool_call_id, None) + self._subagent_output_had_stderr.pop(tool_result.tool_call_id, None) + self._subagent_execution_started.discard(tool_result.tool_call_id) + self._renderable = self._compose() + return self._last_subagent_tool_call = None sub_tool_call = self._ongoing_subagent_tool_calls.pop(tool_result.tool_call_id, None) if sub_tool_call is None: @@ -1439,14 +1587,46 @@ def finish_sub_tool_call(self, tool_result: ToolResult): self._n_finished_subagent_tool_calls += 1 self._renderable = self._compose() - def set_subagent_metadata(self, agent_id: str, subagent_type: str) -> None: - changed = (self._subagent_id, self._subagent_type) != (agent_id, subagent_type) + def set_subagent_metadata( + self, agent_id: str, subagent_type: str, description: str | None = None + ) -> None: + normalized_description = _normalize_subagent_description(description) or None + if normalized_description is None and self._tool_name == "Agent": + normalized_description = self._agent_argument_description() + changed = (self._subagent_id, self._subagent_type, self._subagent_description) != ( + agent_id, + subagent_type, + normalized_description, + ) self._subagent_id = agent_id self._subagent_type = subagent_type + self._subagent_description = normalized_description + if self._owns_agent_activity: + clean_description = normalized_description or _format_subagent_type(subagent_type) + state = self._subagent_activities.get(agent_id) + if state is None: + self._subagent_activities[agent_id] = _SubagentActivityState( + agent_id=agent_id, + subagent_type=subagent_type, + description=clean_description, + observed_at=time.monotonic(), + ) + else: + state.subagent_type = subagent_type + state.description = clean_description if changed: self._renderable = self._compose() - def mark_sub_execution_started(self, tool_call_id: str) -> None: + def mark_sub_execution_started(self, tool_call_id: str, *, agent_id: str | None = None) -> None: + if self._owns_agent_activity: + owner = agent_id or self._subagent_tool_call_owner.get(tool_call_id) + if owner is not None and owner in self._subagent_activities: + state = self._subagent_activities[owner] + state.state = "running" + state.observed_at = time.monotonic() + self._subagent_execution_started.add(tool_call_id) + self._renderable = self._compose() + return if tool_call_id not in self._ongoing_subagent_tool_calls: return if tool_call_id in self._subagent_execution_started: @@ -1455,8 +1635,16 @@ def mark_sub_execution_started(self, tool_call_id: str) -> None: self._renderable = self._compose() def append_sub_output_part( - self, tool_call_id: str, text: str, *, stream: str = "output" + self, tool_call_id: str, text: str, *, stream: str = "output", agent_id: str | None = None ) -> None: + if self._owns_agent_activity: + owner = agent_id or self._subagent_tool_call_owner.get(tool_call_id) + if owner is not None and owner in self._subagent_activities: + state = self._subagent_activities[owner] + state.state = "running" + state.observed_at = time.monotonic() + self._renderable = self._compose() + return if tool_call_id not in self._ongoing_subagent_tool_calls: return if not text: @@ -1538,9 +1726,143 @@ def _subagent_rollup_children(self) -> list[RenderableType]: ) return children + def _agent_argument_description(self) -> str | None: + try: + args = json.loads(self._lexer.complete_json() or "{}", strict=False) + except json.JSONDecodeError: + return None + if not isinstance(args, dict): + return None + description = cast(dict[str, Any], args).get("description") + return _normalize_subagent_description(description) or None + + def _agent_activity_requested_count(self) -> int: + if self._tool_name == "Agent": + return max(1, len(self._subagent_activities)) + try: + args = json.loads(self._lexer.complete_json() or "{}", strict=False) + except json.JSONDecodeError: + return len(self._subagent_activities) + if not isinstance(args, dict): + return len(self._subagent_activities) + raw_agents = cast(dict[str, Any], args).get("agents") + if isinstance(raw_agents, list): + agents = cast(list[Any], raw_agents) + return len(agents) + return len(self._subagent_activities) + + @staticmethod + def _subagent_status_glyph(state: Literal["waiting", "running", "completed", "failed"]) -> str: + if state == "running": + return active_marker_frame(time.monotonic()) + if state == "completed": + return "✓" + if state == "failed": + return "✘" + return "○" + + @staticmethod + def _subagent_status_style( + state: Literal["waiting", "running", "completed", "failed"], + ) -> Style: + if state == "completed": + return tui_rich_style("success") + if state == "failed": + return tui_rich_style("error") + return tui_rich_style("muted") + + def _agent_activity_children(self, *, include_heading: bool) -> list[RenderableType]: + requested_count = self._agent_activity_requested_count() + if not self._subagent_activities and requested_count <= 0: + return [] + width = current_console_width() + children: list[RenderableType] = [] + if include_heading: + heading = Text() + heading.append("● ", style=tui_rich_style("muted")) + heading.append( + "Agents" if self._tool_name == "RunAgents" else "Agent", + style=tui_rich_style("muted") + Style(bold=True), + ) + children.append(heading) + + states = list(self._subagent_activities.values()) + if len(states) <= _MAX_RUN_AGENTS_ACTIVITY_ROWS: + visible = states + else: + selected_indices: list[int] = [ + index for index, state in enumerate(states) if state.state == "running" + ][:_MAX_RUN_AGENTS_ACTIVITY_ROWS] + if len(selected_indices) < _MAX_RUN_AGENTS_ACTIVITY_ROWS: + selected_index_set = set(selected_indices) + for index, state in enumerate(states): + if state.state == "running" or index in selected_index_set: + continue + selected_indices.append(index) + selected_index_set.add(index) + if len(selected_indices) >= _MAX_RUN_AGENTS_ACTIVITY_ROWS: + break + visible = [states[index] for index in sorted(selected_indices)] + hidden = max(0, len(states) - len(visible)) + queued = max(0, requested_count - len(self._subagent_activities)) + total_rows = len(visible) + (1 if queued else 0) + (1 if hidden else 0) + + for index, state in enumerate(visible): + is_last = index == total_rows - 1 + branch = "└─" if is_last else "├─" + gutter = " ⎿ " if is_last else "│ ⎿ " + type_label = _format_subagent_type(state.subagent_type) + status = state.state + glyph = self._subagent_status_glyph(status) + prefix = f"{branch} {glyph} {status} {type_label} " + description_budget = max(1, width - cell_width(prefix)) + row = Text() + row.append(f"{branch} ", style=tui_rich_style("muted")) + row.append(glyph, style=self._subagent_status_style(status)) + row.append(f" {status} ", style=tui_rich_style("muted")) + row.append(type_label, style=tui_rich_style("tool_title") + Style(bold=True)) + row.append(" ", style=tui_rich_style("muted")) + row.append( + truncate_to_width(state.description, description_budget), + style=tui_rich_style("text"), + ) + row.no_wrap = True + row.overflow = "ellipsis" + children.append(row) + + activity = _semantic_subagent_activity(state.current_tool_name) + activity_budget = max(1, width - cell_width(gutter)) + children.append( + Text( + gutter + truncate_to_width(activity, activity_budget), + style=tui_rich_style("muted"), + ) + ) + + extra_index = len(visible) + if queued: + is_last = extra_index == total_rows - 1 + branch = "└─" if is_last else "├─" + children.append(Text(f"{branch} ○ {queued} queued", style=tui_rich_style("muted"))) + extra_index += 1 + if hidden: + branch = "└─" if extra_index == total_rows - 1 else "├─" + children.append(Text(f"{branch} … {hidden} more agents", style=tui_rich_style("muted"))) + return children + def _subagent_activity_children( - self, style_label: str, *, include_completed_subagent: bool = False + self, + style_label: str, + *, + include_completed_subagent: bool = False, + include_run_agents_heading: bool = True, ) -> list[RenderableType]: + if self._owns_agent_activity: + if self._result is not None and not self._is_background_pending: + # Parent-result ownership: once Agent/RunAgents reaches a terminal result, the + # parent renderer is authoritative, so suppress stale pre-result activity rows. + return [] + return self._agent_activity_children(include_heading=include_run_agents_heading) children: list[RenderableType] = [] should_show_activity = include_completed_subagent or not ( style_label == "Subagent" and self._result is not None @@ -1621,7 +1943,11 @@ def _compose(self) -> RenderableType: if card_rendered is not None: return card_rendered children: list[RenderableType] = [] - if self._subagent_id is not None and self._subagent_type is not None: + if ( + self._tool_name not in {"Agent", "RunAgents"} + and self._subagent_id is not None + and self._subagent_type is not None + ): children.append( BulletColumns( Text( @@ -1664,12 +1990,14 @@ def _compose(self) -> RenderableType: ) ) - if self._result is None: + if self._result is None or self._is_background_pending: streamed_output = self._streamed_output_text() if streamed_output: preview = _tail_lines(streamed_output.rstrip("\n"), 8) output_style = "error" if self._streamed_output_had_stderr else "muted" children.append(Text(preview, style=tui_rich_style(output_style))) + if self._tool_name == "RunAgents" and children: + return Group(*children) return render_worklog_entry( label=style.label, target=self._argument, @@ -1711,6 +2039,8 @@ def _compose_card(self) -> RenderableType | None: """ definition = get_tool_renderer(self._tool_name) if definition is None: + if self._tool_name == "RunAgents": + return None definition = generic_renderer() if self._tui_card is None: self._tui_card = ToolExecutionComponent( @@ -1738,7 +2068,11 @@ def _compose_card(self) -> RenderableType | None: self._tui_card.update_args(args) if args_complete: self._tui_card.set_args_complete() - if self._result is not None: + if self._result is not None and not ( + self._tool_name == "RunAgents" + and self._owns_agent_activity + and self._is_background_pending + ): self._tui_card.set_result( ToolResultPayload( text=self._card_result_text(self._result), @@ -1769,7 +2103,8 @@ def _compose_card(self) -> RenderableType | None: activity_children.extend( self._subagent_activity_children( style_label, - include_completed_subagent=style_label == "Subagent" and self._result is not None, + include_completed_subagent=(style_label == "Subagent" and self._result is not None), + include_run_agents_heading=not self._owns_agent_activity, ) ) if activity_children: diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index b440cace..197c2128 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -194,7 +194,13 @@ def _current_terminal_size(self) -> tuple[int, int] | None: return (size.columns, size.rows) def _tick_resize_recovery(self) -> None: - """Detect terminal geometry changes and force a hard preamble invalidation.""" + """Track bounded terminal-geometry recovery after resize. + + Any platform starts a short recovery window that forces redraw and hides + tips while prompt_toolkit settles at the new size. Only Windows also + hard-resets the prompt renderer on resize, because ConPTY can leave the + renderer's diff model out of sync with the real screen after a rewrap. + """ size = self._current_terminal_size() if size is not None: columns, rows = size @@ -204,7 +210,8 @@ def _tick_resize_recovery(self) -> None: self._resize_recovery_remaining = _RESIZE_RECOVERY_FRAMES self._force_refresh = True _handoff_trace(f"RESIZE\t{columns}x{rows}") - self._reset_prompt_renderer("resize") + if os.name == "nt": + self._reset_prompt_renderer("resize") else: _handoff_trace(f"RESIZE_IGNORE\t{columns}x{rows}") if self._resize_recovery_remaining > 0: diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 9aa95004..f6610d0d 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -262,6 +262,7 @@ def __init__( self._current_content_block: _ContentBlock | None = None self._tool_call_blocks: dict[str, _ToolCallBlock] = {} self._subagent_tool_call_ancestry: dict[str, tuple[_ToolCallBlock, int]] = {} + self._subagent_tool_call_owners: dict[str, str] = {} # Per-view so the "log an unknown content-part type once" guarantee is # scoped to this session/view rather than the whole process (keeps the # log-once behavior deterministic and test-isolated). @@ -1013,6 +1014,9 @@ def _working_indicator(self, *, hide_tips: bool = False) -> RenderableType: elapsed_s=elapsed, tokens=get_turn_output_tokens(), token_rate=self._turn_token_rate(now), + interrupt_hint=( + "esc to interrupt" if getattr(self, "_cancel_event", None) is not None else "" + ), ), width=width, ) @@ -1553,6 +1557,7 @@ def cleanup(self, is_interrupt: bool) -> None: self._btw_spinner = None self._hook_blocks.clear() self._subagent_tool_call_ancestry.clear() + self._subagent_tool_call_owners.clear() self._current_step_retry = None if is_interrupt: @@ -1579,6 +1584,7 @@ def discard_retry_attempt(self, retry: StepRetry) -> None: self._current_content_block = None self._tool_call_blocks.clear() self._subagent_tool_call_ancestry.clear() + self._subagent_tool_call_owners.clear() self._last_tool_call_block = None self._held_tool_search_block = None self._current_step_retry = retry @@ -1681,6 +1687,7 @@ def _purge_ancestry_for_block(self, block: _ToolCallBlock) -> None: ] for tool_call_id in stale_ids: del self._subagent_tool_call_ancestry[tool_call_id] + self._subagent_tool_call_owners.pop(tool_call_id, None) def flush_notifications(self) -> None: """Flush rendered notifications to terminal history.""" @@ -1691,42 +1698,24 @@ def flush_notifications(self) -> None: def append_content(self, part: ContentPart) -> None: match part: - case ThinkPart(think=text) | TextPart(text=text): - is_think = isinstance(part, ThinkPart) - # Skip empty TextPart, but still create the block for empty - # ThinkPart so the "Thinking" indicator shows immediately - # (e.g. Anthropic/OpenAI block-start events yield think=""). - if not text and not is_think: + case ThinkPart(think=text, encrypted=encrypted, summary_index=summary_index): + is_think = True + case TextPart(text=text): + if not text: return - self._current_step_retry = None - if self._current_content_block is None: - self._current_content_block = _ContentBlock( - is_think, - show_thinking_stream=self._show_thinking_stream, - paced=self._stream_pacing, - ) - self.refresh_soon() - elif self._current_content_block.is_think != is_think: - transition = ( - FlushReason.TEXT_TO_THINK if is_think else FlushReason.THINK_TO_TEXT - ) - self.flush_content(transition) - self._current_content_block = _ContentBlock( - is_think, - show_thinking_stream=self._show_thinking_stream, - paced=self._stream_pacing, - ) - self.refresh_soon() - if text: - self._current_content_block.append(text) - self.refresh_soon() + is_think = False + summary_index = None + encrypted = None case ImageURLPart(): self._append_content_label("[image]") + return case AudioURLPart(audio_url=audio): suffix = f":{sanitize_ansi(audio.id)}" if audio.id else "" self._append_content_label(f"[audio{suffix}]") + return case VideoURLPart(): self._append_content_label("[video]") + return case _: part_type = part.type if part_type not in self._seen_unknown_content_part_types: @@ -1736,6 +1725,31 @@ def append_content(self, part: ContentPart) -> None: part_type=part_type, ) self._append_content_label(f"[{sanitize_ansi(part_type)}]", unknown=True) + return + + self._current_step_retry = None + if self._current_content_block is None: + self._current_content_block = _ContentBlock( + is_think, + show_thinking_stream=self._show_thinking_stream, + paced=self._stream_pacing, + ) + self.refresh_soon() + elif self._current_content_block.is_think != is_think: + self.flush_content(FlushReason.TEXT_TO_THINK if is_think else FlushReason.THINK_TO_TEXT) + self._current_content_block = _ContentBlock( + is_think, + show_thinking_stream=self._show_thinking_stream, + paced=self._stream_pacing, + ) + self.refresh_soon() + if text or encrypted: + self._current_content_block.append( + text, + summary_index=summary_index, + encrypted=encrypted, + ) + self.refresh_soon() def _append_content_label(self, label: str, *, unknown: bool = False) -> None: """Render a payload-free media or future-content placeholder.""" @@ -1974,7 +1988,7 @@ def _dispatch_subagent_event(self, event: SubagentEvent, *, recursive_depth: int block, parent_depth = ancestry if event.agent_id is not None and event.subagent_type is not None: - block.set_subagent_metadata(event.agent_id, event.subagent_type) + block.set_subagent_metadata(event.agent_id, event.subagent_type, event.description) match event.event: case SubagentEvent() as nested_event: @@ -1999,22 +2013,40 @@ def _dispatch_subagent_event(self, event: SubagentEvent, *, recursive_depth: int ) return self._subagent_tool_call_ancestry[tool_call.id] = (block, tool_depth) - block.append_sub_tool_call(tool_call) + if event.agent_id is not None: + self._subagent_tool_call_owners[tool_call.id] = event.agent_id + block.append_sub_tool_call(tool_call, agent_id=event.agent_id) self.refresh_soon() case ToolCallPart() as tool_call_part: - block.append_sub_tool_call_part(tool_call_part) + owner_agent_id = event.agent_id + if owner_agent_id is None and tool_call_part.stream_call_id is not None: + owner_agent_id = self._subagent_tool_call_owners.get( + tool_call_part.stream_call_id + ) + block.append_sub_tool_call_part(tool_call_part, agent_id=owner_agent_id) self.refresh_soon() case ToolResult() as tool_result: - block.finish_sub_tool_call(tool_result) + owner_agent_id = event.agent_id or self._subagent_tool_call_owners.get( + tool_result.tool_call_id + ) + block.finish_sub_tool_call(tool_result, agent_id=owner_agent_id) + self._subagent_tool_call_owners.pop(tool_result.tool_call_id, None) self.refresh_soon() case ToolExecutionStarted() as started: - block.mark_sub_execution_started(started.tool_call_id) + owner_agent_id = event.agent_id or self._subagent_tool_call_owners.get( + started.tool_call_id + ) + block.mark_sub_execution_started(started.tool_call_id, agent_id=owner_agent_id) self.refresh_soon() case ToolOutputPart() as output_part: + owner_agent_id = event.agent_id or self._subagent_tool_call_owners.get( + output_part.tool_call_id + ) block.append_sub_output_part( output_part.tool_call_id, output_part.text, stream=output_part.stream, + agent_id=owner_agent_id, ) self.refresh_soon() case _: diff --git a/src/pythinker_code/wire/types.py b/src/pythinker_code/wire/types.py index 385334b4..f3802cbb 100644 --- a/src/pythinker_code/wire/types.py +++ b/src/pythinker_code/wire/types.py @@ -367,6 +367,8 @@ class SubagentEvent(BaseModel): """The subagent instance ID.""" subagent_type: str | None = None """The built-in subagent type used by this instance.""" + description: str | None = None + """Short user-facing description of the subagent's assignment.""" event: Event """The event from the subagent.""" # TODO: maybe restrict the event types? to exclude approval request, etc. diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index abacb17a..043d645e 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -351,6 +351,7 @@ async def test_wire_message_serde(): parent_tool_call_id="call_parent_789", agent_id="a1234567", subagent_type="coder", + description="Read failed CI logs", event=StepBegin(n=2), ) assert serialize_wire_message(msg) == snapshot( @@ -360,6 +361,7 @@ async def test_wire_message_serde(): "parent_tool_call_id": "call_parent_789", "agent_id": "a1234567", "subagent_type": "coder", + "description": "Read failed CI logs", "event": {"type": "StepBegin", "payload": {"n": 2}}, }, } @@ -379,6 +381,7 @@ async def test_wire_message_serde(): assert legacy_msg.parent_tool_call_id == "call_parent_legacy" assert legacy_msg.agent_id is None assert legacy_msg.subagent_type is None + assert legacy_msg.description is None assert legacy_msg.event == StepBegin(n=3) with pytest.raises(ValueError): diff --git a/tests/e2e/test_shell_pty_e2e.py b/tests/e2e/test_shell_pty_e2e.py index 48b0e058..ae2289e3 100644 --- a/tests/e2e/test_shell_pty_e2e.py +++ b/tests/e2e/test_shell_pty_e2e.py @@ -925,7 +925,11 @@ def test_shell_clear_reloads_without_replaying_old_turns(tmp_path: Path) -> None def test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path) -> None: scripts = [ - build_shell_tool_call("tc-c1", "sleep 5 && printf should-not-exist > cancel_output.txt"), + build_shell_tool_call( + "tc-c1", + "printf '%s' \"$$\" > cancel_pgid.txt && sleep 30 && " + "printf should-not-exist > cancel_output.txt", + ), "text: Cancel recovery completed.", ] config_path = write_scripted_config(tmp_path, scripts) @@ -944,7 +948,14 @@ def test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path) cancel_mark = shell.mark() shell.send_line("start cancellable command") - shell.read_until_contains("Bash(sleep 5", after=cancel_mark) + pgid_path = work_dir / "cancel_pgid.txt" + started_deadline = time.monotonic() + 10.0 + while not pgid_path.exists(): + if time.monotonic() >= started_deadline: + raise AssertionError("Timed out waiting for cancellable command to start.") + shell.read_available(timeout=0.05) + command_pgid = int(pgid_path.read_text(encoding="utf-8")) + shell.read_until_contains("esc)", after=cancel_mark, timeout=10.0) shell.send_key("escape") # The "Interrupted by user" acknowledgement only prints after the soul # re-raises the cancellation, which first awaits a shielded, disk-first @@ -957,7 +968,8 @@ def test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path) cancel_prompt_mark = shell.mark() _read_until_prompt(shell, after=cancel_prompt_mark) - time.sleep(5.3) + with pytest.raises(ProcessLookupError): + os.killpg(command_pgid, 0) assert not (work_dir / "cancel_output.txt").exists() recovery_mark = shell.mark() diff --git a/tests/e2e/test_shell_pty_prompt_layout_e2e.py b/tests/e2e/test_shell_pty_prompt_layout_e2e.py index 1bfaf21e..1225d5a4 100644 --- a/tests/e2e/test_shell_pty_prompt_layout_e2e.py +++ b/tests/e2e/test_shell_pty_prompt_layout_e2e.py @@ -22,14 +22,20 @@ import json import os +import pty import re +import subprocess import sys import time from pathlib import Path +from textwrap import dedent import pytest +from pythinker_code.ui.shell.components.render_utils import cell_width from tests.e2e.shell_pty_helpers import ( + ShellPTYProcess, + _preexec_for_tty, _set_window_size, list_turn_begin_inputs, make_home_dir, @@ -76,10 +82,10 @@ def _is_input_card_border(row: str) -> bool: return "─" in row and bool(_INPUT_CARD_EFFORT_LABEL.search(row)) -def _has_fossil_border_above_content(rows: list[str]) -> bool: +def _has_fossil_border_above_content(rows: list[str], *, prompt_text: str = _PROMPT_TEXT) -> bool: """True if an input-card border sits between the echoed prompt and the first committed ``⏺`` content row — i.e. a fossilized ghost card above the stream.""" - echo_i = next((i for i, r in enumerate(rows) if _PROMPT_TEXT in r), None) + echo_i = next((i for i, r in enumerate(rows) if prompt_text in r), None) content_i = next((i for i, r in enumerate(rows) if r.strip().startswith("⏺")), None) if echo_i is None or content_i is None or content_i <= echo_i: return False @@ -110,6 +116,20 @@ def _queued_text_fossilized_as_card(rows: list[str], text: str) -> bool: return False +def test_has_fossil_border_above_content_uses_supplied_prompt_text() -> None: + resize_prompt = "resize prompt sentinel 9d2f" + rows = [ + "header", + f"echo: {resize_prompt}", + "──────── ● off", + "⏺ committed output", + f"echo: {_PROMPT_TEXT}", + ] + + assert _has_fossil_border_above_content(rows) is False + assert _has_fossil_border_above_content(rows, prompt_text=resize_prompt) is True + + def test_focus_tui_hides_files_and_never_fossilizes_prompt(tmp_path: Path) -> None: write = { "id": "w1", @@ -229,16 +249,377 @@ def _render_sized(chunks: list[bytes], columns: int, rows: int) -> list[str]: return [line.rstrip() for line in screen.display] -def test_prompt_scene_survives_shrinking_terminal_heights(tmp_path: Path) -> None: - """Mid-turn resizes down to tiny heights never crash or fossilize the card. +class _ContinuousScreen: + """Replay one PTY byte stream into one virtual terminal across resizes.""" + + def __init__(self, *, columns: int, rows: int) -> None: + self._screen = pyte.Screen(columns, rows) + self._stream = pyte.ByteStream(self._screen) + self._fed_chunks = 0 + + def resize(self, *, columns: int, rows: int) -> None: + self._screen.resize(lines=rows, columns=columns) + + def feed(self, chunks: list[bytes]) -> list[str]: + payload = b"".join(chunks[self._fed_chunks :]) + self._fed_chunks = len(chunks) + if payload: + self._stream.feed(payload) + return self.rows() + + def rows(self) -> list[str]: + return [line.rstrip() for line in self._screen.display] + + +_RUN_AGENTS_PTY_SCRIPT = dedent( + r""" + import json + import os + + os.environ["PYTHINKER_REDUCED_MOTION"] = "1" + os.environ["PYTHINKER_TUI_STYLE"] = "card" + + from rich.console import Group + from pythinker_core.tooling import ToolOk + from pythinker_code.ui.shell.console import console + from pythinker_code.ui.shell.tool_renderers import clear_tool_renderers, register_builtin_renderers + from pythinker_code.ui.shell.visualize import _LiveView + from pythinker_code.wire.types import ( + StatusUpdate, + SubagentEvent, + ToolCall, + ToolExecutionStarted, + ToolOutputPart, + ToolResult, + TurnBegin, + ) + + clear_tool_renderers() + register_builtin_renderers() + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="run agents pty smoke")) + view.dispatch_wire_message( + ToolCall( + id="run-agents-root", + function=ToolCall.FunctionBody( + name="RunAgents", + arguments=json.dumps( + { + "summary": "Parallel UI smoke", + "run_in_background": False, + "agents": [ + { + "title": "Map renderer callbacks", + "name": "mapper", + "subagent_type": "explore", + "prompt": "SECRET_PROMPT_CANARY should never render", + }, + { + "title": "Read activity tree", + "name": "reader", + "subagent_type": "review", + "prompt": "SECRET_PROMPT_CANARY should never render either", + }, + ], + } + ), + ), + ) + ) + view.dispatch_wire_message(ToolExecutionStarted(tool_call_id="run-agents-root")) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-root", + agent_id="agent-alpha-raw-id", + subagent_type="explore", + description="Map renderer callbacks", + event=ToolCall( + id="sub-alpha-raw-id", + function=ToolCall.FunctionBody( + name="Grep", + arguments='{"pattern":"SECRET_PROMPT_CANARY","path":"/tmp/raw/path.py"}', + ), + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-root", + agent_id="agent-alpha-raw-id", + subagent_type="explore", + description="Map renderer callbacks", + event=ToolExecutionStarted(tool_call_id="sub-alpha-raw-id"), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-root", + agent_id="agent-alpha-raw-id", + subagent_type="explore", + description="Map renderer callbacks", + event=ToolOutputPart( + tool_call_id="sub-alpha-raw-id", + text="raw command output must stay hidden", + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-root", + agent_id="agent-beta-raw-id", + subagent_type="review", + description="Read activity tree", + event=ToolCall( + id="sub-beta-raw-id", + function=ToolCall.FunctionBody( + name="Read", + arguments='{"file_path":"/tmp/secret-renderer.py"}', + ), + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-root", + agent_id="agent-beta-raw-id", + subagent_type="review", + description="Read activity tree", + event=ToolResult(tool_call_id="sub-beta-raw-id", return_value=ToolOk(output="done")), + ) + ) - Resizes the live PTY through heights 12 → 8 → 6 → 4 while a slow tool keeps - the running prompt on screen. prompt_toolkit fully redraws on SIGWINCH, so - each post-resize frame is rendered from only the bytes emitted after that - resize, on a pyte screen of the new geometry. After restoring the original - size, the turn must still complete and the idle input card must return. + console.print("PYTHINKER_PTY_RUN_AGENTS_BEGIN") + console.print(Group(*view.compose_agent_output(include_working_indicator=False))) + console.print("PYTHINKER_PTY_RUN_AGENTS_END") """ - slow = {"id": "r1", "name": "Shell", "arguments": json.dumps({"command": "sleep 6"})} +) + + +_SINGLE_AGENT_JUDGE_PTY_SCRIPT = dedent( + r""" + import json + import os + + os.environ["PYTHINKER_REDUCED_MOTION"] = "1" + os.environ["PYTHINKER_TUI_STYLE"] = "card" + + from rich.console import Group + from pythinker_core.tooling import ToolOk + from pythinker_code.ui.shell.console import console + from pythinker_code.ui.shell.tool_renderers import clear_tool_renderers, register_builtin_renderers + from pythinker_code.ui.shell.visualize import _LiveView + from pythinker_code.wire.types import ( + StatusUpdate, + SubagentEvent, + ToolCall, + ToolExecutionStarted, + ToolOutputPart, + ToolResult, + TurnBegin, + ) + + clear_tool_renderers() + register_builtin_renderers() + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="single judge pty smoke")) + view.dispatch_wire_message( + ToolCall( + id="judge-root", + function=ToolCall.FunctionBody( + name="Agent", + arguments=json.dumps( + { + "description": "Judge branch review report", + "subagent_type": "judge", + "prompt": "SECRET_PROMPT_CANARY should never render", + } + ), + ), + ) + ) + view.dispatch_wire_message(ToolExecutionStarted(tool_call_id="judge-root")) + for sub_id, tool_name, args in ( + ("sub-read-raw-id", "Read", {"file_path": "/tmp/secret-renderer.py"}), + ("sub-search-raw-id", "Search", {"query": "SECRET_PROMPT_CANARY", "path": "/tmp/raw/path.py"}), + ("sub-shell-raw-id", "Shell", {"command": "git status --short"}), + ): + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-root", + agent_id="agent-judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolCall( + id=sub_id, + function=ToolCall.FunctionBody(name=tool_name, arguments=json.dumps(args)), + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-root", + agent_id="agent-judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolExecutionStarted(tool_call_id=sub_id), + ) + ) + if tool_name != "Shell": + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-root", + agent_id="agent-judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolResult(tool_call_id=sub_id, return_value=ToolOk(output="hidden")), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-root", + agent_id="agent-judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolOutputPart(tool_call_id="sub-shell-raw-id", text="raw command output must stay hidden"), + ) + ) + + console.print("PYTHINKER_PTY_SINGLE_AGENT_BEGIN") + console.print(Group(*view.compose_agent_output(include_working_indicator=False))) + console.print("PYTHINKER_PTY_SINGLE_AGENT_END") + """ +) + + +def _run_python_pty(script: str, *, columns: int, rows: int) -> ShellPTYProcess: + master_fd, slave_fd = pty.openpty() + _set_window_size(master_fd, columns=columns, lines=rows) + _set_window_size(slave_fd, columns=columns, lines=rows) + os.set_blocking(master_fd, False) + env = os.environ.copy() + env["COLUMNS"] = str(columns) + env["LINES"] = str(rows) + env["TERM"] = "xterm-256color" + env["PYTHONUTF8"] = "1" + process = subprocess.Popen( + [sys.executable, "-c", script], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + env=env, + preexec_fn=_preexec_for_tty(slave_fd), + close_fds=True, + ) + os.close(slave_fd) + return ShellPTYProcess(process=process, master_fd=master_fd) + + +def _assert_run_agents_pty_tree(*, columns: int, rows: int) -> str: + shell = _run_python_pty(_RUN_AGENTS_PTY_SCRIPT, columns=columns, rows=rows) + try: + assert shell.wait(timeout=10.0) == 0 + normalized = shell.normalized_text() + rendered_rows = _render_sized(shell._raw_chunks, columns, rows) + rendered = "\n".join(rendered_rows) + assert "PYTHINKER_PTY_RUN_AGENTS_BEGIN" in normalized + assert normalized.count("Agents") == 1 + assert "├─" in normalized + assert "└─" in normalized + assert "searching…" in normalized + assert "reading…" in normalized or "thinking…" in normalized + assert "Map renderer callbacks" in normalized + assert "Read activity tree" in normalized + for leaked in ( + "RunAgents(", + "Grep(", + "Read(", + '"pattern"', + "file_path", + "SECRET_PROMPT_CANARY", + "/tmp/raw/path.py", + "/tmp/secret-renderer.py", + "sub-alpha-raw-id", + "agent-alpha-raw-id", + "sub-beta-raw-id", + "agent-beta-raw-id", + "raw command output must stay hidden", + ): + assert leaked not in normalized + assert all(cell_width(row) <= columns for row in rendered_rows) + assert "Agents" in rendered + return normalized + finally: + shell.close() + + +def _assert_single_agent_judge_pty_tree(*, columns: int, rows: int) -> str: + shell = _run_python_pty(_SINGLE_AGENT_JUDGE_PTY_SCRIPT, columns=columns, rows=rows) + try: + assert shell.wait(timeout=10.0) == 0 + normalized = shell.normalized_text() + rendered_rows = _render_sized(shell._raw_chunks, columns, rows) + rendered = "\n".join(rendered_rows) + assert "PYTHINKER_PTY_SINGLE_AGENT_BEGIN" in normalized + assert normalized.count("Agent(") == 1 + assert "Judge branch review report" in normalized + assert "running command…" in normalized + assert "└─" in normalized + assert "⎿" in normalized + for leaked in ( + "agent Read", + "agent Search", + "agent Shell", + "Read(", + "Search(", + "Shell(", + '"query"', + "file_path", + "SECRET_PROMPT_CANARY", + "/tmp/raw/path.py", + "/tmp/secret-renderer.py", + "git status --short", + "sub-read-raw-id", + "sub-search-raw-id", + "sub-shell-raw-id", + "agent-judge-raw-id", + "raw command output must stay hidden", + ): + assert leaked not in normalized + assert all(cell_width(row) <= columns for row in rendered_rows) + assert "Agent(" in rendered + return normalized + finally: + shell.close() + + +def test_single_agent_judge_tree_renders_through_real_pty_at_narrow_and_normal_widths() -> None: + narrow = _assert_single_agent_judge_pty_tree(columns=64, rows=24) + normal = _assert_single_agent_judge_pty_tree(columns=_COLS, rows=24) + + assert "Judge branch review report" in narrow + assert "Judge branch review report" in normal + + +def test_run_agents_tree_renders_through_real_pty_at_narrow_and_normal_widths() -> None: + narrow = _assert_run_agents_pty_tree(columns=64, rows=24) + normal = _assert_run_agents_pty_tree(columns=_COLS, rows=24) + + assert "Map renderer callbacks" in narrow + assert "Read activity tree" in narrow + assert "Map renderer callbacks" in normal + assert "Read activity tree" in normal + + +def test_prompt_scene_survives_resize_away_and_back_continuously(tmp_path: Path) -> None: + """Mid-turn resizes never crash or fossilize prompt rows. + + The oracle replays the complete PTY byte stream into one pyte screen and + resizes that same virtual screen with the real PTY. This intentionally does + not use post-resize-only bytes: fossilized prompt/sentinel rows are stale + state, so the detector must preserve pre-resize screen history. + """ + resize_prompt = "resize prompt sentinel 9d2f" + slow = {"id": "r1", "name": "Shell", "arguments": json.dumps({"command": "sleep 3"})} config_path = write_scripted_config( tmp_path, [f"tool_call: {json.dumps(slow)}", "text: Resize turn finished."], @@ -254,32 +635,37 @@ def test_prompt_scene_survives_shrinking_terminal_heights(tmp_path: Path) -> Non columns=_COLS, lines=_ROWS, ) + continuous = _ContinuousScreen(columns=_COLS, rows=_ROWS) + + def assert_no_fossils(rows: list[str], *, columns: int) -> None: + joined = "\n".join(rows) + assert all(cell_width(row) <= columns for row in rows) + assert joined.count(resize_prompt) <= 1, "submitted prompt duplicated on screen" + assert sum(1 for row in rows if _is_input_card_border(row)) <= 1 + assert not _has_fossil_border_above_content(rows, prompt_text=resize_prompt) + try: shell.read_until_contains("think first, then code") read_until_prompt_ready(shell, after=shell.mark()) - shell.send_line(_PROMPT_TEXT) - shell.read_until_contains("Bash(sleep 6", timeout=15.0) - - for height in (12, 8, 6, 4): - resize_chunk_start = len(shell._raw_chunks) - _set_window_size(shell.master_fd, columns=_COLS, lines=height) - deadline = time.monotonic() + 2.5 + assert_no_fossils(continuous.feed(shell._raw_chunks), columns=_COLS) + shell.send_line(resize_prompt) + shell.read_until_contains("Bash(sleep 3", timeout=15.0) + assert_no_fossils(continuous.feed(shell._raw_chunks), columns=_COLS) + + for columns, height in ((90, 12), (72, 8), (54, 6), (_COLS, 4), (_COLS, _ROWS)): + continuous.resize(columns=columns, rows=height) + _set_window_size(shell.master_fd, columns=columns, lines=height) + deadline = time.monotonic() + 1.0 while time.monotonic() < deadline: shell.read_available(timeout=0.08) - assert shell.process.poll() is None, f"shell died after resize to {height} rows" - post_resize = shell._raw_chunks[resize_chunk_start:] - if post_resize: - # pyte always yields exactly `height` lines, so assert observable - # behavior instead: the redraw never wraps a row past the terminal - # width and never fossilizes an input-card border above content. - rows = _render_sized(post_resize, _COLS, height) - assert all(len(row) <= _COLS for row in rows) - assert not _has_fossil_border_above_content(rows) - - _set_window_size(shell.master_fd, columns=_COLS, lines=_ROWS) + assert_no_fossils(continuous.feed(shell._raw_chunks), columns=columns) + assert shell.process.poll() is None, f"shell died after resize to {columns}x{height}" + shell.read_until_contains("Resize turn finished.", timeout=20.0) shell.wait_for_quiet(timeout=6.0, quiet_period=0.3) - assert any(_is_input_card_border(r) for r in _render(shell._raw_chunks)), ( + rows = continuous.feed(shell._raw_chunks) + assert_no_fossils(rows, columns=_COLS) + assert any(_is_input_card_border(r) for r in rows), ( "idle input-card border did not return after the resize sequence" ) finally: diff --git a/tests/ui_and_conv/test_empty_think_part_indicator.py b/tests/ui_and_conv/test_empty_think_part_indicator.py index 3739f565..35bd934a 100644 --- a/tests/ui_and_conv/test_empty_think_part_indicator.py +++ b/tests/ui_and_conv/test_empty_think_part_indicator.py @@ -386,7 +386,7 @@ def test_action_spacer_between_run_agents_and_task_output(monkeypatch): assert len(view._tool_call_blocks) == 2 agent_blocks = view.compose_agent_output(include_working_indicator=False) rendered = _render(Group(*agent_blocks)) - assert "RunAgents(" in rendered + assert "Agents(" in rendered assert "TaskOutput(" in rendered spacer_indices = [ diff --git a/tests/ui_and_conv/test_live_content_parts.py b/tests/ui_and_conv/test_live_content_parts.py index 1fa409a0..ae6cb8de 100644 --- a/tests/ui_and_conv/test_live_content_parts.py +++ b/tests/ui_and_conv/test_live_content_parts.py @@ -5,11 +5,14 @@ from collections.abc import Iterable import pytest -from pythinker_core.message import ContentPart +from pythinker_core.message import ContentPart, ThinkPart from rich.console import Console, Group, RenderableType +from rich.style import Style +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER from pythinker_code.ui.shell.visualize import _live_view as live_view_module from pythinker_code.ui.shell.visualize import _LiveView +from pythinker_code.ui.theme import tui_rich_style from pythinker_code.wire.types import ( AudioURLPart, ImageURLPart, @@ -47,6 +50,66 @@ def _capture_scrollback( return emitted +def _segment_styles_for_text(renderable: RenderableType, text: str) -> list[Style]: + console = Console(record=True, width=100, color_system=None) + styles: list[Style] = [] + for segment in console.render(renderable): + if segment.control is not None or text not in segment.text: + continue + segment_style = segment.style + if isinstance(segment_style, str): + styles.append(Style.parse(segment_style)) + elif segment_style is not None: + styles.append(segment_style) + if not styles: + raise AssertionError(f"Text {text!r} not found in rendered segments") + return styles + + +def test_live_view_dispatch_preserves_reasoning_summary_boundaries_and_style( + monkeypatch: pytest.MonkeyPatch, +) -> None: + emitted = _capture_scrollback(monkeypatch) + view = _LiveView(StatusUpdate(context_tokens=1000), show_thinking_stream=True) + + view.dispatch_wire_message(ThinkPart(think="**Planning summary**", summary_index=0)) + view.dispatch_wire_message(ThinkPart(think="**Checking summary**", summary_index=1)) + view.flush_content() + + assert len(emitted) == 1 + output = _render(emitted) + lines = [line for line in output.splitlines() if line.strip()] + assert output.count(TRANSCRIPT_ASSISTANT_MARKER) == 1 + assert len(lines) == 2 + assert "Planning summary" in lines[0] + assert "Checking summary" in lines[1] + assert "**" not in output + + thinking_style = tui_rich_style("thinking_text") + for text in ("Planning summary", "Checking summary"): + styles = _segment_styles_for_text(emitted[0], text) + assert all(style.color == thinking_style.color for style in styles) + assert all(style.italic for style in styles) + + +def test_live_view_preserves_encrypted_only_reasoning_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + emitted = _capture_scrollback(monkeypatch) + view = _LiveView(StatusUpdate(context_tokens=1000), show_thinking_stream=True) + + view.dispatch_wire_message(ThinkPart(think="**Planning**", summary_index=0)) + view.dispatch_wire_message(ThinkPart(think="", encrypted="signature", summary_index=0)) + view.dispatch_wire_message(ThinkPart(think="**Executing**", summary_index=0)) + view.flush_content() + + output = _render(emitted) + lines = [line for line in output.splitlines() if line.strip()] + assert len(lines) == 2 + assert "Planning" in lines[0] + assert "Executing" in lines[1] + + def test_text_media_text_flushes_at_stable_boundaries( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/ui_and_conv/test_live_view_notifications.py b/tests/ui_and_conv/test_live_view_notifications.py index 17d7e010..3593dab8 100644 --- a/tests/ui_and_conv/test_live_view_notifications.py +++ b/tests/ui_and_conv/test_live_view_notifications.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio + from pythinker_core.message import ToolCall from pythinker_core.tooling import ToolResult, ToolReturnValue from rich.console import Console, Group @@ -158,6 +160,15 @@ def test_working_indicator_uses_turn_elapsed_time(monkeypatch): assert "4h" not in rendered +def test_working_indicator_exposes_escape_when_turn_is_cancellable(): + view = _LiveView(StatusUpdate(), asyncio.Event()) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + + rendered = _render(view._working_indicator()) + + assert "esc)" in rendered + + def test_working_indicator_uses_rotating_thinking_words(monkeypatch): now = 90.0 monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now) diff --git a/tests/ui_and_conv/test_nested_subagent_events.py b/tests/ui_and_conv/test_nested_subagent_events.py index 00923e60..0d9d2947 100644 --- a/tests/ui_and_conv/test_nested_subagent_events.py +++ b/tests/ui_and_conv/test_nested_subagent_events.py @@ -109,9 +109,12 @@ def test_nested_tool_lifecycle_rolls_up_under_root_and_first_result_wins( indexed_block, indexed_depth = view._subagent_tool_call_ancestry[call_id] assert indexed_block is root_block assert indexed_depth == expected_depth + # Nested activity rolls up under the root Agent as payload-free semantic rows; + # the leaf's raw output and args never leak into the rendered tree. assert leaf_id in root_block._subagent_execution_started - assert "STREAMED_NESTED_OUTPUT" in _render([view.compose()]) - assert "src/module.py" in _render([view.compose()]) + assert "reading…" in _render([view.compose()]) + assert "STREAMED_NESTED_OUTPUT" not in _render([view.compose()]) + assert "src/module.py" not in _render([view.compose()]) view.dispatch_wire_message( _nested_event( @@ -135,13 +138,15 @@ def test_nested_tool_lifecycle_rolls_up_under_root_and_first_result_wins( ) ) - finished = [ - item for item in root_block._finished_subagent_tool_calls if item.call.id == leaf_id - ] - assert len(finished) == 1 - assert finished[0].result.output == "FIRST_RESULT" - assert root_block._n_finished_subagent_tool_calls == 1 - assert leaf_id not in root_block._subagent_execution_started + # First-result-wins: the leaf finish is recorded once and the late duplicate + # result/output is ignored; neither raw payload is ever surfaced or buffered. + assert leaf_id in root_block._finished_subagent_tool_call_ids + assert root_block._n_finished_subagent_tool_calls == 0 + assert not root_block._finished_subagent_tool_calls + rendered_after_late = _render([view.compose()]) + assert "FIRST_RESULT" not in rendered_after_late + assert "LATE_OUTPUT" not in rendered_after_late + assert "LATE_RESULT" not in rendered_after_late assert "LATE_OUTPUT" not in root_block._subagent_output_parts diff --git a/tests/ui_and_conv/test_render_matrix.py b/tests/ui_and_conv/test_render_matrix.py index 31509277..d00ef496 100644 --- a/tests/ui_and_conv/test_render_matrix.py +++ b/tests/ui_and_conv/test_render_matrix.py @@ -6,12 +6,30 @@ from __future__ import annotations +from collections.abc import Generator + import pytest +from pythinker_core.message import ToolCall from rich.console import Console from pythinker_code.ui.shell.components import render_diff from pythinker_code.ui.shell.components.markdown import pythinker_markdown +from pythinker_code.ui.shell.components.render_utils import cell_width from pythinker_code.ui.shell.motion import ActivitySnapshot, activity_status_line +from pythinker_code.ui.shell.tool_renderers import ( + clear_tool_renderers, + register_builtin_renderers, +) +from pythinker_code.ui.shell.visualize import _LiveView +from pythinker_code.wire.types import ( + StatusUpdate, + SubagentEvent, + ToolExecutionStarted, + TurnBegin, +) +from pythinker_code.wire.types import ( + ToolCall as WireToolCall, +) WIDTHS = [40, 80, 120] _DIFF = " 10 import time\n+ 11 import asyncio\n- 13 old = 1\n+ 13 new = 2" @@ -24,6 +42,54 @@ def _render(renderable, *, width: int, no_color: bool) -> str: return console.export_text() +@pytest.fixture +def _run_agents_renderer_registry() -> Generator[None, None, None]: + clear_tool_renderers() + register_builtin_renderers() + yield + clear_tool_renderers() + + +def _render_run_agents_live(*, width: int, no_color: bool) -> str: + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message( + WireToolCall( + id="run-agents-1", + function=WireToolCall.FunctionBody( + name="RunAgents", + arguments=( + '{"summary":"scan","agents":[' + '{"title":"Find TODO comments","subagent_type":"explore","prompt":"grep secret"}' + "]}" + ), + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="agent-1", + subagent_type="explore", + description="Find TODO comments", + event=ToolCall( + id="nested-1", + function=ToolCall.FunctionBody(name="Grep", arguments='{"pattern":"TODO"}'), + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="agent-1", + subagent_type="explore", + description="Find TODO comments", + event=ToolExecutionStarted(tool_call_id="nested-1"), + ) + ) + return _render(view.compose(), width=width, no_color=no_color) + + @pytest.mark.parametrize("width", WIDTHS) @pytest.mark.parametrize("no_color", [False, True]) def test_inline_diff_renders_boxless_across_configs(width: int, no_color: bool) -> None: @@ -79,3 +145,44 @@ def test_activity_line_full_motion_uses_braille_frame(width: int) -> None: out = _render(activity_status_line(snap, width=width), width=width, no_color=True) assert "●" not in out assert any(frame in out for frame in "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + + +@pytest.mark.parametrize("width", WIDTHS) +@pytest.mark.parametrize("no_color", [False, True]) +def test_run_agents_activity_tree_renders_across_configs( + _run_agents_renderer_registry: None, width: int, no_color: bool +) -> None: + out = _render_run_agents_live(width=width, no_color=no_color) + assert out.count("Agents") == 1 + assert "RunAgents(" not in out + assert "Explore" in out + assert out.count("Find TODO comments") == 1 + assert "running" in out + assert "searching…" in out + assert "├─" in out or "└─" in out + assert "⎿" in out + assert "agent searching" not in out + assert "grep secret" not in out + for line in out.splitlines(): + assert cell_width(line) <= width + + +@pytest.mark.parametrize("width", WIDTHS) +def test_run_agents_reduced_motion_uses_static_running_glyph( + _run_agents_renderer_registry: None, monkeypatch: pytest.MonkeyPatch, width: int +) -> None: + monkeypatch.setenv("PYTHINKER_REDUCED_MOTION", "1") + out = _render_run_agents_live(width=width, no_color=True) + assert "●" in out + assert "running" in out + assert not any(frame in out for frame in "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_run_agents_full_motion_uses_animated_running_glyph( + _run_agents_renderer_registry: None, monkeypatch: pytest.MonkeyPatch, width: int +) -> None: + monkeypatch.delenv("PYTHINKER_REDUCED_MOTION", raising=False) + out = _render_run_agents_live(width=width, no_color=True) + assert "running" in out + assert any(frame in out for frame in "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 85e9e1df..6e388449 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -8,6 +8,7 @@ from rich.style import Style from rich.text import Text +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER from pythinker_code.ui.shell.visualize import ( _ContentBlock, _estimate_tokens, @@ -450,6 +451,22 @@ def _style_for(renderable: Text, text: str) -> Style: return Style.parse(span.style) if isinstance(span.style, str) else span.style +def _segment_styles_for_text(renderable: RenderableType, text: str) -> list[Style]: + console = Console(record=True, width=120, color_system=None) + styles: list[Style] = [] + for segment in console.render(renderable): + if segment.control is not None or text not in segment.text: + continue + segment_style = segment.style + if isinstance(segment_style, str): + styles.append(Style.parse(segment_style)) + elif segment_style is not None: + styles.append(segment_style) + if not styles: + raise AssertionError(f"Text {text!r} not found in rendered segments") + return styles + + def test_composing_and_thinking_labels_are_neutral_grey(): # Both Composing and Thinking read as neutral thinking grey, never the # bright activity-label white or purple-tinted muted color. @@ -740,6 +757,70 @@ def test_stream_mode_compose_final_returns_markdown_bullet(self): result = block.compose_final() assert isinstance(result, BulletColumns) + def test_stream_mode_compose_final_renders_same_index_on_one_markdown_line(self): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Planning", summary_index=0) + block.append(" the implementation**", summary_index=0) + + console = Console(record=True, width=120, color_system=None) + console.print(block.compose_final()) + output = console.export_text() + + assert "**" not in output + assert output.count(TRANSCRIPT_ASSISTANT_MARKER) == 1 + assert "Planning the implementation" in output.splitlines()[0] + + def test_stream_mode_compose_final_preserves_out_of_order_summary_segments(self): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Planning the implementation**", summary_index=2) + block.append("**Evaluating terminal edge cases**", summary_index=1) + block.append("**Finalizing markdown rendering**", summary_index=2) + + console = Console(record=True, width=120, color_system=None) + console.print(block.compose_final()) + lines = [line for line in console.export_text().splitlines() if line.strip()] + + assert len(lines) == 3 + assert "**" not in "\n".join(lines) + assert lines[0].startswith(f"{TRANSCRIPT_ASSISTANT_MARKER} Planning the implementation") + assert any("Evaluating terminal edge cases" in line for line in lines[1:]) + assert any("Finalizing markdown rendering" in line for line in lines[1:]) + + def test_stream_mode_compose_final_unindexed_chunks_still_concatenate(self): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Legacy reasoning", summary_index=None) + block.append(" stays grouped**", summary_index=None) + + console = Console(record=True, width=120, color_system=None) + console.print(block.compose_final()) + lines = [line for line in console.export_text().splitlines() if line.strip()] + + assert lines == [f"{TRANSCRIPT_ASSISTANT_MARKER} Legacy reasoning stays grouped"] + + def test_stream_mode_compose_final_applies_muted_italic_thinking_style_to_segments(self): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Planning the implementation**", summary_index=0) + block.append("**Evaluating terminal edge cases**", summary_index=1) + renderable = block.compose_final() + + thinking_style = tui_rich_style("thinking_text") + for text in ("Planning the implementation", "Evaluating terminal edge cases"): + styles = _segment_styles_for_text(renderable, text) + assert all(style.color == thinking_style.color for style in styles) + assert all(style.italic for style in styles) + + def test_stream_mode_compose_final_drops_empty_summary_boundaries(self): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("", summary_index=0) + block.append("**Only non-empty summary**", summary_index=1) + block.append("", summary_index=2) + + console = Console(record=True, width=120, color_system=None) + console.print(block.compose_final()) + lines = [line for line in console.export_text().splitlines() if line.strip()] + + assert lines == [f"{TRANSCRIPT_ASSISTANT_MARKER} Only non-empty summary"] + def test_stream_mode_compose_final_empty_returns_empty_text(self): from rich.text import Text diff --git a/tests/ui_and_conv/test_subagent_live_stream.py b/tests/ui_and_conv/test_subagent_live_stream.py index d626d6b7..b3fe5f73 100644 --- a/tests/ui_and_conv/test_subagent_live_stream.py +++ b/tests/ui_and_conv/test_subagent_live_stream.py @@ -2,11 +2,18 @@ from __future__ import annotations +from collections.abc import Generator + import pytest from pythinker_core.message import ToolCall from pythinker_core.tooling import ToolOk from rich.console import Console, RenderableType +from pythinker_code.ui.shell.components.render_utils import cell_width +from pythinker_code.ui.shell.tool_renderers import ( + clear_tool_renderers, + register_builtin_renderers, +) from pythinker_code.ui.shell.visualize import _LiveView from pythinker_code.wire.types import ( StatusUpdate, @@ -22,6 +29,14 @@ ) +@pytest.fixture(autouse=True) +def _isolated_tool_renderer_registry() -> Generator[None, None, None]: + clear_tool_renderers() + register_builtin_renderers() + yield + clear_tool_renderers() + + def _render(view: _LiveView, *, width: int = 100) -> str: console = Console(width=width, record=True, highlight=False, color_system=None) console.print(view.compose()) @@ -38,6 +53,39 @@ def _agent_call(call_id: str = "agent-1") -> WireToolCall: ) +def _judge_agent_call(call_id: str = "judge-agent-1") -> WireToolCall: + return WireToolCall( + id=call_id, + function=WireToolCall.FunctionBody( + name="Agent", + arguments=( + '{"description":"Judge branch review report",' + '"subagent_type":"judge",' + '"prompt":"Review this branch and do not leak this prompt"}' + ), + ), + ) + + +def _run_agents_call(call_id: str = "run-agents-1") -> WireToolCall: + return WireToolCall( + id=call_id, + function=WireToolCall.FunctionBody( + name="RunAgents", + arguments=( + '{"summary":"Audit TODOs","run_in_background":false,"agents":[' + '{"title":"Find TODO comments","name":"todo_scan","subagent_type":"explore",' + '"prompt":"grep -r TODO /repo/src/secret.py"},' + '{"title":"Count files","name":"file_count","subagent_type":"explore",' + '"prompt":"read /repo/src/private.py"},' + '{"title":"Queued worker","name":"queued","subagent_type":"explore",' + '"prompt":"do not leak this prompt"}' + "]}" + ), + ), + ) + + def _sub_tool_call(sub_id: str, name: str, args: str) -> ToolCall: return ToolCall( id=sub_id, @@ -45,7 +93,7 @@ def _sub_tool_call(sub_id: str, name: str, args: str) -> ToolCall: ) -def test_subagent_tool_output_part_appears_in_live_view(): +def test_subagent_tool_output_part_updates_single_agent_semantic_activity_without_payload(): view = _LiveView(StatusUpdate(context_tokens=1000)) view.dispatch_wire_message(TurnBegin(user_input="scan")) view.dispatch_wire_message(_agent_call()) @@ -69,7 +117,9 @@ def test_subagent_tool_output_part_appears_in_live_view(): ) output = _render(view) - assert "src/app.py:42" in output + assert "running command…" in output + assert "src/app.py:42" not in output + assert "grep -r TODO" not in output def test_subagent_tool_execution_started_tracked(): @@ -125,7 +175,9 @@ def test_subagent_tool_call_and_args_request_live_refresh(): ) ) assert view._need_recompose is True - assert "src/app.py" in _render(view) + output = _render(view) + assert "reading…" in output + assert "src/app.py" not in output def test_output_part_for_unknown_parent_renders_fallback_without_payload( @@ -194,3 +246,636 @@ def test_output_cleared_after_sub_tool_call_finishes(): output = _render(view) assert "SHOULD_DISAPPEAR" not in output + + +def test_single_judge_agent_uses_stable_semantic_activity_row_without_raw_nested_tools(): + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="review")) + view.dispatch_wire_message(_judge_agent_call()) + + for sub_id, tool_name, args in ( + ("sub-read-raw-id", "Read", '{"file_path":"/repo/src/secret.py"}'), + ("sub-search-raw-id", "Search", '{"query":"private needle","path":"/repo"}'), + ("sub-shell-raw-id", "Shell", '{"command":"git status --short"}'), + ): + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=_sub_tool_call(sub_id, tool_name, args), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolExecutionStarted(tool_call_id=sub_id), + ) + ) + if tool_name != "Shell": + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolResult(tool_call_id=sub_id, return_value=ToolOk(output="hidden")), + ) + ) + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolOutputPart(tool_call_id="sub-shell-raw-id", text="M src/secret.py\n"), + ) + ) + + output = _render(view, width=100) + assert output.count("Agent(") == 1 + assert output.count("● Agent") == 0 + assert output.count("Judge branch review report") >= 1 + assert "running command…" in output + assert "reading…" not in output + assert "searching…" not in output + assert "└─" in output + assert "│ ⎿" in output or " ⎿" in output + for leaked in ( + "agent Read", + "agent Search", + "agent Shell", + "/repo/src/secret.py", + "private needle", + "git status --short", + "M src/secret.py", + "do not leak this prompt", + "judge-raw-id", + "sub-read-raw-id", + "sub-search-raw-id", + "sub-shell-raw-id", + ): + assert leaked not in output + + +def test_single_agent_activity_states_update_one_row_and_parent_result_suppresses_stale_live_activity( + monkeypatch: pytest.MonkeyPatch, +): + emitted: list[RenderableType] = [] + from pythinker_code.ui.shell.visualize import _live_view as live_view_module + + monkeypatch.setattr( + live_view_module, + "emit_scrollback_block", + lambda _console, renderable: emitted.append(renderable), + ) + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="review")) + view.dispatch_wire_message(_judge_agent_call()) + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=_sub_tool_call("sub-read", "Read", '{"file_path":"/repo/hidden.py"}'), + ) + ) + waiting = _render(view) + assert "waiting" in waiting + assert "reading…" in waiting + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolExecutionStarted(tool_call_id="sub-read"), + ) + ) + running = _render(view) + assert "running" in running + assert running.count("Judge branch review report") >= 1 + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolResult(tool_call_id="sub-read", return_value=ToolOk(output="hidden")), + ) + ) + thinking = _render(view) + assert "thinking…" in thinking + assert "hidden.py" not in thinking + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=_sub_tool_call("sub-search", "Grep", '{"pattern":"secret"}'), + ) + ) + searching = _render(view) + assert "searching…" in searching + assert "reading…" not in searching + assert searching.count("Judge branch review report") >= 1 + + view.dispatch_wire_message( + ToolResult(tool_call_id="judge-agent-1", return_value=ToolOk(output="Judge result")) + ) + + assert len(emitted) == 1 + console = Console(width=100, record=True, highlight=False, color_system=None) + console.print(emitted[0]) + output = console.export_text() + assert "Judge result" in output + assert "searching…" not in output + assert "thinking…" not in output + assert "secret" not in output + + +def test_single_agent_activity_render_matrix_hides_raw_details( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("PYTHINKER_REDUCED_MOTION", "1") + monkeypatch.setenv("PYTHINKER_TUI_STYLE", "pythinker") + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="review")) + view.dispatch_wire_message(_judge_agent_call()) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=_sub_tool_call("sub-shell", "Shell", '{"command":"git status --short"}'), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="judge-agent-1", + agent_id="judge-raw-id", + subagent_type="judge", + description="Judge branch review report", + event=ToolExecutionStarted(tool_call_id="sub-shell"), + ) + ) + + output = _render(view, width=44) + assert "running command…" in output + assert "git status --short" not in output + assert "judge-raw-id" not in output + assert "sub-shell" not in output + for line in output.splitlines(): + assert cell_width(line) <= 44 + + +def test_run_agents_activity_tree_keeps_same_type_agents_separate_and_safe(): + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message(_run_agents_call()) + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=_sub_tool_call( + "sub-a1", "Grep", '{"pattern":"TODO","path":"/repo/src/secret.py"}' + ), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=ToolExecutionStarted(tool_call_id="sub-a1"), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=ToolOutputPart(tool_call_id="sub-a1", text="src/app.py:42: # TODO\n"), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a2", + subagent_type="explore", + description="Count files", + event=_sub_tool_call("sub-a2", "Read", '{"file_path":"/repo/src/private.py"}'), + ) + ) + + output = _render(view, width=100) + assert output.count("Agents") == 1 + assert "RunAgents(" not in output + assert "Explore" in output + assert output.count("Find TODO comments") == 1 + assert "searching…" in output + assert output.count("Count files") == 1 + assert "reading…" in output + assert "1 queued" in output + assert "├─" in output + assert "└─" in output + assert "│ ⎿" in output + assert "agent searching" not in output + assert "agent reading" not in output + assert "grep -r TODO" not in output + assert "/repo/src/secret.py" not in output + assert "/repo/src/private.py" not in output + assert "src/app.py:42" not in output + assert "a1" not in output + assert "a2" not in output + assert "do not leak this prompt" not in output + + +def test_run_agents_activity_states_update_one_row_per_agent(): + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message(_run_agents_call()) + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=_sub_tool_call("sub-a1", "Grep", '{"pattern":"TODO"}'), + ) + ) + waiting = _render(view) + assert "waiting" in waiting + assert "searching…" in waiting + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=ToolExecutionStarted(tool_call_id="sub-a1"), + ) + ) + running = _render(view) + assert "running" in running + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=ToolResult(tool_call_id="sub-a1", return_value=ToolOk(output="done")), + ) + ) + thinking = _render(view) + assert "thinking…" in thinking + assert thinking.count("Find TODO comments") == 1 + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=_sub_tool_call("sub-a1b", "Read", '{"file_path":"/repo/next.py"}'), + ) + ) + updated = _render(view) + assert updated.count("Find TODO comments") == 1 + assert "reading…" in updated + + +def test_run_agents_activity_preserves_launch_order_across_state_transitions(): + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message(_run_agents_call()) + + for agent_id, description, tool_name in ( + ("a1", "First launch", "Grep"), + ("a2", "Second launch", "Read"), + ("a3", "Third launch", "Bash"), + ): + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id=agent_id, + subagent_type="explore", + description=description, + event=_sub_tool_call(f"sub-{agent_id}", tool_name, "{}"), + ) + ) + + waiting = _render(view) + assert ( + waiting.index("First launch") + < waiting.index("Second launch") + < waiting.index("Third launch") + ) + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a3", + subagent_type="explore", + description="Third launch", + event=ToolExecutionStarted(tool_call_id="sub-a3"), + ) + ) + running = _render(view) + assert ( + running.index("First launch") + < running.index("Second launch") + < running.index("Third launch") + ) + + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="First launch", + event=ToolResult(tool_call_id="sub-a1", return_value=ToolOk(output="done")), + ) + ) + thinking = _render(view) + assert ( + thinking.index("First launch") + < thinking.index("Second launch") + < thinking.index("Third launch") + ) + + +def test_run_agents_background_result_keeps_one_semantic_live_tree_after_nested_activity( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("PYTHINKER_TUI_STYLE", "card") + monkeypatch.setenv("NO_COLOR", "1") + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message(_run_agents_call()) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=_sub_tool_call("sub-a1", "Grep", '{"pattern":"TODO"}'), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=ToolExecutionStarted(tool_call_id="sub-a1"), + ) + ) + view.dispatch_wire_message( + ToolResult( + tool_call_id="run-agents-1", + return_value=ToolOk( + output=( + "tool_status: launched\n" + "mode: background\n" + "agent_count: 2\n" + "agents:\n" + "- name: todo_scan\n" + " subagent_type: explore\n" + " status: running\n" + " task_id: agent-alpha-raw-id\n" + "- name: file_count\n" + " subagent_type: explore\n" + " status: running\n" + " task_id: agent-beta-raw-id\n" + ) + ), + ) + ) + + output = _render(view, width=80) + assert output.count("Agents") == 1 + assert output.count("Find TODO comments") == 1 + assert "searching…" in output + assert "2 queued" in output + assert "agent-alpha-raw-id" not in output + assert "agent-beta-raw-id" not in output + for line in output.splitlines(): + assert cell_width(line) <= 80 + + +def test_run_agents_parent_result_owns_terminal_rows_after_nested_activity( + monkeypatch: pytest.MonkeyPatch, +): + emitted: list[RenderableType] = [] + from pythinker_code.ui.shell.visualize import _live_view as live_view_module + + monkeypatch.setattr( + live_view_module, + "emit_scrollback_block", + lambda _console, renderable: emitted.append(renderable), + ) + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message(_run_agents_call()) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=_sub_tool_call("sub-a1", "Grep", '{"pattern":"TODO"}'), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=ToolExecutionStarted(tool_call_id="sub-a1"), + ) + ) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a2", + subagent_type="explore", + description="Count files", + event=_sub_tool_call("sub-a2", "Read", '{"file_path":"/repo/src/private.py"}'), + ) + ) + + view.dispatch_wire_message( + ToolResult( + tool_call_id="run-agents-1", + return_value=ToolOk( + output=( + "tool_status: success\n" + "mode: foreground\n" + "agent_count: 2\n" + "agents:\n" + "- name: todo_scan\n" + " subagent_type: explore\n" + " status: completed\n" + "- name: file_count\n" + " subagent_type: explore\n" + " status: completed\n" + ) + ), + ) + ) + + assert len(emitted) == 1 + console = Console(width=100, record=True, highlight=False, color_system=None) + console.print(emitted[0]) + output = console.export_text() + assert output.count("Agents") == 1 + assert "2 agents completed" in output + assert "waiting" not in output + assert "running/background" not in output + assert "thinking…" not in output + assert "searching…" not in output + assert "reading…" not in output + + +def test_run_agents_live_fallback_without_registry_renders_agents_tree(): + clear_tool_renderers() + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message(_run_agents_call()) + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-1", + agent_id="a1", + subagent_type="explore", + description="Find TODO comments", + event=_sub_tool_call("sub-a1", "Grep", '{"pattern":"TODO"}'), + ) + ) + + output = _render(view, width=100) + assert output.count("Agents") == 1 + assert "Find TODO comments" in output + assert "RunAgents(" not in output + + +def test_run_agents_activity_tree_bounds_overflow_and_width(): + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message( + WireToolCall( + id="run-agents-overflow", + function=WireToolCall.FunctionBody( + name="RunAgents", + arguments=( + '{"summary":"many","agents":[' + + ",".join( + f'{{"title":"Worker {i}","subagent_type":"explore","prompt":"hidden {i}"}}' + for i in range(8) + ) + + "]}" + ), + ), + ) + ) + for i in range(8): + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-overflow", + agent_id=f"agent-{i}", + subagent_type="explore", + description=f"Worker {i}", + event=_sub_tool_call(f"sub-{i}", "Read", '{"file_path":"/repo/hidden.py"}'), + ) + ) + + output = _render(view, width=40) + assert output.count("Agents") == 1 + assert "more agents" in output + assert "├─" in output + assert "└─" in output + assert "│ ⎿" in output or " ⎿" in output + assert "/repo/hidden.py" not in output + assert "hidden 7" not in output + for line in output.splitlines(): + assert cell_width(line) <= 40 + + +def test_run_agents_activity_overflow_prioritizes_running_agents_without_reordering_visible_rows(): + view = _LiveView(StatusUpdate(context_tokens=1000)) + view.dispatch_wire_message(TurnBegin(user_input="scan")) + view.dispatch_wire_message( + WireToolCall( + id="run-agents-priority", + function=WireToolCall.FunctionBody( + name="RunAgents", + arguments=( + '{"summary":"many","agents":[' + + ",".join( + f'{{"title":"Worker {i}","subagent_type":"explore","prompt":"hidden {i}"}}' + for i in range(8) + ) + + "]}" + ), + ), + ) + ) + for i in range(8): + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-priority", + agent_id=f"agent-{i}", + subagent_type="explore", + description=f"Worker {i}", + event=_sub_tool_call(f"sub-{i}", "Read", "{}"), + ) + ) + + for i in (6, 7): + view.dispatch_wire_message( + SubagentEvent( + parent_tool_call_id="run-agents-priority", + agent_id=f"agent-{i}", + subagent_type="explore", + description=f"Worker {i}", + event=ToolExecutionStarted(tool_call_id=f"sub-{i}"), + ) + ) + + output = _render(view, width=80) + assert output.count("Agents") == 1 + assert "2 more agents" in output + assert "Worker 4" not in output + assert "Worker 5" not in output + assert "Worker 6" in output + assert "Worker 7" in output + assert ( + output.index("Worker 0") + < output.index("Worker 1") + < output.index("Worker 2") + < output.index("Worker 3") + < output.index("Worker 6") + < output.index("Worker 7") + ) diff --git a/tests/ui_and_conv/test_tool_call_block.py b/tests/ui_and_conv/test_tool_call_block.py index ea846a09..42cfb540 100644 --- a/tests/ui_and_conv/test_tool_call_block.py +++ b/tests/ui_and_conv/test_tool_call_block.py @@ -24,8 +24,8 @@ def _legacy_tui_style(monkeypatch): monkeypatch.setenv("PYTHINKER_TUI_STYLE", "pythinker") -def _plain(renderable) -> str: - console = Console(record=True, width=120, color_system=None) +def _plain(renderable, *, width: int = 120) -> str: + console = Console(record=True, width=width, color_system=None) console.print(renderable) return console.export_text() @@ -158,96 +158,107 @@ def test_tool_call_block_renders_display_cards_under_completed_entry(): assert "Tests passed" in output -def test_completed_subagent_renders_compact_summary(): +def test_completed_agent_shows_completion_without_legacy_tool_rollup(): + # A completed single Agent renders its result (via the result renderer in card + # style, a plain "completed" label otherwise). The old per-tool rollup + # ("N tool calls", "tools: Read ×N") is superseded, and raw nested tool + # payloads never leak into the collapsed view. block = _ToolCallBlock(_tool_call("Agent", '{"description":"Audit UI"}')) - block.set_subagent_metadata("a143aa989", "explore") + block.set_subagent_metadata("a143aa989", "explore", "Audit UI") for index in range(7): call = _tool_call_with_id( f"sub-{index}", "ReadFile", json.dumps({"path": f"web/src/components/file-{index}.tsx"}), ) - block.append_sub_tool_call(call) - block.finish_sub_tool_call(ToolResult(tool_call_id=call.id, return_value=ToolOk(output=""))) + block.append_sub_tool_call(call, agent_id="a143aa989") + block.finish_sub_tool_call( + ToolResult(tool_call_id=call.id, return_value=ToolOk(output="")), + agent_id="a143aa989", + ) block.finish(ToolOk(output="")) output = _plain(block.compose()) - assert "Subagent" in output assert "completed" in output.lower() - assert "7 tool calls" in output - assert "tools: Read ×7" in output - assert output.count("ReadFile") <= 4 + assert "7 tool calls" not in output + assert "tools:" not in output + assert "Read ×7" not in output + for leaked in ("ReadFile", "file-0.tsx", "web/src/components"): + assert leaked not in output -def test_completed_subagent_summarizes_changed_files_and_tool_counts(): +def test_completed_agent_hides_changed_files_and_tool_counts(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"Implement UI"}')) + block.set_subagent_metadata("agent-impl", "coder", "Implement UI") calls = [ _tool_call_with_id("read-1", "ReadFile", json.dumps({"path": "src/app.py"})), - _tool_call_with_id("read-2", "ReadFile", json.dumps({"path": "src/ui.py"})), _tool_call_with_id("write-1", "WriteFile", json.dumps({"path": "src/new.py"})), _tool_call_with_id("edit-1", "StrReplaceFile", json.dumps({"path": "src/existing.py"})), _tool_call_with_id("shell-1", "Shell", json.dumps({"command": "pytest"})), ] for call in calls: - block.append_sub_tool_call(call) - block.finish_sub_tool_call(ToolResult(tool_call_id=call.id, return_value=ToolOk(output=""))) + block.append_sub_tool_call(call, agent_id="agent-impl") + block.finish_sub_tool_call( + ToolResult(tool_call_id=call.id, return_value=ToolOk(output="")), + agent_id="agent-impl", + ) block.finish(ToolOk(output="done")) output = _plain(block.compose()) - assert "tools:" in output - assert "Read ×2" in output - assert "Write" in output - assert "Edit" in output - assert "Shell" in output - assert "changed: src/new.py, src/existing.py" in output + assert "completed" in output.lower() + assert "tools:" not in output + assert "changed:" not in output + for leaked in ("WriteFile", "StrReplaceFile", "src/new.py", "src/existing.py", "pytest"): + assert leaked not in output -def test_append_sub_output_part_accumulates_text(): +def test_agent_sub_output_updates_activity_without_buffering_raw_text(): + # Single Agent uses the payload-free semantic model: streamed sub-output moves + # the owner's activity to "running" but the raw text is never buffered or shown. block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Bash", '{"command":"ls"}') - block.append_sub_tool_call(call) - block.append_sub_output_part("sub-1", "file1.py\n") - block.append_sub_output_part("sub-1", "file2.py\n") - combined = "".join(block._subagent_output_parts["sub-1"]) - assert "file1.py" in combined - assert "file2.py" in combined + block.append_sub_tool_call(call, agent_id="agent-1") + block.append_sub_output_part("sub-1", "file1.py\n", agent_id="agent-1") + block.append_sub_output_part("sub-1", "file2.py\n", agent_id="agent-1") + + assert "sub-1" not in block._subagent_output_parts + output = _plain(block.compose()) + assert "running command…" in output + for leaked in ("file1.py", "file2.py"): + assert leaked not in output def test_append_sub_output_part_discards_unknown_call_id(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) - # no append_sub_tool_call — id is unknown + # no append_sub_tool_call / no owner — id is unknown, nothing is buffered block.append_sub_output_part("ghost-id", "should be ignored\n") assert "ghost-id" not in block._subagent_output_parts -def test_append_sub_output_part_caps_buffer_at_200_chars(): +def test_agent_sub_output_never_leaks_regardless_of_volume(): + # No raw payload is ever surfaced for a single Agent, even for large output. block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Bash", '{"command":"find ."}') - block.append_sub_tool_call(call) - # Fill with >200 chars in one shot - block.append_sub_output_part("sub-1", "x" * 300) - combined = "".join(block._subagent_output_parts["sub-1"]) - assert len(combined) <= 200 - + block.append_sub_tool_call(call, agent_id="agent-1") + block.append_sub_output_part("sub-1", "SECRET_" + "x" * 300, agent_id="agent-1") -def test_append_sub_output_part_caps_buffer_across_multiple_appends(): - block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) - call = _tool_call_with_id("sub-1", "Bash", '{"command":"ls"}') - block.append_sub_tool_call(call) - for _ in range(30): - block.append_sub_output_part("sub-1", "x" * 10) # 300 chars total, 10 at a time - combined = "".join(block._subagent_output_parts["sub-1"]) - assert len(combined) <= 200 + assert "sub-1" not in block._subagent_output_parts + assert "SECRET_" not in _plain(block.compose()) -def test_append_sub_output_part_tracks_stderr(): +def test_agent_sub_stderr_does_not_buffer_raw_text(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Bash", '{"command":"cat missing"}') - block.append_sub_tool_call(call) - block.append_sub_output_part("sub-1", "No such file\n", stream="stderr") - assert block._subagent_output_had_stderr.get("sub-1") is True + block.append_sub_tool_call(call, agent_id="agent-1") + block.append_sub_output_part("sub-1", "No such file\n", stream="stderr", agent_id="agent-1") + + assert "sub-1" not in block._subagent_output_had_stderr + assert "No such file" not in _plain(block.compose()) def test_mark_sub_execution_started_records_id(): @@ -258,10 +269,12 @@ def test_mark_sub_execution_started_records_id(): assert "sub-1" in block._subagent_execution_started -def test_mark_sub_execution_started_discards_unknown_id(): +def test_mark_sub_execution_started_unknown_id_renders_no_phantom_activity(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) block.mark_sub_execution_started("ghost-id") # should not raise - assert "ghost-id" not in block._subagent_execution_started + # An unknown id creates no semantic activity row for the single Agent. + assert "ghost-id" not in block._subagent_activities + assert "ghost-id" not in _plain(block.compose()) def test_finish_sub_tool_call_cleans_up_output_state(): @@ -276,79 +289,101 @@ def test_finish_sub_tool_call_cleans_up_output_state(): assert "sub-1" not in block._subagent_execution_started -def test_running_agent_shows_ongoing_sub_tool_calls(): +def test_running_agent_shows_semantic_activity_not_raw_tool_calls(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Read", '{"file_path":"src/app.py"}') - block.append_sub_tool_call(call) + block.append_sub_tool_call(call, agent_id="agent-1") + block.mark_sub_execution_started("sub-1", agent_id="agent-1") output = _plain(block.compose()) - assert "Read" in output - assert "src/app.py" in output + assert "reading…" in output + assert "Read" not in output + assert "src/app.py" not in output -def test_running_agent_shows_streamed_output_preview(): +def test_running_agent_suppresses_streamed_output_preview(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Bash", '{"command":"grep -r TODO ."}') - block.append_sub_tool_call(call) - block.append_sub_output_part("sub-1", "src/app.py:42: # TODO: fix\n") + block.append_sub_tool_call(call, agent_id="agent-1") + block.append_sub_output_part("sub-1", "src/app.py:42: # TODO: fix\n", agent_id="agent-1") output = _plain(block.compose()) - assert "src/app.py:42" in output + assert "running command…" in output + assert "src/app.py:42" not in output + assert "grep -r TODO" not in output -def test_running_agent_card_style_shows_ongoing_sub_tool_calls(monkeypatch): +def test_running_agent_card_style_shows_semantic_activity_not_raw_tool_calls(monkeypatch): monkeypatch.setenv("PYTHINKER_TUI_STYLE", "card") block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan","prompt":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Read", '{"file_path":"src/app.py"}') - block.append_sub_tool_call(call) + block.append_sub_tool_call(call, agent_id="agent-1") + block.mark_sub_execution_started("sub-1", agent_id="agent-1") output = _plain(block.compose()) - assert "Read" in output - assert "src/app.py" in output + assert "reading…" in output + assert "src/app.py" not in output -def test_running_agent_card_style_shows_streamed_output_preview(monkeypatch): +def test_running_agent_card_style_suppresses_streamed_output_preview(monkeypatch): monkeypatch.setenv("PYTHINKER_TUI_STYLE", "card") block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan","prompt":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Bash", '{"command":"grep -r TODO ."}') - block.append_sub_tool_call(call) - block.mark_sub_execution_started("sub-1") - block.append_sub_output_part("sub-1", "src/app.py:42: # TODO: fix\n") + block.append_sub_tool_call(call, agent_id="agent-1") + block.mark_sub_execution_started("sub-1", agent_id="agent-1") + block.append_sub_output_part("sub-1", "src/app.py:42: # TODO: fix\n", agent_id="agent-1") output = _plain(block.compose()) - assert "src/app.py:42" in output + assert "running command…" in output + assert "src/app.py:42" not in output -def test_running_agent_shows_only_last_4_output_lines(): +def test_running_agent_never_leaks_streamed_output_lines(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") call = _tool_call_with_id("sub-1", "Bash", '{"command":"find ."}') - block.append_sub_tool_call(call) + block.append_sub_tool_call(call, agent_id="agent-1") lines = [f"line{i}\n" for i in range(10)] - block.append_sub_output_part("sub-1", "".join(lines)) + block.append_sub_output_part("sub-1", "".join(lines), agent_id="agent-1") output = _plain(block.compose()) - assert "line9" in output - assert "line6" in output - assert "line5" not in output - assert "line0" not in output + assert "running command…" in output + for i in range(10): + assert f"line{i}" not in output -def test_running_agent_caps_visible_running_rows_at_2(): +def test_running_agent_renders_one_payload_free_row_per_active_agent(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) for i in range(5): + agent_id = f"agent-{i}" + block.set_subagent_metadata(agent_id, "coder", f"task {i}") call = _tool_call_with_id(f"sub-{i}", "Read", f'{{"file_path":"src/file{i}.py"}}') - block.append_sub_tool_call(call) + block.append_sub_tool_call(call, agent_id=agent_id) + block.mark_sub_execution_started(f"sub-{i}", agent_id=agent_id) output = _plain(block.compose()) - assert "more running" in output + assert output.count("reading…") >= 1 + for i in range(5): + assert f"src/file{i}.py" not in output -def test_running_agent_rows_stay_visible_with_finished_sub_tool_calls(): +def test_running_agent_activity_survives_finished_sub_tool_calls(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) + block.set_subagent_metadata("agent-1", "coder", "scan") for i in range(4): call = _tool_call_with_id(f"done-{i}", "Read", f'{{"file_path":"src/done{i}.py"}}') - block.append_sub_tool_call(call) - block.finish_sub_tool_call(ToolResult(tool_call_id=call.id, return_value=ToolOk(output=""))) + block.append_sub_tool_call(call, agent_id="agent-1") + block.finish_sub_tool_call( + ToolResult(tool_call_id=call.id, return_value=ToolOk(output="")), + agent_id="agent-1", + ) running = _tool_call_with_id("live-1", "Read", '{"file_path":"src/live.py"}') - block.append_sub_tool_call(running) + block.append_sub_tool_call(running, agent_id="agent-1") + block.mark_sub_execution_started("live-1", agent_id="agent-1") output = _plain(block.compose()) - assert "src/live.py" in output + assert "reading…" in output + assert "src/live.py" not in output + assert "src/done0.py" not in output def test_finished_sub_tool_calls_not_shown_in_output_preview(): @@ -459,6 +494,65 @@ def test_run_agents_background_launch_stays_background_pending(): assert block.is_background_pending +def test_run_agents_background_launch_keeps_live_agent_activity(): + block = _ToolCallBlock( + _tool_call( + "RunAgents", + '{"summary":"scan","run_in_background":true,"agents":[{"name":"a","prompt":"p"}]}', + ) + ) + block.set_subagent_metadata("agent-abc", "explore", "Audit the renderer") + block.finish( + ToolOk( + output=( + "tool_status: launched\n" + "mode: background\n" + "agent_count: 1\n" + "agents:\n" + "- name: a\n" + " subagent_type: explore\n" + " status: starting\n" + " task_id: agent-abc\n" + ) + ) + ) + + output = _plain(block.compose()) + + assert "waiting Explore Audit the renderer" in output + assert "Run Agents completed" not in output + + +@pytest.mark.usefixtures("_card_style_with_builtin_renderers") +def test_run_agents_background_launch_keeps_live_agent_activity_in_card_style(): + block = _ToolCallBlock( + _tool_call( + "RunAgents", + '{"summary":"scan","run_in_background":true,"agents":[{"name":"a","prompt":"p"}]}', + ) + ) + block.set_subagent_metadata("agent-abc", "explore", "Audit the renderer") + block.finish( + ToolOk( + output=( + "tool_status: launched\n" + "mode: background\n" + "agent_count: 1\n" + "agents:\n" + "- name: a\n" + " subagent_type: explore\n" + " status: starting\n" + " task_id: agent-abc\n" + ) + ) + ) + + output = _plain(block.compose()) + + assert "waiting Explore Audit the renderer" in output + assert "Run Agents completed" not in output + + def test_run_agents_foreground_completion_is_not_background_pending(): block = _ToolCallBlock( _tool_call( @@ -483,6 +577,39 @@ def test_run_agents_foreground_completion_is_not_background_pending(): assert not block.is_background_pending +def test_run_agents_sanitizes_multiline_descriptions_without_breaking_same_type_activity(): + block = _ToolCallBlock( + _tool_call( + "RunAgents", + '{"summary":"scan","run_in_background":false,"agents":[{"name":"a"},{"name":"b"}]}', + ) + ) + block.set_subagent_metadata( + "agent-alpha", + "explore", + "Map\trenderer\ncallbacks\x1b[31m now\x1b[0m\r", + ) + block.set_subagent_metadata("agent-beta", "explore", "Read activity tree") + block.append_sub_tool_call( + _tool_call_with_id("sub-alpha", "ReadFile", '{"path":"src/renderer.py"}'), + agent_id="agent-alpha", + ) + block.mark_sub_execution_started("sub-alpha", agent_id="agent-alpha") + + output = _plain(block.compose(), width=52) + lines = output.splitlines() + + assert output.count("running Explore") == 1 + assert "Map renderer callbacks now" in output + assert "Map\trenderer" not in output + assert "\r" not in output + assert "\x1b" not in output + assert "reading…" in output + assert "thinking…" in output + assert "callbacks now" not in lines + assert all(len(line) <= 52 for line in lines) + + def test_lsp_card_boundary_passes_nested_count_extras_to_renderer( _card_style_with_builtin_renderers, ): diff --git a/tests/ui_and_conv/test_tui_blocks_integration.py b/tests/ui_and_conv/test_tui_blocks_integration.py index d730dcfd..4feb94ed 100644 --- a/tests/ui_and_conv/test_tui_blocks_integration.py +++ b/tests/ui_and_conv/test_tui_blocks_integration.py @@ -214,7 +214,7 @@ def test_card_style_finished_subagent_shows_compact_result(_force_card_style, mo assert "Agent finished" not in rendered -def test_card_style_completed_subagent_has_blank_before_tools_rollup(_force_card_style): +def test_card_style_completed_agent_shows_result_not_tools_rollup(_force_card_style): import json from pythinker_core.tooling import ToolOk @@ -241,14 +241,14 @@ def test_card_style_completed_subagent_has_blank_before_tools_rollup(_force_card rendered = render_plain(block.compose(), width=120) lines = [line.rstrip() for line in rendered.splitlines()] - expand_idx = next( - index - for index, line in enumerate(lines) - if "expand" in line.lower() and "ctrl" in line.lower() - ) - tools_idx = next(index for index, line in enumerate(lines) if "tools:" in line) - assert tools_idx > expand_idx - assert any(lines[j] == "" for j in range(expand_idx + 1, tools_idx)) + # Blessed supersession: a completed single Agent renders its result body with an + # expand affordance; the legacy per-tool rollup is gone and raw sub-tool payloads + # never leak into the collapsed card. + assert any("detail line 0" in line for line in lines) + assert any("expand" in line.lower() and "ctrl" in line.lower() for line in lines) + assert not any("tools:" in line for line in lines) + for leaked in ("Grep", "term0", "term1", "term2"): + assert leaked not in rendered def test_card_style_running_task_output_uses_solid_circle(_force_card_style, monkeypatch): diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 6d4bbf0e..ce185154 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -19,6 +19,7 @@ from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.shell.components import ( ToolExecutionComponent, + cell_width, compute_edit_diff_string, render_diff, render_plain, @@ -869,7 +870,7 @@ def test_running_tool_headers_do_not_duplicate_status_bullets(): "summary": "audit", "agents": [{"name": "scan", "prompt": "check", "subagent_type": "explore"}], }, - "RunAgents(", + "Agents", ), ("AskUserQuestion", {"questions": [{"question": "Continue?"}]}, "Ask("), ("Think", {"thought": "check"}, "Think"), @@ -901,7 +902,7 @@ def test_streaming_missing_args_use_preparing_rows_not_tool_ellipsis_placeholder ("AskUserQuestion", "Ask"), ("TaskOutput", "TaskOutput"), ("TaskStop", "TaskStop"), - ("RunAgents", "RunAgents"), + ("RunAgents", "Agents"), ] for tool, label in cases: @@ -1488,12 +1489,13 @@ def test_run_agents_renders_compact_professional_summary(): ), width=120, ) - assert "⏺ RunAgents(" in rendered + assert "Agents" in rendered + assert "RunAgents(" not in rendered assert "Run code and security scans" in rendered assert "code-reviewer" in rendered assert "security-reviewer" in rendered - assert "2 code-reviewer agents finished" in rendered or "2 agents finished" in rendered - assert "Done" in rendered + assert "2 agents completed" in rendered + assert "completed" in rendered # Successful agent summaries are suppressed — only the findings table shows assert "No correctness findings" not in rendered assert "No exploitable security issues" not in rendered @@ -1533,13 +1535,310 @@ def test_run_agents_rows_align_columns_and_drop_redundant_name(): ), width=120, ) - assert "2 background agents launched" in rendered + assert "2 agents running/background" in rendered assert "qa" in rendered assert "code-reviewer" in rendered + assert "running/background" in rendered assert "Initializing" not in rendered assert "Mode" not in rendered +def test_run_agents_call_header_does_not_speculate_child_rows_or_leak_prompts(): + rendered = _render_running( + "RunAgents", + { + "summary": "Inspect repo", + "run_in_background": False, + "agents": [ + { + "title": "Find TODO comments", + "name": "todo_scan", + "prompt": "grep -r TODO /repo/src/private.py", + "subagent_type": "explore", + }, + {"name": "count_files", "prompt": "ls /repo", "subagent_type": "explore"}, + ], + }, + width=100, + ) + assert rendered.count("Agents") == 1 + assert "Inspect repo" in rendered + assert "Find TODO comments" not in rendered + assert "count_files" not in rendered + assert "todo_scan" not in rendered + assert "grep -r TODO" not in rendered + assert "/repo/src/private.py" not in rendered + + +@pytest.mark.parametrize("width", [40, 80, 120]) +def test_run_agents_result_fits_width_and_normalizes_statuses(width: int): + rendered = _render( + "RunAgents", + {"summary": "mixed", "agents": [{"title": "done"}, {"title": "failed"}]}, + output=( + "tool_status: success\n" + "mode: foreground\n" + "agent_count: 4\n" + "agents:\n" + "- name: done\n" + " subagent_type: explore\n" + " status: completed\n" + "- name: failed\n" + " subagent_type: explore\n" + " status: failed\n" + " brief: failed hard\n" + "- name: running\n" + " subagent_type: explore\n" + " status: running\n" + "- name: deferred\n" + " subagent_type: explore\n" + " status: deferred\n" + ), + width=width, + ) + assert "completed" in rendered + assert "failed" in rendered + assert "running/background" in rendered + assert "queued" in rendered + for line in rendered.splitlines(): + assert cell_width(line) <= width + + +def test_run_agents_suffix_rows_stay_width_safe_in_narrow_layout(): + width = 28 + defn = get_tool_renderer("RunAgents") + assert defn is not None + comp = ToolExecutionComponent("RunAgents", "tc-1", definition=defn, cwd="/repo") + comp.update_args({"summary": "mixed", "agents": [{"title": "done"}, {"title": "failed"}]}) + comp.set_args_complete() + comp.mark_execution_started() + comp.set_result( + ToolResultPayload( + text=( + "tool_status: success\n" + "mode: foreground\n" + "agent_count: 3\n" + "agents:\n" + "- name: done\n" + " subagent_type: x\n" + " status: completed\n" + "- name: failed\n" + " subagent_type: x\n" + " status: failed\n" + "- name: lost\n" + " subagent_type: x\n" + " status: vortex\n" + ) + ) + ) + rendered = render_plain(comp.render(width=width), width=width) + assert "· Done" in rendered + assert "· Failed" in rendered + assert "· Unknown" in rendered + assert " completed " not in rendered + assert " failed " not in rendered + assert " unknown " not in rendered + for line in rendered.splitlines(): + assert cell_width(line) <= width + + +def test_run_agents_no_color_keeps_glyphs_and_status_words(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("NO_COLOR", "1") + rendered = _render( + "RunAgents", + {"summary": "mixed", "agents": [{"title": "done"}, {"title": "failed"}]}, + output=( + "tool_status: success\n" + "mode: foreground\n" + "agent_count: 4\n" + "agents:\n" + "- name: done\n" + " subagent_type: explore\n" + " status: completed\n" + "- name: failed\n" + " subagent_type: explore\n" + " status: failed\n" + "- name: queued\n" + " subagent_type: explore\n" + " status: deferred\n" + "- name: lost\n" + " subagent_type: explore\n" + " status: vortex\n" + ), + width=100, + ) + assert "✓" in rendered and "completed" in rendered + assert "✘" in rendered and "failed" in rendered + assert "○" in rendered and "queued" in rendered + assert "?" in rendered and "unknown" in rendered + assert "lost" in rendered + assert "running/background" not in rendered + + +def test_run_agents_background_result_descriptions_prefer_hydrated_then_title_no_leaks( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("NO_COLOR", "1") + width = 64 + rendered = _render( + "RunAgents", + { + "summary": "background fanout", + "run_in_background": True, + "agents": [ + { + "name": "internal_worker_alpha", + "title": "Title should be replaced", + "prompt": "SECRET_PROMPT_CANARY alpha", + "subagent_type": "explore", + }, + { + "name": "internal_worker_beta", + "title": "Fallback title beta", + "prompt": "SECRET_PROMPT_CANARY beta", + "subagent_type": "explore", + }, + ], + }, + output=( + "tool_status: launched\n" + "mode: background\n" + "agent_count: 2\n" + "agents:\n" + "- name: internal_worker_alpha\n" + " subagent_type: explore\n" + " status: running\n" + " task_id: agent-alpha-raw-id\n" + " result: |\n" + " kind: agent\n" + " status: running\n" + " agent_id: sub-alpha-raw-id\n" + " description: Parsed override alpha with extra whitespace\n" + "- name: internal_worker_beta\n" + " subagent_type: explore\n" + " status: running\n" + " task_id: agent-beta-raw-id\n" + ), + width=width, + ) + assert rendered.count("Agents") == 1 + assert "2 agents running/background" in rendered + assert rendered.count("running/background") >= 1 + assert "Parsed override alpha" in rendered + assert "Fallback title beta" in rendered + for leaked in ( + "Title should be replaced", + "SECRET_PROMPT_CANARY", + "agent-alpha-raw-id", + "sub-alpha-raw-id", + "agent-beta-raw-id", + "internal_worker_alpha", + "internal_worker_beta", + ): + assert leaked not in rendered + for line in rendered.splitlines(): + assert cell_width(line) <= width + + +def test_run_agents_failed_brief_never_leaks_in_collapsed_rows(): + sentinel_brief = "LEAK_BRIEF_COMMAND_SENTINEL /private/secret-token STACK_SECRET_SENTINEL" + output = ( + "tool_status: success\n" + "mode: foreground\n" + "agent_count: 1\n" + "agents:\n" + "- name: failed_agent\n" + " subagent_type: explore\n" + " status: failed\n" + f" brief: {sentinel_brief}\n" + ) + + collapsed = _render( + "RunAgents", + {"summary": "failed", "agents": [{"title": "failed_agent"}]}, + output=output, + width=120, + ) + assert "failed_agent" in collapsed + assert "failed" in collapsed + assert sentinel_brief not in collapsed + assert "/private/secret-token" not in collapsed + assert "STACK_SECRET_SENTINEL" not in collapsed + + expanded = _render( + "RunAgents", + {"summary": "failed", "agents": [{"title": "failed_agent"}]}, + output=output, + expanded=True, + width=120, + ) + assert sentinel_brief in expanded + + +def test_run_agents_error_result_collapsed_hides_raw_text_until_expanded(): + raw_error = ( + "Traceback (most recent call last):\n" + ' File "/private/secret-token/tool.py", line 7, in run\n' + "RuntimeError: STACK_SECRET_SENTINEL agent-id-secret-123\n" + ) + + collapsed = _render( + "RunAgents", + {"summary": "failed", "agents": [{"title": "agent"}]}, + output=raw_error, + is_error=True, + width=120, + ) + assert "Agents" in collapsed + assert "failed" in collapsed.lower() + assert "/private/secret-token" not in collapsed + assert "STACK_SECRET_SENTINEL" not in collapsed + assert "agent-id-secret-123" not in collapsed + + expanded = _render( + "RunAgents", + {"summary": "failed", "agents": [{"title": "agent"}]}, + output=raw_error, + is_error=True, + expanded=True, + width=120, + ) + assert "/private/secret-token" in expanded + assert "STACK_SECRET_SENTINEL" in expanded + assert "agent-id-secret-123" in expanded + + +def test_run_agents_parse_fallback_collapsed_hides_raw_text_until_expanded(): + raw_result = ( + "malformed RunAgents result /private/secret-token\n" + "internal id agent-id-secret-123\n" + "STACK_SECRET_SENTINEL\n" + ) + + collapsed = _render( + "RunAgents", + {"summary": "malformed", "agents": [{"title": "agent"}]}, + output=raw_result, + width=120, + ) + assert "Agents" in collapsed + assert "result unavailable" in collapsed + assert "/private/secret-token" not in collapsed + assert "STACK_SECRET_SENTINEL" not in collapsed + assert "agent-id-secret-123" not in collapsed + + expanded = _render( + "RunAgents", + {"summary": "malformed", "agents": [{"title": "agent"}]}, + output=raw_result, + expanded=True, + width=120, + ) + assert "/private/secret-token" in expanded + assert "STACK_SECRET_SENTINEL" in expanded + assert "agent-id-secret-123" in expanded + + # --------------------------------------------------------------------------- # AskUserQuestion # --------------------------------------------------------------------------- diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 7db8a0b9..3b8eaa9b 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -1000,7 +1000,7 @@ def test_prompt_composing_activity_is_pinned_below_stream_body() -> None: assert "Composing" in pinned_tail -def test_pinned_tail_prefers_active_subagent_tool_over_composing() -> None: +def test_pinned_tail_suppresses_active_agent_tool_label_in_favor_of_composing() -> None: import re import time as _time from collections import deque @@ -1043,8 +1043,8 @@ def test_pinned_tail_prefers_active_subagent_tool_over_composing() -> None: tail = re.sub(r"\x1b\[[0-9;]*m", "", view.render_pinned_status_tail(100).value) - assert "agent Read src/pythinker_code/ui/shell/prompt.py" in tail - assert "Composing" not in tail + assert "agent Read src/pythinker_code/ui/shell/prompt.py" not in tail + assert "Composing" in tail def test_render_pinned_status_tail_empty_when_turn_inactive() -> None: @@ -3414,7 +3414,11 @@ class _App: view._reset_prompt_renderer("boom") # must not raise -def test_resize_change_forces_absolute_prompt_repaint(monkeypatch) -> None: +@pytest.mark.parametrize(("platform", "expected_resets"), [("posix", []), ("nt", ["resize"])]) +def test_resize_change_uses_platform_safe_repaint( + monkeypatch, platform: str, expected_resets: list[str] +) -> None: + monkeypatch.setattr(_interactive_mod.os, "name", platform) view = object.__new__(_PromptLiveView) view._last_terminal_size = (80, 24) view._resize_recovery_remaining = 0 @@ -3426,7 +3430,29 @@ def test_resize_change_forces_absolute_prompt_repaint(monkeypatch) -> None: view._tick_resize_recovery() - assert resets == ["resize"] + assert resets == expected_resets + assert view._last_terminal_size == (100, 30) + assert view._resize_recovery_remaining == _interactive_mod._RESIZE_RECOVERY_FRAMES - 1 + assert view._force_refresh is True + + +def test_first_posix_terminal_observation_uses_platform_safe_repaint(monkeypatch) -> None: + monkeypatch.setattr(_interactive_mod.os, "name", "posix") + view = object.__new__(_PromptLiveView) + view._last_terminal_size = None + view._resize_recovery_remaining = 0 + view._force_refresh = False + view._current_terminal_size = lambda: (100, 30) # type: ignore[method-assign] + + resets: list[str] = [] + monkeypatch.setattr(view, "_reset_prompt_renderer", lambda reason: resets.append(reason)) + + view._tick_resize_recovery() + + assert resets == [] + assert view._last_terminal_size == (100, 30) + assert view._resize_recovery_remaining == _interactive_mod._RESIZE_RECOVERY_FRAMES - 1 + assert view._force_refresh is True @pytest.mark.asyncio