diff options
Diffstat (limited to 'packages/kernel/src/runtime/run-turn.test.ts')
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.test.ts | 6810 |
1 files changed, 3405 insertions, 3405 deletions
diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index 59e7fab..8d20975 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -8,3429 +8,3429 @@ import { createLogger } from "../logging/logger.js"; 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(_messages, _tools) { - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) { - yield event; - } - })(); - }, - }; + let callIndex = 0; + return { + id: "fake", + stream(_messages, _tools) { + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; } function createCapturingProvider(script: ProviderEvent[][]): { - provider: ProviderContract; - capturedMessages: ChatMessage[][]; + provider: ProviderContract; + capturedMessages: ChatMessage[][]; } { - const capturedMessages: ChatMessage[][] = []; - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream(messages, _tools) { - capturedMessages.push([...messages]); - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) { - yield event; - } - })(); - }, - }; - return { provider, capturedMessages }; + const capturedMessages: ChatMessage[][] = []; + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream(messages, _tools) { + capturedMessages.push([...messages]); + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; + return { provider, capturedMessages }; } 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 userMessage: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "hello" }], + role: "user", + chunks: [{ type: "text", text: "hello" }], }; describe("runTurn", () => { - it("emits events with the conversationId and turnId from input", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-42", - turnId: "turn-99", - emit, - }); - - expect(events.length).toBeGreaterThan(0); - for (const event of events) { - expect(event.conversationId).toBe("conv-42"); - if (event.type !== "status") { - expect(event.turnId).toBe("turn-99"); - } - } - }); - - it("text-only turn emits correct events and returns correct result", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "reasoning-delta", delta: "thinking..." }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(result.finishReason).toBe("stop"); - expect(result.messages).toHaveLength(1); - expect(result.messages[0]?.role).toBe("assistant"); - - const chunks = result.messages[0]?.chunks ?? []; - expect(chunks).toHaveLength(2); - expect(chunks[0]).toEqual({ type: "text", text: "Hello world" }); - expect(chunks[1]).toEqual({ type: "thinking", text: "thinking..." }); - - expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); - - const eventTypes = events.map((e) => e.type); - expect(eventTypes).toEqual([ - "turn-start", - "text-delta", - "text-delta", - "reasoning-delta", - "usage", - "step-complete", - "done", - ]); - }); - - it("turn with one tool call executes tool, feeds result back, then finishes", async () => { - const tool = createFakeTool("greet", async (input) => ({ - content: `Hello, ${(input as { name: string }).name}!`, - })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "greet", input: { name: "World" } }, - { 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: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(result.finishReason).toBe("stop"); - expect(result.messages).toHaveLength(3); - expect(result.messages[0]?.role).toBe("assistant"); - expect(result.messages[1]?.role).toBe("tool"); - expect(result.messages[2]?.role).toBe("assistant"); - - const toolResultChunk = result.messages[1]?.chunks[0]; - expect(toolResultChunk?.type).toBe("tool-result"); - if (toolResultChunk?.type === "tool-result") { - expect(toolResultChunk.content).toBe("Hello, World!"); - expect(toolResultChunk.toolCallId).toBe("tc1"); - expect(toolResultChunk.isError).toBe(false); - } - - const eventTypes = events.map((e) => e.type); - expect(eventTypes).toContain("tool-call"); - expect(eventTypes).toContain("tool-result"); - expect(eventTypes).toContain("text-delta"); - }); - - it("passes updated messages to subsequent provider calls", async () => { - const capturedMessages: ChatMessage[][] = []; - let callIndex = 0; - const script: ProviderEvent[][] = [ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]; - - const provider: ProviderContract = { - id: "fake", - stream(messages, _tools) { - capturedMessages.push([...messages]); - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) yield event; - })(); - }, - }; - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(capturedMessages).toHaveLength(2); - expect(capturedMessages[0] ?? []).toHaveLength(1); - expect(capturedMessages[0]?.[0]?.role).toBe("user"); - - expect(capturedMessages[1] ?? []).toHaveLength(3); - expect(capturedMessages[1]?.[0]?.role).toBe("user"); - expect(capturedMessages[1]?.[1]?.role).toBe("assistant"); - expect(capturedMessages[1]?.[2]?.role).toBe("tool"); - }); - - it("maxConcurrent: 1 runs tools sequentially", async () => { - const log: string[] = []; - - const toolA = createFakeTool("a", async () => { - log.push("a:start"); - await delay(10); - log.push("a:end"); - return { content: "a" }; - }); - - const toolB = createFakeTool("b", async () => { - log.push("b:start"); - await delay(10); - log.push("b:end"); - return { content: "b" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const aEndIdx = log.indexOf("a:end"); - const bStartIdx = log.indexOf("b:start"); - expect(aEndIdx).toBeLessThan(bStartIdx); - }); - - it("maxConcurrent: 2 runs tools in parallel", async () => { - const log: string[] = []; - - const toolA = createFakeTool("a", async () => { - log.push("a:start"); - await delay(20); - log.push("a:end"); - return { content: "a" }; - }); - - const toolB = createFakeTool("b", async () => { - log.push("b:start"); - await delay(20); - log.push("b:end"); - return { content: "b" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 2, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const aStartIdx = log.indexOf("a:start"); - const bStartIdx = log.indexOf("b:start"); - const aEndIdx = log.indexOf("a:end"); - const bEndIdx = log.indexOf("b:end"); - - expect(aStartIdx).toBeLessThan(aEndIdx); - expect(bStartIdx).toBeLessThan(bEndIdx); - expect(aStartIdx).toBeLessThan(bEndIdx); - expect(bStartIdx).toBeLessThan(aEndIdx); - }); - - it("maxConcurrent: 0 runs all tools in parallel (unlimited)", async () => { - const log: string[] = []; - - const toolA = createFakeTool("a", async () => { - log.push("a:start"); - await delay(20); - log.push("a:end"); - return { content: "a" }; - }); - - const toolB = createFakeTool("b", async () => { - log.push("b:start"); - await delay(20); - log.push("b:end"); - return { content: "b" }; - }); - - const toolC = createFakeTool("c", async () => { - log.push("c:start"); - await delay(20); - log.push("c:end"); - return { content: "c" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "tool-call", toolCallId: "tc3", toolName: "c", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB, toolC], - dispatch: { maxConcurrent: 0, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const aStartIdx = log.indexOf("a:start"); - const bStartIdx = log.indexOf("b:start"); - const cStartIdx = log.indexOf("c:start"); - const aEndIdx = log.indexOf("a:end"); - const bEndIdx = log.indexOf("b:end"); - const cEndIdx = log.indexOf("c:end"); - - expect(aStartIdx).toBeLessThan(aEndIdx); - expect(bStartIdx).toBeLessThan(bEndIdx); - expect(cStartIdx).toBeLessThan(cEndIdx); - expect(aStartIdx).toBeLessThan(bEndIdx); - expect(bStartIdx).toBeLessThan(aEndIdx); - expect(cStartIdx).toBeLessThan(aEndIdx); - }); - - it("eager: true launches tool before step finish", async () => { - const log: string[] = []; - - const tool = createFakeTool("test", async () => { - log.push("tool:start"); - await delay(5); - log.push("tool:end"); - return { content: "done" }; - }); - - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = callCount++; - if (idx === 0) { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - } as ProviderEvent; - log.push("provider:after-tool-call"); - await delay(50); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - log.push("provider:finish"); - })(); - } - return (async function* () { - yield { type: "text-delta", delta: "done" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: true }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const toolStartIdx = log.indexOf("tool:start"); - const finishIdx = log.indexOf("provider:finish"); - expect(toolStartIdx).toBeLessThan(finishIdx); - }); - - it("eager: false does not launch tool before step finish", async () => { - const log: string[] = []; - - const tool = createFakeTool("test", async () => { - log.push("tool:start"); - await delay(5); - log.push("tool:end"); - return { content: "done" }; - }); - - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = callCount++; - if (idx === 0) { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - } as ProviderEvent; - log.push("provider:after-tool-call"); - await delay(50); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - log.push("provider:finish"); - })(); - } - return (async function* () { - yield { type: "text-delta", delta: "done" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const toolStartIdx = log.indexOf("tool:start"); - const finishIdx = log.indexOf("provider:finish"); - expect(toolStartIdx).toBeGreaterThan(finishIdx); - }); - - it("abort mid-turn synthesizes error results for unresolved tool calls", async () => { - const ac = new AbortController(); - - const tool = createFakeTool("slow", async (_input, ctx) => { - await delay(200); - if (ctx.signal.aborted) return { content: "Aborted", isError: true }; - return { content: "done" }; - }); - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "slow", - input: {}, - } as ProviderEvent; - yield { - type: "tool-call", - toolCallId: "tc2", - toolName: "slow", - input: { x: 1 }, - } 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: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - const toolResults = events.filter((e) => e.type === "tool-result"); - for (const tr of toolResults) { - if (tr.type === "tool-result") { - expect(tr.isError).toBe(true); - } - } - }); - - it("abort before any step returns aborted immediately", async () => { - const ac = new AbortController(); - ac.abort(); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "should not appear" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - expect(result.messages).toHaveLength(0); - }); - - it("de-duplicates identical tool calls in a batch", async () => { - let execCount = 0; - - const tool = createFakeTool("dedup", async (_input) => { - execCount++; - return { content: `result-${execCount}` }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "dedup", input: { x: 1 } }, - { type: "tool-call", toolCallId: "tc2", toolName: "dedup", input: { x: 1 } }, - { type: "tool-call", toolCallId: "tc3", toolName: "dedup", input: { x: 2 } }, - { 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: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(execCount).toBe(2); - - const toolResults = events.filter((e) => e.type === "tool-result"); - expect(toolResults).toHaveLength(3); - - const tc1Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc1"); - const tc2Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc2"); - const tc3Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc3"); - - expect(tc1Result).toBeDefined(); - expect(tc2Result).toBeDefined(); - expect(tc3Result).toBeDefined(); - - if (tc1Result?.type === "tool-result" && tc2Result?.type === "tool-result") { - expect(tc1Result.content).toBe(tc2Result.content); - expect(tc1Result.content).toBe("result-1"); - } - if (tc3Result?.type === "tool-result") { - expect(tc3Result.content).toBe("result-2"); - } - - expect(result.finishReason).toBe("stop"); - }); - - it("serializes non-concurrency-safe tools even with maxConcurrent > 1", async () => { - const log: string[] = []; - - const unsafeTool: ToolContract = { - name: "unsafe", - description: "Unsafe tool", - parameters: { type: "object" }, - concurrencySafe: false, - execute: async () => { - log.push("unsafe:start"); - await delay(10); - log.push("unsafe:end"); - return { content: "done" }; - }, - }; - - const safeTool: ToolContract = { - name: "safe", - description: "Safe tool", - parameters: { type: "object" }, - execute: async () => { - log.push("safe:start"); - await delay(10); - log.push("safe:end"); - return { content: "done" }; - }, - }; - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "unsafe", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "safe", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [unsafeTool, safeTool], - dispatch: { maxConcurrent: 5, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const unsafeEndIdx = log.indexOf("unsafe:end"); - const safeStartIdx = log.indexOf("safe:start"); - expect(unsafeEndIdx).toBeLessThan(safeStartIdx); - }); - - it("handles unknown tool name gracefully", async () => { - const provider = createFakeProvider([ - [ - { - type: "tool-call", - toolCallId: "tc1", - toolName: "nonexistent", - input: {}, - }, - { 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: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - const toolResults = events.filter((e) => e.type === "tool-result"); - expect(toolResults).toHaveLength(1); - if (toolResults[0]?.type === "tool-result") { - expect(toolResults[0]?.isError).toBe(true); - expect(toolResults[0]?.content).toContain("Unknown tool"); - } - - expect(result.finishReason).toBe("stop"); - }); - - it("handles provider error gracefully", async () => { - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("provider crashed"); - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(result.finishReason).toBe("error"); - - const errorEvents = events.filter((e) => e.type === "error"); - expect(errorEvents).toHaveLength(1); - if (errorEvents[0]?.type === "error") { - expect(errorEvents[0]?.message).toContain("provider crashed"); - } - }); - - it("forwards cwd from RunTurnInput to ToolExecuteContext", async () => { - let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("cwdcheck", async (_input, ctx) => { - capturedCwd = ctx.cwd; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - cwd: "/some/dir", - }); - - expect(capturedCwd).toBe("/some/dir"); - }); - - it("forwards undefined cwd when RunTurnInput has no cwd", async () => { - let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("cwdcheck", async (_input, ctx) => { - capturedCwd = ctx.cwd; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(capturedCwd).toBeUndefined(); - }); - - it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => { - let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("computercheck", async (_input, ctx) => { - capturedComputerId = ctx.computerId; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - computerId: "ssh-host-alias", - }); - - expect(capturedComputerId).toBe("ssh-host-alias"); - }); - - it("forwards undefined computerId when RunTurnInput has no computerId", async () => { - let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("computercheck", async (_input, ctx) => { - capturedComputerId = ctx.computerId; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(capturedComputerId).toBeUndefined(); - }); - - it("aggregates usage across multiple steps", async () => { - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "usage", usage: { inputTokens: 20, outputTokens: 10 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(result.usage).toEqual({ inputTokens: 30, outputTokens: 15 }); - }); - - it("emits tool-output events from tool ctx.onOutput", async () => { - const tool: ToolContract = { - name: "streaming", - description: "A tool that streams output", - parameters: { type: "object" }, - execute: async (_input, ctx) => { - ctx.onOutput("line 1\n", "stdout"); - ctx.onOutput("err 1\n", "stderr"); - return { content: "done" }; - }, - }; - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "streaming", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - const outputs = events.filter((e) => e.type === "tool-output"); - expect(outputs).toHaveLength(2); - if (outputs[0]?.type === "tool-output") { - expect(outputs[0]?.data).toBe("line 1\n"); - expect(outputs[0]?.stream).toBe("stdout"); - expect(outputs[0]?.toolCallId).toBe("tc1"); - } - if (outputs[1]?.type === "tool-output") { - expect(outputs[1]?.data).toBe("err 1\n"); - expect(outputs[1]?.stream).toBe("stderr"); - } - }); - - function createTestLogger(): { - logger: Logger; - sink: LogSink & { records: LogRecord[] }; - deps: LogDeps; - } { - let idCounter = 0; - const deps: LogDeps = { - now: () => 1000 + idCounter * 100, - newId: () => `span-${++idCounter}`, - }; - const records: LogRecord[] = []; - const sink: LogSink & { records: LogRecord[] } = { - records, - emit: (record) => records.push(record), - }; - const logger = createLogger({ extensionId: "test" }, sink, deps); - return { logger, sink, deps }; - } - - describe("span instrumentation", () => { - it("emits turn + step span open/close in order", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const spanOpens = sink.records.filter((r) => r.kind === "span-open"); - const spanCloses = sink.records.filter((r) => r.kind === "span-close"); - - expect(spanOpens.length).toBeGreaterThanOrEqual(2); // turn + step - expect(spanCloses.length).toBeGreaterThanOrEqual(2); - - const turnOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "turn"); - const stepOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "step"); - expect(turnOpen).toBeDefined(); - expect(stepOpen).toBeDefined(); - - if (turnOpen?.kind === "span-open") { - expect(turnOpen.extensionId).toBe("test"); - expect(turnOpen.attributes?.conversationId).toBe("conv-1"); - expect(turnOpen.attributes?.turnId).toBe("turn-1"); - } - - const turnClose = spanCloses.find((r) => r.kind === "span-close" && r.name === "turn"); - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.status).toBe("ok"); - expect(turnClose.durationMs).toBeGreaterThanOrEqual(0); - } - }); - - it("emits tool-call spans for dispatched tools", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const toolCallSpans = sink.records.filter( - (r) => r.kind === "span-open" && r.name === "tool-call", - ); - expect(toolCallSpans).toHaveLength(1); - if (toolCallSpans[0]?.kind === "span-open") { - expect(toolCallSpans[0].attributes?.name).toBe("echo"); - expect(toolCallSpans[0].attributes?.toolCallId).toBe("tc1"); - } - - const toolCallCloses = sink.records.filter( - (r) => r.kind === "span-close" && r.name === "tool-call", - ); - expect(toolCallCloses).toHaveLength(1); - if (toolCallCloses[0]?.kind === "span-close") { - expect(toolCallCloses[0].status).toBe("ok"); - } - }); - - it("tools receive ctx.log (correlated logger)", async () => { - let capturedLog: Logger | undefined; - - const tool = createFakeTool("logtest", async (_input, ctx) => { - capturedLog = ctx.log; - ctx.log.info("tool ran", { key: "value" }); - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "logtest", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(capturedLog).toBeDefined(); - - const toolLogs = sink.records.filter( - (r) => r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "tool ran", - ); - expect(toolLogs).toHaveLength(1); - if (toolLogs[0]?.kind === "log") { - expect(toolLogs[0].attributes?.key).toBe("value"); - expect(toolLogs[0].extensionId).toBe("test"); - } - }); - - it("an aborted turn still closes its turn span", async () => { - const ac = new AbortController(); - ac.abort(); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "should not appear" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - signal: ac.signal, - logger, - }); - - const turnCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "turn"); - expect(turnCloses).toHaveLength(1); - if (turnCloses[0]?.kind === "span-close") { - expect(turnCloses[0].attributes?.finishReason).toBe("aborted"); - } - }); - - it("a provider error closes the step span with error status", async () => { - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("provider exploded"); - })(); - }, - }; - - const { logger, sink } = createTestLogger(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(result.finishReason).toBe("error"); - - const stepCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "step"); - expect(stepCloses).toHaveLength(1); - if (stepCloses[0]?.kind === "span-close") { - expect(stepCloses[0].status).toBe("error"); - expect(stepCloses[0].attributes?.["error.message"]).toContain("provider exploded"); - } - }); - - it("emits a prompt span with verbatim body and small scalar attributes", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const promptOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "prompt"); - expect(promptOpens).toHaveLength(1); - - const promptOpen = promptOpens[0]; - if (promptOpen?.kind === "span-open") { - expect(promptOpen.body).toBeDefined(); - const parsed = JSON.parse(promptOpen.body as string); - expect(parsed.messages).toEqual([userMessage]); - expect(parsed.tools).toHaveLength(1); - expect(parsed.tools[0].name).toBe("echo"); - - expect(promptOpen.attributes?.messageCount).toBe(1); - expect(promptOpen.attributes?.toolCount).toBe(1); - } - - const promptCloses = sink.records.filter( - (r) => r.kind === "span-close" && r.name === "prompt", - ); - expect(promptCloses).toHaveLength(1); - - const logRecords = sink.records.filter( - (r) => - r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "prompt:before", - ); - expect(logRecords).toHaveLength(0); - }); - - it("emits ttft and decode spans for a generating step", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); - const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); - const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); - const decodeCloses = sink.records.filter( - (r) => r.kind === "span-close" && r.name === "decode", - ); - - expect(ttftOpens).toHaveLength(1); - expect(ttftCloses).toHaveLength(1); - expect(decodeOpens).toHaveLength(1); - expect(decodeCloses).toHaveLength(1); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - expect(stepOpen).toBeDefined(); - - if ( - ttftOpens[0]?.kind === "span-open" && - ttftCloses[0]?.kind === "span-close" && - decodeOpens[0]?.kind === "span-open" && - decodeCloses[0]?.kind === "span-close" && - stepOpen?.kind === "span-open" - ) { - // ttft and decode are children of step - expect(ttftOpens[0].parentSpanId).toBe(stepOpen.spanId); - expect(decodeOpens[0].parentSpanId).toBe(stepOpen.spanId); - - // ttft closes before decode opens (in order) - const ttftCloseIdx = sink.records.indexOf(ttftCloses[0]); - const decodeOpenIdx = sink.records.indexOf(decodeOpens[0]); - expect(ttftCloseIdx).toBeLessThan(decodeOpenIdx); - - // ttft has firstToken: true - expect(ttftCloses[0].attributes?.firstToken).toBe(true); - - // durations from fake clock - expect(ttftCloses[0].durationMs).toBeGreaterThanOrEqual(0); - expect(decodeCloses[0].durationMs).toBeGreaterThanOrEqual(0); - } - }); - - it("first token counts a reasoning delta", async () => { - const provider = createFakeProvider([ - [ - { type: "reasoning-delta", delta: "thinking..." }, - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); - expect(ttftCloses).toHaveLength(1); - - // The ttft span should close at the reasoning delta, not at the text delta - if (ttftCloses[0]?.kind === "span-close") { - expect(ttftCloses[0].attributes?.firstToken).toBe(true); - } - }); - - it("a step with no content token does not emit a misleading decode", async () => { - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - // First step (tool-call-only) should have ttft with firstToken: false and no decode - const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); - const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); - const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); - - // There should be 2 ttft opens (one per step) and 2 ttft closes - expect(ttftOpens).toHaveLength(2); - expect(ttftCloses).toHaveLength(2); - - // First step: tool-call-only, no first token - if (ttftCloses[0]?.kind === "span-close") { - expect(ttftCloses[0].attributes?.firstToken).toBe(false); - } - - // Second step: has text-delta, should have firstToken: true and decode span - if (ttftCloses[1]?.kind === "span-close") { - expect(ttftCloses[1].attributes?.firstToken).toBe(true); - } - - // Only one decode span (for the second step) - expect(decodeOpens).toHaveLength(1); - }); - - it("turn span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(turnClose.attributes?.usage_inputTokens).toBeUndefined(); - expect(turnClose.attributes?.usage_outputTokens).toBeUndefined(); - } - }); - - it("step span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 7, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); - expect(stepClose).toBeDefined(); - if (stepClose?.kind === "span-close") { - expect(stepClose.attributes?.["usage.inputTokens"]).toBe(7); - expect(stepClose.attributes?.["usage.outputTokens"]).toBe(3); - expect(stepClose.attributes?.usage_inputTokens).toBeUndefined(); - expect(stepClose.attributes?.usage_outputTokens).toBeUndefined(); - } - }); - - it("turn + step spans stamp usage.cacheReadTokens / usage.cacheWriteTokens when the provider Usage carries them", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 }, - }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); - const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); - - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBe(3); - expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); - } - - expect(stepClose).toBeDefined(); - if (stepClose?.kind === "span-close") { - expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBe(3); - expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); - } - }); - - it("turn + step spans OMIT the cache-token attrs when the provider Usage lacks them", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); - const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); - - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); - expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); - } - - expect(stepClose).toBeDefined(); - if (stepClose?.kind === "span-close") { - expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); - expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); - } - }); - }); - - describe("provider logger threading", () => { - it("passes step span logger to provider.stream opts when logger provided", async () => { - let capturedOpts: Record<string, unknown> | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedOpts = opts !== undefined ? { ...opts } : undefined; - return (async function* () { - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { logger } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(capturedOpts).toBeDefined(); - expect(capturedOpts?.logger).toBeDefined(); - expect(typeof (capturedOpts?.logger as Record<string, unknown>).info).toBe("function"); - expect(typeof (capturedOpts?.logger as Record<string, unknown>).span).toBe("function"); - }); - - it("passes undefined for opts.logger when no logger provided", async () => { - let capturedOpts: Record<string, unknown> | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedOpts = opts !== undefined ? { ...opts } : undefined; - return (async function* () { - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - }); - - expect(capturedOpts).toBeDefined(); - expect(capturedOpts?.logger).toBeUndefined(); - }); - - it("threads providerOpts.model through to provider.stream opts", async () => { - let capturedOpts: Record<string, unknown> | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedOpts = opts !== undefined ? { ...opts } : undefined; - return (async function* () { - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - providerOpts: { model: "some-model-id" }, - }); - - expect(capturedOpts?.model).toBe("some-model-id"); - }); - }); - - describe("span tree nesting", () => { - it("turn span is root (parentSpanId undefined)", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); - expect(turnOpen).toBeDefined(); - if (turnOpen?.kind === "span-open") { - expect(turnOpen.parentSpanId).toBeUndefined(); - } - }); - - it("step span is a child of turn span", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - expect(turnOpen).toBeDefined(); - expect(stepOpen).toBeDefined(); - if (turnOpen?.kind === "span-open" && stepOpen?.kind === "span-open") { - expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); - } - }); - - it("prompt span is a child of step span", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - const promptOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "prompt"); - expect(stepOpen).toBeDefined(); - expect(promptOpen).toBeDefined(); - if (stepOpen?.kind === "span-open" && promptOpen?.kind === "span-open") { - expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); - } - }); - - it("provider logger creates spans nested under step", async () => { - let capturedLogger: Logger | undefined; - let providerReqSpanId: string | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedLogger = opts?.logger; - return (async function* () { - // Open provider.request span inside the stream (like a real provider) - if (capturedLogger !== undefined) { - const span = capturedLogger.span("provider.request"); - providerReqSpanId = span.id; - span.end(); - } - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(capturedLogger).toBeDefined(); - expect(providerReqSpanId).toBeDefined(); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - const provReqOpen = sink.records.find( - (r) => r.kind === "span-open" && r.name === "provider.request", - ); - expect(stepOpen).toBeDefined(); - expect(provReqOpen).toBeDefined(); - if (stepOpen?.kind === "span-open" && provReqOpen?.kind === "span-open") { - expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); - expect(provReqOpen.spanId).toBe(providerReqSpanId); - } - }); - - it("tool-call spans are children of step span", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - const tcOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "tool-call"); - expect(stepOpen).toBeDefined(); - expect(tcOpen).toBeDefined(); - if (stepOpen?.kind === "span-open" && tcOpen?.kind === "span-open") { - expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); - } - }); - - it("full parent chain: turn → step → {prompt, provider.request, tool-call}", async () => { - let capturedLogger: Logger | undefined; - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let streamCallCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedLogger = opts?.logger; - streamCallCount++; - return (async function* () { - // Simulate provider opening a provider.request span - // INSIDE the stream on the first call only (like a real provider) - if (streamCallCount === 1 && capturedLogger !== undefined) { - const span = capturedLogger.span("provider.request"); - span.end(); - } - if (streamCallCount === 1) { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - yield { type: "text-delta", delta: "done" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const spanOpens = sink.records.filter((r) => r.kind === "span-open") as Array< - Extract<LogRecord, { kind: "span-open" }> - >; - - const turnOpen = spanOpens.find((r) => r.name === "turn"); - const stepOpen = spanOpens.find((r) => r.name === "step"); - const promptOpen = spanOpens.find((r) => r.name === "prompt"); - const provReqOpen = spanOpens.find((r) => r.name === "provider.request"); - const tcOpen = spanOpens.find((r) => r.name === "tool-call"); - - expect(turnOpen).toBeDefined(); - expect(stepOpen).toBeDefined(); - expect(promptOpen).toBeDefined(); - expect(provReqOpen).toBeDefined(); - expect(tcOpen).toBeDefined(); - - if ( - turnOpen?.kind === "span-open" && - stepOpen?.kind === "span-open" && - promptOpen?.kind === "span-open" && - provReqOpen?.kind === "span-open" && - tcOpen?.kind === "span-open" - ) { - // turn = root - expect(turnOpen.parentSpanId).toBeUndefined(); - - // step = child of turn - expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); - - // prompt = child of step - expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); - - // provider.request = child of step - expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); - - // tool-call = child of step - expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); - } - }); - }); - - describe("lifecycle events", () => { - it("emits turn-start as the first event with conversation + turn ids", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-42", - turnId: "turn-99", - emit, - }); - - expect(events[0]?.type).toBe("turn-start"); - if (events[0]?.type === "turn-start") { - expect(events[0].conversationId).toBe("conv-42"); - expect(events[0].turnId).toBe("turn-99"); - } - }); - - it("emits a single done event last, carrying the finishReason", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe(result.finishReason); - expect(lastEvent.conversationId).toBe("conv-1"); - expect(lastEvent.turnId).toBe("turn-1"); - } - - const doneEvents = events.filter((e) => e.type === "done"); - expect(doneEvents).toHaveLength(1); - }); - - it("emits done after a tool-call turn", async () => { - const tool = createFakeTool("echo", async (input) => ({ - content: `echo: ${JSON.stringify(input)}`, - })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", 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: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe(result.finishReason); - } - }); - - it('still emits done with reason "aborted" when the turn is aborted via signal', async () => { - const ac = new AbortController(); - ac.abort(); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "should not appear" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe("aborted"); - } - }); - - it('still emits done with reason "error" when the provider errors', async () => { - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("provider crashed"); - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - expect(result.finishReason).toBe("error"); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe("error"); - } - }); - - it("turn-start precedes every delta and done follows every delta", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "reasoning-delta", delta: "thinking..." }, - { type: "text-delta", delta: " world" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const turnStartIdx = events.findIndex((e) => e.type === "turn-start"); - const doneIdx = events.findIndex((e) => e.type === "done"); - - expect(turnStartIdx).toBe(0); - expect(doneIdx).toBe(events.length - 1); - - for (let i = 0; i < events.length; i++) { - const e = events[i]; - if (e?.type === "text-delta" || e?.type === "reasoning-delta") { - expect(i).toBeGreaterThan(turnStartIdx); - expect(i).toBeLessThan(doneIdx); - } - } - }); - }); - - describe("stepId", () => { - it("tool-call and tool-result events carry stepId", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const toolCallEvt = events.find((e) => e.type === "tool-call"); - const toolResultEvt = events.find((e) => e.type === "tool-result"); - - expect(toolCallEvt).toBeDefined(); - expect(toolResultEvt).toBeDefined(); - - if (toolCallEvt?.type === "tool-call" && toolResultEvt?.type === "tool-result") { - expect(toolCallEvt.stepId).toBeDefined(); - expect(toolResultEvt.stepId).toBeDefined(); - expect(toolCallEvt.stepId).toBe(toolResultEvt.stepId); - } - }); - - it("tool calls in the SAME step share one stepId; a later step gets a different one", async () => { - const toolA = createFakeTool("a", async () => ({ content: "a-result" })); - const toolB = createFakeTool("b", async () => ({ content: "b-result" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "tool-call", toolCallId: "tc3", toolName: "a", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const toolCallEvts = events.filter((e) => e.type === "tool-call"); - expect(toolCallEvts.length).toBeGreaterThanOrEqual(2); - - const step0Calls = toolCallEvts.filter( - (e) => e.type === "tool-call" && (e.toolCallId === "tc1" || e.toolCallId === "tc2"), - ); - const step1Call = toolCallEvts.find((e) => e.type === "tool-call" && e.toolCallId === "tc3"); - - expect(step0Calls).toHaveLength(2); - if (step0Calls[0]?.type === "tool-call" && step0Calls[1]?.type === "tool-call") { - expect(step0Calls[0].stepId).toBe(step0Calls[1].stepId); - } - - if (step1Call?.type === "tool-call" && step0Calls[0]?.type === "tool-call") { - expect(step1Call.stepId).not.toBe(step0Calls[0].stepId); - } - }); - - it("tool chunks in the result carry stepId", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - }); - - const toolCallMsg = result.messages.find( - (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), - ); - const toolResultMsg = result.messages.find((m) => m.role === "tool"); - - expect(toolCallMsg).toBeDefined(); - expect(toolResultMsg).toBeDefined(); - - const tcChunk = toolCallMsg?.chunks.find((c) => c.type === "tool-call"); - const trChunk = toolResultMsg?.chunks[0]; - - expect(tcChunk?.type).toBe("tool-call"); - expect(trChunk?.type).toBe("tool-result"); - - if (tcChunk?.type === "tool-call" && trChunk?.type === "tool-result") { - expect(tcChunk.stepId).toBeDefined(); - expect(trChunk.stepId).toBeDefined(); - expect(tcChunk.stepId).toBe(trChunk.stepId); - } - }); - }); - - describe("timing events (now provided)", () => { - function createCounterNow(): { now: () => number; tick: (ms: number) => void } { - let current = 0; - return { - now: () => current, - tick: (ms: number) => { - current += ms; - }, - }; - } - - it("emits step-complete per step with timing when now provided", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const { events, emit } = createCollectingEmit(); - - // Advance clock during stream: first token at +50ms, stream ends at +200ms - let streamCallCount = 0; - const wrappedProvider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = streamCallCount++; - return (async function* () { - if (idx === 0) { - clock.tick(50); // stream starts - yield { type: "text-delta", delta: "Hello" } as ProviderEvent; - // first token seen at 150 (100+50) - clock.tick(100); - yield { type: "text-delta", delta: " world" } as ProviderEvent; - clock.tick(50); - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - await runTurn({ - provider: wrappedProvider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); - expect(stepCompleteEvts).toHaveLength(1); - - const sc = stepCompleteEvts[0]; - if (sc?.type === "step-complete") { - expect(sc.conversationId).toBe("conv-1"); - expect(sc.turnId).toBe("turn-1"); - expect(sc.stepId).toBeDefined(); - expect(sc.genTotalMs).toBe(200); // 50+100+50 - expect(sc.ttftMs).toBe(50); // stream start → first text-delta - expect(sc.decodeMs).toBe(150); // first token → stream end - const ttft = sc.ttftMs; - const decode = sc.decodeMs; - const genTotal = sc.genTotalMs; - if (ttft !== undefined && decode !== undefined && genTotal !== undefined) { - expect(genTotal).toBe(ttft + decode); - } - } - }); - - it("step-complete omits ttft/decode but keeps genTotalMs for a no-content step", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let streamCallCount = 0; - const wrappedProvider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = streamCallCount++; - return (async function* () { - if (idx === 0) { - clock.tick(80); // stream starts at 180 - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - clock.tick(20); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - clock.tick(50); - yield { type: "text-delta", delta: "done" } as ProviderEvent; - clock.tick(50); - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider: wrappedProvider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); - expect(stepCompleteEvts).toHaveLength(2); - - // First step: tool-call-only, no content token - const sc0 = stepCompleteEvts[0]; - if (sc0?.type === "step-complete") { - expect(sc0.stepId).toBeDefined(); - expect(sc0.genTotalMs).toBe(100); // 80+20 - expect(sc0.ttftMs).toBeUndefined(); - expect(sc0.decodeMs).toBeUndefined(); - } - - // Second step: has text-delta - const sc1 = stepCompleteEvts[1]; - if (sc1?.type === "step-complete") { - expect(sc1.stepId).toBeDefined(); - expect(sc1.genTotalMs).toBe(100); // 50+50 - expect(sc1.ttftMs).toBe(50); - expect(sc1.decodeMs).toBe(50); - } - }); - - it("usage event carries stepId", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const usageEvts = events.filter((e) => e.type === "usage"); - expect(usageEvts).toHaveLength(1); - const ue = usageEvts[0]; - if (ue?.type === "usage") { - expect(ue.stepId).toBeDefined(); - } - }); - - it("tool-result carries durationMs (execution time) when now provided", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const tool = createFakeTool("slow", async () => { - clock.tick(200); // tool takes 200ms to execute - return { content: "done" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "slow", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const toolResultEvts = events.filter((e) => e.type === "tool-result"); - expect(toolResultEvts).toHaveLength(1); - const tr = toolResultEvts[0]; - if (tr?.type === "tool-result") { - expect(tr.durationMs).toBeDefined(); - expect(tr.durationMs).toBe(200); - } - }); - - it("done carries durationMs and aggregate usage when now provided", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const wrappedProvider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - return (async function* () { - clock.tick(80); // stream duration - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider: wrappedProvider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const doneEvts = events.filter((e) => e.type === "done"); - expect(doneEvts).toHaveLength(1); - const d = doneEvts[0]; - if (d?.type === "done") { - expect(d.durationMs).toBeDefined(); - expect(d.durationMs).toBeGreaterThan(0); - expect(d.usage).toBeDefined(); - if (d.usage !== undefined) { - expect(d.usage.inputTokens).toBe(10); - expect(d.usage.outputTokens).toBe(5); - } - } - }); - - it("no now → timing fields absent", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - // no now - }); - - // step-complete still emitted (with stepId, no timing) - const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); - expect(stepCompleteEvts).toHaveLength(2); - for (const sc of stepCompleteEvts) { - if (sc?.type === "step-complete") { - expect(sc.stepId).toBeDefined(); - expect(sc.ttftMs).toBeUndefined(); - expect(sc.decodeMs).toBeUndefined(); - expect(sc.genTotalMs).toBeUndefined(); - } - } - - // usage still carries stepId - const usageEvts = events.filter((e) => e.type === "usage"); - for (const ue of usageEvts) { - if (ue?.type === "usage") { - expect(ue.stepId).toBeDefined(); - } - } - - // no durationMs on tool-result - const toolResultEvts = events.filter((e) => e.type === "tool-result"); - for (const tr of toolResultEvts) { - if (tr?.type === "tool-result") { - expect(tr.durationMs).toBeUndefined(); - } - } - - // no durationMs on done, but usage is present (independent of now) - const doneEvts = events.filter((e) => e.type === "done"); - expect(doneEvts).toHaveLength(1); - const d = doneEvts[0]; - if (d?.type === "done") { - expect(d.durationMs).toBeUndefined(); - expect(d.usage).toBeDefined(); - if (d.usage !== undefined) { - expect(d.usage.inputTokens).toBe(15); - expect(d.usage.outputTokens).toBe(8); - } - } - }); - }); - - describe("contextSize", () => { - it("single-step turn: contextSize equals step inputTokens + outputTokens", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 100, outputTokens: 50 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const doneEvt = events.find((e) => e.type === "done"); - expect(doneEvt).toBeDefined(); - if (doneEvt?.type === "done") { - expect(doneEvt.contextSize).toBe(150); - } - }); - - it("multi-step turn: contextSize equals ONLY the last step's inputTokens + outputTokens", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "usage", usage: { inputTokens: 100, outputTokens: 20 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "usage", usage: { inputTokens: 300, outputTokens: 80 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const doneEvt = events.find((e) => e.type === "done"); - expect(doneEvt).toBeDefined(); - if (doneEvt?.type === "done") { - expect(doneEvt.contextSize).toBe(380); - expect(doneEvt.usage).toBeDefined(); - if (doneEvt.usage !== undefined) { - expect(doneEvt.contextSize).not.toBe(doneEvt.usage.inputTokens); - } - } - }); - - it("no usage reported: contextSize is undefined", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const doneEvt = events.find((e) => e.type === "done"); - expect(doneEvt).toBeDefined(); - if (doneEvt?.type === "done") { - expect(doneEvt.contextSize).toBeUndefined(); - expect(doneEvt.usage).toBeUndefined(); - } - }); - }); - - describe("drainSteering", () => { - it("drainSteering called once at the tool-result boundary; returned messages appended to the next step's provider input (after tool results)", async () => { - let drainCallCount = 0; - const steeringMessage: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "steer!" }], - }; - - const { provider, capturedMessages } = createCapturingProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return [steeringMessage]; - }, - }); - - expect(drainCallCount).toBe(1); - // The provider was called twice (tool-call step, then text step). - expect(capturedMessages).toHaveLength(2); - const secondStepMessages = capturedMessages[1] ?? []; - // user, assistant(tool-call), tool-result, steering(user) — in order, - // steering appended AFTER the tool results, before the next call. - expect(secondStepMessages).toHaveLength(4); - expect(secondStepMessages[0]?.role).toBe("user"); - expect(secondStepMessages[1]?.role).toBe("assistant"); - expect(secondStepMessages[2]?.role).toBe("tool"); - expect(secondStepMessages[3]).toEqual(steeringMessage); - expect(secondStepMessages[3]?.role).toBe("user"); - // Steering is fed to the next provider call, NOT surfaced in the - // turn result — the caller owns the steering messages' lifecycle. - expect(result.messages).toHaveLength(3); - }); - - it("drainSteering omitted → no injection; turn byte-identical to before", async () => { - const { provider, capturedMessages } = createCapturingProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - // drainSteering omitted — must be a strict no-op. - }); - - expect(capturedMessages).toHaveLength(2); - const secondStepMessages = capturedMessages[1] ?? []; - // user, assistant(tool-call), tool-result — NO steering injected. - expect(secondStepMessages).toHaveLength(3); - expect(secondStepMessages[0]?.role).toBe("user"); - expect(secondStepMessages[1]?.role).toBe("assistant"); - expect(secondStepMessages[2]?.role).toBe("tool"); - expect(result.messages).toHaveLength(3); - }); - - it("drainSteering returns [] → no injection", async () => { - let drainCallCount = 0; - const { provider, capturedMessages } = createCapturingProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - // Called at the boundary, but returned nothing → no injection. - expect(drainCallCount).toBe(1); - expect(capturedMessages).toHaveLength(2); - const secondStepMessages = capturedMessages[1] ?? []; - expect(secondStepMessages).toHaveLength(3); - expect(secondStepMessages[2]?.role).toBe("tool"); - }); - - it("drainSteering NOT called when a step has no tool calls (text-only turn)", async () => { - let drainCallCount = 0; - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hello" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - expect(drainCallCount).toBe(0); - }); - - it("multiple tool-call steps → drainSteering called once per tool-call step", async () => { - let drainCallCount = 0; - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "tool-call", toolCallId: "tc2", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - // Steps 0 and 1 each produced tool calls → drained once each. - // Step 2 (text-only) → no boundary → no drain. Total = 2. - expect(drainCallCount).toBe(2); - }); - - it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => { - let drainCallCount = 0; - // 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step - // to end the turn naturally. - const STEPS_WITH_TOOLS = 100; - const script: ProviderEvent[][] = []; - for (let i = 0; i < STEPS_WITH_TOOLS; i++) { - script.push([ - { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ]); - } - // Final step: text only, no tool calls → natural end. - script.push([ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ]); - const provider = createFakeProvider(script); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - // Turn ended naturally, NOT via max-steps. - expect(result.finishReason).toBe("stop"); - // Every tool-call step (0..99) is followed by a next step → each - // triggers a drain. The text-only step breaks before draining. - expect(drainCallCount).toBe(STEPS_WITH_TOOLS); - // All 101 steps produced messages (100 tool steps with assistant + - // tool messages, 1 text-only step with an assistant message). - expect(result.messages.length).toBe(STEPS_WITH_TOOLS * 2 + 1); - }); - }); - - // ── Retry with backoff ────────────────────────────────────────────────── - // - // PURE tests: a fake `sleep` (records calls, resolves instantly, can abort - // on a chosen call) + a pure `delayFor` (the canonical schedule + 8h budget). - // A stub `ProviderContract` whose `stream` yields a retryable error N times - // then a finish. ZERO mocks of `@dispatch/*` modules — effects injected. - - /** The canonical backoff schedule (matches the orchestrator's concrete strategy). */ - const RETRY_SCHEDULE_MS = [5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000]; - const RETRY_TAIL_MS = 1_800_000; // 30m - const RETRY_BUDGET_MS = 8 * 60 * 60 * 1000; // 8h - - /** Cumulative scheduled sleep through `attempt` (sum of delay[0..attempt]). */ - function cumulativeSleepMs(attempt: number): number { - let sum = 0; - for (let i = 0; i <= attempt; i++) { - sum += i < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[i] : RETRY_TAIL_MS; - } - return sum; - } - - /** Pure, deterministic delay decision (no I/O, no clock). */ - function delayFor(attempt: number): number | undefined { - const delay = attempt < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[attempt] : RETRY_TAIL_MS; - if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop - return delay; - } - - /** The full schedule delayFor would emit (until budget exhausted). */ - function fullSchedule(): number[] { - const result: number[] = []; - let attempt = 0; - while (true) { - const delay = delayFor(attempt); - if (delay === undefined) break; - result.push(delay); - attempt++; - } - return result; - } - - /** - * Fake, controllable `sleep`: records every call's delay, resolves - * instantly (no real waiting), and can abort the controller on a chosen - * 1-based call index to simulate "abort during sleep". - */ - function createFakeSleep(controller: AbortController): { - sleep: (ms: number, signal: AbortSignal) => Promise<void>; - calls: number[]; - abortOnCall: (n: number) => void; - } { - const calls: number[] = []; - let abortAt: number | undefined; - const sleep = async (ms: number, _signal: AbortSignal): Promise<void> => { - calls.push(ms); - if (abortAt !== undefined && calls.length === abortAt) { - controller.abort(); - throw new Error("aborted"); - } - // Otherwise resolve instantly (no real waiting). - }; - return { - sleep, - calls, - abortOnCall: (n: number) => { - abortAt = n; - }, - }; - } - - /** A provider that yields a retryable error `errorCount` times, then success. */ - function createRetryingProvider(opts: { - errorCount: number; - error?: { message: string; code?: string; retryable?: boolean }; - success?: ProviderEvent[]; - }): { provider: ProviderContract; streamCalls: { value: number } } { - const streamCalls = { value: 0 }; - const error: ProviderEvent = { - type: "error", - message: opts.error?.message ?? "overloaded", - ...(opts.error?.code !== undefined ? { code: opts.error.code } : {}), - ...(opts.error?.retryable !== undefined ? { retryable: opts.error.retryable } : {}), - }; - const success = opts.success ?? [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ]; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = streamCalls.value++; - return (async function* () { - if (idx < opts.errorCount) { - yield error; - return; - } - for (const event of success) yield event; - })(); - }, - }; - return { provider, streamCalls }; - } - - describe("retry with backoff", () => { - it("retries a retryable emitted error on schedule then succeeds", async () => { - const { provider } = createRetryingProvider({ - errorCount: 3, - error: { message: "HTTP 429: overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(result.finishReason).toBe("stop"); - // 3 retries: 5s, 10s, 30s. - expect(fake.calls).toEqual([5_000, 10_000, 30_000]); - // 3 provider-retry events (one per sleep), then the successful text. - const retryEvents = events.filter((e) => e.type === "provider-retry"); - expect(retryEvents).toHaveLength(3); - if (retryEvents[0]?.type === "provider-retry") { - expect(retryEvents[0].attempt).toBe(0); - expect(retryEvents[0].delayMs).toBe(5_000); - expect(retryEvents[0].message).toBe("HTTP 429: overloaded"); - expect(retryEvents[0].code).toBe("429"); - expect(retryEvents[0].conversationId).toBe("conv-1"); - expect(retryEvents[0].turnId).toBe("turn-1"); - } - if (retryEvents[1]?.type === "provider-retry") { - expect(retryEvents[1].attempt).toBe(1); - expect(retryEvents[1].delayMs).toBe(10_000); - } - if (retryEvents[2]?.type === "provider-retry") { - expect(retryEvents[2].attempt).toBe(2); - expect(retryEvents[2].delayMs).toBe(30_000); - } - // The error was suppressed (no error event emitted — retry succeeded). - expect(events.filter((e) => e.type === "error")).toHaveLength(0); - // The successful content still streams. - const deltas = events.filter((e) => e.type === "text-delta"); - expect(deltas).toHaveLength(1); - }); - - it("sleep is called with the full schedule [5s,10s,30s,60s,5m,10m,15m,30m,30m…]", async () => { - // Provider errors forever → retries until budget exhausted → gives up. - const { provider } = createRetryingProvider({ - errorCount: Number.POSITIVE_INFINITY, - error: { message: "overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - // Budget exhausted → give up → error. - expect(result.finishReason).toBe("error"); - - // The sleep schedule matches the pure delayFor output exactly. - expect(fake.calls).toEqual(fullSchedule()); - - // Head of the schedule (the 8 stepped delays). - expect(fake.calls.slice(0, 8)).toEqual([ - 5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000, - ]); - // Tail repeats 30m. - expect(fake.calls[8]).toBe(1_800_000); - expect(fake.calls.at(-1)).toBe(1_800_000); - - // 8h cumulative budget cap: head (3705s) + 13×30m = ~7h31m, then stop. - // 21 retries (attempts 0..20), then delayFor(21) → undefined → give up. - expect(fake.calls).toHaveLength(21); - const totalSlept = fake.calls.reduce((a, b) => a + b, 0); - expect(totalSlept).toBeLessThanOrEqual(RETRY_BUDGET_MS); - expect(totalSlept).toBe(3_705_000 + 13 * 1_800_000); // 27_105_000 - - // One provider-retry per sleep, plus a final error (give-up). - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(21); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - const errEvt = events.find((e) => e.type === "error"); - if (errEvt?.type === "error") { - expect(errEvt.message).toBe("overloaded"); - expect(errEvt.code).toBe("429"); - } - }); - - it("does NOT retry after content was emitted (safety invariant)", async () => { - // Provider yields text (content) THEN a retryable error. Because content - // was emitted, retrying is unsafe (would duplicate partial output). - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - callCount++; - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - yield { - type: "error", - message: "overloaded", - code: "429", - retryable: true, - } as ProviderEvent; - })(); - }, - }; - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - // No retries: stream called exactly once. - expect(callCount).toBe(1); - expect(fake.calls).toHaveLength(0); - // The error is emitted (give-up) and partial content preserved. - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); - expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); - }); - - it("does NOT retry a non-retryable emitted error (retryable: false)", async () => { - const { provider, streamCalls } = createRetryingProvider({ - errorCount: 1, - error: { message: "bad request", code: "400", retryable: false }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(streamCalls.value).toBe(1); // no retry - expect(fake.calls).toHaveLength(0); - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); - }); - - it("does NOT retry a non-retryable emitted error (retryable absent)", async () => { - const { provider, streamCalls } = createRetryingProvider({ - errorCount: 1, - error: { message: "bad request", code: "400" }, // no retryable field - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(streamCalls.value).toBe(1); // no retry - expect(fake.calls).toHaveLength(0); - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - }); - - it("give-up emits the final error when budget is exhausted", async () => { - // Custom delayFor that allows exactly 1 retry then stops. - const shortDelayFor = (attempt: number): number | undefined => - attempt === 0 ? 100 : undefined; - const { provider } = createRetryingProvider({ - errorCount: Number.POSITIVE_INFINITY, - error: { message: "overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor: shortDelayFor, sleep: fake.sleep }, - }); - - expect(result.finishReason).toBe("error"); - expect(fake.calls).toEqual([100]); // one retry, then give up - // One provider-retry (attempt 0), then the final error. - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(1); - const errs = events.filter((e) => e.type === "error"); - expect(errs).toHaveLength(1); - if (errs[0]?.type === "error") { - expect(errs[0].message).toBe("overloaded"); - expect(errs[0].code).toBe("429"); - } - }); - - it("abort during sleep seals the turn aborted", async () => { - const { provider } = createRetryingProvider({ - errorCount: Number.POSITIVE_INFINITY, - error: { message: "overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - fake.abortOnCall(2); // abort on the 2nd sleep - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(result.finishReason).toBe("aborted"); - // Two sleeps attempted; the 2nd aborted. - expect(fake.calls).toHaveLength(2); - // No terminal error emitted (it was an abort, not a give-up). - expect(events.filter((e) => e.type === "error")).toHaveLength(0); - // One provider-retry before the aborted sleep (attempt 0). - const retries = events.filter((e) => e.type === "provider-retry"); - expect(retries).toHaveLength(2); - // The done event carries reason "aborted". - const done = events.find((e) => e.type === "done"); - if (done?.type === "done") { - expect(done.reason).toBe("aborted"); - } - }); - - it("omitting retry keeps the pre-retry behavior (backward-compatible)", async () => { - // A retryable error with no retry configured → ends the step as today. - const { provider, streamCalls } = createRetryingProvider({ - errorCount: 1, - error: { message: "overloaded", code: "429", retryable: true }, - }); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - // no retry field - }); - - expect(streamCalls.value).toBe(1); // no retry - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); - }); - - it("retries a THROWN error (retryable-by-default when pre-content)", async () => { - // A thrown error (no retryable flag) before content is retried. - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - callCount++; - return (async function* () { - if (callCount <= 2) { - throw new Error("network blip"); - } - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(callCount).toBe(3); // 2 throws retried, 3rd succeeds - expect(fake.calls).toEqual([5_000, 10_000]); - expect(result.finishReason).toBe("stop"); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(2); - // Thrown errors have no code. - if (events[0]?.type === "provider-retry") { - expect(events[0].code).toBeUndefined(); - expect(events[0].message).toBe("network blip"); - } - expect(events.filter((e) => e.type === "error")).toHaveLength(0); - }); - - it("does NOT retry a thrown error after content was emitted", async () => { - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - callCount++; - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("network blip"); - })(); - }, - }; - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(callCount).toBe(1); - expect(fake.calls).toHaveLength(0); - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); - }); - - it("provider-retry events interleave correctly: error → retry-event → sleep → retry", async () => { - // Verify ordering: each provider-retry event comes BEFORE its sleep, - // and the successful content comes only after the last retry. - const { provider } = createRetryingProvider({ - errorCount: 2, - error: { message: "overloaded", code: "429", retryable: true }, - success: [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - const types = events.map((e) => e.type); - // turn-start, provider-retry(0), provider-retry(1), text-delta, step-complete, done - expect(types[0]).toBe("turn-start"); - const firstRetryIdx = types.indexOf("provider-retry"); - const textIdx = types.indexOf("text-delta"); - expect(firstRetryIdx).toBeGreaterThan(0); - expect(textIdx).toBeGreaterThan(firstRetryIdx); - // Both retries precede the text. - const retryCount = types.filter((t) => t === "provider-retry").length; - expect(retryCount).toBe(2); - }); - }); + it("emits events with the conversationId and turnId from input", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-42", + turnId: "turn-99", + emit, + }); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.conversationId).toBe("conv-42"); + if (event.type !== "status") { + expect(event.turnId).toBe("turn-99"); + } + } + }); + + it("text-only turn emits correct events and returns correct result", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "reasoning-delta", delta: "thinking..." }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(result.finishReason).toBe("stop"); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.role).toBe("assistant"); + + const chunks = result.messages[0]?.chunks ?? []; + expect(chunks).toHaveLength(2); + expect(chunks[0]).toEqual({ type: "text", text: "Hello world" }); + expect(chunks[1]).toEqual({ type: "thinking", text: "thinking..." }); + + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); + + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toEqual([ + "turn-start", + "text-delta", + "text-delta", + "reasoning-delta", + "usage", + "step-complete", + "done", + ]); + }); + + it("turn with one tool call executes tool, feeds result back, then finishes", async () => { + const tool = createFakeTool("greet", async (input) => ({ + content: `Hello, ${(input as { name: string }).name}!`, + })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "greet", input: { name: "World" } }, + { 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: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(result.finishReason).toBe("stop"); + expect(result.messages).toHaveLength(3); + expect(result.messages[0]?.role).toBe("assistant"); + expect(result.messages[1]?.role).toBe("tool"); + expect(result.messages[2]?.role).toBe("assistant"); + + const toolResultChunk = result.messages[1]?.chunks[0]; + expect(toolResultChunk?.type).toBe("tool-result"); + if (toolResultChunk?.type === "tool-result") { + expect(toolResultChunk.content).toBe("Hello, World!"); + expect(toolResultChunk.toolCallId).toBe("tc1"); + expect(toolResultChunk.isError).toBe(false); + } + + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toContain("tool-call"); + expect(eventTypes).toContain("tool-result"); + expect(eventTypes).toContain("text-delta"); + }); + + it("passes updated messages to subsequent provider calls", async () => { + const capturedMessages: ChatMessage[][] = []; + let callIndex = 0; + const script: ProviderEvent[][] = [ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]; + + const provider: ProviderContract = { + id: "fake", + stream(messages, _tools) { + capturedMessages.push([...messages]); + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) yield event; + })(); + }, + }; + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedMessages).toHaveLength(2); + expect(capturedMessages[0] ?? []).toHaveLength(1); + expect(capturedMessages[0]?.[0]?.role).toBe("user"); + + expect(capturedMessages[1] ?? []).toHaveLength(3); + expect(capturedMessages[1]?.[0]?.role).toBe("user"); + expect(capturedMessages[1]?.[1]?.role).toBe("assistant"); + expect(capturedMessages[1]?.[2]?.role).toBe("tool"); + }); + + it("maxConcurrent: 1 runs tools sequentially", async () => { + const log: string[] = []; + + const toolA = createFakeTool("a", async () => { + log.push("a:start"); + await delay(10); + log.push("a:end"); + return { content: "a" }; + }); + + const toolB = createFakeTool("b", async () => { + log.push("b:start"); + await delay(10); + log.push("b:end"); + return { content: "b" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const aEndIdx = log.indexOf("a:end"); + const bStartIdx = log.indexOf("b:start"); + expect(aEndIdx).toBeLessThan(bStartIdx); + }); + + it("maxConcurrent: 2 runs tools in parallel", async () => { + const log: string[] = []; + + const toolA = createFakeTool("a", async () => { + log.push("a:start"); + await delay(20); + log.push("a:end"); + return { content: "a" }; + }); + + const toolB = createFakeTool("b", async () => { + log.push("b:start"); + await delay(20); + log.push("b:end"); + return { content: "b" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 2, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const aStartIdx = log.indexOf("a:start"); + const bStartIdx = log.indexOf("b:start"); + const aEndIdx = log.indexOf("a:end"); + const bEndIdx = log.indexOf("b:end"); + + expect(aStartIdx).toBeLessThan(aEndIdx); + expect(bStartIdx).toBeLessThan(bEndIdx); + expect(aStartIdx).toBeLessThan(bEndIdx); + expect(bStartIdx).toBeLessThan(aEndIdx); + }); + + it("maxConcurrent: 0 runs all tools in parallel (unlimited)", async () => { + const log: string[] = []; + + const toolA = createFakeTool("a", async () => { + log.push("a:start"); + await delay(20); + log.push("a:end"); + return { content: "a" }; + }); + + const toolB = createFakeTool("b", async () => { + log.push("b:start"); + await delay(20); + log.push("b:end"); + return { content: "b" }; + }); + + const toolC = createFakeTool("c", async () => { + log.push("c:start"); + await delay(20); + log.push("c:end"); + return { content: "c" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "tool-call", toolCallId: "tc3", toolName: "c", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB, toolC], + dispatch: { maxConcurrent: 0, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const aStartIdx = log.indexOf("a:start"); + const bStartIdx = log.indexOf("b:start"); + const cStartIdx = log.indexOf("c:start"); + const aEndIdx = log.indexOf("a:end"); + const bEndIdx = log.indexOf("b:end"); + const cEndIdx = log.indexOf("c:end"); + + expect(aStartIdx).toBeLessThan(aEndIdx); + expect(bStartIdx).toBeLessThan(bEndIdx); + expect(cStartIdx).toBeLessThan(cEndIdx); + expect(aStartIdx).toBeLessThan(bEndIdx); + expect(bStartIdx).toBeLessThan(aEndIdx); + expect(cStartIdx).toBeLessThan(aEndIdx); + }); + + it("eager: true launches tool before step finish", async () => { + const log: string[] = []; + + const tool = createFakeTool("test", async () => { + log.push("tool:start"); + await delay(5); + log.push("tool:end"); + return { content: "done" }; + }); + + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = callCount++; + if (idx === 0) { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + } as ProviderEvent; + log.push("provider:after-tool-call"); + await delay(50); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + log.push("provider:finish"); + })(); + } + return (async function* () { + yield { type: "text-delta", delta: "done" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: true }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const toolStartIdx = log.indexOf("tool:start"); + const finishIdx = log.indexOf("provider:finish"); + expect(toolStartIdx).toBeLessThan(finishIdx); + }); + + it("eager: false does not launch tool before step finish", async () => { + const log: string[] = []; + + const tool = createFakeTool("test", async () => { + log.push("tool:start"); + await delay(5); + log.push("tool:end"); + return { content: "done" }; + }); + + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = callCount++; + if (idx === 0) { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + } as ProviderEvent; + log.push("provider:after-tool-call"); + await delay(50); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + log.push("provider:finish"); + })(); + } + return (async function* () { + yield { type: "text-delta", delta: "done" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const toolStartIdx = log.indexOf("tool:start"); + const finishIdx = log.indexOf("provider:finish"); + expect(toolStartIdx).toBeGreaterThan(finishIdx); + }); + + it("abort mid-turn synthesizes error results for unresolved tool calls", async () => { + const ac = new AbortController(); + + const tool = createFakeTool("slow", async (_input, ctx) => { + await delay(200); + if (ctx.signal.aborted) return { content: "Aborted", isError: true }; + return { content: "done" }; + }); + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "slow", + input: {}, + } as ProviderEvent; + yield { + type: "tool-call", + toolCallId: "tc2", + toolName: "slow", + input: { x: 1 }, + } 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: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + const toolResults = events.filter((e) => e.type === "tool-result"); + for (const tr of toolResults) { + if (tr.type === "tool-result") { + expect(tr.isError).toBe(true); + } + } + }); + + it("abort before any step returns aborted immediately", async () => { + const ac = new AbortController(); + ac.abort(); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "should not appear" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + expect(result.messages).toHaveLength(0); + }); + + it("de-duplicates identical tool calls in a batch", async () => { + let execCount = 0; + + const tool = createFakeTool("dedup", async (_input) => { + execCount++; + return { content: `result-${execCount}` }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "dedup", input: { x: 1 } }, + { type: "tool-call", toolCallId: "tc2", toolName: "dedup", input: { x: 1 } }, + { type: "tool-call", toolCallId: "tc3", toolName: "dedup", input: { x: 2 } }, + { 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: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(execCount).toBe(2); + + const toolResults = events.filter((e) => e.type === "tool-result"); + expect(toolResults).toHaveLength(3); + + const tc1Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc1"); + const tc2Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc2"); + const tc3Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc3"); + + expect(tc1Result).toBeDefined(); + expect(tc2Result).toBeDefined(); + expect(tc3Result).toBeDefined(); + + if (tc1Result?.type === "tool-result" && tc2Result?.type === "tool-result") { + expect(tc1Result.content).toBe(tc2Result.content); + expect(tc1Result.content).toBe("result-1"); + } + if (tc3Result?.type === "tool-result") { + expect(tc3Result.content).toBe("result-2"); + } + + expect(result.finishReason).toBe("stop"); + }); + + it("serializes non-concurrency-safe tools even with maxConcurrent > 1", async () => { + const log: string[] = []; + + const unsafeTool: ToolContract = { + name: "unsafe", + description: "Unsafe tool", + parameters: { type: "object" }, + concurrencySafe: false, + execute: async () => { + log.push("unsafe:start"); + await delay(10); + log.push("unsafe:end"); + return { content: "done" }; + }, + }; + + const safeTool: ToolContract = { + name: "safe", + description: "Safe tool", + parameters: { type: "object" }, + execute: async () => { + log.push("safe:start"); + await delay(10); + log.push("safe:end"); + return { content: "done" }; + }, + }; + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "unsafe", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "safe", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [unsafeTool, safeTool], + dispatch: { maxConcurrent: 5, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const unsafeEndIdx = log.indexOf("unsafe:end"); + const safeStartIdx = log.indexOf("safe:start"); + expect(unsafeEndIdx).toBeLessThan(safeStartIdx); + }); + + it("handles unknown tool name gracefully", async () => { + const provider = createFakeProvider([ + [ + { + type: "tool-call", + toolCallId: "tc1", + toolName: "nonexistent", + input: {}, + }, + { 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: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + const toolResults = events.filter((e) => e.type === "tool-result"); + expect(toolResults).toHaveLength(1); + if (toolResults[0]?.type === "tool-result") { + expect(toolResults[0]?.isError).toBe(true); + expect(toolResults[0]?.content).toContain("Unknown tool"); + } + + expect(result.finishReason).toBe("stop"); + }); + + it("handles provider error gracefully", async () => { + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("provider crashed"); + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(result.finishReason).toBe("error"); + + const errorEvents = events.filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + if (errorEvents[0]?.type === "error") { + expect(errorEvents[0]?.message).toContain("provider crashed"); + } + }); + + it("forwards cwd from RunTurnInput to ToolExecuteContext", async () => { + let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("cwdcheck", async (_input, ctx) => { + capturedCwd = ctx.cwd; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + cwd: "/some/dir", + }); + + expect(capturedCwd).toBe("/some/dir"); + }); + + it("forwards undefined cwd when RunTurnInput has no cwd", async () => { + let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("cwdcheck", async (_input, ctx) => { + capturedCwd = ctx.cwd; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedCwd).toBeUndefined(); + }); + + it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + computerId: "ssh-host-alias", + }); + + expect(capturedComputerId).toBe("ssh-host-alias"); + }); + + it("forwards undefined computerId when RunTurnInput has no computerId", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedComputerId).toBeUndefined(); + }); + + it("aggregates usage across multiple steps", async () => { + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "usage", usage: { inputTokens: 20, outputTokens: 10 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(result.usage).toEqual({ inputTokens: 30, outputTokens: 15 }); + }); + + it("emits tool-output events from tool ctx.onOutput", async () => { + const tool: ToolContract = { + name: "streaming", + description: "A tool that streams output", + parameters: { type: "object" }, + execute: async (_input, ctx) => { + ctx.onOutput("line 1\n", "stdout"); + ctx.onOutput("err 1\n", "stderr"); + return { content: "done" }; + }, + }; + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "streaming", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + const outputs = events.filter((e) => e.type === "tool-output"); + expect(outputs).toHaveLength(2); + if (outputs[0]?.type === "tool-output") { + expect(outputs[0]?.data).toBe("line 1\n"); + expect(outputs[0]?.stream).toBe("stdout"); + expect(outputs[0]?.toolCallId).toBe("tc1"); + } + if (outputs[1]?.type === "tool-output") { + expect(outputs[1]?.data).toBe("err 1\n"); + expect(outputs[1]?.stream).toBe("stderr"); + } + }); + + function createTestLogger(): { + logger: Logger; + sink: LogSink & { records: LogRecord[] }; + deps: LogDeps; + } { + let idCounter = 0; + const deps: LogDeps = { + now: () => 1000 + idCounter * 100, + newId: () => `span-${++idCounter}`, + }; + const records: LogRecord[] = []; + const sink: LogSink & { records: LogRecord[] } = { + records, + emit: (record) => records.push(record), + }; + const logger = createLogger({ extensionId: "test" }, sink, deps); + return { logger, sink, deps }; + } + + describe("span instrumentation", () => { + it("emits turn + step span open/close in order", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const spanOpens = sink.records.filter((r) => r.kind === "span-open"); + const spanCloses = sink.records.filter((r) => r.kind === "span-close"); + + expect(spanOpens.length).toBeGreaterThanOrEqual(2); // turn + step + expect(spanCloses.length).toBeGreaterThanOrEqual(2); + + const turnOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "turn"); + const stepOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "step"); + expect(turnOpen).toBeDefined(); + expect(stepOpen).toBeDefined(); + + if (turnOpen?.kind === "span-open") { + expect(turnOpen.extensionId).toBe("test"); + expect(turnOpen.attributes?.conversationId).toBe("conv-1"); + expect(turnOpen.attributes?.turnId).toBe("turn-1"); + } + + const turnClose = spanCloses.find((r) => r.kind === "span-close" && r.name === "turn"); + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.status).toBe("ok"); + expect(turnClose.durationMs).toBeGreaterThanOrEqual(0); + } + }); + + it("emits tool-call spans for dispatched tools", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const toolCallSpans = sink.records.filter( + (r) => r.kind === "span-open" && r.name === "tool-call", + ); + expect(toolCallSpans).toHaveLength(1); + if (toolCallSpans[0]?.kind === "span-open") { + expect(toolCallSpans[0].attributes?.name).toBe("echo"); + expect(toolCallSpans[0].attributes?.toolCallId).toBe("tc1"); + } + + const toolCallCloses = sink.records.filter( + (r) => r.kind === "span-close" && r.name === "tool-call", + ); + expect(toolCallCloses).toHaveLength(1); + if (toolCallCloses[0]?.kind === "span-close") { + expect(toolCallCloses[0].status).toBe("ok"); + } + }); + + it("tools receive ctx.log (correlated logger)", async () => { + let capturedLog: Logger | undefined; + + const tool = createFakeTool("logtest", async (_input, ctx) => { + capturedLog = ctx.log; + ctx.log.info("tool ran", { key: "value" }); + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "logtest", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(capturedLog).toBeDefined(); + + const toolLogs = sink.records.filter( + (r) => r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "tool ran", + ); + expect(toolLogs).toHaveLength(1); + if (toolLogs[0]?.kind === "log") { + expect(toolLogs[0].attributes?.key).toBe("value"); + expect(toolLogs[0].extensionId).toBe("test"); + } + }); + + it("an aborted turn still closes its turn span", async () => { + const ac = new AbortController(); + ac.abort(); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "should not appear" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + signal: ac.signal, + logger, + }); + + const turnCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "turn"); + expect(turnCloses).toHaveLength(1); + if (turnCloses[0]?.kind === "span-close") { + expect(turnCloses[0].attributes?.finishReason).toBe("aborted"); + } + }); + + it("a provider error closes the step span with error status", async () => { + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("provider exploded"); + })(); + }, + }; + + const { logger, sink } = createTestLogger(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(result.finishReason).toBe("error"); + + const stepCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "step"); + expect(stepCloses).toHaveLength(1); + if (stepCloses[0]?.kind === "span-close") { + expect(stepCloses[0].status).toBe("error"); + expect(stepCloses[0].attributes?.["error.message"]).toContain("provider exploded"); + } + }); + + it("emits a prompt span with verbatim body and small scalar attributes", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const promptOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "prompt"); + expect(promptOpens).toHaveLength(1); + + const promptOpen = promptOpens[0]; + if (promptOpen?.kind === "span-open") { + expect(promptOpen.body).toBeDefined(); + const parsed = JSON.parse(promptOpen.body as string); + expect(parsed.messages).toEqual([userMessage]); + expect(parsed.tools).toHaveLength(1); + expect(parsed.tools[0].name).toBe("echo"); + + expect(promptOpen.attributes?.messageCount).toBe(1); + expect(promptOpen.attributes?.toolCount).toBe(1); + } + + const promptCloses = sink.records.filter( + (r) => r.kind === "span-close" && r.name === "prompt", + ); + expect(promptCloses).toHaveLength(1); + + const logRecords = sink.records.filter( + (r) => + r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "prompt:before", + ); + expect(logRecords).toHaveLength(0); + }); + + it("emits ttft and decode spans for a generating step", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); + const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); + const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); + const decodeCloses = sink.records.filter( + (r) => r.kind === "span-close" && r.name === "decode", + ); + + expect(ttftOpens).toHaveLength(1); + expect(ttftCloses).toHaveLength(1); + expect(decodeOpens).toHaveLength(1); + expect(decodeCloses).toHaveLength(1); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + expect(stepOpen).toBeDefined(); + + if ( + ttftOpens[0]?.kind === "span-open" && + ttftCloses[0]?.kind === "span-close" && + decodeOpens[0]?.kind === "span-open" && + decodeCloses[0]?.kind === "span-close" && + stepOpen?.kind === "span-open" + ) { + // ttft and decode are children of step + expect(ttftOpens[0].parentSpanId).toBe(stepOpen.spanId); + expect(decodeOpens[0].parentSpanId).toBe(stepOpen.spanId); + + // ttft closes before decode opens (in order) + const ttftCloseIdx = sink.records.indexOf(ttftCloses[0]); + const decodeOpenIdx = sink.records.indexOf(decodeOpens[0]); + expect(ttftCloseIdx).toBeLessThan(decodeOpenIdx); + + // ttft has firstToken: true + expect(ttftCloses[0].attributes?.firstToken).toBe(true); + + // durations from fake clock + expect(ttftCloses[0].durationMs).toBeGreaterThanOrEqual(0); + expect(decodeCloses[0].durationMs).toBeGreaterThanOrEqual(0); + } + }); + + it("first token counts a reasoning delta", async () => { + const provider = createFakeProvider([ + [ + { type: "reasoning-delta", delta: "thinking..." }, + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); + expect(ttftCloses).toHaveLength(1); + + // The ttft span should close at the reasoning delta, not at the text delta + if (ttftCloses[0]?.kind === "span-close") { + expect(ttftCloses[0].attributes?.firstToken).toBe(true); + } + }); + + it("a step with no content token does not emit a misleading decode", async () => { + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + // First step (tool-call-only) should have ttft with firstToken: false and no decode + const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); + const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); + const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); + + // There should be 2 ttft opens (one per step) and 2 ttft closes + expect(ttftOpens).toHaveLength(2); + expect(ttftCloses).toHaveLength(2); + + // First step: tool-call-only, no first token + if (ttftCloses[0]?.kind === "span-close") { + expect(ttftCloses[0].attributes?.firstToken).toBe(false); + } + + // Second step: has text-delta, should have firstToken: true and decode span + if (ttftCloses[1]?.kind === "span-close") { + expect(ttftCloses[1].attributes?.firstToken).toBe(true); + } + + // Only one decode span (for the second step) + expect(decodeOpens).toHaveLength(1); + }); + + it("turn span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(turnClose.attributes?.usage_inputTokens).toBeUndefined(); + expect(turnClose.attributes?.usage_outputTokens).toBeUndefined(); + } + }); + + it("step span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 7, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); + expect(stepClose).toBeDefined(); + if (stepClose?.kind === "span-close") { + expect(stepClose.attributes?.["usage.inputTokens"]).toBe(7); + expect(stepClose.attributes?.["usage.outputTokens"]).toBe(3); + expect(stepClose.attributes?.usage_inputTokens).toBeUndefined(); + expect(stepClose.attributes?.usage_outputTokens).toBeUndefined(); + } + }); + + it("turn + step spans stamp usage.cacheReadTokens / usage.cacheWriteTokens when the provider Usage carries them", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 }, + }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); + const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); + + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBe(3); + expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); + } + + expect(stepClose).toBeDefined(); + if (stepClose?.kind === "span-close") { + expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBe(3); + expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); + } + }); + + it("turn + step spans OMIT the cache-token attrs when the provider Usage lacks them", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); + const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); + + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); + expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); + } + + expect(stepClose).toBeDefined(); + if (stepClose?.kind === "span-close") { + expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); + expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); + } + }); + }); + + describe("provider logger threading", () => { + it("passes step span logger to provider.stream opts when logger provided", async () => { + let capturedOpts: Record<string, unknown> | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedOpts = opts !== undefined ? { ...opts } : undefined; + return (async function* () { + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { logger } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(capturedOpts).toBeDefined(); + expect(capturedOpts?.logger).toBeDefined(); + expect(typeof (capturedOpts?.logger as Record<string, unknown>).info).toBe("function"); + expect(typeof (capturedOpts?.logger as Record<string, unknown>).span).toBe("function"); + }); + + it("passes undefined for opts.logger when no logger provided", async () => { + let capturedOpts: Record<string, unknown> | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedOpts = opts !== undefined ? { ...opts } : undefined; + return (async function* () { + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + }); + + expect(capturedOpts).toBeDefined(); + expect(capturedOpts?.logger).toBeUndefined(); + }); + + it("threads providerOpts.model through to provider.stream opts", async () => { + let capturedOpts: Record<string, unknown> | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedOpts = opts !== undefined ? { ...opts } : undefined; + return (async function* () { + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + providerOpts: { model: "some-model-id" }, + }); + + expect(capturedOpts?.model).toBe("some-model-id"); + }); + }); + + describe("span tree nesting", () => { + it("turn span is root (parentSpanId undefined)", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); + expect(turnOpen).toBeDefined(); + if (turnOpen?.kind === "span-open") { + expect(turnOpen.parentSpanId).toBeUndefined(); + } + }); + + it("step span is a child of turn span", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + expect(turnOpen).toBeDefined(); + expect(stepOpen).toBeDefined(); + if (turnOpen?.kind === "span-open" && stepOpen?.kind === "span-open") { + expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); + } + }); + + it("prompt span is a child of step span", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + const promptOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "prompt"); + expect(stepOpen).toBeDefined(); + expect(promptOpen).toBeDefined(); + if (stepOpen?.kind === "span-open" && promptOpen?.kind === "span-open") { + expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); + } + }); + + it("provider logger creates spans nested under step", async () => { + let capturedLogger: Logger | undefined; + let providerReqSpanId: string | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedLogger = opts?.logger; + return (async function* () { + // Open provider.request span inside the stream (like a real provider) + if (capturedLogger !== undefined) { + const span = capturedLogger.span("provider.request"); + providerReqSpanId = span.id; + span.end(); + } + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(capturedLogger).toBeDefined(); + expect(providerReqSpanId).toBeDefined(); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + const provReqOpen = sink.records.find( + (r) => r.kind === "span-open" && r.name === "provider.request", + ); + expect(stepOpen).toBeDefined(); + expect(provReqOpen).toBeDefined(); + if (stepOpen?.kind === "span-open" && provReqOpen?.kind === "span-open") { + expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); + expect(provReqOpen.spanId).toBe(providerReqSpanId); + } + }); + + it("tool-call spans are children of step span", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + const tcOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "tool-call"); + expect(stepOpen).toBeDefined(); + expect(tcOpen).toBeDefined(); + if (stepOpen?.kind === "span-open" && tcOpen?.kind === "span-open") { + expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); + } + }); + + it("full parent chain: turn → step → {prompt, provider.request, tool-call}", async () => { + let capturedLogger: Logger | undefined; + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let streamCallCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedLogger = opts?.logger; + streamCallCount++; + return (async function* () { + // Simulate provider opening a provider.request span + // INSIDE the stream on the first call only (like a real provider) + if (streamCallCount === 1 && capturedLogger !== undefined) { + const span = capturedLogger.span("provider.request"); + span.end(); + } + if (streamCallCount === 1) { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + yield { type: "text-delta", delta: "done" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const spanOpens = sink.records.filter((r) => r.kind === "span-open") as Array< + Extract<LogRecord, { kind: "span-open" }> + >; + + const turnOpen = spanOpens.find((r) => r.name === "turn"); + const stepOpen = spanOpens.find((r) => r.name === "step"); + const promptOpen = spanOpens.find((r) => r.name === "prompt"); + const provReqOpen = spanOpens.find((r) => r.name === "provider.request"); + const tcOpen = spanOpens.find((r) => r.name === "tool-call"); + + expect(turnOpen).toBeDefined(); + expect(stepOpen).toBeDefined(); + expect(promptOpen).toBeDefined(); + expect(provReqOpen).toBeDefined(); + expect(tcOpen).toBeDefined(); + + if ( + turnOpen?.kind === "span-open" && + stepOpen?.kind === "span-open" && + promptOpen?.kind === "span-open" && + provReqOpen?.kind === "span-open" && + tcOpen?.kind === "span-open" + ) { + // turn = root + expect(turnOpen.parentSpanId).toBeUndefined(); + + // step = child of turn + expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); + + // prompt = child of step + expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); + + // provider.request = child of step + expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); + + // tool-call = child of step + expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); + } + }); + }); + + describe("lifecycle events", () => { + it("emits turn-start as the first event with conversation + turn ids", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-42", + turnId: "turn-99", + emit, + }); + + expect(events[0]?.type).toBe("turn-start"); + if (events[0]?.type === "turn-start") { + expect(events[0].conversationId).toBe("conv-42"); + expect(events[0].turnId).toBe("turn-99"); + } + }); + + it("emits a single done event last, carrying the finishReason", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe(result.finishReason); + expect(lastEvent.conversationId).toBe("conv-1"); + expect(lastEvent.turnId).toBe("turn-1"); + } + + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + }); + + it("emits done after a tool-call turn", async () => { + const tool = createFakeTool("echo", async (input) => ({ + content: `echo: ${JSON.stringify(input)}`, + })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", 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: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe(result.finishReason); + } + }); + + it('still emits done with reason "aborted" when the turn is aborted via signal', async () => { + const ac = new AbortController(); + ac.abort(); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "should not appear" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe("aborted"); + } + }); + + it('still emits done with reason "error" when the provider errors', async () => { + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("provider crashed"); + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + expect(result.finishReason).toBe("error"); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe("error"); + } + }); + + it("turn-start precedes every delta and done follows every delta", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "reasoning-delta", delta: "thinking..." }, + { type: "text-delta", delta: " world" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const turnStartIdx = events.findIndex((e) => e.type === "turn-start"); + const doneIdx = events.findIndex((e) => e.type === "done"); + + expect(turnStartIdx).toBe(0); + expect(doneIdx).toBe(events.length - 1); + + for (let i = 0; i < events.length; i++) { + const e = events[i]; + if (e?.type === "text-delta" || e?.type === "reasoning-delta") { + expect(i).toBeGreaterThan(turnStartIdx); + expect(i).toBeLessThan(doneIdx); + } + } + }); + }); + + describe("stepId", () => { + it("tool-call and tool-result events carry stepId", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const toolCallEvt = events.find((e) => e.type === "tool-call"); + const toolResultEvt = events.find((e) => e.type === "tool-result"); + + expect(toolCallEvt).toBeDefined(); + expect(toolResultEvt).toBeDefined(); + + if (toolCallEvt?.type === "tool-call" && toolResultEvt?.type === "tool-result") { + expect(toolCallEvt.stepId).toBeDefined(); + expect(toolResultEvt.stepId).toBeDefined(); + expect(toolCallEvt.stepId).toBe(toolResultEvt.stepId); + } + }); + + it("tool calls in the SAME step share one stepId; a later step gets a different one", async () => { + const toolA = createFakeTool("a", async () => ({ content: "a-result" })); + const toolB = createFakeTool("b", async () => ({ content: "b-result" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "tool-call", toolCallId: "tc3", toolName: "a", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const toolCallEvts = events.filter((e) => e.type === "tool-call"); + expect(toolCallEvts.length).toBeGreaterThanOrEqual(2); + + const step0Calls = toolCallEvts.filter( + (e) => e.type === "tool-call" && (e.toolCallId === "tc1" || e.toolCallId === "tc2"), + ); + const step1Call = toolCallEvts.find((e) => e.type === "tool-call" && e.toolCallId === "tc3"); + + expect(step0Calls).toHaveLength(2); + if (step0Calls[0]?.type === "tool-call" && step0Calls[1]?.type === "tool-call") { + expect(step0Calls[0].stepId).toBe(step0Calls[1].stepId); + } + + if (step1Call?.type === "tool-call" && step0Calls[0]?.type === "tool-call") { + expect(step1Call.stepId).not.toBe(step0Calls[0].stepId); + } + }); + + it("tool chunks in the result carry stepId", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + }); + + const toolCallMsg = result.messages.find( + (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), + ); + const toolResultMsg = result.messages.find((m) => m.role === "tool"); + + expect(toolCallMsg).toBeDefined(); + expect(toolResultMsg).toBeDefined(); + + const tcChunk = toolCallMsg?.chunks.find((c) => c.type === "tool-call"); + const trChunk = toolResultMsg?.chunks[0]; + + expect(tcChunk?.type).toBe("tool-call"); + expect(trChunk?.type).toBe("tool-result"); + + if (tcChunk?.type === "tool-call" && trChunk?.type === "tool-result") { + expect(tcChunk.stepId).toBeDefined(); + expect(trChunk.stepId).toBeDefined(); + expect(tcChunk.stepId).toBe(trChunk.stepId); + } + }); + }); + + describe("timing events (now provided)", () => { + function createCounterNow(): { now: () => number; tick: (ms: number) => void } { + let current = 0; + return { + now: () => current, + tick: (ms: number) => { + current += ms; + }, + }; + } + + it("emits step-complete per step with timing when now provided", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const { events, emit } = createCollectingEmit(); + + // Advance clock during stream: first token at +50ms, stream ends at +200ms + let streamCallCount = 0; + const wrappedProvider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = streamCallCount++; + return (async function* () { + if (idx === 0) { + clock.tick(50); // stream starts + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + // first token seen at 150 (100+50) + clock.tick(100); + yield { type: "text-delta", delta: " world" } as ProviderEvent; + clock.tick(50); + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + await runTurn({ + provider: wrappedProvider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); + expect(stepCompleteEvts).toHaveLength(1); + + const sc = stepCompleteEvts[0]; + if (sc?.type === "step-complete") { + expect(sc.conversationId).toBe("conv-1"); + expect(sc.turnId).toBe("turn-1"); + expect(sc.stepId).toBeDefined(); + expect(sc.genTotalMs).toBe(200); // 50+100+50 + expect(sc.ttftMs).toBe(50); // stream start → first text-delta + expect(sc.decodeMs).toBe(150); // first token → stream end + const ttft = sc.ttftMs; + const decode = sc.decodeMs; + const genTotal = sc.genTotalMs; + if (ttft !== undefined && decode !== undefined && genTotal !== undefined) { + expect(genTotal).toBe(ttft + decode); + } + } + }); + + it("step-complete omits ttft/decode but keeps genTotalMs for a no-content step", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let streamCallCount = 0; + const wrappedProvider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = streamCallCount++; + return (async function* () { + if (idx === 0) { + clock.tick(80); // stream starts at 180 + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + clock.tick(20); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + clock.tick(50); + yield { type: "text-delta", delta: "done" } as ProviderEvent; + clock.tick(50); + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider: wrappedProvider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); + expect(stepCompleteEvts).toHaveLength(2); + + // First step: tool-call-only, no content token + const sc0 = stepCompleteEvts[0]; + if (sc0?.type === "step-complete") { + expect(sc0.stepId).toBeDefined(); + expect(sc0.genTotalMs).toBe(100); // 80+20 + expect(sc0.ttftMs).toBeUndefined(); + expect(sc0.decodeMs).toBeUndefined(); + } + + // Second step: has text-delta + const sc1 = stepCompleteEvts[1]; + if (sc1?.type === "step-complete") { + expect(sc1.stepId).toBeDefined(); + expect(sc1.genTotalMs).toBe(100); // 50+50 + expect(sc1.ttftMs).toBe(50); + expect(sc1.decodeMs).toBe(50); + } + }); + + it("usage event carries stepId", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const usageEvts = events.filter((e) => e.type === "usage"); + expect(usageEvts).toHaveLength(1); + const ue = usageEvts[0]; + if (ue?.type === "usage") { + expect(ue.stepId).toBeDefined(); + } + }); + + it("tool-result carries durationMs (execution time) when now provided", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const tool = createFakeTool("slow", async () => { + clock.tick(200); // tool takes 200ms to execute + return { content: "done" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "slow", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const toolResultEvts = events.filter((e) => e.type === "tool-result"); + expect(toolResultEvts).toHaveLength(1); + const tr = toolResultEvts[0]; + if (tr?.type === "tool-result") { + expect(tr.durationMs).toBeDefined(); + expect(tr.durationMs).toBe(200); + } + }); + + it("done carries durationMs and aggregate usage when now provided", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const wrappedProvider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + return (async function* () { + clock.tick(80); // stream duration + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider: wrappedProvider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const doneEvts = events.filter((e) => e.type === "done"); + expect(doneEvts).toHaveLength(1); + const d = doneEvts[0]; + if (d?.type === "done") { + expect(d.durationMs).toBeDefined(); + expect(d.durationMs).toBeGreaterThan(0); + expect(d.usage).toBeDefined(); + if (d.usage !== undefined) { + expect(d.usage.inputTokens).toBe(10); + expect(d.usage.outputTokens).toBe(5); + } + } + }); + + it("no now → timing fields absent", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + // no now + }); + + // step-complete still emitted (with stepId, no timing) + const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); + expect(stepCompleteEvts).toHaveLength(2); + for (const sc of stepCompleteEvts) { + if (sc?.type === "step-complete") { + expect(sc.stepId).toBeDefined(); + expect(sc.ttftMs).toBeUndefined(); + expect(sc.decodeMs).toBeUndefined(); + expect(sc.genTotalMs).toBeUndefined(); + } + } + + // usage still carries stepId + const usageEvts = events.filter((e) => e.type === "usage"); + for (const ue of usageEvts) { + if (ue?.type === "usage") { + expect(ue.stepId).toBeDefined(); + } + } + + // no durationMs on tool-result + const toolResultEvts = events.filter((e) => e.type === "tool-result"); + for (const tr of toolResultEvts) { + if (tr?.type === "tool-result") { + expect(tr.durationMs).toBeUndefined(); + } + } + + // no durationMs on done, but usage is present (independent of now) + const doneEvts = events.filter((e) => e.type === "done"); + expect(doneEvts).toHaveLength(1); + const d = doneEvts[0]; + if (d?.type === "done") { + expect(d.durationMs).toBeUndefined(); + expect(d.usage).toBeDefined(); + if (d.usage !== undefined) { + expect(d.usage.inputTokens).toBe(15); + expect(d.usage.outputTokens).toBe(8); + } + } + }); + }); + + describe("contextSize", () => { + it("single-step turn: contextSize equals step inputTokens + outputTokens", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 100, outputTokens: 50 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const doneEvt = events.find((e) => e.type === "done"); + expect(doneEvt).toBeDefined(); + if (doneEvt?.type === "done") { + expect(doneEvt.contextSize).toBe(150); + } + }); + + it("multi-step turn: contextSize equals ONLY the last step's inputTokens + outputTokens", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 100, outputTokens: 20 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "usage", usage: { inputTokens: 300, outputTokens: 80 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const doneEvt = events.find((e) => e.type === "done"); + expect(doneEvt).toBeDefined(); + if (doneEvt?.type === "done") { + expect(doneEvt.contextSize).toBe(380); + expect(doneEvt.usage).toBeDefined(); + if (doneEvt.usage !== undefined) { + expect(doneEvt.contextSize).not.toBe(doneEvt.usage.inputTokens); + } + } + }); + + it("no usage reported: contextSize is undefined", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const doneEvt = events.find((e) => e.type === "done"); + expect(doneEvt).toBeDefined(); + if (doneEvt?.type === "done") { + expect(doneEvt.contextSize).toBeUndefined(); + expect(doneEvt.usage).toBeUndefined(); + } + }); + }); + + describe("drainSteering", () => { + it("drainSteering called once at the tool-result boundary; returned messages appended to the next step's provider input (after tool results)", async () => { + let drainCallCount = 0; + const steeringMessage: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "steer!" }], + }; + + const { provider, capturedMessages } = createCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return [steeringMessage]; + }, + }); + + expect(drainCallCount).toBe(1); + // The provider was called twice (tool-call step, then text step). + expect(capturedMessages).toHaveLength(2); + const secondStepMessages = capturedMessages[1] ?? []; + // user, assistant(tool-call), tool-result, steering(user) — in order, + // steering appended AFTER the tool results, before the next call. + expect(secondStepMessages).toHaveLength(4); + expect(secondStepMessages[0]?.role).toBe("user"); + expect(secondStepMessages[1]?.role).toBe("assistant"); + expect(secondStepMessages[2]?.role).toBe("tool"); + expect(secondStepMessages[3]).toEqual(steeringMessage); + expect(secondStepMessages[3]?.role).toBe("user"); + // Steering is fed to the next provider call, NOT surfaced in the + // turn result — the caller owns the steering messages' lifecycle. + expect(result.messages).toHaveLength(3); + }); + + it("drainSteering omitted → no injection; turn byte-identical to before", async () => { + const { provider, capturedMessages } = createCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + // drainSteering omitted — must be a strict no-op. + }); + + expect(capturedMessages).toHaveLength(2); + const secondStepMessages = capturedMessages[1] ?? []; + // user, assistant(tool-call), tool-result — NO steering injected. + expect(secondStepMessages).toHaveLength(3); + expect(secondStepMessages[0]?.role).toBe("user"); + expect(secondStepMessages[1]?.role).toBe("assistant"); + expect(secondStepMessages[2]?.role).toBe("tool"); + expect(result.messages).toHaveLength(3); + }); + + it("drainSteering returns [] → no injection", async () => { + let drainCallCount = 0; + const { provider, capturedMessages } = createCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + // Called at the boundary, but returned nothing → no injection. + expect(drainCallCount).toBe(1); + expect(capturedMessages).toHaveLength(2); + const secondStepMessages = capturedMessages[1] ?? []; + expect(secondStepMessages).toHaveLength(3); + expect(secondStepMessages[2]?.role).toBe("tool"); + }); + + it("drainSteering NOT called when a step has no tool calls (text-only turn)", async () => { + let drainCallCount = 0; + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hello" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + expect(drainCallCount).toBe(0); + }); + + it("multiple tool-call steps → drainSteering called once per tool-call step", async () => { + let drainCallCount = 0; + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "tool-call", toolCallId: "tc2", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + // Steps 0 and 1 each produced tool calls → drained once each. + // Step 2 (text-only) → no boundary → no drain. Total = 2. + expect(drainCallCount).toBe(2); + }); + + it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => { + let drainCallCount = 0; + // 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step + // to end the turn naturally. + const STEPS_WITH_TOOLS = 100; + const script: ProviderEvent[][] = []; + for (let i = 0; i < STEPS_WITH_TOOLS; i++) { + script.push([ + { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ]); + } + // Final step: text only, no tool calls → natural end. + script.push([ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ]); + const provider = createFakeProvider(script); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + // Turn ended naturally, NOT via max-steps. + expect(result.finishReason).toBe("stop"); + // Every tool-call step (0..99) is followed by a next step → each + // triggers a drain. The text-only step breaks before draining. + expect(drainCallCount).toBe(STEPS_WITH_TOOLS); + // All 101 steps produced messages (100 tool steps with assistant + + // tool messages, 1 text-only step with an assistant message). + expect(result.messages.length).toBe(STEPS_WITH_TOOLS * 2 + 1); + }); + }); + + // ── Retry with backoff ────────────────────────────────────────────────── + // + // PURE tests: a fake `sleep` (records calls, resolves instantly, can abort + // on a chosen call) + a pure `delayFor` (the canonical schedule + 8h budget). + // A stub `ProviderContract` whose `stream` yields a retryable error N times + // then a finish. ZERO mocks of `@dispatch/*` modules — effects injected. + + /** The canonical backoff schedule (matches the orchestrator's concrete strategy). */ + const RETRY_SCHEDULE_MS = [5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000]; + const RETRY_TAIL_MS = 1_800_000; // 30m + const RETRY_BUDGET_MS = 8 * 60 * 60 * 1000; // 8h + + /** Cumulative scheduled sleep through `attempt` (sum of delay[0..attempt]). */ + function cumulativeSleepMs(attempt: number): number { + let sum = 0; + for (let i = 0; i <= attempt; i++) { + sum += i < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[i] : RETRY_TAIL_MS; + } + return sum; + } + + /** Pure, deterministic delay decision (no I/O, no clock). */ + function delayFor(attempt: number): number | undefined { + const delay = attempt < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[attempt] : RETRY_TAIL_MS; + if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop + return delay; + } + + /** The full schedule delayFor would emit (until budget exhausted). */ + function fullSchedule(): number[] { + const result: number[] = []; + let attempt = 0; + while (true) { + const delay = delayFor(attempt); + if (delay === undefined) break; + result.push(delay); + attempt++; + } + return result; + } + + /** + * Fake, controllable `sleep`: records every call's delay, resolves + * instantly (no real waiting), and can abort the controller on a chosen + * 1-based call index to simulate "abort during sleep". + */ + function createFakeSleep(controller: AbortController): { + sleep: (ms: number, signal: AbortSignal) => Promise<void>; + calls: number[]; + abortOnCall: (n: number) => void; + } { + const calls: number[] = []; + let abortAt: number | undefined; + const sleep = async (ms: number, _signal: AbortSignal): Promise<void> => { + calls.push(ms); + if (abortAt !== undefined && calls.length === abortAt) { + controller.abort(); + throw new Error("aborted"); + } + // Otherwise resolve instantly (no real waiting). + }; + return { + sleep, + calls, + abortOnCall: (n: number) => { + abortAt = n; + }, + }; + } + + /** A provider that yields a retryable error `errorCount` times, then success. */ + function createRetryingProvider(opts: { + errorCount: number; + error?: { message: string; code?: string; retryable?: boolean }; + success?: ProviderEvent[]; + }): { provider: ProviderContract; streamCalls: { value: number } } { + const streamCalls = { value: 0 }; + const error: ProviderEvent = { + type: "error", + message: opts.error?.message ?? "overloaded", + ...(opts.error?.code !== undefined ? { code: opts.error.code } : {}), + ...(opts.error?.retryable !== undefined ? { retryable: opts.error.retryable } : {}), + }; + const success = opts.success ?? [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ]; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = streamCalls.value++; + return (async function* () { + if (idx < opts.errorCount) { + yield error; + return; + } + for (const event of success) yield event; + })(); + }, + }; + return { provider, streamCalls }; + } + + describe("retry with backoff", () => { + it("retries a retryable emitted error on schedule then succeeds", async () => { + const { provider } = createRetryingProvider({ + errorCount: 3, + error: { message: "HTTP 429: overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(result.finishReason).toBe("stop"); + // 3 retries: 5s, 10s, 30s. + expect(fake.calls).toEqual([5_000, 10_000, 30_000]); + // 3 provider-retry events (one per sleep), then the successful text. + const retryEvents = events.filter((e) => e.type === "provider-retry"); + expect(retryEvents).toHaveLength(3); + if (retryEvents[0]?.type === "provider-retry") { + expect(retryEvents[0].attempt).toBe(0); + expect(retryEvents[0].delayMs).toBe(5_000); + expect(retryEvents[0].message).toBe("HTTP 429: overloaded"); + expect(retryEvents[0].code).toBe("429"); + expect(retryEvents[0].conversationId).toBe("conv-1"); + expect(retryEvents[0].turnId).toBe("turn-1"); + } + if (retryEvents[1]?.type === "provider-retry") { + expect(retryEvents[1].attempt).toBe(1); + expect(retryEvents[1].delayMs).toBe(10_000); + } + if (retryEvents[2]?.type === "provider-retry") { + expect(retryEvents[2].attempt).toBe(2); + expect(retryEvents[2].delayMs).toBe(30_000); + } + // The error was suppressed (no error event emitted — retry succeeded). + expect(events.filter((e) => e.type === "error")).toHaveLength(0); + // The successful content still streams. + const deltas = events.filter((e) => e.type === "text-delta"); + expect(deltas).toHaveLength(1); + }); + + it("sleep is called with the full schedule [5s,10s,30s,60s,5m,10m,15m,30m,30m…]", async () => { + // Provider errors forever → retries until budget exhausted → gives up. + const { provider } = createRetryingProvider({ + errorCount: Number.POSITIVE_INFINITY, + error: { message: "overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + // Budget exhausted → give up → error. + expect(result.finishReason).toBe("error"); + + // The sleep schedule matches the pure delayFor output exactly. + expect(fake.calls).toEqual(fullSchedule()); + + // Head of the schedule (the 8 stepped delays). + expect(fake.calls.slice(0, 8)).toEqual([ + 5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000, + ]); + // Tail repeats 30m. + expect(fake.calls[8]).toBe(1_800_000); + expect(fake.calls.at(-1)).toBe(1_800_000); + + // 8h cumulative budget cap: head (3705s) + 13×30m = ~7h31m, then stop. + // 21 retries (attempts 0..20), then delayFor(21) → undefined → give up. + expect(fake.calls).toHaveLength(21); + const totalSlept = fake.calls.reduce((a, b) => a + b, 0); + expect(totalSlept).toBeLessThanOrEqual(RETRY_BUDGET_MS); + expect(totalSlept).toBe(3_705_000 + 13 * 1_800_000); // 27_105_000 + + // One provider-retry per sleep, plus a final error (give-up). + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(21); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + const errEvt = events.find((e) => e.type === "error"); + if (errEvt?.type === "error") { + expect(errEvt.message).toBe("overloaded"); + expect(errEvt.code).toBe("429"); + } + }); + + it("does NOT retry after content was emitted (safety invariant)", async () => { + // Provider yields text (content) THEN a retryable error. Because content + // was emitted, retrying is unsafe (would duplicate partial output). + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + callCount++; + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + yield { + type: "error", + message: "overloaded", + code: "429", + retryable: true, + } as ProviderEvent; + })(); + }, + }; + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + // No retries: stream called exactly once. + expect(callCount).toBe(1); + expect(fake.calls).toHaveLength(0); + // The error is emitted (give-up) and partial content preserved. + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); + expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); + }); + + it("does NOT retry a non-retryable emitted error (retryable: false)", async () => { + const { provider, streamCalls } = createRetryingProvider({ + errorCount: 1, + error: { message: "bad request", code: "400", retryable: false }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(streamCalls.value).toBe(1); // no retry + expect(fake.calls).toHaveLength(0); + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); + }); + + it("does NOT retry a non-retryable emitted error (retryable absent)", async () => { + const { provider, streamCalls } = createRetryingProvider({ + errorCount: 1, + error: { message: "bad request", code: "400" }, // no retryable field + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(streamCalls.value).toBe(1); // no retry + expect(fake.calls).toHaveLength(0); + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + }); + + it("give-up emits the final error when budget is exhausted", async () => { + // Custom delayFor that allows exactly 1 retry then stops. + const shortDelayFor = (attempt: number): number | undefined => + attempt === 0 ? 100 : undefined; + const { provider } = createRetryingProvider({ + errorCount: Number.POSITIVE_INFINITY, + error: { message: "overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor: shortDelayFor, sleep: fake.sleep }, + }); + + expect(result.finishReason).toBe("error"); + expect(fake.calls).toEqual([100]); // one retry, then give up + // One provider-retry (attempt 0), then the final error. + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(1); + const errs = events.filter((e) => e.type === "error"); + expect(errs).toHaveLength(1); + if (errs[0]?.type === "error") { + expect(errs[0].message).toBe("overloaded"); + expect(errs[0].code).toBe("429"); + } + }); + + it("abort during sleep seals the turn aborted", async () => { + const { provider } = createRetryingProvider({ + errorCount: Number.POSITIVE_INFINITY, + error: { message: "overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + fake.abortOnCall(2); // abort on the 2nd sleep + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(result.finishReason).toBe("aborted"); + // Two sleeps attempted; the 2nd aborted. + expect(fake.calls).toHaveLength(2); + // No terminal error emitted (it was an abort, not a give-up). + expect(events.filter((e) => e.type === "error")).toHaveLength(0); + // One provider-retry before the aborted sleep (attempt 0). + const retries = events.filter((e) => e.type === "provider-retry"); + expect(retries).toHaveLength(2); + // The done event carries reason "aborted". + const done = events.find((e) => e.type === "done"); + if (done?.type === "done") { + expect(done.reason).toBe("aborted"); + } + }); + + it("omitting retry keeps the pre-retry behavior (backward-compatible)", async () => { + // A retryable error with no retry configured → ends the step as today. + const { provider, streamCalls } = createRetryingProvider({ + errorCount: 1, + error: { message: "overloaded", code: "429", retryable: true }, + }); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + // no retry field + }); + + expect(streamCalls.value).toBe(1); // no retry + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); + }); + + it("retries a THROWN error (retryable-by-default when pre-content)", async () => { + // A thrown error (no retryable flag) before content is retried. + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + callCount++; + return (async function* () { + if (callCount <= 2) { + throw new Error("network blip"); + } + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(callCount).toBe(3); // 2 throws retried, 3rd succeeds + expect(fake.calls).toEqual([5_000, 10_000]); + expect(result.finishReason).toBe("stop"); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(2); + // Thrown errors have no code. + if (events[0]?.type === "provider-retry") { + expect(events[0].code).toBeUndefined(); + expect(events[0].message).toBe("network blip"); + } + expect(events.filter((e) => e.type === "error")).toHaveLength(0); + }); + + it("does NOT retry a thrown error after content was emitted", async () => { + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + callCount++; + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("network blip"); + })(); + }, + }; + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(callCount).toBe(1); + expect(fake.calls).toHaveLength(0); + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); + }); + + it("provider-retry events interleave correctly: error → retry-event → sleep → retry", async () => { + // Verify ordering: each provider-retry event comes BEFORE its sleep, + // and the successful content comes only after the last retry. + const { provider } = createRetryingProvider({ + errorCount: 2, + error: { message: "overloaded", code: "429", retryable: true }, + success: [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + const types = events.map((e) => e.type); + // turn-start, provider-retry(0), provider-retry(1), text-delta, step-complete, done + expect(types[0]).toBe("turn-start"); + const firstRetryIdx = types.indexOf("provider-retry"); + const textIdx = types.indexOf("text-delta"); + expect(firstRetryIdx).toBeGreaterThan(0); + expect(textIdx).toBeGreaterThan(firstRetryIdx); + // Both retries precede the text. + const retryCount = types.filter((t) => t === "provider-retry").length; + expect(retryCount).toBe(2); + }); + }); }); |
