stream Claude replies token-by-token from stream_event deltas

A live capture against claude 2.1.150 (both --print and the interactive
stream-json transport clide uses) confirms --include-partial-messages
emits the in-progress reply as stream_event envelopes wrapping Anthropic
streaming deltas — NOT assistant events with partial:true, which is what
T-168 assumed, so that handler never fired and streaming was inert.

Replace it: accumulate content_block_delta text per message id (tracked
from message_start, since deltas carry no id) and emit a placeholder under
a stable partial-<id> uuid the controller upserts in place; the matching
single-text-block assistant event reuses that uuid to finalize, while
tool_use / thinking blocks keep their own uuids and append in order. Tests
rewritten against the captured shape; spike doc records it.

T-184.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:16:52 +02:00
co-authored by Claude
parent 494a6f13a5
commit d0ad8ed4aa
6 changed files with 185 additions and 79 deletions
@@ -2296,3 +2296,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:34:57', '2026-05-30 21:34:57', '2026-05-30 21:34:57', NULL, 'd73e085a28c3c4b2a7ce515d33012de4', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:34:57', '2026-05-30 21:34:57', '2026-05-30 21:34:57', NULL, 'd73e085a28c3c4b2a7ce515d33012de4', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'in_progress', 'done', NULL, '2026-05-31 08:49:07', '2026-05-31 08:49:07', '2026-05-31 08:49:07', NULL, '7a3ab42f4a4571c00dbb55b2079e1b9b', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'in_progress', 'done', NULL, '2026-05-31 08:49:07', '2026-05-31 08:49:07', '2026-05-31 08:49:07', NULL, '7a3ab42f4a4571c00dbb55b2079e1b9b', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 08:49:08', '2026-05-31 08:49:08', '2026-05-31 08:49:08', NULL, 'e7e9fd59ba8adf45431b759b693d4022', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 08:49:08', '2026-05-31 08:49:08', '2026-05-31 08:49:08', NULL, 'e7e9fd59ba8adf45431b759b693d4022', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'status', 'in_progress', 'done', NULL, '2026-05-31 09:30:11', '2026-05-31 09:30:11', '2026-05-31 09:30:11', NULL, 'd9c300f2f35571747572037bc8cb6983', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 09:49:55', '2026-05-31 09:49:55', '2026-05-31 09:49:55', NULL, 'c307a16d7851ec46ee57ff8422380ff7', 1) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -2874,3 +2874,5 @@ Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-30 21:34:57', NULL, 'caa8f38a5ae02650d2b3552a00ab796e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-30 21:34:57', NULL, 'caa8f38a5ae02650d2b3552a00ab796e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-31 08:49:07', NULL, 'b81c7bf1be527e38ef37925f2debd1c5', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-31 08:49:07', NULL, 'b81c7bf1be527e38ef37925f2debd1c5', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-31 08:49:08', NULL, '4c86cfc6e81700b9b94df5420705d849', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-31 08:49:08', NULL, '4c86cfc6e81700b9b94df5420705d849', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'done', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-31 09:30:11', NULL, '9589b5d69a790d02598466697638dde6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'task', 'T-164', 'Validate partial-message wire shape from --include-partial-messages against a live capture', 'T-168 follow-up. T-168 wired --include-partial-messages into the interactive stream-json session (stream_json_session.dart) and added a partial-streaming handler that ASSUMES claude emits incremental assistant events carrying partial:true sharing one message.id. That shape is UNVERIFIED: the 2.1.150 spike (docs/spikes/cc-stream-json-control-protocol-2.1.150.md) only documents non-partial assistant events (one per content block, no partial flag), and the flag''s --help says it ''only works with --print'' while clide runs the interactive control protocol (--input-format/--output-format stream-json, not --print). The handler degrades gracefully (unmatched shapes fall through to parseTranscriptChunk and are ignored — token streaming is inert but nothing breaks), and the T-168 fixtures are synthetic so they pass regardless of reality. Do: capture a real claude turn over the interactive stream-json transport with --include-partial-messages and record the actual partial event envelope (likely a stream_event content_block_delta, not assistant+partial:true). Then either (a) fix the handler in stream_json_session.dart::_onLine to match the real shape and add a fixture from the live capture, or (b) if the flag is a true no-op in the interactive (non-print) mode, drop --include-partial-messages from the argv and the dead partial-handler branch, and pin the finding in the spike doc. Either way, update the UNVERIFIED-WIRE-SHAPE comment in stream_json_session.dart and the spike. Acceptance: the partial path is validated against a live capture, the handler matches the real shape (or is removed), a fixture derived from the capture covers it, and the spike + code comment reflect the confirmed shape.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-30 11:42:53', '2026-05-31 09:49:55', NULL, 'ef7ba71512bcda769d81f79858afab05', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed ### Fixed
- Claude replies now stream token-by-token. The `--include-partial-messages`
output arrives as `stream_event` deltas (not `assistant`+`partial:true` as
first assumed), so the previous handler never fired; the session now reads the
real shape, growing a placeholder in place and finalizing it from the matching
`assistant` event. (T-184)
- Claude conversation card actions (copy + custom) are now keyboard-focusable - Claude conversation card actions (copy + custom) are now keyboard-focusable
and always reachable — revealed on hover or focus, activatable by Tab + and always reachable — revealed on hover or focus, activatable by Tab +
Enter/Space, with Semantics labels for assistive tech (T-174). Enter/Space, with Semantics labels for assistive tech (T-174).
@@ -68,6 +68,19 @@ One JSON object per line. Types seen:
`1000000`) **and `maxOutputTokens`** — i.e. the context-window *size* IS exposed `1000000`) **and `maxOutputTokens`** — i.e. the context-window *size* IS exposed
here (relevant to the T-158 budget gap; the remaining-budget % still is not). here (relevant to the T-158 budget gap; the remaining-budget % still is not).
- `rate_limit_event``{rate_limit_info:{status,resetsAt,rateLimitType,…}}`. - `rate_limit_event``{rate_limit_info:{status,resetsAt,rateLimitType,…}}`.
- `stream_event`**only with `--include-partial-messages`** (T-184). Wraps the
raw Anthropic streaming deltas as they arrive: `{type:"stream_event", event:{…}}`
where `event.type` is `message_start` (carries `message.id`) → `content_block_start`
`content_block_delta` (`delta:{type:"text_delta",text}` / `thinking_delta` /
`input_json_delta`, with `index`) → `content_block_stop``message_delta`
`message_stop`. The full per-block `assistant` event still arrives interleaved.
**Verified against 2.1.150 in BOTH `--print` and the interactive `--input-format
stream-json` transport** — the flag's `--help` claim that it "only works with
--print" is wrong; it streams in interactive mode too. `content_block_delta`
events do NOT carry a message id, so track the id from the preceding
`message_start`. (This is the real shape; the earlier guess of
`assistant`+`partial:true` was wrong — `StreamJsonSession` streams text from
`stream_event` and finalises via the matching `assistant` event.)
The existing `parseTranscriptChunk` (transcript_reader.dart) parses the The existing `parseTranscriptChunk` (transcript_reader.dart) parses the
`assistant`/`user` events as-is (same `message.content` shapes; missing `assistant`/`user` events as-is (same `message.content` shapes; missing
+93 -54
View File
@@ -207,18 +207,28 @@ class StreamJsonSession {
SessionStatus _status = const SessionStatus(); SessionStatus _status = const SessionStatus();
int _localSeq = 0; int _localSeq = 0;
/// Partial-message accumulation keyed by `message.id` (T-168). /// Token-by-token streaming state (T-168, wire shape verified by T-184).
/// ///
/// When `--include-partial-messages` is active, the claude process emits /// With `--include-partial-messages`, claude emits the in-progress reply as
/// incremental `assistant` events that share the same `message.id`. Emitting /// `stream_event` envelopes wrapping Anthropic streaming deltas
/// each partial as a new [ConversationItem] would produce duplicates. Instead /// (`message_start` → `content_block_delta{text_delta}` → …), interleaved
/// we accumulate the latest content per message id here and emit only a /// with the final per-block `assistant` events. We accumulate the streaming
/// [_PartialUpdate] signal so the controller can upsert rather than append. /// text per message id and emit a placeholder item with a stable
/// /// `partial-<message.id>` uuid so the controller upserts it in place as it
/// A null value means the message has been finalised (a non-partial event /// grows. The final text `assistant` event then reuses that same uuid to
/// with the same id arrived) — subsequent partial events for that id are /// finalise the placeholder; tool_use / thinking blocks keep their own uuids
/// ignored (shouldn't happen, but guard against it). /// and append in order. See docs/spikes (§ stream_event) for the captured
final _partialIds = <String>{}; /// shape.
final _streamText = <String, String>{};
/// Message ids whose streamed text placeholder has already been finalised by
/// a real (single-text-block) `assistant` event — so a rare second text
/// block for the same message appends normally instead of overwriting it.
final _streamFinalized = <String>{};
/// The message id currently streaming (`content_block_delta` events carry no
/// message id, so we track the one announced by the last `message_start`).
String? _streamingMsgId;
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head /// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
/// is the one currently shown in the composer zone. /// is the one currently shown in the composer zone.
@@ -290,7 +300,7 @@ class StreamJsonSession {
void _onLine(String line) { void _onLine(String line) {
final trimmed = line.trim(); final trimmed = line.trim();
if (trimmed.isEmpty || !trimmed.startsWith('{')) return; if (trimmed.isEmpty || !trimmed.startsWith('{')) return;
final Map<String, dynamic> ev; Map<String, dynamic> ev;
try { try {
ev = (jsonDecode(trimmed) as Map).cast<String, dynamic>(); ev = (jsonDecode(trimmed) as Map).cast<String, dynamic>();
} catch (_) { } catch (_) {
@@ -302,58 +312,42 @@ class StreamJsonSession {
_onControlRequest(ev); _onControlRequest(ev);
return; return;
} }
// A `result` ends the turn — clear the busy/interruptible state and clear // A `result` ends the turn — clear the busy/interruptible state and reset
// any partial-message tracking so the next turn is fresh. // streaming state so the next turn is fresh.
if (ev['type'] == 'result') { if (ev['type'] == 'result') {
_setBusy(false); _setBusy(false);
_partialIds.clear(); _streamText.clear();
_streamFinalized.clear();
_streamingMsgId = null;
} }
// Partial-message streaming (T-168). UNVERIFIED WIRE SHAPE: this assumes // Token-by-token streaming: `stream_event` envelopes carry the in-progress
// `--include-partial-messages` emits incremental `assistant` events with // reply as Anthropic streaming deltas (T-168, shape confirmed by T-184).
// `partial: true` sharing one `message.id`. That shape has NOT been if (ev['type'] == 'stream_event') {
// confirmed against the live binary — the 2.1.150 spike only documents _onStreamEvent(ev);
// non-partial assistant events (one per content block, no `partial` flag), return;
// and the flag's help says it "only works with --print" (we run the }
// interactive control protocol). If the real shape differs (e.g. a
// `stream_event` delta envelope), these lines fall through to the normal // Finalise a streamed reply: when the real text `assistant` event for a
// parse below and are ignored — streaming is inert but nothing breaks. // message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
// T-184 validates this against a live capture and fixes or removes it. // so the controller replaces the placeholder in place rather than appending
// When matched, we override the uuid with `partial-<message.id>` so the // a duplicate. Only a single-text-block event finalises (tool_use/thinking
// controller upserts the item in place rather than appending each tick. // blocks are separate events that keep their own uuids and append in order);
final isPartial = ev['partial'] == true; // a second text block for the same id also appends normally.
if (isPartial && ev['type'] == 'assistant') { if (ev['type'] == 'assistant') {
final message = ev['message']; final message = ev['message'];
final msgId = message is Map ? message['id'] as String? : null; final msgId = message is Map ? message['id'] as String? : null;
if (msgId != null) { final content = message is Map ? message['content'] : null;
_partialIds.add(msgId); final isSingleText = content is List && content.length == 1 && content.single is Map && (content.single as Map)['type'] == 'text';
// Use a stable uuid derived from the message id so the controller if (msgId != null && _streamText.containsKey(msgId) && !_streamFinalized.contains(msgId) && isSingleText) {
// can upsert this item in place on every partial update. ev = {...ev, 'uuid': 'partial-$msgId'};
final stableUuid = 'partial-$msgId'; _streamFinalized.add(msgId);
final overridden = Map<String, dynamic>.from(ev);
overridden['uuid'] = stableUuid;
final parsed = parseTranscriptChunk(jsonEncode(overridden));
for (final item in parsed.items) {
_items.add(item);
}
_mergeStatus(parsed.status.merge(_statusFromEvent(ev)));
return;
} }
} }
// For a final (non-partial) assistant event whose message.id was seen as
// a partial, remove the partial-id from tracking and let the normal path
// emit the final item (it will append at a new position since its uuid
// differs from the `partial-<id>` placeholder).
if (!isPartial && ev['type'] == 'assistant') {
final message = ev['message'];
final msgId = message is Map ? message['id'] as String? : null;
if (msgId != null) _partialIds.remove(msgId);
}
// Items + assistant model/tokens reuse the transcript parser (identical // Items + assistant model/tokens reuse the transcript parser (identical
// message.content shapes). // message.content shapes). Parse the possibly-rewritten event.
final parsed = parseTranscriptChunk(trimmed); final parsed = parseTranscriptChunk(jsonEncode(ev));
for (final item in parsed.items) { for (final item in parsed.items) {
_items.add(item); _items.add(item);
} }
@@ -362,6 +356,51 @@ class StreamJsonSession {
_mergeStatus(parsed.status.merge(_statusFromEvent(ev))); _mergeStatus(parsed.status.merge(_statusFromEvent(ev)));
} }
/// Handle a `stream_event` (a wrapped Anthropic streaming delta). We render
/// only assistant *text* token-by-token: `message_start` announces the id,
/// `content_block_delta{text_delta}` grows it. Each tick emits a placeholder
/// item with a stable `partial-<id>` uuid so the controller upserts in place;
/// the final `assistant` event finalises it (see [_onLine]). Thinking and
/// tool_use blocks are left to render from their final `assistant` events.
void _onStreamEvent(Map<String, dynamic> ev) {
final event = ev['event'];
if (event is! Map) return;
switch (event['type']) {
case 'message_start':
final msg = event['message'];
final id = msg is Map ? msg['id'] as String? : null;
if (id != null) {
_streamingMsgId = id;
_streamText[id] = '';
_streamFinalized.remove(id);
}
case 'content_block_delta':
final delta = event['delta'];
final msgId = _streamingMsgId;
if (delta is Map && delta['type'] == 'text_delta' && msgId != null) {
final text = (_streamText[msgId] ?? '') + (delta['text'] as String? ?? '');
_streamText[msgId] = text;
final synthetic = {
'type': 'assistant',
'message': {
'id': msgId,
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
'uuid': 'partial-$msgId',
};
final parsed = parseTranscriptChunk(jsonEncode(synthetic));
for (final item in parsed.items) {
_items.add(item);
}
}
case 'message_stop':
_streamingMsgId = null;
}
}
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt] /// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
/// item the UI resolves; every other subtype is answered with an error so /// item the UI resolves; every other subtype is answered with an error so
/// the turn never hangs waiting on us (D-78). /// the turn never hangs waiting on us (D-78).
@@ -104,10 +104,36 @@ String rateLimitEvent({String? status, String? resetsAt}) => jsonEncode({
}, },
}); });
String partialAssistantText(String messageId, String text) => jsonEncode({ // Real `--include-partial-messages` wire shape (captured from claude 2.1.150,
// interactive stream-json mode — T-184): partials arrive as `stream_event`
// envelopes wrapping Anthropic streaming deltas, NOT `assistant`+`partial:true`.
String streamMessageStart(String messageId) => jsonEncode({
'type': 'stream_event',
'event': {
'type': 'message_start',
'message': {'id': messageId, 'role': 'assistant', 'content': <dynamic>[]},
},
});
String streamTextDelta(String text, {int index = 0}) => jsonEncode({
'type': 'stream_event',
'event': {
'type': 'content_block_delta',
'index': index,
'delta': {'type': 'text_delta', 'text': text},
},
});
String streamMessageStop() => jsonEncode({
'type': 'stream_event',
'event': {'type': 'message_stop'},
});
// The final per-block `assistant` event carrying a message id (so the session
// can pair it with a streamed placeholder).
String assistantTextWithId(String messageId, String text, {String uuid = 'final-uuid'}) => jsonEncode({
'type': 'assistant', 'type': 'assistant',
'partial': true, 'uuid': uuid,
'uuid': 'partial-uuid',
'message': { 'message': {
'id': messageId, 'id': messageId,
'role': 'assistant', 'role': 'assistant',
@@ -232,36 +258,55 @@ void main() {
}); });
}); });
group('partial-message streaming (T-168)', () { group('token streaming via stream_event (T-168, shape verified by T-184)', () {
test('a partial assistant event uses a stable uuid (partial-<msgId>) for in-place updates', () async { test('content_block_delta text streams under a stable partial-<msgId> uuid, accumulating', () async {
proc.emit(partialAssistantText('msg-1', 'hello so far')); proc.emit(streamMessageStart('msg-1'));
proc.emit(streamTextDelta('one '));
proc.emit(streamTextDelta('two three'));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
// The item emitted uses the stable partial uuid so the controller can upsert it.
expect(items.whereType<AssistantTextMessage>(), hasLength(1));
expect(items.whereType<AssistantTextMessage>().first.uuid, 'partial-msg-1');
expect(items.whereType<AssistantTextMessage>().first.text, 'hello so far');
});
test('two partial events for the same message.id both carry the stable uuid', () async {
proc.emit(partialAssistantText('msg-2', 'hello'));
await Future<void>.delayed(Duration.zero);
proc.emit(partialAssistantText('msg-2', 'hello world'));
await Future<void>.delayed(Duration.zero);
// Both carry the same stable uuid so a ConversationController can upsert them.
final parts = items.whereType<AssistantTextMessage>().toList(); final parts = items.whereType<AssistantTextMessage>().toList();
expect(parts.every((m) => m.uuid == 'partial-msg-2'), isTrue); // Each delta emits an upserting placeholder; all share the stable uuid and
// the latest carries the accumulated text.
expect(parts, isNotEmpty);
expect(parts.every((m) => m.uuid == 'partial-msg-1'), isTrue);
expect(parts.last.text, 'one two three');
}); });
test('partial tracking is cleared after a result event', () async { test('the final assistant text event finalises the placeholder in place (same uuid)', () async {
proc.emit(partialAssistantText('msg-3', 'streaming')); proc.emit(streamMessageStart('msg-2'));
proc.emit(streamTextDelta('hel'));
proc.emit(assistantTextWithId('msg-2', 'hello there'));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final parts = items.whereType<AssistantTextMessage>().toList();
// The final, complete text reuses the placeholder uuid so the controller
// replaces rather than appends — no duplicate.
expect(parts.last.uuid, 'partial-msg-2');
expect(parts.last.text, 'hello there');
});
test('a tool_use block keeps its own uuid and appends after the streamed text', () async {
proc.emit(streamMessageStart('msg-3'));
proc.emit(streamTextDelta('working'));
proc.emit(assistantTextWithId('msg-3', 'working on it')); // finalises partial-msg-3
proc.emit(assistantToolUse()); // separate block, own uuid
await Future<void>.delayed(Duration.zero);
final tool = items.whereType<AssistantToolUse>().single;
expect(tool.uuid, isNot('partial-msg-3'));
expect(items.last, isA<AssistantToolUse>());
});
test('streaming state resets after a result so the next turn streams cleanly', () async {
proc.emit(streamMessageStart('msg-4'));
proc.emit(streamTextDelta('first'));
proc.emit(streamMessageStop());
proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}})); proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}}));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
// Clearing partial tracking is internal state — observable only via the // A new turn reusing the same id still streams (no leftover finalised flag).
// session not crashing and accepting a new partial for the same id. proc.emit(streamMessageStart('msg-4'));
proc.emit(partialAssistantText('msg-3', 'new turn')); proc.emit(streamTextDelta('second'));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect(items.whereType<AssistantTextMessage>(), isNotEmpty); final parts = items.whereType<AssistantTextMessage>().toList();
expect(parts.last.text, 'second');
}); });
}); });