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
+93 -54
View File
@@ -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).