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:
@@ -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', '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', '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;
|
||||
|
||||
@@ -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).', '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.', '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);
|
||||
|
||||
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### 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
|
||||
and always reachable — revealed on hover or focus, activatable by Tab +
|
||||
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
|
||||
here (relevant to the T-158 budget gap; the remaining-budget % still is not).
|
||||
- `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
|
||||
`assistant`/`user` events as-is (same `message.content` shapes; missing
|
||||
|
||||
@@ -207,18 +207,28 @@ class StreamJsonSession {
|
||||
SessionStatus _status = const SessionStatus();
|
||||
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
|
||||
/// incremental `assistant` events that share the same `message.id`. Emitting
|
||||
/// each partial as a new [ConversationItem] would produce duplicates. Instead
|
||||
/// we accumulate the latest content per message id here and emit only a
|
||||
/// [_PartialUpdate] signal so the controller can upsert rather than append.
|
||||
///
|
||||
/// A null value means the message has been finalised (a non-partial event
|
||||
/// with the same id arrived) — subsequent partial events for that id are
|
||||
/// ignored (shouldn't happen, but guard against it).
|
||||
final _partialIds = <String>{};
|
||||
/// With `--include-partial-messages`, claude emits the in-progress reply as
|
||||
/// `stream_event` envelopes wrapping Anthropic streaming deltas
|
||||
/// (`message_start` → `content_block_delta{text_delta}` → …), interleaved
|
||||
/// with the final per-block `assistant` events. We accumulate the streaming
|
||||
/// 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
|
||||
/// grows. The final text `assistant` event then reuses that same uuid to
|
||||
/// finalise the placeholder; tool_use / thinking blocks keep their own uuids
|
||||
/// and append in order. See docs/spikes (§ stream_event) for the captured
|
||||
/// 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
|
||||
/// is the one currently shown in the composer zone.
|
||||
@@ -290,7 +300,7 @@ class StreamJsonSession {
|
||||
void _onLine(String line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) return;
|
||||
final Map<String, dynamic> ev;
|
||||
Map<String, dynamic> ev;
|
||||
try {
|
||||
ev = (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
@@ -302,58 +312,42 @@ class StreamJsonSession {
|
||||
_onControlRequest(ev);
|
||||
return;
|
||||
}
|
||||
// A `result` ends the turn — clear the busy/interruptible state and clear
|
||||
// any partial-message tracking so the next turn is fresh.
|
||||
// A `result` ends the turn — clear the busy/interruptible state and reset
|
||||
// streaming state so the next turn is fresh.
|
||||
if (ev['type'] == 'result') {
|
||||
_setBusy(false);
|
||||
_partialIds.clear();
|
||||
_streamText.clear();
|
||||
_streamFinalized.clear();
|
||||
_streamingMsgId = null;
|
||||
}
|
||||
|
||||
// Partial-message streaming (T-168). UNVERIFIED WIRE SHAPE: this assumes
|
||||
// `--include-partial-messages` emits incremental `assistant` events with
|
||||
// `partial: true` sharing one `message.id`. That shape has NOT been
|
||||
// confirmed against the live binary — the 2.1.150 spike only documents
|
||||
// non-partial assistant events (one per content block, no `partial` flag),
|
||||
// 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
|
||||
// parse below and are ignored — streaming is inert but nothing breaks.
|
||||
// T-184 validates this against a live capture and fixes or removes it.
|
||||
// When matched, we override the uuid with `partial-<message.id>` so the
|
||||
// controller upserts the item in place rather than appending each tick.
|
||||
final isPartial = ev['partial'] == true;
|
||||
if (isPartial && ev['type'] == 'assistant') {
|
||||
// Token-by-token streaming: `stream_event` envelopes carry the in-progress
|
||||
// reply as Anthropic streaming deltas (T-168, shape confirmed by T-184).
|
||||
if (ev['type'] == 'stream_event') {
|
||||
_onStreamEvent(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalise a streamed reply: when the real text `assistant` event for a
|
||||
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
|
||||
// so the controller replaces the placeholder in place rather than appending
|
||||
// a duplicate. Only a single-text-block event finalises (tool_use/thinking
|
||||
// blocks are separate events that keep their own uuids and append in order);
|
||||
// a second text block for the same id also appends normally.
|
||||
if (ev['type'] == 'assistant') {
|
||||
final message = ev['message'];
|
||||
final msgId = message is Map ? message['id'] as String? : null;
|
||||
if (msgId != null) {
|
||||
_partialIds.add(msgId);
|
||||
// Use a stable uuid derived from the message id so the controller
|
||||
// can upsert this item in place on every partial update.
|
||||
final stableUuid = 'partial-$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;
|
||||
final content = message is Map ? message['content'] : null;
|
||||
final isSingleText = content is List && content.length == 1 && content.single is Map && (content.single as Map)['type'] == 'text';
|
||||
if (msgId != null && _streamText.containsKey(msgId) && !_streamFinalized.contains(msgId) && isSingleText) {
|
||||
ev = {...ev, 'uuid': 'partial-$msgId'};
|
||||
_streamFinalized.add(msgId);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// message.content shapes).
|
||||
final parsed = parseTranscriptChunk(trimmed);
|
||||
// message.content shapes). Parse the possibly-rewritten event.
|
||||
final parsed = parseTranscriptChunk(jsonEncode(ev));
|
||||
for (final item in parsed.items) {
|
||||
_items.add(item);
|
||||
}
|
||||
@@ -362,6 +356,51 @@ class StreamJsonSession {
|
||||
_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]
|
||||
/// item the UI resolves; every other subtype is answered with an error so
|
||||
/// 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',
|
||||
'partial': true,
|
||||
'uuid': 'partial-uuid',
|
||||
'uuid': uuid,
|
||||
'message': {
|
||||
'id': messageId,
|
||||
'role': 'assistant',
|
||||
@@ -232,36 +258,55 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('partial-message streaming (T-168)', () {
|
||||
test('a partial assistant event uses a stable uuid (partial-<msgId>) for in-place updates', () async {
|
||||
proc.emit(partialAssistantText('msg-1', 'hello so far'));
|
||||
group('token streaming via stream_event (T-168, shape verified by T-184)', () {
|
||||
test('content_block_delta text streams under a stable partial-<msgId> uuid, accumulating', () async {
|
||||
proc.emit(streamMessageStart('msg-1'));
|
||||
proc.emit(streamTextDelta('one '));
|
||||
proc.emit(streamTextDelta('two three'));
|
||||
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();
|
||||
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 {
|
||||
proc.emit(partialAssistantText('msg-3', 'streaming'));
|
||||
test('the final assistant text event finalises the placeholder in place (same uuid)', () async {
|
||||
proc.emit(streamMessageStart('msg-2'));
|
||||
proc.emit(streamTextDelta('hel'));
|
||||
proc.emit(assistantTextWithId('msg-2', 'hello there'));
|
||||
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>{}}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
// Clearing partial tracking is internal state — observable only via the
|
||||
// session not crashing and accepting a new partial for the same id.
|
||||
proc.emit(partialAssistantText('msg-3', 'new turn'));
|
||||
// A new turn reusing the same id still streams (no leftover finalised flag).
|
||||
proc.emit(streamMessageStart('msg-4'));
|
||||
proc.emit(streamTextDelta('second'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(items.whereType<AssistantTextMessage>(), isNotEmpty);
|
||||
final parts = items.whereType<AssistantTextMessage>().toList();
|
||||
expect(parts.last.text, 'second');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user