Skip to content

Show the chat run: show_process weaving and the ChatStream events view - #454

Merged
rejojer merged 4 commits into
mainfrom
feat/chat-process-display
Sep 1, 2026
Merged

Show the chat run: show_process weaving and the ChatStream events view#454
rejojer merged 4 commits into
mainfrom
feat/chat-process-display

Conversation

@rejojer

@rejojer rejojer commented Sep 1, 2026

Copy link
Copy Markdown
Member

What

chat(stream=True) now shows the run. It returns a ChatStream with two views over one run:

# Text view — ordinary iteration, process woven in by default
for piece in client.chat("What status?", doc_id=doc, stream=True):
    print(piece, end="")
# [thinking] I need to check the report first.
#
# [tool] get_document {"doc_name": "report.pdf"}
#   -> get_document: {"success": true, ... (+304 chars)
#
# The project is on track: ...

# Typed view — machine-readable, full data, never clipped
for ev in client.chat("What status?", doc_id=doc, stream=True).events:
    ...  # {"type": "thinking"|"answer", "delta": ...}
         # {"type": "tool_call", "call_id", "name", "arguments"}
         # {"type": "tool_result", "call_id", "name", "output"}

API

  • show_process on chat()on by default ("on where available"). False = bare answer stream; a dict (pageindex.ChatProcessOptions) selects parts: thinking / tool_calls / tool_results (bools, default on) and max_chars (line cap, default 200). Explicit True without stream=True raises.
  • ChatStream: iterating yields text (next() works; existing for piece in client.chat(..., stream=True) code is unchanged in shape), .events yields typed dicts. One run serves one view; close() kills the stream like a closed generator.
  • Non-stream chat() and the protocol surfaces (chat_completions / responses / messages) are untouched.

Behavior changes (release-notes material)

  1. Streamed chat() output now includes the process by default — pass show_process=False for the old bare answer stream (and when joining a stream back into conversation history).
  2. On managed-cloud clients, the stream weaves what the endpoint actually serves: tool-call lines parsed from its block_metadata chunk tags (that wire carries no thinking and no tool results). Old-wire chunks (no block_metadata) remain plain answer text. As a consequence, the bare answer view no longer leaks the tool-argument JSON the endpoint interleaves into delta.contentchat_completions()'s raw text mode is deliberately left as-is.

Engine

One typed-event primitive per lane (_chat_events_agen for the in-process agent, _cloud_chunk_events for managed chunks) with _weave as a pure renderer over it — both views, both lanes, one code path. The chat lane's prologue is shared via _chat_agent (pure refactor; chat_completions behavior unchanged).

Verification

  • 456 tests green — 19 new, red-verified TDD (weave shape, dict config incl. the falsy-{} trap, events sequence with parsed arguments and unclipped output, one-view claim, next() compat, closed-stream deadness, managed weave / clean-off / old-wire compat).
  • Review fixes (42e8bbd): a woven tool result pairs with its call by call_id — standalone with its arguments echoed when parallel calls separate them; empty deltas dropped at the event source; show_process validated before the managed request is sent.
  • Without-frameworks CI leg simulated (agents blocked): managed weaving and all validation paths run without openai-agents.
  • Pyright flat vs main (touched files net −2).

https://claude.ai/code/session_014GN8u3zdH3RpeftHZChavP

…nts view

chat(stream=True) now returns a ChatStream: iterating it yields the
answer text with the run woven in by default — "[thinking] " sections,
one "[tool] name arguments" line per call with its clipped result —
and .events yields the run as typed dicts (thinking/answer deltas,
tool_call with parsed arguments, tool_result with the full output).
One run serves one view; close() kills it like a closed generator.

- show_process: on by default ("on where available"); False for the
  bare answer stream; a dict (ChatProcessOptions: thinking, tool_calls,
  tool_results, max_chars) selects the parts. Explicit True without
  stream=True raises.
- Managed clients weave what the endpoint serves: tool-call lines
  parsed from its block_metadata chunk tags (that wire carries no
  thinking and no tool results); old-wire chunks stay plain answer
  text, and the bare answer view no longer leaks tool-argument JSON.
- Engine: one typed-event primitive (_chat_events_agen /
  _cloud_chunk_events) with _weave as a pure renderer over it; the
  chat lane's prologue is shared via _chat_agent, behavior unchanged
  on chat_completions/responses/messages.

453 tests green (16 new, red-verified), no-openai-agents leg simulated,
pyright flat vs main.

Claude-Session: https://claude.ai/code/session_014GN8u3zdH3RpeftHZChavP
Comment thread pageindex/local_chat.py
arguments = getattr(raw, "arguments", "") or ""
try:
arguments = json.loads(arguments)
except (ValueError, TypeError):
Comment thread pageindex/local_chat.py
arguments = "".join(pieces)
try:
arguments = json.loads(arguments)
except ValueError:
@rejojer

