From 90760351fcce9d37e1e27e1963e16589f87756b2 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 2 Apr 2026 09:32:00 -0700 Subject: [PATCH] [ai] Fix fatal stream errors surfacing as [object Object] Replace String(error) with getErrorMessage() that prefers error.message, falls back to JSON.stringify for plain objects, avoiding the [object Object] pitfall. Extract shared utility from durable-agent.ts and use it in do-stream-step.ts and workflow-chat-transport.ts. Closes #1545 Signed-off-by: Claude Opus 4.6 (1M context) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Peter Wielander --- .changeset/fix-object-object-error.md | 5 ++ packages/ai/src/agent/do-stream-step.ts | 4 +- packages/ai/src/agent/durable-agent.ts | 18 +----- packages/ai/src/get-error-message.test.ts | 57 +++++++++++++++++++ packages/ai/src/get-error-message.ts | 24 ++++++++ .../ai/src/workflow-chat-transport.test.ts | 41 +++++++++++++ packages/ai/src/workflow-chat-transport.ts | 3 +- 7 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-object-object-error.md create mode 100644 packages/ai/src/get-error-message.test.ts create mode 100644 packages/ai/src/get-error-message.ts diff --git a/.changeset/fix-object-object-error.md b/.changeset/fix-object-object-error.md new file mode 100644 index 0000000000..a0489b88fd --- /dev/null +++ b/.changeset/fix-object-object-error.md @@ -0,0 +1,5 @@ +--- +"@workflow/ai": patch +--- + +Fix fatal stream errors surfacing as `[object Object]` instead of real error messages diff --git a/packages/ai/src/agent/do-stream-step.ts b/packages/ai/src/agent/do-stream-step.ts index ef054559e6..6bd84cc2f9 100644 --- a/packages/ai/src/agent/do-stream-step.ts +++ b/packages/ai/src/agent/do-stream-step.ts @@ -21,6 +21,7 @@ import type { StreamTextTransform, TelemetrySettings, } from './durable-agent.js'; +import { getErrorMessage } from '../get-error-message.js'; import { recordSpan } from './telemetry.js'; import type { CompatibleLanguageModel } from './types.js'; @@ -462,8 +463,7 @@ export async function doStreamStep( const error = part.error; controller.enqueue({ type: 'error', - errorText: - error instanceof Error ? error.message : String(error), + errorText: getErrorMessage(error), }); break; diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index 73a7586eee..0a268f1745 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -26,6 +26,7 @@ import { type UIMessageChunk, } from 'ai'; import { convertToLanguageModelPrompt, standardizePrompt } from 'ai/internal'; +import { getErrorMessage } from '../get-error-message.js'; import { streamTextIterator } from './stream-text-iterator.js'; import { recordSpan } from './telemetry.js'; import type { CompatibleLanguageModel } from './types.js'; @@ -1501,23 +1502,6 @@ function safeParseInput(input: string | undefined): unknown { } } -// Matches AI SDK's getErrorMessage from @ai-sdk/provider-utils -function getErrorMessage(error: unknown): string { - if (error == null) { - return 'unknown error'; - } - - if (typeof error === 'string') { - return error; - } - - if (error instanceof Error) { - return error.message; - } - - return JSON.stringify(error); -} - function resolveProviderToolResult( toolCall: LanguageModelV3ToolCall, providerExecutedToolResults?: Map< diff --git a/packages/ai/src/get-error-message.test.ts b/packages/ai/src/get-error-message.test.ts new file mode 100644 index 0000000000..28637ed715 --- /dev/null +++ b/packages/ai/src/get-error-message.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { getErrorMessage } from './get-error-message.js'; + +describe('getErrorMessage', () => { + it('should return message from Error instance', () => { + expect(getErrorMessage(new Error('something broke'))).toBe( + 'something broke' + ); + }); + + it('should return string errors as-is', () => { + expect(getErrorMessage('plain string error')).toBe('plain string error'); + }); + + it('should JSON-serialize plain objects instead of [object Object]', () => { + const error = { code: 'STREAM_FAILED', detail: 'token limit' }; + const msg = getErrorMessage(error); + expect(msg).not.toBe('[object Object]'); + expect(msg).toBe(JSON.stringify(error)); + }); + + it('should JSON-serialize nested objects', () => { + const error = { outer: { inner: 'value' } }; + expect(getErrorMessage(error)).toBe(JSON.stringify(error)); + }); + + it('should return "unknown error" for null', () => { + expect(getErrorMessage(null)).toBe('unknown error'); + }); + + it('should return "unknown error" for undefined', () => { + expect(getErrorMessage(undefined)).toBe('unknown error'); + }); + + it('should handle number errors', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('should handle boolean errors', () => { + expect(getErrorMessage(true)).toBe('true'); + }); + + it('should handle array errors', () => { + expect(getErrorMessage(['a', 'b'])).toBe(JSON.stringify(['a', 'b'])); + }); + + it('should handle empty string', () => { + expect(getErrorMessage('')).toBe(''); + }); + + it('should handle Error subclass', () => { + class CustomError extends Error { + code = 'CUSTOM'; + } + expect(getErrorMessage(new CustomError('custom msg'))).toBe('custom msg'); + }); +}); diff --git a/packages/ai/src/get-error-message.ts b/packages/ai/src/get-error-message.ts new file mode 100644 index 0000000000..401ba1b0bc --- /dev/null +++ b/packages/ai/src/get-error-message.ts @@ -0,0 +1,24 @@ +/** + * Safely extract a human-readable message from an unknown error value. + * + * Avoids the `String(error)` pitfall that produces `"[object Object]"` + * when the thrown value is a plain object rather than an Error instance. + * + * Matches the behaviour of AI SDK's `getErrorMessage` from + * `@ai-sdk/provider-utils`. + */ +export function getErrorMessage(error: unknown): string { + if (error == null) { + return 'unknown error'; + } + + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + return JSON.stringify(error); +} diff --git a/packages/ai/src/workflow-chat-transport.test.ts b/packages/ai/src/workflow-chat-transport.test.ts index 3420ea3cf1..cf969ef92c 100644 --- a/packages/ai/src/workflow-chat-transport.test.ts +++ b/packages/ai/src/workflow-chat-transport.test.ts @@ -469,6 +469,47 @@ describe('WorkflowChatTransport', () => { }); }); + describe('reconnection error formatting', () => { + it('should format object errors with JSON instead of [object Object]', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const transport = new WorkflowChatTransport({ + fetch: mockFetch, + maxConsecutiveErrors: 1, + }); + + // Return a stream that throws a plain-object error on parse + mockFetch.mockResolvedValue({ + ok: true, + headers: new Headers(), + body: new ReadableStream({ + start(controller) { + // Send malformed data to trigger a parse error + controller.enqueue( + new TextEncoder().encode('data: INVALID_JSON\n\n') + ); + controller.close(); + }, + }), + }); + + const stream = await transport.reconnectToStream({ + chatId: 'test-chat', + }); + + const reader = stream!.getReader(); + await expect(reader.read()).rejects.toThrow( + /Failed to reconnect after 1 consecutive errors/ + ); + // Crucially, the error message should never contain [object Object] + await expect( + reader.read().catch((e: Error) => e.message) + ).resolves.not.toContain?.('[object Object]'); + + errorSpy.mockRestore(); + }); + }); + describe('callbacks', () => { it('should call onChatSendMessage callback', async () => { const onChatSendMessage = vi.fn(); diff --git a/packages/ai/src/workflow-chat-transport.ts b/packages/ai/src/workflow-chat-transport.ts index efc354c063..a7724c1e8b 100644 --- a/packages/ai/src/workflow-chat-transport.ts +++ b/packages/ai/src/workflow-chat-transport.ts @@ -8,6 +8,7 @@ import { type UIMessageChunk, uiMessageChunkSchema, } from 'ai'; +import { getErrorMessage } from './get-error-message.js'; import { iteratorToStream, streamToIterator } from './stream-iterator.js'; export interface SendMessagesOptions { @@ -404,7 +405,7 @@ export class WorkflowChatTransport if (consecutiveErrors >= this.maxConsecutiveErrors) { throw new Error( - `Failed to reconnect after ${this.maxConsecutiveErrors} consecutive errors. Last error: ${error instanceof Error ? error.message : String(error)}` + `Failed to reconnect after ${this.maxConsecutiveErrors} consecutive errors. Last error: ${getErrorMessage(error)}` ); } }