Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jolly-ravens-start.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 5 additions & 3 deletions packages/ai-client/src/connection-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1028,7 +1030,7 @@ export function normalizeConnectionAdapter(
timestamp: Date.now(),
finishReason: 'stop',
}
push(synthetic)
push(synthetic, runContext?.runId)
}
} catch (err) {
if (!abortSignal?.aborted && !hasTerminalEvent) {
Expand All @@ -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
}
Expand Down
38 changes: 27 additions & 11 deletions packages/ai-client/tests/chat-client-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
},
],
},
},
],
Expand Down Expand Up @@ -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,
Expand All @@ -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: [
{
Expand All @@ -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 () => {
Expand Down
Loading