Skip to content

Commit 780c276

Browse files
authored
feat(tui): improve reasoning and agent activity rendering (#231)
* fix(core): preserve reasoning summary indices * test(core): cover completed reasoning summaries * fix(tui): render reasoning summary boundaries * feat(tui): render agent-centric activity tree * fix(tui): harden RunAgents activity rendering * fix(tui): prioritize active agent rows * fix(tui): avoid reset ghosts on posix resize * test(tui): tighten pty resize regressions * fix(core): retain encrypted reasoning boundaries * fix(tui): normalize agent activity labels * feat(tui): extend agent activity tree to single Agent calls Route single `Agent` tool calls through the same payload-free semantic activity tree previously used only for `RunAgents`, so a lone subagent renders per-agent status rows (waiting/running/thinking + reading…/ searching…/running command…) instead of raw nested tool calls, args, or streamed output. Derive the running description from the tool `description` argument, suppress stale pre-result activity once the parent returns, and render the completed result via the dedicated result renderer. Update the block, nested-lifecycle, integration, and card tests to the new semantic contract (raw payloads never leak; first-result-wins dedup and ancestry roll-up preserved), and fix the stale `RunAgents(`->`Agents(` label assertion in the action-spacer test. * fix(core): keep encrypted reasoning on summary-less responses Address CodeRabbit review findings on the branch: - core: a non-streaming OpenAI Responses reasoning item can return encrypted_content with an empty summary; the conversion loop dropped it entirely, diverging from the streaming output_item.done path and making the reasoning boundary non-replayable. Emit an encrypted ThinkPart in that case, mirroring the streaming behavior, with focused coverage. - tui: render "1 agent" (singular) instead of "1 agents" in the grouped agents summary, and make the uniform-status check explicitly boolean. The two other flagged reasoning findings (summary-index preservation and cross-output merge collisions) were verified against the code and tests and are non-issues: real deltas always carry valid indices, and encrypted boundaries already block adjacent cross-output merges. * fix(tui): annotate agent activity mutators * fix(tui): keep background agent activity live * test(tui): synchronize shell cancellation e2e * fix(tui): preserve pending agent activity in cards * fix(tui): preserve reasoning and cancellation boundaries
1 parent f84091b commit 780c276

26 files changed

Lines changed: 3000 additions & 336 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- 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.
19+
- 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.
1820
- 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.
1921
- 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.
2022
- **Behavior change:** `!` shell commands now run through the detected configured shell (`<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.

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_responses.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,18 @@ def _responses_finish_reason(response: Response) -> str | None:
475475
return response.status
476476

477477

478+
def _reasoning_summary_index(value: object) -> int | None:
479+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
480+
return None
481+
return value
482+
483+
484+
def _responses_output_index(value: object) -> int | None:
485+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
486+
return None
487+
return value
488+
489+
478490
class OpenAIResponsesStreamedMessage:
479491
def __init__(self, response: Response | AsyncStream[ResponseStreamEvent]):
480492
if isinstance(response, Response):
@@ -535,16 +547,30 @@ async def _convert_non_stream_response(
535547
),
536548
)
537549
elif item.type == "reasoning":
538-
for summary in item.summary:
550+
encrypted_content = getattr(item, "encrypted_content", None)
551+
emitted_summary = False
552+
for summary_index, summary in enumerate(getattr(item, "summary", ())):
553+
emitted_summary = True
539554
yield ThinkPart(
540555
think=summary.text,
541-
encrypted=item.encrypted_content,
556+
encrypted=encrypted_content,
557+
summary_index=summary_index,
558+
)
559+
if not emitted_summary and encrypted_content is not None:
560+
# Mirror the streaming `output_item.done` path: a reasoning item
561+
# can carry encrypted_content with no summary parts, and dropping
562+
# it here would make the reasoning boundary non-replayable.
563+
yield ThinkPart(
564+
think="",
565+
encrypted=encrypted_content,
566+
summary_index=None,
542567
)
543568

544569
async def _convert_stream_response(
545570
self, response: AsyncStream[ResponseStreamEvent]
546571
) -> AsyncIterator[StreamedMessagePart]:
547572
"""Convert streaming Responses events into message parts."""
573+
reasoning_summary_indices_by_output: dict[int, int | None] = {}
548574
try:
549575
async for chunk in response:
550576
if isinstance(chunk, ResponseCreatedEvent):
@@ -565,16 +591,40 @@ async def _convert_stream_response(
565591
elif chunk.type == "response.output_item.done":
566592
item = chunk.item
567593
if item.type == "reasoning":
568-
yield ThinkPart(think="", encrypted=item.encrypted_content)
594+
output_index = _responses_output_index(getattr(chunk, "output_index", None))
595+
summary_index = (
596+
reasoning_summary_indices_by_output.pop(output_index, None)
597+
if output_index is not None
598+
else None
599+
)
600+
yield ThinkPart(
601+
think="",
602+
encrypted=item.encrypted_content,
603+
summary_index=summary_index,
604+
)
569605
elif isinstance(chunk, ResponseFunctionCallArgumentsDeltaEvent):
570606
yield ToolCallPart(
571607
arguments_part=chunk.delta,
572608
stream_index=chunk.output_index,
573609
)
574610
elif chunk.type == "response.reasoning_summary_part.added":
575-
yield ThinkPart(think="")
611+
summary_index = _reasoning_summary_index(getattr(chunk, "summary_index", None))
612+
output_index = _responses_output_index(getattr(chunk, "output_index", None))
613+
if output_index is not None:
614+
reasoning_summary_indices_by_output[output_index] = summary_index
615+
yield ThinkPart(
616+
think="",
617+
summary_index=summary_index,
618+
)
576619
elif chunk.type == "response.reasoning_summary_text.delta":
577-
yield ThinkPart(think=chunk.delta)
620+
summary_index = _reasoning_summary_index(getattr(chunk, "summary_index", None))
621+
output_index = _responses_output_index(getattr(chunk, "output_index", None))
622+
if output_index is not None:
623+
reasoning_summary_indices_by_output[output_index] = summary_index
624+
yield ThinkPart(
625+
think=getattr(chunk, "delta", ""),
626+
summary_index=summary_index,
627+
)
578628
elif isinstance(chunk, ResponseErrorEvent):
579629
self._finish_reason = "failed"
580630
return

packages/pythinker-core/src/pythinker_core/message.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,14 @@ class ThinkPart(ContentPart):
9898
think: str
9999
encrypted: str | None = None
100100
"""Encrypted thinking content, or signature."""
101+
summary_index: int | None = Field(default=None, exclude_if=lambda value: value is None)
101102

102103
@override
103104
def merge_in_place(self, other: Any) -> bool:
104105
if not isinstance(other, ThinkPart):
105106
return False
107+
if self.summary_index != other.summary_index:
108+
return False
106109
if self.encrypted:
107110
return False
108111
self.think += other.think

packages/pythinker-core/tests/test_message.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,21 @@ def test_message_with_empty_list_content():
301301
)
302302

303303

304+
def test_think_part_summary_index_round_trips_through_pydantic() -> None:
305+
part = ThinkPart(think="Plan", summary_index=3)
306+
dumped = part.model_dump()
307+
308+
assert dumped == snapshot(
309+
{
310+
"type": "think",
311+
"think": "Plan",
312+
"encrypted": None,
313+
"summary_index": 3,
314+
}
315+
)
316+
assert ThinkPart.model_validate(dumped) == part
317+
318+
304319
def test_message_extract_text():
305320
message = Message(
306321
role="user",

packages/pythinker-core/tests/test_stream_message_assembler.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22

33
from pythinker_core.chat_provider import APIStreamProtocolError
4-
from pythinker_core.message import TextPart, ToolCall, ToolCallPart
4+
from pythinker_core.message import TextPart, ThinkPart, ToolCall, ToolCallPart
55
from pythinker_core.stream_message_assembler import StreamMessageAssembler
66

77

@@ -364,6 +364,71 @@ def test_terminal_failure_reasons_are_rejected(finish_reason: str, category: str
364364
assert "private" not in str(caught.value)
365365

366366

367+
def test_same_summary_index_merges_adjacent_think_parts() -> None:
368+
assembler = StreamMessageAssembler()
369+
assembler.add(ThinkPart(think="Plan", summary_index=0))
370+
assembler.add(ThinkPart(think=" next", summary_index=0))
371+
assembler.add(ThinkPart(think="Check", summary_index=1))
372+
373+
message = assembler.finish(response_id=None, finish_reason="completed")
374+
375+
assert message.content == [
376+
ThinkPart(think="Plan next", summary_index=0),
377+
ThinkPart(think="Check", summary_index=1),
378+
]
379+
380+
381+
def test_different_summary_indices_stay_separate() -> None:
382+
assembler = StreamMessageAssembler()
383+
assembler.add(ThinkPart(think="Plan", summary_index=0))
384+
assembler.add(ThinkPart(think="Check", summary_index=1))
385+
386+
message = assembler.finish(response_id=None, finish_reason="completed")
387+
388+
assert message.content == [
389+
ThinkPart(think="Plan", summary_index=0),
390+
ThinkPart(think="Check", summary_index=1),
391+
]
392+
393+
394+
def test_non_adjacent_duplicate_summary_indices_stay_separate() -> None:
395+
assembler = StreamMessageAssembler()
396+
assembler.add(ThinkPart(think="Plan", summary_index=0))
397+
assembler.add(ThinkPart(think="Check", summary_index=1))
398+
assembler.add(ThinkPart(think="Finish", summary_index=0))
399+
400+
message = assembler.finish(response_id=None, finish_reason="completed")
401+
402+
assert message.content == [
403+
ThinkPart(think="Plan", summary_index=0),
404+
ThinkPart(think="Check", summary_index=1),
405+
ThinkPart(think="Finish", summary_index=0),
406+
]
407+
408+
409+
def test_unindexed_legacy_think_parts_still_merge() -> None:
410+
assembler = StreamMessageAssembler()
411+
assembler.add(ThinkPart(think="Plan"))
412+
assembler.add(ThinkPart(think=" next"))
413+
414+
message = assembler.finish(response_id=None, finish_reason="completed")
415+
416+
assert message.content == [ThinkPart(think="Plan next")]
417+
418+
419+
def test_indexed_and_unindexed_think_parts_do_not_merge() -> None:
420+
assembler = StreamMessageAssembler()
421+
assembler.add(ThinkPart(think="Plan", summary_index=0))
422+
assembler.add(ThinkPart(think=" legacy"))
423+
424+
message = assembler.finish(response_id=None, finish_reason="completed")
425+
426+
assert message.content == [
427+
ThinkPart(think="Plan", summary_index=0),
428+
ThinkPart(think=" legacy"),
429+
]
430+
431+
367432
def test_content_parts_assemble_independently_from_tool_calls() -> None:
368433
assembler = StreamMessageAssembler()
369434
assembler.add(TextPart(text="hello "))

0 commit comments

Comments
 (0)