Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/pythinker-core/src/pythinker_core/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/pythinker-core/tests/test_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 66 additions & 1 deletion packages/pythinker-core/tests/test_stream_message_assembler.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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 "))
Expand Down
Loading
Loading