From d6f0f4ba0703b3c7bd16a0d2b07dea137b8f583d Mon Sep 17 00:00:00 2001 From: pythonlearner1025 <77006616+pythonlearner1025@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:32:19 +0000 Subject: [PATCH] fix(acp): group streamed text by ACP messageId so a tool call cannot split a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant messages were rendered split mid-sentence with a tool card wedged in between the halves. In stored history the second half is a separate text item that literally starts with a space: [21] text "Three" [22] tool_call toolu_0166kpDv... (grep ...) [23] text " characterization agents are running in parallel, ..." The adapter already publishes the grouping key. ACP defines `messageId` as "all chunks belonging to the same message share the same messageId", the Claude adapter stamps the Anthropic assistant message id on every agent_message_chunk, Codex stamps the response item id, and `zContentChunk` in acp/schema.ts parses it. history-apply.ts then threw it away: chunks became `{type,text}` and a delta merged only when the LAST item was text, so any tool_call appended between two deltas of the same message ended that block permanently. Tool calls are reported asynchronously, so parallel and background tools land between deltas routinely — 13 of 271 stored text blocks on one box, about 5%. Carry `messageId` onto the text/thought item and resolve the merge target with it: scan back over the items a message can be interrupted by (tool_call, subagent_task, images) and continue the block the id points at. The scan stops at the first text-like item, so two different messages are never joined, and no content heuristic is involved — the discriminator is the id and nothing else. Deltas without a `messageId` keep the previous last-item-only behavior exactly, so adapters that publish none are unaffected. Adjacent blocks still compact regardless of their ids; a block that ends up spanning two messages simply stops claiming one. `parseAssistantTextTags` propagates the id into the parts it splits out, so a split does not lose the grouping. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) --- packages/shared/src/acp/history-apply.ts | 183 ++++++++---- packages/shared/src/ai.ts | 10 + .../shared/tests/acp-history-apply.test.ts | 272 ++++++++++++++++++ 3 files changed, 417 insertions(+), 48 deletions(-) diff --git a/packages/shared/src/acp/history-apply.ts b/packages/shared/src/acp/history-apply.ts index 0c4da4735..0f1f2ded8 100644 --- a/packages/shared/src/acp/history-apply.ts +++ b/packages/shared/src/acp/history-apply.ts @@ -632,6 +632,76 @@ const compactToolCallContentForHistory = ( return dedupeAdjacentToolCallContent(compacted); }; +/** The two item types a streamed `agent_message_chunk` / `agent_thought_chunk` produces. */ +type StreamedTextMessage = Extract; + +/** + * Index of the item a streamed delta continues, or `-1` when it starts a new block. + * + * A delta that arrives right after its own block still merges into it, exactly as before — + * that is the whole behavior when the adapter publishes no `messageId`. + * + * What `messageId` adds is reach. ACP defines it as the grouping key for streamed chunks + * ("all chunks belonging to the same message share the same `messageId`"), while `tool_call` + * notifications are reported asynchronously: a parallel or background tool can report itself + * between two deltas of the SAME message. Without the id the text block ended at the tool + * call and the rest of the sentence became a second block below the tool card. So when the + * delta carries an id we scan back over the items a message can be interrupted by + * (`tool_call`, `subagent_task`, images, …) and continue the block the id points at. + * + * The scan stops at the first text-like item either way: only the message that owns that + * block may continue it, so text from two different messages is never joined across a tool + * call, and no content heuristic is involved. + */ +const findStreamedTextMergeIndex = ( + items: readonly MessageContent[], + kind: StreamedTextMessage['type'], + messageId: string | undefined +): number => { + const lastIndex = items.length - 1; + if (items[lastIndex]?.type === kind) return lastIndex; + if (!messageId) return -1; + + for (let index = lastIndex; index >= 0; index--) { + const item = items[index]; + if (!item) continue; + if (item.type !== 'text' && item.type !== 'thought') continue; + return item.type === kind && item.messageId === messageId ? index : -1; + } + return -1; +}; + +/** + * Rewrite a text/thought block with merged text, keeping its `messageId` honest: a block + * that ends up holding two messages describes neither, so it keeps no id and later deltas + * fall back to plain adjacency. + */ +const mergeStreamedTextItem = ( + existing: StreamedTextMessage, + text: string, + messageId: string | undefined +): MessageContent => { + if ( + existing.messageId !== undefined && + messageId !== undefined && + existing.messageId !== messageId + ) { + const { messageId: _spansTwoMessages, ...rest } = existing; + return { ...rest, text } as MessageContent; + } + return { ...existing, text } as MessageContent; +}; + +/** A new text/thought block, tagged with the message it was streamed from (when known). */ +const createStreamedTextItem = ( + kind: StreamedTextMessage['type'], + text: string, + messageId: string | undefined +): MessageContent => + (messageId === undefined + ? { type: kind, text } + : { type: kind, text, messageId }) as MessageContent; + const compactAdjacentTextAndThought = (items: MessageContent[]): MessageContent[] => { if (items.length === 0) return items; const compacted: MessageContent[] = []; @@ -650,11 +720,13 @@ const compactAdjacentTextAndThought = (items: MessageContent[]): MessageContent[ (nextItem.type === 'text' || nextItem.type === 'thought') && last.type === nextItem.type ) { - const existing = last as Extract; - const next = nextItem as Extract; + const existing = last as StreamedTextMessage; + const next = nextItem as StreamedTextMessage; const text = sanitizeLodyInternalInstructions(existing.text + next.text); if (text) { - compacted[compacted.length - 1] = { ...existing, text }; + // Adjacent blocks still compact regardless of their ids; the merged block just + // stops claiming a message id when the two came from different messages. + compacted[compacted.length - 1] = mergeStreamedTextItem(existing, text, next.messageId); } else { compacted.pop(); } @@ -941,8 +1013,14 @@ const mergeToolCallMessage = ( * unlike Codex which uses dedicated agent_thought_chunk notifications. * * This function extracts thinking content and converts it to the unified `thought` type. + * + * The blocks it produces inherit `messageId` from the block being split, so a later delta of + * the same message still finds them (see `findStreamedTextMergeIndex`). */ -const parseClaudeCodeThinkingTags = (text: string): MessageContent[] => { +const parseClaudeCodeThinkingTags = ( + text: string, + messageId: string | undefined +): MessageContent[] => { const result: MessageContent[] = []; // Only tags anchored to line boundaries are structural (same rationale as the @@ -963,13 +1041,13 @@ const parseClaudeCodeThinkingTags = (text: string): MessageContent[] => { if (textBeforeEnd > lastIndex) { const textBefore = text.slice(lastIndex, textBeforeEnd); if (textBefore) { - result.push({ type: 'text', text: textBefore }); + result.push(createStreamedTextItem('text', textBefore, messageId)); } } const thinkingContent = match[2]; if (thinkingContent) { - result.push({ type: 'thought', text: thinkingContent }); + result.push(createStreamedTextItem('thought', thinkingContent, messageId)); } lastIndex = thinkingRegex.lastIndex; @@ -979,19 +1057,23 @@ const parseClaudeCodeThinkingTags = (text: string): MessageContent[] => { if (lastIndex < text.length) { const textAfter = text.slice(lastIndex); if (textAfter) { - result.push({ type: 'text', text: textAfter }); + result.push(createStreamedTextItem('text', textAfter, messageId)); } } // If no thinking tags found, return the original text if (result.length === 0) { - return [{ type: 'text', text }]; + return [createStreamedTextItem('text', text, messageId)]; } return result; }; -const parseCodexProposedPlanTags = (text: string, turnId: string): MessageContent[] => { +const parseCodexProposedPlanTags = ( + text: string, + turnId: string, + messageId: string | undefined +): MessageContent[] => { // Codex may still emit proposed-plan markup as ordinary assistant text. Keep parsing in Lody, // but only for line-isolated control tags; inline mentions like `` in prose or // code must remain visible text. @@ -1018,7 +1100,7 @@ const parseCodexProposedPlanTags = (text: string, turnId: string): MessageConten if (textBeforeEnd > lastIndex) { const textBefore = text.slice(lastIndex, textBeforeEnd); if (textBefore) { - result.push({ type: 'text', text: textBefore }); + result.push(createStreamedTextItem('text', textBefore, messageId)); } } @@ -1028,13 +1110,13 @@ const parseCodexProposedPlanTags = (text: string, turnId: string): MessageConten } if (insertIndex === undefined) { - return [{ type: 'text', text }]; + return [createStreamedTextItem('text', text, messageId)]; } if (lastIndex < text.length) { const textAfter = text.slice(lastIndex); if (textAfter) { - result.push({ type: 'text', text: textAfter }); + result.push(createStreamedTextItem('text', textAfter, messageId)); } } @@ -1051,13 +1133,17 @@ const parseCodexProposedPlanTags = (text: string, turnId: string): MessageConten return result; }; -const parseAssistantTextTags = (text: string, turnId: string): MessageContent[] => { - const withThoughts = parseClaudeCodeThinkingTags(text); +const parseAssistantTextTags = ( + text: string, + turnId: string, + messageId: string | undefined +): MessageContent[] => { + const withThoughts = parseClaudeCodeThinkingTags(text, messageId); return withThoughts.flatMap((item) => { if (item.type !== 'text') { return [item]; } - return parseCodexProposedPlanTags(item.text, turnId); + return parseCodexProposedPlanTags(item.text, turnId, messageId); }); }; @@ -1075,7 +1161,10 @@ export const buildMessageContentFromNotification = ( case 'text': // Note: Claude Code streams tags across multiple chunks. // We handle parsing in postProcessThinkingTags() after all chunks are merged. - return [{ type: 'text', text: update.content.text }]; + // `messageId` rides along so the applier can tell which message a delta continues. + return [ + createStreamedTextItem('text', update.content.text, update.messageId ?? undefined), + ]; case 'image': case 'audio': case 'resource_link': @@ -1087,7 +1176,9 @@ export const buildMessageContentFromNotification = ( case 'agent_thought_chunk': switch (update.content.type) { case 'text': - return [{ type: 'thought', text: update.content.text }]; + return [ + createStreamedTextItem('thought', update.content.text, update.messageId ?? undefined), + ]; case 'image': case 'audio': case 'resource_link': @@ -1540,14 +1631,14 @@ class NotificationOnHistoryApplier { const text = sanitizeLodyInternalInstructions(message.text); if (!text) return; const entryIndex = this.ensureActiveAssistantEntry(); - this.appendOrMergeAdjacentText(entryIndex, 'text', text); + this.appendOrMergeStreamedText(entryIndex, 'text', text, message.messageId); return; } case 'thought': { const text = sanitizeLodyInternalInstructions(message.text); if (!text) return; const entryIndex = this.ensureActiveAssistantEntry(); - this.appendOrMergeAdjacentText(entryIndex, 'thought', text); + this.appendOrMergeStreamedText(entryIndex, 'thought', text, message.messageId); return; } case 'available_commands': { @@ -1603,34 +1694,32 @@ class NotificationOnHistoryApplier { this.changed = true; } - private appendOrMergeAdjacentText( + private appendOrMergeStreamedText( entryIndex: number, - kind: Extract['type'], - delta: string + kind: StreamedTextMessage['type'], + delta: string, + messageId: string | undefined ) { if (!delta) return; const items = this.ensureEntryItems(entryIndex); - const last = items[items.length - 1]; - if (last && last.type === kind) { - const existing = last as Extract; + const mergeIndex = findStreamedTextMergeIndex(items, kind, messageId); + if (mergeIndex >= 0) { + const existing = items[mergeIndex] as Extract; const text = sanitizeLodyInternalInstructions(mergeStreamChunk(existing.text, delta)); if (!text) { - items.pop(); + items.splice(mergeIndex, 1); this.touchedAssistantEntryIndices.add(entryIndex); this.changed = true; return; } - items[items.length - 1] = { - ...existing, - text, - } as MessageContent; + items[mergeIndex] = mergeStreamedTextItem(existing, text, messageId); this.touchedAssistantEntryIndices.add(entryIndex); this.changed = true; return; } - items.push({ type: kind, text: delta } as MessageContent); + items.push(createStreamedTextItem(kind, delta, messageId)); this.touchedAssistantEntryIndices.add(entryIndex); this.changed = true; } @@ -1755,7 +1844,7 @@ class NotificationOnHistoryApplier { continue; } - const parsed = parseAssistantTextTags(text, entry.id); + const parsed = parseAssistantTextTags(text, entry.id, item.messageId); if ( parsed.length !== 1 || @@ -1912,31 +2001,29 @@ export const applyMessageContentsBatch = ( return entryStates.length - 1; }; - const appendOrMergeAdjacentText = ( + const appendOrMergeStreamedText = ( entryIndex: number, - kind: Extract['type'], - delta: string + kind: StreamedTextMessage['type'], + delta: string, + messageId: string | undefined ) => { if (!delta) return; const state = entryStates[entryIndex]; if (!state) return; // Most ACP updates stream text/thought in many small deltas. - // Keep the stored representation compact by merging adjacent deltas. - const last = state.items[state.items.length - 1]; - if (last && last.type === kind) { - const existing = last as Extract; + // Keep the stored representation compact by merging them back into one block. + const mergeIndex = findStreamedTextMergeIndex(state.items, kind, messageId); + if (mergeIndex >= 0) { + const existing = state.items[mergeIndex] as Extract; const text = sanitizeLodyInternalInstructions(mergeStreamChunk(existing.text, delta)); if (text) { - state.items[state.items.length - 1] = { - ...existing, - text, - } as MessageContent; + state.items[mergeIndex] = mergeStreamedTextItem(existing, text, messageId); } else { - state.items.pop(); + state.items.splice(mergeIndex, 1); } } else { - state.items.push({ type: kind, text: delta } as MessageContent); + state.items.push(createStreamedTextItem(kind, delta, messageId)); } state.dirty = true; }; @@ -2036,14 +2123,14 @@ export const applyMessageContentsBatch = ( const text = sanitizeLodyInternalInstructions(message.text); if (!text) break; const entryIndex = ensureActiveAssistantEntry(); - appendOrMergeAdjacentText(entryIndex, 'text', text); + appendOrMergeStreamedText(entryIndex, 'text', text, message.messageId); break; } case 'thought': { const text = sanitizeLodyInternalInstructions(message.text); if (!text) break; const entryIndex = ensureActiveAssistantEntry(); - appendOrMergeAdjacentText(entryIndex, 'thought', text); + appendOrMergeStreamedText(entryIndex, 'thought', text, message.messageId); break; } case 'plan': { @@ -2103,7 +2190,7 @@ export const applyMessageContentsBatch = ( newItems.push(item); continue; } - const parsed = parseAssistantTextTags(item.text, state.entry.id); + const parsed = parseAssistantTextTags(item.text, state.entry.id, item.messageId); if ( parsed.length !== 1 || parsed[0]?.type !== 'text' || diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index d5ff410f5..05b61d4f6 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1423,6 +1423,14 @@ export type MessageContent = text: string; /** Mention regions of `text`. See `message-text-spans.ts`. */ spans?: MessageTextSpan[]; + /** + * ACP `messageId` of the agent message this block was streamed from, when the adapter + * published one. Every chunk of one message carries the same id, so a delta can find + * the block it continues even when a `tool_call` landed between two of them — see + * `findStreamedTextMergeIndex` in `acp/history-apply.ts`. Absent for blocks that were + * not streamed from an ACP chunk, and for a block that spans more than one message. + */ + messageId?: string; } | ({ type: 'image'; @@ -1432,6 +1440,8 @@ export type MessageContent = | { type: 'thought'; text: string; + /** See `messageId` on the `text` variant. */ + messageId?: string; } | { type: 'plan'; diff --git a/packages/shared/tests/acp-history-apply.test.ts b/packages/shared/tests/acp-history-apply.test.ts index 5682c847d..e37ee1a7d 100644 --- a/packages/shared/tests/acp-history-apply.test.ts +++ b/packages/shared/tests/acp-history-apply.test.ts @@ -245,6 +245,278 @@ describe('acp history apply', () => { expect(history).toEqual([]); }); + it('merges deltas of one message that a tool call interleaved into a single text block', () => { + // Tool calls are reported asynchronously, so a parallel/background tool can report itself + // between two deltas of the same assistant message. Both deltas carry the same ACP + // `messageId`, which is what says they are one message and not two. + const notifications = [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_01Three', + content: { type: 'text', text: 'Three' }, + }), + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_0166kpDv', + title: 'grep', + kind: 'search', + status: 'in_progress', + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_01Three', + content: { + type: 'text', + text: ' characterization agents are running in parallel, plus the full suite on step 1.', + }, + }), + ]; + + const history = applyNotificationOnHistory([], notifications); + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['text', 'tool_call']); + expect(items[0]).toEqual({ + type: 'text', + messageId: 'msg_01Three', + text: 'Three characterization agents are running in parallel, plus the full suite on step 1.', + }); + }); + + it('merges deltas of one message across a tool call applied in a later batch', () => { + // The live path applies notifications in flushes, so the reordered delta usually arrives + // in a separate call: the grouping has to survive in stored history, not in applier state. + const first = applyNotificationOnHistory( + [], + [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_split', + content: { type: 'text', text: 'Reading the' }, + }), + ] + ); + const second = applyNotificationOnHistory(first, [ + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_read', + title: 'read', + kind: 'read', + status: 'in_progress', + }), + ]); + const third = applyNotificationOnHistory(second, [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_split', + content: { type: 'text', text: ' applier now.' }, + }), + ]); + + const items = (third[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['text', 'tool_call']); + expect((items[0] as { text?: string }).text).toBe('Reading the applier now.'); + }); + + it('keeps text from two different messages in separate blocks across a tool call', () => { + const notifications = [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_first', + content: { type: 'text', text: 'Let me check the parser.' }, + }), + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_read', + title: 'read', + kind: 'read', + status: 'completed', + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_second', + content: { type: 'text', text: 'The parser drops the id.' }, + }), + ]; + + const history = applyNotificationOnHistory([], notifications); + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['text', 'tool_call', 'text']); + expect((items[0] as { text?: string }).text).toBe('Let me check the parser.'); + expect((items[2] as { text?: string }).text).toBe('The parser drops the id.'); + }); + + it('merges thought deltas of one message across an interleaved tool call', () => { + const notifications = [ + makeNotification({ + sessionUpdate: 'agent_thought_chunk', + messageId: 'msg_thinking', + content: { type: 'text', text: 'I should' }, + }), + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_bash', + title: 'bash', + kind: 'execute', + status: 'in_progress', + }), + makeNotification({ + sessionUpdate: 'agent_thought_chunk', + messageId: 'msg_thinking', + content: { type: 'text', text: ' check the tests first.' }, + }), + ]; + + const history = applyNotificationOnHistory([], notifications); + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['thought', 'tool_call']); + expect((items[0] as { text?: string }).text).toBe('I should check the tests first.'); + }); + + it('does not merge a text delta into the thought block of the same message', () => { + const notifications = [ + makeNotification({ + sessionUpdate: 'agent_thought_chunk', + messageId: 'msg_mixed', + content: { type: 'text', text: 'Thinking about it.' }, + }), + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_grep', + title: 'grep', + kind: 'search', + status: 'in_progress', + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_mixed', + content: { type: 'text', text: 'Here is the answer.' }, + }), + ]; + + const history = applyNotificationOnHistory([], notifications); + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['thought', 'tool_call', 'text']); + }); + + it('keeps adjacent-only merging for chunks that publish no messageId', () => { + // Regression guard for adapters that do not publish `messageId`: a tool call still ends + // the text block, exactly as before, because nothing says the deltas share a message. + const notifications = [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Three' }, + }), + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_0166kpDv', + title: 'grep', + kind: 'search', + status: 'in_progress', + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ' characterization agents are running in parallel.' }, + }), + ]; + + const history = applyNotificationOnHistory([], notifications); + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items).toEqual([ + { type: 'text', text: 'Three' }, + expect.objectContaining({ type: 'tool_call', toolCallId: 'toolu_0166kpDv' }), + { type: 'text', text: ' characterization agents are running in parallel.' }, + ]); + }); + + it('keeps the message id on a text block split by tags', () => { + const notifications = [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_thinking_tags', + content: { type: 'text', text: '\nWeigh it.\n\nDone' }, + }), + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'toolu_edit', + title: 'edit', + kind: 'edit', + status: 'in_progress', + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_thinking_tags', + content: { type: 'text', text: ' weighing.' }, + }), + ]; + + const history = applyNotificationOnHistory([], notifications); + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['thought', 'text', 'tool_call']); + expect((items[1] as { text?: string }).text).toBe('\nDone weighing.'); + }); + + it('drops the message id from a block that ends up spanning two messages', () => { + // Two messages whose deltas arrive back to back still compact into one block (unchanged + // behavior), but the block then belongs to neither message, so it claims neither id. + const history = applyNotificationOnHistory( + [], + [ + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_first', + content: { type: 'text', text: 'Hello' }, + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + messageId: 'msg_second', + content: { type: 'text', text: ' world' }, + }), + ] + ); + + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items).toEqual([{ type: 'text', text: 'Hello world' }]); + }); + + it('merges message-content batches by messageId across an interleaved tool call', () => { + const history = applyMessageContentsBatch( + [], + [ + { type: 'text', text: 'Three', messageId: 'msg_01Three' }, + { type: 'tool_call', toolCallId: 'toolu_0166kpDv', title: 'grep', status: 'in_progress' }, + { + type: 'text', + text: ' characterization agents are running in parallel, plus the full suite on step 1.', + messageId: 'msg_01Three', + }, + ], + { createId: () => 'turn-1' } + ); + + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['text', 'tool_call']); + expect(items[0]).toEqual({ + type: 'text', + messageId: 'msg_01Three', + text: 'Three characterization agents are running in parallel, plus the full suite on step 1.', + }); + }); + + it('keeps message-content batches from two messages in separate blocks', () => { + const history = applyMessageContentsBatch( + [], + [ + { type: 'text', text: 'Let me check the parser.', messageId: 'msg_first' }, + { type: 'tool_call', toolCallId: 'toolu_read', title: 'read', status: 'completed' }, + { type: 'text', text: 'The parser drops the id.', messageId: 'msg_second' }, + ], + { createId: () => 'turn-1' } + ); + + const items = (history[0]?.items ?? []) as unknown as MessageContent[]; + expect(items.map((item) => item.type)).toEqual(['text', 'tool_call', 'text']); + }); + it('parses Claude Code tags into thought + text', () => { const notifications = [ makeNotification({