summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src/runtime/dispatch.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/kernel/src/runtime/dispatch.test.ts')
-rw-r--r--packages/kernel/src/runtime/dispatch.test.ts980
1 files changed, 490 insertions, 490 deletions
diff --git a/packages/kernel/src/runtime/dispatch.test.ts b/packages/kernel/src/runtime/dispatch.test.ts
index afbfb39..dfe2ac7 100644
--- a/packages/kernel/src/runtime/dispatch.test.ts
+++ b/packages/kernel/src/runtime/dispatch.test.ts
@@ -11,51 +11,51 @@ import { runTurn } from "./run-turn.js";
// ---------------------------------------------------------------------------
function delay(ms: number): Promise<void> {
- return new Promise((resolve) => {
- setTimeout(resolve, ms);
- });
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
}
function createFakeProvider(script: ProviderEvent[][]): ProviderContract {
- let callIndex = 0;
- return {
- id: "fake",
- stream() {
- const events = script[callIndex] ?? [];
- callIndex++;
- return (async function* () {
- for (const event of events) {
- yield event;
- }
- })();
- },
- };
+ let callIndex = 0;
+ return {
+ id: "fake",
+ stream() {
+ const events = script[callIndex] ?? [];
+ callIndex++;
+ return (async function* () {
+ for (const event of events) {
+ yield event;
+ }
+ })();
+ },
+ };
}
function createFakeTool(
- name: string,
- handler?: (input: unknown, ctx: ToolExecuteContext) => Promise<ToolResult>,
- opts?: { concurrencySafe?: boolean },
+ name: string,
+ handler?: (input: unknown, ctx: ToolExecuteContext) => Promise<ToolResult>,
+ opts?: { concurrencySafe?: boolean },
): ToolContract {
- return {
- name,
- description: `Fake tool: ${name}`,
- parameters: { type: "object" },
- ...(opts?.concurrencySafe !== undefined ? { concurrencySafe: opts.concurrencySafe } : {}),
- execute: handler ?? (async (input) => ({ content: `${name}: ${JSON.stringify(input)}` })),
- };
+ return {
+ name,
+ description: `Fake tool: ${name}`,
+ parameters: { type: "object" },
+ ...(opts?.concurrencySafe !== undefined ? { concurrencySafe: opts.concurrencySafe } : {}),
+ execute: handler ?? (async (input) => ({ content: `${name}: ${JSON.stringify(input)}` })),
+ };
}
function createCollectingEmit(): { events: AgentEvent[]; emit: (event: AgentEvent) => void } {
- const events: AgentEvent[] = [];
- return { events, emit: (event) => events.push(event) };
+ const events: AgentEvent[] = [];
+ return { events, emit: (event) => events.push(event) };
}
const noopEmit = () => {};
const userMessage: ChatMessage = {
- role: "user",
- chunks: [{ type: "text", text: "hello" }],
+ role: "user",
+ chunks: [{ type: "text", text: "hello" }],
};
const ABORTED_RESULT: ToolResult = { content: "Aborted", isError: true };
@@ -65,158 +65,158 @@ const ABORTED_RESULT: ToolResult = { content: "Aborted", isError: true };
// ===========================================================================
describe("executeToolCall", () => {
- it("returns the tool's result when the tool resolves before abort", async () => {
- const ac = new AbortController();
- const tool = createFakeTool("echo", async (input) => ({
- content: `echo: ${JSON.stringify(input)}`,
- }));
-
- const result = await executeToolCall(
- { id: "tc1", name: "echo", input: { x: 1 } },
- tool,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- expect(result).toEqual({ content: 'echo: {"x":1}' });
- });
-
- it("returns Aborted immediately when signal is already aborted at call time", async () => {
- const ac = new AbortController();
- ac.abort();
- const tool = createFakeTool("echo", async () => ({ content: "should not run" }));
-
- const result = await executeToolCall(
- { id: "tc1", name: "echo", input: {} },
- tool,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- expect(result).toEqual(ABORTED_RESULT);
- });
-
- it("returns Aborted when a hanging tool is raced against an abort signal", async () => {
- const ac = new AbortController();
- // A tool that never resolves and ignores ctx.signal
- const tool = createFakeTool("hang", () => new Promise<ToolResult>(() => {}));
-
- const promise = executeToolCall(
- { id: "tc1", name: "hang", input: {} },
- tool,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- // Abort after the tool has started
- await delay(10);
- ac.abort();
-
- const result = await promise;
- expect(result).toEqual(ABORTED_RESULT);
- });
-
- it("returns the tool's own result when a signal-aware tool resolves on abort", async () => {
- const ac = new AbortController();
- const toolResult: ToolResult = { content: "aborted by tool", isError: true };
- const tool = createFakeTool("aware", (_input, ctx) => {
- return new Promise<ToolResult>((resolve) => {
- ctx.signal.addEventListener("abort", () => resolve(toolResult), { once: true });
- });
- });
-
- const promise = executeToolCall(
- { id: "tc1", name: "aware", input: {} },
- tool,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- await delay(10);
- ac.abort();
-
- const result = await promise;
- // The tool listens to the signal and resolves its own result. Whether
- // the tool's result or the race's "Aborted" wins is timing-dependent;
- // both are isError and let the turn seal with finishReason "aborted".
- expect(result.isError).toBe(true);
- expect(result.content).toBe("aborted by tool");
- });
-
- it("swallows a late rejection from the orphaned tool promise after abort wins the race", async () => {
- const ac = new AbortController();
- let rejectTool: ((err: Error) => void) | undefined;
- const tool = createFakeTool("late-reject", () => {
- return new Promise<ToolResult>((_resolve, reject) => {
- rejectTool = reject;
- });
- });
-
- const promise = executeToolCall(
- { id: "tc1", name: "late-reject", input: {} },
- tool,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- await delay(10);
- ac.abort();
-
- const result = await promise;
- expect(result).toEqual(ABORTED_RESULT);
-
- // The tool rejects AFTER the race already resolved with "Aborted".
- // The no-op catch must swallow this — no unhandled rejection.
- rejectTool?.(new Error("late boom"));
- // Give the microtask queue a tick to flush
- await delay(5);
- // If we reach here without an unhandledRejection crashing the process,
- // the test passes. (vitest surfaces unhandled rejections as failures.)
- });
-
- it("returns an error result when the tool rejects before abort", async () => {
- const ac = new AbortController();
- const tool = createFakeTool("boom", async () => {
- throw new Error("tool exploded");
- });
-
- const result = await executeToolCall(
- { id: "tc1", name: "boom", input: {} },
- tool,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- expect(result.isError).toBe(true);
- expect(result.content).toContain("tool exploded");
- });
-
- it("returns Unknown tool when the tool is undefined", async () => {
- const ac = new AbortController();
- const result = await executeToolCall(
- { id: "tc1", name: "nonexistent", input: {} },
- undefined,
- ac.signal,
- noopEmit,
- "conv-1",
- "turn-1",
- );
-
- expect(result.isError).toBe(true);
- expect(result.content).toContain("Unknown tool");
- });
+ it("returns the tool's result when the tool resolves before abort", async () => {
+ const ac = new AbortController();
+ const tool = createFakeTool("echo", async (input) => ({
+ content: `echo: ${JSON.stringify(input)}`,
+ }));
+
+ const result = await executeToolCall(
+ { id: "tc1", name: "echo", input: { x: 1 } },
+ tool,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ expect(result).toEqual({ content: 'echo: {"x":1}' });
+ });
+
+ it("returns Aborted immediately when signal is already aborted at call time", async () => {
+ const ac = new AbortController();
+ ac.abort();
+ const tool = createFakeTool("echo", async () => ({ content: "should not run" }));
+
+ const result = await executeToolCall(
+ { id: "tc1", name: "echo", input: {} },
+ tool,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ expect(result).toEqual(ABORTED_RESULT);
+ });
+
+ it("returns Aborted when a hanging tool is raced against an abort signal", async () => {
+ const ac = new AbortController();
+ // A tool that never resolves and ignores ctx.signal
+ const tool = createFakeTool("hang", () => new Promise<ToolResult>(() => {}));
+
+ const promise = executeToolCall(
+ { id: "tc1", name: "hang", input: {} },
+ tool,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ // Abort after the tool has started
+ await delay(10);
+ ac.abort();
+
+ const result = await promise;
+ expect(result).toEqual(ABORTED_RESULT);
+ });
+
+ it("returns the tool's own result when a signal-aware tool resolves on abort", async () => {
+ const ac = new AbortController();
+ const toolResult: ToolResult = { content: "aborted by tool", isError: true };
+ const tool = createFakeTool("aware", (_input, ctx) => {
+ return new Promise<ToolResult>((resolve) => {
+ ctx.signal.addEventListener("abort", () => resolve(toolResult), { once: true });
+ });
+ });
+
+ const promise = executeToolCall(
+ { id: "tc1", name: "aware", input: {} },
+ tool,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ await delay(10);
+ ac.abort();
+
+ const result = await promise;
+ // The tool listens to the signal and resolves its own result. Whether
+ // the tool's result or the race's "Aborted" wins is timing-dependent;
+ // both are isError and let the turn seal with finishReason "aborted".
+ expect(result.isError).toBe(true);
+ expect(result.content).toBe("aborted by tool");
+ });
+
+ it("swallows a late rejection from the orphaned tool promise after abort wins the race", async () => {
+ const ac = new AbortController();
+ let rejectTool: ((err: Error) => void) | undefined;
+ const tool = createFakeTool("late-reject", () => {
+ return new Promise<ToolResult>((_resolve, reject) => {
+ rejectTool = reject;
+ });
+ });
+
+ const promise = executeToolCall(
+ { id: "tc1", name: "late-reject", input: {} },
+ tool,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ await delay(10);
+ ac.abort();
+
+ const result = await promise;
+ expect(result).toEqual(ABORTED_RESULT);
+
+ // The tool rejects AFTER the race already resolved with "Aborted".
+ // The no-op catch must swallow this — no unhandled rejection.
+ rejectTool?.(new Error("late boom"));
+ // Give the microtask queue a tick to flush
+ await delay(5);
+ // If we reach here without an unhandledRejection crashing the process,
+ // the test passes. (vitest surfaces unhandled rejections as failures.)
+ });
+
+ it("returns an error result when the tool rejects before abort", async () => {
+ const ac = new AbortController();
+ const tool = createFakeTool("boom", async () => {
+ throw new Error("tool exploded");
+ });
+
+ const result = await executeToolCall(
+ { id: "tc1", name: "boom", input: {} },
+ tool,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("tool exploded");
+ });
+
+ it("returns Unknown tool when the tool is undefined", async () => {
+ const ac = new AbortController();
+ const result = await executeToolCall(
+ { id: "tc1", name: "nonexistent", input: {} },
+ undefined,
+ ac.signal,
+ noopEmit,
+ "conv-1",
+ "turn-1",
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Unknown tool");
+ });
});
// ===========================================================================
@@ -224,312 +224,312 @@ describe("executeToolCall", () => {
// ===========================================================================
describe("runTurn abort-race durability", () => {
- // Required test 1: A hanging tool (never resolves, ignores ctx.signal)
- // must not keep runTurn from returning when the signal aborts.
- it("hanging tool + abort → runTurn returns with finishReason aborted and emits done", async () => {
- const ac = new AbortController();
-
- // A tool whose execute returns a promise that NEVER resolves and
- // ignores ctx.signal entirely.
- const tool = createFakeTool("hang", () => new Promise<ToolResult>(() => {}));
-
- // Use eager: true so the tool starts BEFORE the signal aborts.
- // This exercises the race (not the early signal.aborted return).
- const provider: ProviderContract = {
- id: "fake",
- stream() {
- return (async function* () {
- yield {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "hang",
- input: {},
- } as ProviderEvent;
- ac.abort();
- await delay(10);
- yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
- })();
- },
- };
-
- const { events, emit } = createCollectingEmit();
-
- const result = await runTurn({
- provider,
- messages: [userMessage],
- tools: [tool],
- dispatch: { maxConcurrent: 1, eager: true },
- conversationId: "conv-1",
- turnId: "turn-1",
- emit,
- signal: ac.signal,
- });
-
- // runTurn returned (didn't hang) → the race worked.
- expect(result.finishReason).toBe("aborted");
-
- // A done event was emitted with reason "aborted".
- const doneEvents = events.filter((e) => e.type === "done");
- expect(doneEvents).toHaveLength(1);
- if (doneEvents[0]?.type === "done") {
- expect(doneEvents[0].reason).toBe("aborted");
- }
- });
-
- // Required test 2: A signal-aware tool that resolves its own result on
- // abort must also let runTurn return with finishReason "aborted".
- it("signal-aware tool + abort → runTurn returns with finishReason aborted", async () => {
- const ac = new AbortController();
-
- const tool = createFakeTool("aware", (_input, ctx) => {
- return new Promise<ToolResult>((resolve) => {
- ctx.signal.addEventListener(
- "abort",
- () => resolve({ content: "aborted by tool", isError: true }),
- { once: true },
- );
- });
- });
-
- const provider: ProviderContract = {
- id: "fake",
- stream() {
- return (async function* () {
- yield {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "aware",
- input: {},
- } as ProviderEvent;
- ac.abort();
- await delay(10);
- yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
- })();
- },
- };
-
- const { events, emit } = createCollectingEmit();
-
- const result = await runTurn({
- provider,
- messages: [userMessage],
- tools: [tool],
- dispatch: { maxConcurrent: 1, eager: true },
- conversationId: "conv-1",
- turnId: "turn-1",
- emit,
- signal: ac.signal,
- });
-
- expect(result.finishReason).toBe("aborted");
-
- const doneEvents = events.filter((e) => e.type === "done");
- expect(doneEvents).toHaveLength(1);
- if (doneEvents[0]?.type === "done") {
- expect(doneEvents[0].reason).toBe("aborted");
- }
-
- // When the step is aborted, tool-result MESSAGES are omitted from the
- // result (the tool-result EVENT is still emitted by executeStep for
- // live UI updates, but the message is not persisted). This prevents
- // orphaned `tool` messages from breaking the next turn's provider
- // request. The assistant message has its tool-call chunks stripped.
- const toolResultMsg = result.messages.find((m) => m.role === "tool");
- expect(toolResultMsg).toBeUndefined();
-
- // The assistant message should NOT contain tool-call chunks.
- const assistantMsg = result.messages.find(
- (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"),
- );
- expect(assistantMsg).toBeUndefined();
- });
-
- // Required test 3 (regression guard): Without abort, a normal tool runs
- // and its result is used; finishReason reflects the model.
- it("no abort → tool runs normally and its result is used (regression)", async () => {
- const tool = createFakeTool("normal", async (input) => ({
- content: `result: ${JSON.stringify(input)}`,
- }));
-
- const provider = createFakeProvider([
- [
- { type: "tool-call", toolCallId: "tc1", toolName: "normal", input: { x: 1 } },
- { type: "finish", reason: "tool-calls" },
- ],
- [
- { type: "text-delta", delta: "done" },
- { type: "finish", reason: "stop" },
- ],
- ]);
-
- const { events, emit } = createCollectingEmit();
-
- const result = await runTurn({
- provider,
- messages: [userMessage],
- tools: [tool],
- dispatch: { maxConcurrent: 1, eager: true },
- conversationId: "conv-1",
- turnId: "turn-1",
- emit,
- });
-
- // finishReason reflects the model (second step's "stop").
- expect(result.finishReason).toBe("stop");
-
- // The tool's result was used (fed back, not "Aborted").
- const toolResultMsg = result.messages.find((m) => m.role === "tool");
- expect(toolResultMsg).toBeDefined();
- const trChunk = toolResultMsg?.chunks[0];
- expect(trChunk?.type).toBe("tool-result");
- if (trChunk?.type === "tool-result") {
- expect(trChunk.content).toBe('result: {"x":1}');
- expect(trChunk.isError).toBe(false);
- }
-
- // done event emitted with reason "stop".
- const doneEvents = events.filter((e) => e.type === "done");
- expect(doneEvents).toHaveLength(1);
- if (doneEvents[0]?.type === "done") {
- expect(doneEvents[0].reason).toBe("stop");
- }
- });
-
- // Bonus: multiple hanging tools + abort → all resolve via the race,
- // drain() doesn't deadlock, and runTurn returns. Tool-result messages
- // are omitted from the result (aborted step); the turn seals cleanly.
- it("multiple hanging tools + abort → drain completes and runTurn returns", async () => {
- const ac = new AbortController();
-
- // Two tools that never resolve and ignore ctx.signal.
- const toolA = createFakeTool("hangA", () => new Promise<ToolResult>(() => {}));
- const toolB = createFakeTool("hangB", () => new Promise<ToolResult>(() => {}));
-
- const provider: ProviderContract = {
- id: "fake",
- stream() {
- return (async function* () {
- yield {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "hangA",
- input: {},
- } as ProviderEvent;
- yield {
- type: "tool-call",
- toolCallId: "tc2",
- toolName: "hangB",
- input: {},
- } as ProviderEvent;
- ac.abort();
- await delay(10);
- yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
- })();
- },
- };
-
- const { events, emit } = createCollectingEmit();
-
- const result = await runTurn({
- provider,
- messages: [userMessage],
- tools: [toolA, toolB],
- dispatch: { maxConcurrent: 2, eager: true },
- conversationId: "conv-1",
- turnId: "turn-1",
- emit,
- signal: ac.signal,
- });
-
- expect(result.finishReason).toBe("aborted");
-
- // tool-result EVENTS are still emitted by executeStep (for live UI),
- // but tool-result MESSAGES are omitted from the result (not persisted).
- const toolResultEvents = events.filter((e) => e.type === "tool-result");
- expect(toolResultEvents).toHaveLength(2);
- for (const tr of toolResultEvents) {
- if (tr.type === "tool-result") {
- expect(tr.isError).toBe(true);
- }
- }
-
- // No tool messages in the result (they would orphan on the next turn).
- const toolMessages = result.messages.filter((m) => m.role === "tool");
- expect(toolMessages).toHaveLength(0);
-
- // Assistant message has no tool-call chunks.
- const assistantMsgs = result.messages.filter((m) => m.role === "assistant");
- for (const msg of assistantMsgs) {
- expect(msg.chunks.some((c) => c.type === "tool-call")).toBe(false);
- }
-
- const doneEvents = events.filter((e) => e.type === "done");
- expect(doneEvents).toHaveLength(1);
- if (doneEvents[0]?.type === "done") {
- expect(doneEvents[0].reason).toBe("aborted");
- }
- });
-
- // Critical regression: after an aborted tool call, the result messages
- // must NOT contain orphaned tool messages. If they did, the next turn
- // would send a `tool` role message to the provider without a preceding
- // `assistant` message carrying `tool_calls` → 400 error.
- it("aborted step produces no tool messages and no tool-call chunks in result", async () => {
- const ac = new AbortController();
-
- // Tool that hangs forever
- const tool = createFakeTool("hang", () => new Promise<ToolResult>(() => {}));
-
- const provider: ProviderContract = {
- id: "fake",
- stream() {
- return (async function* () {
- yield { type: "text-delta", delta: "Let me run that for you" } as ProviderEvent;
- yield {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "hang",
- input: {},
- } as ProviderEvent;
- ac.abort();
- await delay(10);
- yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
- })();
- },
- };
-
- const result = await runTurn({
- provider,
- messages: [userMessage],
- tools: [tool],
- dispatch: { maxConcurrent: 1, eager: true },
- conversationId: "conv-1",
- turnId: "turn-1",
- emit: noopEmit,
- signal: ac.signal,
- });
-
- expect(result.finishReason).toBe("aborted");
-
- // No tool messages in the result
- const toolMessages = result.messages.filter((m) => m.role === "tool");
- expect(toolMessages).toHaveLength(0);
-
- // The assistant message should preserve text but NOT tool-call chunks
- const assistantMsg = result.messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- if (assistantMsg !== undefined) {
- const hasToolCall = assistantMsg.chunks.some((c) => c.type === "tool-call");
- expect(hasToolCall).toBe(false);
- // Text content should be preserved
- const hasText = assistantMsg.chunks.some((c) => c.type === "text");
- expect(hasText).toBe(true);
- }
-
- // Simulate what the next turn would see: the result messages are the
- // conversation history (minus the user message). If we feed these to
- // a simple converter, there should be NO `tool` role messages.
- const toolRoleCount = result.messages.filter((m) => m.role === "tool").length;
- expect(toolRoleCount).toBe(0);
- });
+ // Required test 1: A hanging tool (never resolves, ignores ctx.signal)
+ // must not keep runTurn from returning when the signal aborts.
+ it("hanging tool + abort → runTurn returns with finishReason aborted and emits done", async () => {
+ const ac = new AbortController();
+
+ // A tool whose execute returns a promise that NEVER resolves and
+ // ignores ctx.signal entirely.
+ const tool = createFakeTool("hang", () => new Promise<ToolResult>(() => {}));
+
+ // Use eager: true so the tool starts BEFORE the signal aborts.
+ // This exercises the race (not the early signal.aborted return).
+ const provider: ProviderContract = {
+ id: "fake",
+ stream() {
+ return (async function* () {
+ yield {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "hang",
+ input: {},
+ } as ProviderEvent;
+ ac.abort();
+ await delay(10);
+ yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: true },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ signal: ac.signal,
+ });
+
+ // runTurn returned (didn't hang) → the race worked.
+ expect(result.finishReason).toBe("aborted");
+
+ // A done event was emitted with reason "aborted".
+ const doneEvents = events.filter((e) => e.type === "done");
+ expect(doneEvents).toHaveLength(1);
+ if (doneEvents[0]?.type === "done") {
+ expect(doneEvents[0].reason).toBe("aborted");
+ }
+ });
+
+ // Required test 2: A signal-aware tool that resolves its own result on
+ // abort must also let runTurn return with finishReason "aborted".
+ it("signal-aware tool + abort → runTurn returns with finishReason aborted", async () => {
+ const ac = new AbortController();
+
+ const tool = createFakeTool("aware", (_input, ctx) => {
+ return new Promise<ToolResult>((resolve) => {
+ ctx.signal.addEventListener(
+ "abort",
+ () => resolve({ content: "aborted by tool", isError: true }),
+ { once: true },
+ );
+ });
+ });
+
+ const provider: ProviderContract = {
+ id: "fake",
+ stream() {
+ return (async function* () {
+ yield {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "aware",
+ input: {},
+ } as ProviderEvent;
+ ac.abort();
+ await delay(10);
+ yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: true },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ signal: ac.signal,
+ });
+
+ expect(result.finishReason).toBe("aborted");
+
+ const doneEvents = events.filter((e) => e.type === "done");
+ expect(doneEvents).toHaveLength(1);
+ if (doneEvents[0]?.type === "done") {
+ expect(doneEvents[0].reason).toBe("aborted");
+ }
+
+ // When the step is aborted, tool-result MESSAGES are omitted from the
+ // result (the tool-result EVENT is still emitted by executeStep for
+ // live UI updates, but the message is not persisted). This prevents
+ // orphaned `tool` messages from breaking the next turn's provider
+ // request. The assistant message has its tool-call chunks stripped.
+ const toolResultMsg = result.messages.find((m) => m.role === "tool");
+ expect(toolResultMsg).toBeUndefined();
+
+ // The assistant message should NOT contain tool-call chunks.
+ const assistantMsg = result.messages.find(
+ (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"),
+ );
+ expect(assistantMsg).toBeUndefined();
+ });
+
+ // Required test 3 (regression guard): Without abort, a normal tool runs
+ // and its result is used; finishReason reflects the model.
+ it("no abort → tool runs normally and its result is used (regression)", async () => {
+ const tool = createFakeTool("normal", async (input) => ({
+ content: `result: ${JSON.stringify(input)}`,
+ }));
+
+ const provider = createFakeProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "normal", input: { x: 1 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: true },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ });
+
+ // finishReason reflects the model (second step's "stop").
+ expect(result.finishReason).toBe("stop");
+
+ // The tool's result was used (fed back, not "Aborted").
+ const toolResultMsg = result.messages.find((m) => m.role === "tool");
+ expect(toolResultMsg).toBeDefined();
+ const trChunk = toolResultMsg?.chunks[0];
+ expect(trChunk?.type).toBe("tool-result");
+ if (trChunk?.type === "tool-result") {
+ expect(trChunk.content).toBe('result: {"x":1}');
+ expect(trChunk.isError).toBe(false);
+ }
+
+ // done event emitted with reason "stop".
+ const doneEvents = events.filter((e) => e.type === "done");
+ expect(doneEvents).toHaveLength(1);
+ if (doneEvents[0]?.type === "done") {
+ expect(doneEvents[0].reason).toBe("stop");
+ }
+ });
+
+ // Bonus: multiple hanging tools + abort → all resolve via the race,
+ // drain() doesn't deadlock, and runTurn returns. Tool-result messages
+ // are omitted from the result (aborted step); the turn seals cleanly.
+ it("multiple hanging tools + abort → drain completes and runTurn returns", async () => {
+ const ac = new AbortController();
+
+ // Two tools that never resolve and ignore ctx.signal.
+ const toolA = createFakeTool("hangA", () => new Promise<ToolResult>(() => {}));
+ const toolB = createFakeTool("hangB", () => new Promise<ToolResult>(() => {}));
+
+ const provider: ProviderContract = {
+ id: "fake",
+ stream() {
+ return (async function* () {
+ yield {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "hangA",
+ input: {},
+ } as ProviderEvent;
+ yield {
+ type: "tool-call",
+ toolCallId: "tc2",
+ toolName: "hangB",
+ input: {},
+ } as ProviderEvent;
+ ac.abort();
+ await delay(10);
+ yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [toolA, toolB],
+ dispatch: { maxConcurrent: 2, eager: true },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ signal: ac.signal,
+ });
+
+ expect(result.finishReason).toBe("aborted");
+
+ // tool-result EVENTS are still emitted by executeStep (for live UI),
+ // but tool-result MESSAGES are omitted from the result (not persisted).
+ const toolResultEvents = events.filter((e) => e.type === "tool-result");
+ expect(toolResultEvents).toHaveLength(2);
+ for (const tr of toolResultEvents) {
+ if (tr.type === "tool-result") {
+ expect(tr.isError).toBe(true);
+ }
+ }
+
+ // No tool messages in the result (they would orphan on the next turn).
+ const toolMessages = result.messages.filter((m) => m.role === "tool");
+ expect(toolMessages).toHaveLength(0);
+
+ // Assistant message has no tool-call chunks.
+ const assistantMsgs = result.messages.filter((m) => m.role === "assistant");
+ for (const msg of assistantMsgs) {
+ expect(msg.chunks.some((c) => c.type === "tool-call")).toBe(false);
+ }
+
+ const doneEvents = events.filter((e) => e.type === "done");
+ expect(doneEvents).toHaveLength(1);
+ if (doneEvents[0]?.type === "done") {
+ expect(doneEvents[0].reason).toBe("aborted");
+ }
+ });
+
+ // Critical regression: after an aborted tool call, the result messages
+ // must NOT contain orphaned tool messages. If they did, the next turn
+ // would send a `tool` role message to the provider without a preceding
+ // `assistant` message carrying `tool_calls` → 400 error.
+ it("aborted step produces no tool messages and no tool-call chunks in result", async () => {
+ const ac = new AbortController();
+
+ // Tool that hangs forever
+ const tool = createFakeTool("hang", () => new Promise<ToolResult>(() => {}));
+
+ const provider: ProviderContract = {
+ id: "fake",
+ stream() {
+ return (async function* () {
+ yield { type: "text-delta", delta: "Let me run that for you" } as ProviderEvent;
+ yield {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "hang",
+ input: {},
+ } as ProviderEvent;
+ ac.abort();
+ await delay(10);
+ yield { type: "finish", reason: "tool-calls" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: true },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit: noopEmit,
+ signal: ac.signal,
+ });
+
+ expect(result.finishReason).toBe("aborted");
+
+ // No tool messages in the result
+ const toolMessages = result.messages.filter((m) => m.role === "tool");
+ expect(toolMessages).toHaveLength(0);
+
+ // The assistant message should preserve text but NOT tool-call chunks
+ const assistantMsg = result.messages.find((m) => m.role === "assistant");
+ expect(assistantMsg).toBeDefined();
+ if (assistantMsg !== undefined) {
+ const hasToolCall = assistantMsg.chunks.some((c) => c.type === "tool-call");
+ expect(hasToolCall).toBe(false);
+ // Text content should be preserved
+ const hasText = assistantMsg.chunks.some((c) => c.type === "text");
+ expect(hasText).toBe(true);
+ }
+
+ // Simulate what the next turn would see: the result messages are the
+ // conversation history (minus the user message). If we feed these to
+ // a simple converter, there should be NO `tool` role messages.
+ const toolRoleCount = result.messages.filter((m) => m.role === "tool").length;
+ expect(toolRoleCount).toBe(0);
+ });
});