From f5eb69fd80319f42d43a3b89d717efd9d3b31366 Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Tue, 29 Sep 2026 00:14:48 +0800 Subject: [PATCH 1/5] fix(ai): propagate after-tool middleware errors --- .changeset/ai-after-tool-hook-error.md | 5 +++ docs/advanced/middleware.md | 2 + docs/config.json | 2 +- .../src/activities/chat/tools/tool-calls.ts | 7 ++++ packages/ai/tests/middleware.test.ts | 42 +++++++++++++++++++ testing/e2e/src/routes/api.middleware-test.ts | 11 +++++ testing/e2e/tests/middleware.spec.ts | 33 +++++++++++++++ 7 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 .changeset/ai-after-tool-hook-error.md diff --git a/.changeset/ai-after-tool-hook-error.md b/.changeset/ai-after-tool-hook-error.md new file mode 100644 index 0000000000..49c7ab1341 --- /dev/null +++ b/.changeset/ai-after-tool-hook-error.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': patch +--- + +Treat a failed `onAfterToolCall` middleware hook as a chat run error instead of recording a second, contradictory tool result. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index f5a5a0ccfc..d0c4a96f3f 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -567,6 +567,8 @@ The `hookCtx` provides: ### onAfterToolCall Called after each tool execution (or skip). All middleware run — there is no short-circuiting. +If this hook throws, the chat run ends through `onError`. Catch errors inside +the hook when its work is optional. ```typescript import { type ChatMiddleware } from "@tanstack/ai"; diff --git a/docs/config.json b/docs/config.json index a5361a2351..ce37c24f9e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -301,7 +301,7 @@ "label": "Middleware", "to": "advanced/middleware", "addedAt": "2026-04-15", - "updatedAt": "2026-09-25" + "updatedAt": "2026-09-29" }, { "label": "Built-in Middleware", diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 4b24c60f35..211dfcdbee 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -683,6 +683,7 @@ export async function* executeServerTool( subagentInterrupts?: Array, ): AsyncGenerator { const startTime = Date.now() + let resultRecorded = false try { if (!tool.execute) { throw new Error(`Tool ${toolName} has no execute() implementation`) @@ -717,6 +718,7 @@ export async function* executeServerTool( duration, ...(outcome.error ? { state: 'output-error' as const } : {}), }) + resultRecorded = true await middlewareHooks?.onAfterToolCall?.({ toolCall, tool, @@ -760,6 +762,7 @@ export async function* executeServerTool( output: finalResult, duration, }) + resultRecorded = true if (middlewareHooks?.onAfterToolCall) { await middlewareHooks.onAfterToolCall({ @@ -773,6 +776,10 @@ export async function* executeServerTool( }) } } catch (error: unknown) { + // A post-execution hook failed after the tool result was recorded. Let the + // run handle that failure; reporting it as a second tool result would give + // the model contradictory outcomes and call the hook a second time. + if (resultRecorded) throw error const duration = Date.now() - startTime // Flush remaining events diff --git a/packages/ai/tests/middleware.test.ts b/packages/ai/tests/middleware.test.ts index 1929a591f9..aa38a23be1 100644 --- a/packages/ai/tests/middleware.test.ts +++ b/packages/ai/tests/middleware.test.ts @@ -1401,6 +1401,48 @@ describe('chat() middleware', () => { // onAfterToolCall error path // ========================================================================== describe('onAfterToolCall error handling', () => { + it('does not turn a failed after-hook into a second tool result', async () => { + const execute = vi.fn(() => ({ sent: true })) + const onAfterToolCall = vi.fn() + const onError = vi.fn() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('tc-1', 'sendEmail'), + ev.toolArgs('tc-1', '{}'), + ev.toolEnd('tc-1', 'sendEmail', { input: {} }), + ev.runFinished('tool_calls'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Send it' }], + tools: [serverTool('sendEmail', execute)], + middleware: [ + { + name: 'failing-after-hook', + onAfterToolCall: (...args) => { + onAfterToolCall(...args) + if (onAfterToolCall.mock.calls.length === 1) { + throw new Error('post-hook failed') + } + }, + onError: (_ctx, info) => onError(info), + }, + ], + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow('post-hook failed') + expect(execute).toHaveBeenCalledOnce() + expect(onAfterToolCall).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledOnce() + }) + it('should report tool execution errors in onAfterToolCall', async () => { const afterCalls: Array<{ ok: boolean; error?: unknown }> = [] diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index 2977005c34..b53d690653 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -566,6 +566,17 @@ export const Route = createFileRoute('/api/middleware-test')({ middleware.push(chunkTransformMiddleware) if (middlewareMode === 'tool-skip') middleware.push(toolSkipMiddleware) + if (middlewareMode === 'after-tool-error') { + let calls = 0 + middleware.push({ + name: 'after-tool-error', + onAfterToolCall() { + // Return on a second call so the old behavior would emit two + // conflicting results instead of a RUN_ERROR. + if (++calls === 1) throw new Error('post-hook failed') + }, + }) + } if (middlewareMode === 'capability') { // Order matters: the provider's setup() must run before the // consumer reads the capability. Array order is preserved. diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index c7e05b6d47..400e7feb82 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -34,6 +34,39 @@ async function fetchPhaseCapture( } test.describe('Middleware Lifecycle', () => { + test('a failed after-tool hook ends the run without conflicting tool results', async ({ + page, + testId, + aimockPort, + }) => { + const response = await page.request.post('/api/middleware-test', { + data: { + threadId: testId, + runId: `${testId}-after-tool-error`, + messages: [ + { id: 'user-1', role: 'user', content: '[with-tool] run test' }, + ], + tools: [], + context: [], + forwardedProps: { + scenario: 'with-tool', + middlewareMode: 'after-tool-error', + testId, + aimockPort, + }, + }, + }) + const events = (await response.text()) + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => JSON.parse(line.slice(6)) as { type: string }) + + expect(events.some((event) => event.type === 'RUN_ERROR')).toBe(true) + expect(events.some((event) => event.type === 'TOOL_CALL_RESULT')).toBe( + false, + ) + }) + test('adapter RUN_ERROR calls onError once', async ({ page, testId, From 77046a4ce3c58f6609d75b6645f49cb1960d0940 Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Tue, 29 Sep 2026 01:04:18 +0800 Subject: [PATCH 2/5] docs(ai): clarify after-tool hook failure order --- docs/advanced/middleware.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index d0c4a96f3f..e310422dcd 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -566,9 +566,10 @@ The `hookCtx` provides: ### onAfterToolCall -Called after each tool execution (or skip). All middleware run — there is no short-circuiting. -If this hook throws, the chat run ends through `onError`. Catch errors inside -the hook when its work is optional. +Called after each tool execution (or skip). Hooks run in order. +If one hook throws, later hooks do not run. +The chat run ends through `onError`. Catch errors inside the hook when its work +is optional. ```typescript import { type ChatMiddleware } from "@tanstack/ai"; From c03895294fce9df97cbd81b15f4ba536717cffd3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 7 Oct 2026 17:47:24 +0200 Subject: [PATCH 3/5] docs: update middleware page date --- docs/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.json b/docs/config.json index a2d731cf03..ecf3d932d2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -301,7 +301,7 @@ "label": "Middleware", "to": "advanced/middleware", "addedAt": "2026-04-15", - "updatedAt": "2026-09-30" + "updatedAt": "2026-10-07" }, { "label": "Built-in Middleware", From e5226480c794aa172f1cc2daac889bee432cd48a Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Tue, 13 Oct 2026 21:56:10 +0800 Subject: [PATCH 4/5] fix(ai): propagate later hook failures through durable streams --- .changeset/ai-after-tool-hook-error.md | 2 + docs/advanced/middleware.md | 3 + packages/ai/src/activities/chat/index.ts | 20 ++++- packages/ai/src/delivery-completion.ts | 15 ++++ packages/ai/src/stream-to-response.ts | 40 +++++----- .../stream-to-response-durability.test.ts | 65 ++++++++++++++++ .../middleware-test/after-tool-error.json | 24 ++++++ testing/e2e/src/routes/api.middleware-test.ts | 20 +++-- testing/e2e/tests/middleware.spec.ts | 76 +++++++++++-------- 9 files changed, 207 insertions(+), 58 deletions(-) create mode 100644 packages/ai/src/delivery-completion.ts create mode 100644 testing/e2e/fixtures/middleware-test/after-tool-error.json diff --git a/.changeset/ai-after-tool-hook-error.md b/.changeset/ai-after-tool-hook-error.md index 49c7ab1341..c10c60ce99 100644 --- a/.changeset/ai-after-tool-hook-error.md +++ b/.changeset/ai-after-tool-hook-error.md @@ -3,3 +3,5 @@ --- Treat a failed `onAfterToolCall` middleware hook as a chat run error instead of recording a second, contradictory tool result. + +Deliver and persist that error for durable streams even after an earlier tool iteration emitted `RUN_FINISHED`. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index ce890bc2f9..cfbb308629 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -679,6 +679,9 @@ If one hook throws, later hooks do not run. The chat run ends through `onError`. Catch errors inside the hook when its work is optional. +Durable response helpers also deliver and store the `RUN_ERROR`, even after an +earlier tool iteration finished. + ```typescript import { type ChatMiddleware } from "@tanstack/ai"; diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index a1ef64f639..027ac516a4 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -115,6 +115,7 @@ import { import { MiddlewareRunner } from './middleware/compose' import { getRunDetached } from './middleware/run-store' import { publishRunDetachedSignal } from '../../delivery-detach' +import { publishRunCompletionSignal } from '../../delivery-completion' import { publishRunDisconnectHandler } from '../../delivery-disconnect' import { provideSandboxRuntime } from './middleware/sandbox-runtime' import { provideRunDisconnect } from './middleware/run-disconnect' @@ -1066,6 +1067,7 @@ class TextEngine< // observe both cancellation sources via ctx.abortSignal. private readonly toolAbortSignal?: AbortSignal private terminalHookCalled = false + private runEnded = false /** * Latched the first time the delivery socket closes; see `notifyDisconnected`. * Also read by `subscribe` so a listener registered AFTER the disconnect (a @@ -1357,6 +1359,7 @@ class TextEngine< } private async runTerminalHook(): Promise { + this.runEnded = true if (this.terminalHookCalled || this.isCancelled()) return this.terminalHookCalled = true @@ -1428,6 +1431,7 @@ class TextEngine< if (this.earlyTermination) { yield* this.emitSuccessfulEarlyTermination() + this.runEnded = true if (!this.terminalHookCalled) { this.terminalHookCalled = true await this.middlewareRunner.runOnFinish(this.middlewareCtx, { @@ -1566,6 +1570,7 @@ class TextEngine< 'continuationRunId' in error && typeof error.continuationRunId === 'string' ) { + this.runEnded = true this.terminalHookCalled = true yield* this.pipeThroughMiddleware({ type: EventType.RUN_FINISHED, @@ -1655,6 +1660,10 @@ class TextEngine< this.middlewareCtx.toolNames = this.eventToolNames } + hasRunEnded(): boolean { + return this.runEnded + } + private async beginCycle(): Promise { if (this.cyclePhase === 'processText') { await this.beginIteration() @@ -3688,6 +3697,7 @@ class TextEngine< }) } for (const output of terminalOutputs) { + this.runEnded = true yield this.publicInterruptTerminal(output) } return true @@ -5790,19 +5800,20 @@ function validateInterruptDefinitions( /** * The slice of the engine that the durable delivery sink reaches back into, in - * BOTH directions: it reads the detach verdict (`wasDetached`) and pushes the - * socket-closed fact in (`notifyDisconnected`). Filled by the generator body as - * soon as its engine exists. + * BOTH directions: it reads run completion and detach state, and pushes the + * socket-closed fact in (`notifyDisconnected`). Filled by the generator body + * as soon as its engine exists. */ interface DeliveryEngineRef { current?: { wasDetached: () => boolean notifyDisconnected: () => void + hasRunEnded: () => boolean } } /** - * Publish both delivery-side seams for `stream`. + * Publish delivery-side state and callbacks for `stream`. * * Shared by the two streaming paths so they cannot drift apart — the * structured-output path having been wired for one seam and not the other is @@ -5813,6 +5824,7 @@ function publishDeliverySeams( stream: object, engineRef: DeliveryEngineRef, ): void { + publishRunCompletionSignal(stream, () => engineRef.current?.hasRunEnded()) // A thunk, evaluated on the sink's teardown path: the engine does not exist // yet, and the verdict it will report is only written during `onAbort`. publishRunDetachedSignal( diff --git a/packages/ai/src/delivery-completion.ts b/packages/ai/src/delivery-completion.ts new file mode 100644 index 0000000000..4db74b491a --- /dev/null +++ b/packages/ai/src/delivery-completion.ts @@ -0,0 +1,15 @@ +/** Chat streams can emit RUN_FINISHED before the agent loop actually ends. */ +const completionSignals = new WeakMap boolean | undefined>() + +/** Publish final-run state before terminal hooks and cleanup can fail. */ +export function publishRunCompletionSignal( + stream: object, + completed: () => boolean | undefined, +): void { + completionSignals.set(stream, completed) +} + +/** Undefined preserves terminal-event handling for user-provided iterables. */ +export function wasRunCompleted(stream: object): boolean | undefined { + return completionSignals.get(stream)?.() +} diff --git a/packages/ai/src/stream-to-response.ts b/packages/ai/src/stream-to-response.ts index 165dd1f94f..e7f0f0b147 100644 --- a/packages/ai/src/stream-to-response.ts +++ b/packages/ai/src/stream-to-response.ts @@ -5,6 +5,7 @@ import { isTerminalRunStatus, } from './activities/chat/middleware/run-store' import { wasRunDetached } from './delivery-detach' +import { wasRunCompleted } from './delivery-completion' import { notifyRunDisconnected } from './delivery-disconnect' import { resolveResumeRunId } from './stream-durability' import { EventType } from './types' @@ -715,14 +716,12 @@ export function durableStreamSource( async function* produce(): AsyncIterable { let batch: Array = [] - let terminalPersisted = false - // Whether a terminal event was actually delivered LIVE to the consumer (as - // opposed to only appended to the log). Distinguishes "the run already ended - // on the wire" from "the log has a terminal but the consumer never saw one", - // which governs whether a late durability-cleanup failure may be rethrown. - // Only ever assigned inside the nested flush() closure, which TS's - // control-flow analysis can't observe (see the disable at the read site). - let terminalForwarded = false + // A persisted terminal need not have reached the live consumer. Chat + // iteration terminals are provisional until the engine ends the run. + const terminals: { + persisted?: 'RUN_FINISHED' | 'RUN_ERROR' + forwarded?: 'RUN_FINISHED' | 'RUN_ERROR' + } = {} let failure: RecordedFailure | undefined let terminalCause: unknown let hasTerminalCause = false @@ -756,17 +755,14 @@ export function durableStreamSource( validateOffset(offset) idByChunk.set(chunk, offset) }) - if ( - toForward.some( - (chunk) => - chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR', - ) - ) { - terminalPersisted = true + for (const chunk of toForward) { + if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + terminals.persisted = chunk.type + } } for (const chunk of toForward) { if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - terminalForwarded = true + terminals.forwarded = chunk.type } yield chunk } @@ -908,7 +904,7 @@ export function durableStreamSource( // What is ALREADY IN THE LOG is deliberately NOT a conjunct. An agent-loop // run emits one `RUN_FINISHED` PER ITERATION — the intermediate // `finishReason: 'tool_calls'` terminal is flushed at its boundary - // mid-run — so `terminalPersisted` means "some terminal is in the log", + // mid-run — so `terminals.persisted` means "some terminal is in the log", // never "the run ended". Gating on it terminalized the log of a healthy, // still-running agent for every tool-calling run. Nor can the sink // distinguish a final terminal from an intermediate one by its @@ -922,6 +918,11 @@ export function durableStreamSource( !hasTerminalCause && wasRunDetached(stream) + const completed = wasRunCompleted(stream) !== false + const terminalPersisted = + terminals.persisted === 'RUN_ERROR' || + (terminals.persisted === 'RUN_FINISHED' && completed) + if ( !detached && needsTerminalPersistence(terminalPersisted, cancelled, hasTerminalCause) @@ -933,7 +934,6 @@ export function durableStreamSource( const cause = hasTerminalCause ? terminalCause : { name: 'AbortError' } try { await durability.append([runErrorChunk(cause)]) - terminalPersisted = true } catch (terminalError) { // Rethrown to the live consumer below, but a joiner replaying the log // only ever sees a generic incomplete error — so record the real @@ -982,7 +982,9 @@ export function durableStreamSource( // provider that throws AFTER emitting its own terminal, whose error is // otherwise neither delivered nor logged.) if (failure !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- terminalForwarded is set only inside the flush() closure, which TS CFA narrows away here + const terminalForwarded = + terminals.forwarded === 'RUN_ERROR' || + (terminals.forwarded === 'RUN_FINISHED' && completed) if (!terminalForwarded) { // eslint-disable-next-line no-unsafe-finally throw failure.error diff --git a/packages/ai/tests/stream-to-response-durability.test.ts b/packages/ai/tests/stream-to-response-durability.test.ts index 515dcac330..e683da558a 100644 --- a/packages/ai/tests/stream-to-response-durability.test.ts +++ b/packages/ai/tests/stream-to-response-durability.test.ts @@ -101,6 +101,71 @@ function fixedOffsetDurability( } describe('toServerSentEventsResponse with durability', () => { + it.each(['SSE', 'NDJSON'] as const)( + 'delivers and persists a later after-tool error over %s', + async (format) => { + const { adapter } = createMockAdapter({ + iterations: ['first', 'second'].map((id) => [ + ev.runStarted(), + ev.toolStart(id, 'lookup'), + ev.toolArgs(id, '{}'), + ev.toolEnd(id), + ev.runFinished('tool_calls'), + ]), + }) + const execute = vi.fn(() => 'found') + const onError = vi.fn() + const onAfterToolCall = vi.fn(() => { + if (onAfterToolCall.mock.calls.length === 2) { + throw new Error('second hook failed') + } + }) + const stream = chat({ + adapter, + messages: [{ role: 'user' as const, content: 'Look twice' }], + tools: [ + toolDefinition({ + name: 'lookup', + description: 'Return a value', + }).server(execute), + ], + middleware: [{ name: 'fail-second-hook', onAfterToolCall, onError }], + }) + const durability = memoryStream( + new Request(`https://example.test/chat?runId=after-hook-${format}`, { + method: 'POST', + }), + ) + const response = ( + format === 'SSE' ? toServerSentEventsResponse : toHttpResponse + )(stream, { durability: { adapter: durability } }) + const body = await readBody(response) + const events = + format === 'SSE' ? parseSseEvents(body) : parseNdjsonEvents(body) + const types = events.map((event) => field(event, 'type')) + expect(types).toContain(EventType.RUN_FINISHED) + expect(types.at(-1)).toBe(EventType.RUN_ERROR) + expect(types.filter((type) => type === EventType.RUN_ERROR)).toHaveLength( + 1, + ) + expect(field(events.at(-1)!, 'message')).toBe('second hook failed') + expect( + events.filter( + (event) => field(event, 'type') === EventType.TOOL_CALL_RESULT, + ), + ).toHaveLength(1) + expect(execute).toHaveBeenCalledTimes(2) + expect(onAfterToolCall).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledOnce() + + const stored = (await durability.snapshot()).map((entry) => entry.chunk) + expect(stored.at(-1)?.type).toBe(EventType.RUN_ERROR) + expect( + stored.filter((chunk) => chunk.type === EventType.RUN_ERROR), + ).toHaveLength(1) + }, + ) + it('keeps persisting while a connected client does not read', async () => { const durability = memoryStream( new Request('https://example.test/api/chat?runId=unread-connected', { diff --git a/testing/e2e/fixtures/middleware-test/after-tool-error.json b/testing/e2e/fixtures/middleware-test/after-tool-error.json new file mode 100644 index 0000000000..69895e1656 --- /dev/null +++ b/testing/e2e/fixtures/middleware-test/after-tool-error.json @@ -0,0 +1,24 @@ +{ + "fixtures": [ + { + "match": { "userMessage": "[with-tool] run twice", "sequenceIndex": 0 }, + "response": { + "toolCalls": [ + { "name": "get_weather", "arguments": "{\"city\":\"NYC\"}" } + ] + } + }, + { + "match": { "userMessage": "[with-tool] run twice", "sequenceIndex": 1 }, + "response": { + "toolCalls": [ + { "name": "get_weather", "arguments": "{\"city\":\"London\"}" } + ] + } + }, + { + "match": { "userMessage": "[with-tool] run twice", "sequenceIndex": 2 }, + "response": { "content": "Both cities are sunny." } + } + ] +} diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index 14fc689a78..f126868af8 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -5,6 +5,7 @@ import { createCapability, EventType, maxIterations, + memoryStream, toServerSentEventsResponse, toolDefinition, } from '@tanstack/ai' @@ -577,14 +578,18 @@ export const Route = createFileRoute('/api/middleware-test')({ middleware.push(chunkTransformMiddleware) if (middlewareMode === 'tool-skip') middleware.push(toolSkipMiddleware) - if (middlewareMode === 'after-tool-error') { + if ( + middlewareMode === 'after-tool-error' || + middlewareMode === 'after-tool-error-durable' + ) { let calls = 0 + const failingCall = + middlewareMode === 'after-tool-error-durable' ? 2 : 1 middleware.push({ name: 'after-tool-error', onAfterToolCall() { - // Return on a second call so the old behavior would emit two - // conflicting results instead of a RUN_ERROR. - if (++calls === 1) throw new Error('post-hook failed') + // Throw once so retrying the hook would hide the failure. + if (++calls === failingCall) throw new Error('post-hook failed') }, }) } @@ -741,7 +746,12 @@ export const Route = createFileRoute('/api/middleware-test')({ ? teeForPhaseCapture(rawStream, testId) : rawStream - return toServerSentEventsResponse(stream, { abortController }) + return toServerSentEventsResponse(stream, { + abortController, + ...(middlewareMode === 'after-tool-error-durable' && { + durability: { adapter: memoryStream(request) }, + }), + }) } catch (error: any) { console.error('[api.middleware-test] Error:', error.message) if (error.name === 'AbortError' || abortController.signal.aborted) { diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index 186f316986..2456e898d7 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -34,38 +34,54 @@ async function fetchPhaseCapture( } test.describe('Middleware Lifecycle', () => { - test('a failed after-tool hook ends the run without conflicting tool results', async ({ - page, - testId, - aimockPort, - }) => { - const response = await page.request.post('/api/middleware-test', { - data: { - threadId: testId, - runId: `${testId}-after-tool-error`, - messages: [ - { id: 'user-1', role: 'user', content: '[with-tool] run test' }, - ], - tools: [], - context: [], - forwardedProps: { - scenario: 'with-tool', - middlewareMode: 'after-tool-error', - testId, - aimockPort, + for (const durable of [false, true]) { + test(`a failed after-tool hook ends the run (durable=${durable})`, async ({ + page, + testId, + aimockPort, + }) => { + const response = await page.request.post('/api/middleware-test', { + data: { + threadId: testId, + runId: `${testId}-after-tool-error`, + messages: [ + { + id: 'user-1', + role: 'user', + content: durable + ? '[with-tool] run twice' + : '[with-tool] run test', + }, + ], + tools: [], + context: [], + forwardedProps: { + scenario: 'with-tool', + middlewareMode: durable + ? 'after-tool-error-durable' + : 'after-tool-error', + testId, + aimockPort, + }, }, - }, + }) + const events = (await response.text()) + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => JSON.parse(line.slice(6)) as { type: string }) + + expect(events.some((event) => event.type === 'RUN_ERROR')).toBe(true) + expect(events.at(-1)?.type).toBe('RUN_ERROR') + expect(events.filter((event) => event.type === 'RUN_ERROR')).toHaveLength( + 1, + ) + expect( + events.filter((event) => event.type === 'TOOL_CALL_RESULT'), + ).toHaveLength(durable ? 1 : 0) + if (durable) + expect(events.some((event) => event.type === 'RUN_FINISHED')).toBe(true) }) - const events = (await response.text()) - .split('\n') - .filter((line) => line.startsWith('data: ')) - .map((line) => JSON.parse(line.slice(6)) as { type: string }) - - expect(events.some((event) => event.type === 'RUN_ERROR')).toBe(true) - expect(events.some((event) => event.type === 'TOOL_CALL_RESULT')).toBe( - false, - ) - }) + } test('adapter RUN_ERROR calls onError once', async ({ page, From 313b7afe06e930c565ef6ff883211d878d51299c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 22 Oct 2026 14:55:01 +0200 Subject: [PATCH 5/5] test: pass threadId, which main now requires --- packages/ai/tests/middleware.test.ts | 1 + packages/ai/tests/stream-to-response-durability.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/ai/tests/middleware.test.ts b/packages/ai/tests/middleware.test.ts index 769e923c0a..93b88f9d3a 100644 --- a/packages/ai/tests/middleware.test.ts +++ b/packages/ai/tests/middleware.test.ts @@ -1402,6 +1402,7 @@ describe('chat() middleware', () => { const stream = chat({ adapter, + threadId: 'thread-1', messages: [{ role: 'user', content: 'Send it' }], tools: [serverTool('sendEmail', execute)], middleware: [ diff --git a/packages/ai/tests/stream-to-response-durability.test.ts b/packages/ai/tests/stream-to-response-durability.test.ts index 6a3a240c33..56c93b8b68 100644 --- a/packages/ai/tests/stream-to-response-durability.test.ts +++ b/packages/ai/tests/stream-to-response-durability.test.ts @@ -122,6 +122,7 @@ describe('toServerSentEventsResponse with durability', () => { }) const stream = chat({ adapter, + threadId: 'thread-1', messages: [{ role: 'user' as const, content: 'Look twice' }], tools: [ toolDefinition({