Skip to content

Commit 7131bcc

Browse files
committed
fix error handling
1 parent f052c3b commit 7131bcc

File tree

6 files changed

+97
-21
lines changed

6 files changed

+97
-21
lines changed

packages/xl-ai/src/api/formats/json/errorHandling.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import { testAIModels } from "../../../testUtil/testAIModels.js";
1212
import { buildAIRequest } from "../../aiRequest/builder.js";
1313

1414
// Separate test suite for error handling with its own server
15-
// skipping because it throws a (false) unhandled promise rejection in vitest
16-
describe.skip("Error handling", () => {
15+
describe("Error handling", () => {
1716
// Create a separate server for error tests with custom handlers
1817
const errorServer = setupServer();
1918

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export class ChunkExecutionError extends Error {
2+
constructor(
3+
message: string,
4+
public readonly chunk: any,
5+
options?: { cause?: unknown },
6+
) {
7+
super(message, options);
8+
this.name = "ChunkExecutionError";
9+
}
10+
}

packages/xl-ai/src/streamTool/StreamToolExecutor.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
11
import { getErrorMessage } from "@ai-sdk/provider-utils";
22
import { parsePartialJson } from "ai";
3+
import { ChunkExecutionError } from "./ChunkExecutionError.js";
34
import { StreamTool, StreamToolCall } from "./streamTool.js";
45

5-
export class ChunkExecutionError extends Error {
6-
constructor(
7-
message: string,
8-
public readonly chunk: any,
9-
options?: { cause?: unknown },
10-
) {
11-
super(message, options);
12-
this.name = "ChunkExecutionError";
13-
}
14-
}
15-
166
/**
177
* The Operation types wraps a StreamToolCall with metadata on whether
188
* it's an update to an existing and / or or a possibly partial (i.e.: incomplete, streaming in progress) operation

packages/xl-ai/src/streamTool/preprocess.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ChunkExecutionError } from "./ChunkExecutionError.js";
12
import { filterValidOperations } from "./filterValidOperations.js";
23
import { StreamTool, StreamToolCall } from "./streamTool.js";
34
import { toValidatedOperations } from "./toValidatedOperations.js";
@@ -35,8 +36,8 @@ export async function* preprocessOperationsStreaming<
3536
(chunk) => {
3637
if (!chunk.isPossiblyPartial) {
3738
// only throw if the operation is not possibly partial
38-
// TODO: I think there's a bug here in unit tests, for example if operations don't include $. validate with main
39-
throw new Error("invalid operation: " + chunk.operation.error);
39+
40+
throw new ChunkExecutionError("invalid operation: " + chunk.operation.error, chunk);
4041
}
4142
},
4243
);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Chat } from "@ai-sdk/react";
2+
import { BlockNoteEditor } from "@blocknote/core";
3+
import { ChatTransport, DefaultChatTransport, UIMessage, UIMessageChunk } from "ai";
4+
import { describe, expect, it } from "vitest";
5+
import { aiDocumentFormats } from "../../../server.js";
6+
import { setupToolCallStreaming } from "./chatHandlers.js";
7+
8+
class FakeTransport extends DefaultChatTransport<any> {
9+
constructor(private chunks: UIMessageChunk[]) {
10+
super();
11+
}
12+
13+
override async sendMessages({ }: Parameters<ChatTransport<UIMessage>['sendMessages']>[0]) {
14+
const chunks = this.chunks;
15+
return new ReadableStream<UIMessageChunk>({
16+
start(controller) {
17+
for (const chunk of chunks) {
18+
controller.enqueue(chunk);
19+
}
20+
controller.close();
21+
},
22+
});
23+
}
24+
}
25+
26+
describe("setupToolCallStreaming", () => {
27+
it("should handle missing tool error gracefully", async () => {
28+
const editor = BlockNoteEditor.create();
29+
const chat = new Chat({
30+
transport: new FakeTransport([
31+
{ type: "start" },
32+
{ type: "start-step" },
33+
{
34+
type: "tool-input-start",
35+
toolCallId: "call_1",
36+
toolName: "applyDocumentOperations",
37+
},
38+
{
39+
type: "tool-input-available",
40+
toolCallId: "call_1",
41+
toolName: "applyDocumentOperations",
42+
input: { operations: [{ type: "testTool", value: "hello" }] },
43+
},
44+
{ type: "finish-step" },
45+
{ type: "finish", finishReason: "stop" },
46+
]),
47+
});
48+
49+
const streamTools = aiDocumentFormats.html
50+
.getStreamToolsProvider({ withDelays: false })
51+
.getStreamTools(editor, false);
52+
53+
const streaming = setupToolCallStreaming(streamTools, chat);
54+
55+
await chat.sendMessage({
56+
role: "user",
57+
parts: [{ type: "text", text: "ignored" }],
58+
});
59+
60+
const ret = await streaming;
61+
62+
expect(chat.status).toBe("ready");
63+
expect(ret.ok).toBe(false);
64+
if (!ret.ok) {
65+
expect(ret.error).toBeDefined();
66+
// We can check if the error message contains "No matching function" or similar
67+
// The error is likely wrapped or is the ChunkExecutionError
68+
// console.log(ret.error);
69+
}
70+
});
71+
});

packages/xl-ai/src/streamTool/vercelAiSdk/util/chatHandlers.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { getErrorMessage } from "@ai-sdk/provider-utils";
22
import type { Chat } from "@ai-sdk/react";
33
import { DeepPartial, isToolUIPart, UIMessage } from "ai";
4+
import { ChunkExecutionError } from "../../ChunkExecutionError.js";
45
import { Result, StreamTool, StreamToolCall } from "../../streamTool.js";
5-
import {
6-
ChunkExecutionError,
7-
StreamToolExecutor,
8-
} from "../../StreamToolExecutor.js";
6+
import { StreamToolExecutor } from "../../StreamToolExecutor.js";
97
import { objectStreamToOperationsResult } from "./UIMessageStreamToOperationsResult.js";
108

119
/**
@@ -42,7 +40,7 @@ export async function setupToolCallStreaming(
4240

4341
const appendableStream = createAppendableStream<any>();
4442

45-
appendableStream.output.pipeTo(executor.writable);
43+
const pipeToPromise = appendableStream.output.pipeTo(executor.writable);
4644

4745
const toolCallStreams = new Map<string, ToolCallStreamData>();
4846

@@ -112,10 +110,17 @@ export async function setupToolCallStreaming(
112110
await appendableStream.finalize();
113111
// let all stream executors finish, this can take longer due to artificial delays
114112
// (e.g. to simulate human typing behaviour)
115-
const result = (await Promise.allSettled([executor.finish()]))[0];
113+
const results = await Promise.allSettled([executor.finish(), pipeToPromise]); // awaiting pipeToPromise as well to prevent unhandled promises
114+
const result = results[0];
115+
116+
if (results[1].status === "rejected" && (results[0].status !== "rejected" || results[0].reason !== results[1].reason)){
117+
throw new Error("unexpected, pipeToPromise rejected but executor.finish() doesn't have same error!?")
118+
}
116119

117120
let error: ChunkExecutionError | undefined;
121+
118122
if (result.status === "rejected") {
123+
119124
if (result.reason instanceof ChunkExecutionError) {
120125
error = result.reason;
121126
} else {

0 commit comments

Comments
 (0)