From 24f27819234da5abc0e1318a5cfe1baca81480ad Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 17 Aug 2026 22:53:52 +0800 Subject: [PATCH 1/3] fix: preserve fallback structured output timestamp ordering --- .changeset/gentle-dots-fallback-timestamps.md | 5 +++ packages/ai/src/activities/chat/index.ts | 2 + .../chat-structured-output-stream.test.ts | 38 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 .changeset/gentle-dots-fallback-timestamps.md diff --git a/.changeset/gentle-dots-fallback-timestamps.md b/.changeset/gentle-dots-fallback-timestamps.md new file mode 100644 index 000000000..43420ced7 --- /dev/null +++ b/.changeset/gentle-dots-fallback-timestamps.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': patch +--- + +Fix fallback structured-output lifecycle timestamps being emitted before the provider request settles. diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 552424dac..a7288a0c1 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -4909,6 +4909,8 @@ async function* fallbackStructuredOutputStream( return } + const completedAt = Date.now() + yield { type: EventType.TEXT_MESSAGE_START, messageId, diff --git a/packages/ai/tests/chat-structured-output-stream.test.ts b/packages/ai/tests/chat-structured-output-stream.test.ts index 4e2012844..0e501e0d6 100644 --- a/packages/ai/tests/chat-structured-output-stream.test.ts +++ b/packages/ai/tests/chat-structured-output-stream.test.ts @@ -471,6 +471,44 @@ describe('chat({ outputSchema, stream: true })', () => { expect(finished).toBeDefined() expect('usage' in finished!).toBe(false) }) + + it('keeps synthesized lifecycle timestamps ordered after a delayed provider result', async () => { + const adapter = makeAdapter({ + structuredOutput: async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { data: validPerson, rawText: JSON.stringify(validPerson) } + }, + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }) + + const chunks = await collectChunks(stream) + const start = chunks.find( + (c) => + c.type === EventType.CUSTOM && + (c as { name?: string }).name === 'structured-output.start', + ) + const content = chunks.find((c) => c.type === EventType.TEXT_MESSAGE_CONTENT) + const complete = chunks.find( + (c) => + c.type === EventType.CUSTOM && + (c as { name?: string }).name === 'structured-output.complete', + ) + const finished = chunks.find((c) => c.type === EventType.RUN_FINISHED) + + expect(start).toBeDefined() + expect(content).toBeDefined() + expect(complete).toBeDefined() + expect(finished).toBeDefined() + expect(start!.timestamp).toBeLessThanOrEqual(content!.timestamp) + expect(content!.timestamp).toBeLessThanOrEqual(complete!.timestamp) + expect(complete!.timestamp).toBeLessThanOrEqual(finished!.timestamp) + }) }) describe('lifecycle ordering', () => { From e7e59799b825a0d63e69f369c0025a0081a92b53 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:00:20 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- packages/ai/tests/chat-structured-output-stream.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/tests/chat-structured-output-stream.test.ts b/packages/ai/tests/chat-structured-output-stream.test.ts index 0e501e0d6..7a48856c7 100644 --- a/packages/ai/tests/chat-structured-output-stream.test.ts +++ b/packages/ai/tests/chat-structured-output-stream.test.ts @@ -493,7 +493,9 @@ describe('chat({ outputSchema, stream: true })', () => { c.type === EventType.CUSTOM && (c as { name?: string }).name === 'structured-output.start', ) - const content = chunks.find((c) => c.type === EventType.TEXT_MESSAGE_CONTENT) + const content = chunks.find( + (c) => c.type === EventType.TEXT_MESSAGE_CONTENT, + ) const complete = chunks.find( (c) => c.type === EventType.CUSTOM && From d24f6fa16e0a503e09dbca1ce3f8bf38bc2b34f8 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 19 Aug 2026 12:10:18 +0800 Subject: [PATCH 3/3] test(ai): cover fallback structured output lifecycle ordering --- packages/ai/src/activities/chat/index.ts | 2 - .../chat-structured-output-stream.test.ts | 45 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index a7288a0c1..552424dac 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -4909,8 +4909,6 @@ async function* fallbackStructuredOutputStream( return } - const completedAt = Date.now() - yield { type: EventType.TEXT_MESSAGE_START, messageId, diff --git a/packages/ai/tests/chat-structured-output-stream.test.ts b/packages/ai/tests/chat-structured-output-stream.test.ts index 7a48856c7..43f02b15b 100644 --- a/packages/ai/tests/chat-structured-output-stream.test.ts +++ b/packages/ai/tests/chat-structured-output-stream.test.ts @@ -488,11 +488,15 @@ describe('chat({ outputSchema, stream: true })', () => { }) const chunks = await collectChunks(stream) + const runStarted = chunks.find((c) => c.type === EventType.RUN_STARTED) const start = chunks.find( (c) => c.type === EventType.CUSTOM && (c as { name?: string }).name === 'structured-output.start', ) + const textStart = chunks.find( + (c) => c.type === EventType.TEXT_MESSAGE_START, + ) const content = chunks.find( (c) => c.type === EventType.TEXT_MESSAGE_CONTENT, ) @@ -503,13 +507,48 @@ describe('chat({ outputSchema, stream: true })', () => { ) const finished = chunks.find((c) => c.type === EventType.RUN_FINISHED) + expect(runStarted).toBeDefined() expect(start).toBeDefined() + expect(textStart).toBeDefined() expect(content).toBeDefined() expect(complete).toBeDefined() expect(finished).toBeDefined() - expect(start!.timestamp).toBeLessThanOrEqual(content!.timestamp) - expect(content!.timestamp).toBeLessThanOrEqual(complete!.timestamp) - expect(complete!.timestamp).toBeLessThanOrEqual(finished!.timestamp) + expect(runStarted!.timestamp!).toBeLessThanOrEqual(start!.timestamp!) + expect(start!.timestamp!).toBeLessThanOrEqual(textStart!.timestamp!) + expect(textStart!.timestamp!).toBeLessThanOrEqual(content!.timestamp!) + expect(content!.timestamp!).toBeLessThanOrEqual(complete!.timestamp!) + expect(complete!.timestamp!).toBeLessThanOrEqual(finished!.timestamp!) + }) + + it('keeps synthesized error lifecycle timestamps ordered after a delayed provider rejection', async () => { + const adapter = makeAdapter({ + structuredOutput: async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + throw new Error('provider rejected the request') + }, + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }) + + const chunks = await collectChunks(stream) + const runStarted = chunks.find((c) => c.type === EventType.RUN_STARTED) + const start = chunks.find( + (c) => + c.type === EventType.CUSTOM && + (c as { name?: string }).name === 'structured-output.start', + ) + const error = chunks.find((c) => c.type === EventType.RUN_ERROR) + + expect(runStarted).toBeDefined() + expect(start).toBeDefined() + expect(error).toBeDefined() + expect(runStarted!.timestamp!).toBeLessThanOrEqual(start!.timestamp!) + expect(start!.timestamp!).toBeLessThanOrEqual(error!.timestamp!) }) })