Skip to content
Merged
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/fix-object-object-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/ai": patch
---

Fix fatal stream errors surfacing as `[object Object]` instead of real error messages
4 changes: 2 additions & 2 deletions packages/ai/src/agent/do-stream-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
18 changes: 1 addition & 17 deletions packages/ai/src/agent/durable-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<
Expand Down
57 changes: 57 additions & 0 deletions packages/ai/src/get-error-message.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 24 additions & 0 deletions packages/ai/src/get-error-message.ts
Original file line number Diff line number Diff line change
@@ -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);
}
41 changes: 41 additions & 0 deletions packages/ai/src/workflow-chat-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion packages/ai/src/workflow-chat-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UI_MESSAGE extends UIMessage> {
Expand Down Expand Up @@ -404,7 +405,7 @@ export class WorkflowChatTransport<UI_MESSAGE extends UIMessage>

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)}`
);
}
}
Expand Down
Loading