From 35d5df3bc0ac5d9406a13d14c8d0ee2da756e198 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 15 Aug 2026 13:40:18 -0400 Subject: [PATCH 1/2] fix(ai): preserve existing message ids on interrupt snapshots Keep ModelMessage.id on MESSAGES_SNAPSHOT so interrupt boundaries do not rewrite stable identity. Generate snapshot__ only when an id is missing. Fixes #1107 --- .changeset/fix-1107-snapshot-message-ids.md | 5 +++ packages/ai/src/activities/chat/index.ts | 4 ++- packages/ai/tests/chat.test.ts | 36 +++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-1107-snapshot-message-ids.md diff --git a/.changeset/fix-1107-snapshot-message-ids.md b/.changeset/fix-1107-snapshot-message-ids.md new file mode 100644 index 0000000000..82f1e30e7a --- /dev/null +++ b/.changeset/fix-1107-snapshot-message-ids.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': patch +--- + +Preserve existing message IDs on interrupt `MESSAGES_SNAPSHOT` events. diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 15fb215ba0..2130c6be03 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -2177,7 +2177,9 @@ class TextEngine< ? undefined : JSON.stringify(message.content) return { - id: `snapshot_${this.runIdOverride ?? this.requestId}_${index}`, + id: + message.id || + `snapshot_${this.runIdOverride ?? this.requestId}_${index}`, role: message.role, ...(content !== undefined ? { content } : {}), ...('toolCalls' in message && message.toolCalls diff --git a/packages/ai/tests/chat.test.ts b/packages/ai/tests/chat.test.ts index 0e362875c9..cc72fbab60 100644 --- a/packages/ai/tests/chat.test.ts +++ b/packages/ai/tests/chat.test.ts @@ -678,6 +678,42 @@ describe('chat()', () => { }) }) + it('preserves existing message ids on the interrupt MESSAGES_SNAPSHOT', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart('stream-assistant'), + { + ...ev.toolStart('call_1', 'clientSearch'), + parentMessageId: 'stream-assistant', + }, + ev.toolArgs('call_1', '{"query":"test"}'), + ev.runFinished('tool_calls'), + ], + ], + }) + + const chunks = await collectChunks( + chat({ + adapter, + runId: 'interrupt-run', + messages: [{ id: 'user-1', role: 'user', content: 'Search' }], + tools: [clientTool('clientSearch')], + }) as AsyncIterable, + ) + + const snapshot = chunks.find( + (chunk) => chunk.type === EventType.MESSAGES_SNAPSHOT, + ) + expect(snapshot).toMatchObject({ + messages: [ + { id: 'user-1', role: 'user', content: 'Search' }, + { id: 'stream-assistant', role: 'assistant' }, + ], + }) + }) + it('should yield an interrupt outcome for client tools', async () => { const { adapter } = createMockAdapter({ iterations: [ From b6c9128a25d50b2ae9ff13f367a4ebcd298fa033 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 15 Aug 2026 13:57:16 -0400 Subject: [PATCH 2/2] test(ai): cover snapshot id fallback when a message has no id --- packages/ai/tests/chat.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/ai/tests/chat.test.ts b/packages/ai/tests/chat.test.ts index cc72fbab60..dac3497b4e 100644 --- a/packages/ai/tests/chat.test.ts +++ b/packages/ai/tests/chat.test.ts @@ -714,6 +714,36 @@ describe('chat()', () => { }) }) + it('generates a snapshot id on the interrupt MESSAGES_SNAPSHOT when a message has no id', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('call_1', 'clientSearch'), + ev.toolArgs('call_1', '{"query":"test"}'), + ev.runFinished('tool_calls'), + ], + ], + }) + + const chunks = await collectChunks( + chat({ + adapter, + runId: 'interrupt-run', + messages: [{ role: 'user', content: 'Search' }], + tools: [clientTool('clientSearch')], + }) as AsyncIterable, + ) + + const snapshot = chunks.find( + (chunk) => chunk.type === EventType.MESSAGES_SNAPSHOT, + ) + expect(snapshot?.messages[0]).toMatchObject({ + id: 'snapshot_interrupt-run_0', + role: 'user', + }) + }) + it('should yield an interrupt outcome for client tools', async () => { const { adapter } = createMockAdapter({ iterations: [