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..dac3497b4e 100644 --- a/packages/ai/tests/chat.test.ts +++ b/packages/ai/tests/chat.test.ts @@ -678,6 +678,72 @@ 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('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: [