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
@@ -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');
});
});