rejojer commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…idate show_process before the managed request

Three review findings on the show_process weave, all red-verified:

- _weave nested every tool result under the most recent [tool] line,
  which misattributes results when a turn makes parallel calls (the SDK
  streams all calls, then all results). A result now nests only when the
  line above is its own call (by call_id); otherwise it stands alone
  with its call's clipped arguments echoed, so same-name parallel calls
  stay tellable apart. Hidden-call mode falls out unchanged (no stored
  arguments, no echo).

- Empty deltas now stop at the event source. chat_completions and the
  managed chunk lane both filter them; the new local lane did not, so a
  mid-stream "" (litellm forwards annotated/provider-field empties)
  leaked into the bare view and flipped _weave sections, splitting one
  thinking burst into repeated labels.

- The managed streaming lane sent the billed request before
  _process_options ran, so a config typo cost a real chat call. chat()
  now chokes on bad show_process before dispatching, matching the local
  lane's validate-first order.

Two docstring truths: close()'s "a run never consumed never starts"
holds only for own-model chat (the managed request is already on the
wire), and the module docstring now covers the managed chunk weave.

456 tests green; the three new ones red-verified; managed-lane paths
re-run with the agents package blocked; pyright adds nothing on touched
lines.

Claude-Session: https://claude.ai/code/session_01XeeD2214z6Vd6qKcAi9ZvJ
…am overloads

Four review fixes plus two coverage gaps, each red- or
mutation-verified:

- _cloud_chunk_events treated any non-tool_use tag inside an open tool
  block as answer text, so argument JSON leaked into the
  show_process=False answer — the one meant to be appended back as
  conversation history. Inside an open block nothing is answer:
  argument chunks now accumulate under any tag. And non-string
  argument pieces stringify at the join instead of killing the whole
  stream with a raw TypeError.

- _process_options sorted unknown keys before repr-ing them, so
  mixed-type keys ({1: True, "foo": 1}) raised a bare TypeError past
  the caller's `except PageIndexAPIError`; sorting the reprs keeps the
  single error type.

- chat() gains @overload on stream, so the docstring's own `.events`
  usage type-checks for py.typed consumers (previously pyright ruled
  `Cannot access attribute "events" for class "str"` on the exact
  documented snippet). pageindex/ error count unchanged (234).

- Coverage: the streaming lane's whole `finally` could be deleted with
  the suite still green — the new abandonment test pins the teardown
  (pump exits, turn 2 emits nothing, _aclose_backend closes the
  per-call client). And FakeModel emitted only the reasoning event
  production never sends (litellm folds reasoning into
  reasoning_content, which arrives as summary deltas); it now
  alternates variants, so dropping either from the isinstance tuple
  goes red.

459 tests green; managed-path tests re-run with the agents package
blocked; flake8 parity on every touched file.

Claude-Session: https://claude.ai/code/session_01DWBCCTDzwuVamBf5MvQ4eP
Comment thread pageindex/client.py
model: Optional[str] = None,
reasoning_effort: Optional[str] = None,
show_process: Union[bool, Mapping[str, Any], None] = None,
) -> str: ...
Comment thread pageindex/client.py
model: Optional[str] = None,
reasoning_effort: Optional[str] = None,
show_process: Union[bool, Mapping[str, Any], None] = None,
) -> "ChatStream": ...
Comment thread pageindex/client.py
reasoning_effort: Optional[str] = None,
) -> Union[str, Iterator[str]]:
show_process: Union[bool, Mapping[str, Any], None] = None,
) -> Union[str, "ChatStream"]: ...
…sy show_process message

ChatStream.events was a property whose getter latched the stream's one
view on mere attribute access: a debugger variable pane, hasattr, or
getattr(stream, "events", None) — which PageIndexAPIError escapes, as
getattr only swallows AttributeError — was enough to make a later
`for chunk in stream:` refuse, with nothing consumed. The getter now
returns a lazy generator: the managed refusal, the view claim and the
run start all happen on first consumption, so introspection is
side-effect free and the text view stays usable after a probe.

And the stream=False guard's message told falsy-but-not-False values
("show_process=0", "") that they passed show_process=True; the check
itself is the ruled falsy-{} trap and stands, but the message now
names the off values and echoes what was got.

461 tests green (2 red-verified new: inert read on both lanes, plus
the falsy-message case); changed managed-path tests re-run with the
agents package blocked; pyright pageindex/ 234 -> 234.

Claude-Session: https://claude.ai/code/session_01DWBCCTDzwuVamBf5MvQ4eP
@rejojer
rejojer merged commit 555c370 into main Sep 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant