summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-29 16:52:28 +0900
committerAdam Malczewski <[email protected]>2026-05-29 16:52:28 +0900
commit5b3e1ac64681e233f35e1b4d2230d9988667c37e (patch)
tree483b68c5336008c1d531db1f9cda93f97e6b9df0 /packages/core/tests
parent9c401530254b764d9c7a74c992bec612f32ccd8e (diff)
downloaddispatch-5b3e1ac64681e233f35e1b4d2230d9988667c37e.tar.gz
dispatch-5b3e1ac64681e233f35e1b4d2230d9988667c37e.zip
fix: handle unavailable tool calls via native v6 tool-error event, not synthetic invalid tool
- Removed __invalid__ tool definition, experimental_repairToolCall, and v4-era NoSuchToolError catch block — AI SDK v6 already emits a native tool-error stream event with the original tool name - Added synthesizeResidualToolResults() helper to fill orphaned tool-call IDs with isError: true results for abort/error terminal paths - tool-error handler now break's instead of return's — lets sibling tools execute normally via the manual executor loop - Added final safety net after execution loop to catch any genuinely orphaned tool-call IDs before round-tripping to the LLM - Propagated isError through toModelMessages so error results are properly flagged in conversation history - Updated tests: tool-error event now continues to idle (not error), added sibling-orphan prevention test
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/agent/agent.test.ts74
1 files changed, 67 insertions, 7 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 82ca830..e0a999d 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -749,14 +749,18 @@ describe("Agent", () => {
});
});
- it("tool-error stream event yields a synthetic tool-result + error chunk and aborts the turn", async () => {
+ it("tool-error stream event yields a synthetic tool-result + error chunk and continues the turn", async () => {
// Provider-executed tools (Anthropic server tools) bypass our
// manual executor and surface as a `tool-error` stream event.
// We must:
// 1. Synthesize a tool-result with isError=true so the chunks
- // reflect that the tool ran and failed.
+ // reflect that the tool ran and failed — this keeps the
+ // tool-call/tool-result pairing complete and avoids the AI SDK
+ // throwing MissingToolResultsError on the next round-trip.
// 2. Emit an error chunk so the UI shows the failure.
- // 3. Transition the agent to "error" status (no further steps).
+ // 3. NOT transition to "error" status — the step breaks out of the
+ // stream loop and the turn ends normally (here, with no further
+ // tool calls pending, the agent completes to idle).
vi.mocked(streamText).mockReturnValue(
makeMockStreamResult([
{
@@ -783,16 +787,72 @@ describe("Agent", () => {
toolResult: { toolCallId: "tc_server", isError: true },
});
- // Error chunk
+ // Error chunk for visibility
const errEvent = events.find((e) => e.type === "error");
expect(errEvent).toBeDefined();
const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
expect(typeof errMsg).toBe("string");
expect((errMsg as string).includes("upstream tool failure")).toBe(true);
- // Status transitions to error
- const errStatusEvent = events.filter((e) => e.type === "status").at(-1);
- expect(errStatusEvent).toMatchObject({ type: "status", status: "error" });
+ // Status does NOT transition to error — the turn completes to idle.
+ const lastStatus = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
+
+ // The turn produced a `done` event (it did not abort).
+ expect(events.some((e) => e.type === "done")).toBe(true);
+ });
+
+ it("tool-error leaves sibling tool calls to be resolved by the executor (not orphaned)", async () => {
+ // When one tool in a batch errors, its siblings — whose tool-call
+ // events were already yielded — must still receive a result, otherwise
+ // the tool-call IDs are orphaned in the chunks (no matching result)
+ // and the next LLM round-trip throws MissingToolResultsError. The
+ // tool-error handler breaks out of the stream loop WITHOUT executing
+ // the unresolved siblings inline; the normal manual-executor pass then
+ // runs them. Here `sibling_tool` is not a registered tool, so the
+ // executor returns an "Unknown tool" error result — completing the
+ // tool-call/tool-result pairing with `isError: true`.
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ {
+ type: "tool-call",
+ toolCallId: "tc_sibling",
+ toolName: "sibling_tool",
+ input: {},
+ },
+ {
+ type: "tool-error",
+ toolCallId: "tc_failed",
+ toolName: "failed_tool",
+ error: new Error("boom"),
+ },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events: AgentEvent[] = [];
+ for await (const event of agent.run("trigger")) {
+ events.push(event);
+ }
+
+ const toolResults = events.filter((e) => e.type === "tool-result");
+ // One for the failed tool, one for the sibling resolved by the executor.
+ const siblingResult = toolResults.find(
+ (e) => "toolResult" in e && e.toolResult.toolCallId === "tc_sibling",
+ );
+ expect(siblingResult).toBeDefined();
+ expect(siblingResult).toMatchObject({
+ type: "tool-result",
+ toolResult: { toolCallId: "tc_sibling", isError: true },
+ });
+ const siblingMsg =
+ siblingResult && "toolResult" in siblingResult ? siblingResult.toolResult.result : "";
+ expect((siblingMsg as string).includes("sibling_tool")).toBe(true);
+
+ // Status completes to idle (the turn continued, not aborted).
+ const lastStatus = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
});
it("abort stream event surfaces as an error event and stops the turn", async () => {