diff --git a/.changeset/jolly-ravens-start.md b/.changeset/jolly-ravens-start.md new file mode 100644 index 000000000..2ea6a8ee7 --- /dev/null +++ b/.changeset/jolly-ravens-start.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Stamp synthesized RUN_FINISHED / RUN_ERROR with the client request runId so interrupt resume settles when the provider continuation omits a terminal event. diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 1553f0713..69af126b2 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -1012,7 +1012,9 @@ export function normalizeConnectionAdapter( // If the connect stream ended cleanly without a terminal event, // synthesize RUN_FINISHED so request-scoped consumers can complete. - // Reuse the caller's threadId/runId so client-side activeRunIds tracking matches. + // The event payload may carry an upstream/provider runId when one was + // observed, but stamp the caller's request runId so getChunkRunId() + // correlates to activeRunIds / currentRunId (same as real stream chunks). if (!abortSignal?.aborted && !hasTerminalEvent) { const synthetic: RunFinishedEvent = { type: EventType.RUN_FINISHED, @@ -1028,7 +1030,7 @@ export function normalizeConnectionAdapter( timestamp: Date.now(), finishReason: 'stop', } - push(synthetic) + push(synthetic, runContext?.runId) } } catch (err) { if (!abortSignal?.aborted && !hasTerminalEvent) { @@ -1052,7 +1054,7 @@ export function normalizeConnectionAdapter( timestamp: Date.now(), message, } - push(synthetic) + push(synthetic, runContext?.runId) } catch { // fall through to rethrow the original error } diff --git a/packages/ai-client/tests/chat-client-resume.test.ts b/packages/ai-client/tests/chat-client-resume.test.ts index e6511ed4f..47169442a 100644 --- a/packages/ai-client/tests/chat-client-resume.test.ts +++ b/packages/ai-client/tests/chat-client-resume.test.ts @@ -72,7 +72,19 @@ async function createInterruptedClient(continuation: Script) { timestamp: Date.now(), outcome: { type: 'interrupt', - interrupts: [{ id: 'interrupt-1', reason: 'client_tool_input' }], + // Full legacy approval metadata (kind + toolName + input) hydrates as + // a public generic interrupt so resolveInterrupt() can drive resume. + interrupts: [ + { + id: 'interrupt-1', + reason: 'approval_required', + metadata: { + kind: 'approval', + toolName: 'confirm', + input: {}, + }, + }, + ], }, }, ], @@ -352,7 +364,7 @@ describe('ChatClient resume', () => { expect(client.getPendingInterrupts()).toEqual([]) }) - it.skip('correlates a synthesized resume finish to the client request run', async () => { + it('correlates a synthesized resume finish to the client request run', async () => { const client = await createInterruptedClient([ { type: EventType.RUN_STARTED, @@ -364,12 +376,16 @@ describe('ChatClient resume', () => { resolveGenericInterrupt(client) - await vi.waitFor(() => expect(client.getInterrupts()).toEqual([])) - expect(client.getResumeState()).toBeNull() - expect(client.getSessionGenerating()).toBe(false) + // Wait for the full settle — interrupts are hidden while status is + // `submitting`, so an empty list alone is not a terminal signal. + await vi.waitFor(() => { + expect(client.getInterrupts()).toEqual([]) + expect(client.getResumeState()).toBeNull() + expect(client.getSessionGenerating()).toBe(false) + }) }) - it.skip('correlates a synthesized resume error to the client request run', async () => { + it('correlates a synthesized resume error to the client request run', async () => { const client = await createInterruptedClient({ chunks: [ { @@ -384,11 +400,11 @@ describe('ChatClient resume', () => { resolveGenericInterrupt(client) - await vi.waitFor(() => - expect(client.getInterrupts()[0]?.status).toBe('error'), - ) - expect(client.getResumeState()).not.toBeNull() - expect(client.getSessionGenerating()).toBe(false) + await vi.waitFor(() => { + expect(client.getInterrupts()[0]?.status).toBe('error') + expect(client.getResumeState()).not.toBeNull() + expect(client.getSessionGenerating()).toBe(false) + }) }) it('resumeInterrupts reconnects with the full current message history', async () => {