// Agent Core tests cover agent loop behavior. import { EventStream } from "@openclaw/ai/event-stream"; import { Type } from "typebox"; import { describe, expect, it, vi } from "vitest"; import { agentLoop, agentLoopContinue, runAgentLoop, runAgentLoopContinue } from "./agent-loop.js"; import { Agent } from "./agent.js"; import { TRANSCRIPT_NOT_CONTINUABLE_ERROR_CODE, TranscriptNotContinuableError } from "./errors.js"; import { type AssistantMessage, createAssistantMessageEventStream, type Context, type Message, type Model, } from "./llm.js"; import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool, AgentToolResult, StreamFn, } from "./types.js"; const model: Model = { id: "test-model", name: "Test Model", api: "test-api", provider: "test-provider", baseUrl: "https://example.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 1000, }; const config: AgentLoopConfig = { model, convertToLlm: (messages) => messages as Message[], }; const TEST_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; const failingStreamFn: StreamFn = async () => { throw new Error("provider exploded"); }; async function collectEvents(stream: AsyncIterable): Promise { const events: AgentEvent[] = []; for await (const event of stream) { events.push(event); } return events; } function expectTerminalFailure(events: AgentEvent[], result: AgentMessage[]): void { expect(events.map((event) => event.type)).toContain("agent_end"); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "provider exploded", }); } describe("agentLoop EventStream failures", () => { it("ends the public stream when a new prompt run rejects", async () => { const stream = agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [] }, config, undefined, failingStreamFn, ); expect(stream).toBeInstanceOf(EventStream); const events = await collectEvents(stream); const result = await stream.result(); expectTerminalFailure(events, result); }); it("ends the public stream when a continue run rejects", async () => { const context: AgentContext = { systemPrompt: "", messages: [{ role: "user", content: "hello", timestamp: 1 }], }; const stream = agentLoopContinue(context, config, undefined, failingStreamFn); const events = await collectEvents(stream); const result = await stream.result(); expectTerminalFailure(events, result); }); it("persists and replays interruption guidance after Agent aborts a rejected run", async () => { let markStarted = () => {}; const started = new Promise((resolve) => { markStarted = resolve; }); const agent = new Agent({ initialState: { model }, convertToLlm: (messages) => messages.filter( (message): message is Message => message.role === "user" || message.role === "assistant" || message.role === "toolResult", ), streamFn: async (_model, _context, options) => { markStarted(); await new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new Error("provider aborted")), { once: true, }); }); throw new Error("unreachable"); }, }); const interrupted = agent.prompt("perform side effect"); await started; agent.abort(); await interrupted; expect(agent.state.messages.at(-1)).toMatchObject({ role: "custom", customType: "openclaw:turn-aborted", }); let replayedMessages: Message[] = []; let transformedMessages: AgentMessage[] = []; agent.transformContext = async (messages) => { transformedMessages = messages; return messages; }; agent.streamFn = async (_model, context) => { replayedMessages = context.messages; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: { role: "assistant", content: [{ type: "text", text: "continued safely" }], api: model.api, provider: model.provider, model: model.id, usage: TEST_USAGE, stopReason: "stop", timestamp: 2, }, }); stream.end(); }); return stream; }; await agent.prompt("continue"); expect(transformedMessages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "custom", customType: "openclaw:turn-aborted", }), ]), ); expect(replayedMessages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "user", content: expect.arrayContaining([ expect.objectContaining({ type: "text", text: expect.stringContaining("may have partially executed"), }), ]), }), ]), ); }); }); describe("agentLoop continuation guards", () => { const assistantTailContext: AgentContext = { systemPrompt: "", messages: [ { role: "assistant", content: [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: TEST_USAGE, stopReason: "stop", timestamp: 1, }, ], }; it("throws a coded error from the public continue stream guard", () => { expect(() => agentLoopContinue(assistantTailContext, config)).toThrowError( TranscriptNotContinuableError, ); try { agentLoopContinue(assistantTailContext, config); } catch (error) { expect(error).toMatchObject({ code: TRANSCRIPT_NOT_CONTINUABLE_ERROR_CODE, role: "assistant", }); } }); it("throws a coded error from the async continue runner guard", async () => { await expect( runAgentLoopContinue(assistantTailContext, config, async () => undefined), ).rejects.toMatchObject({ code: TRANSCRIPT_NOT_CONTINUABLE_ERROR_CODE, role: "assistant", }); }); it("throws a coded error from Agent.continue", async () => { const agent = new Agent({ initialState: { messages: assistantTailContext.messages }, streamFn: failingStreamFn, }); await expect(agent.continue()).rejects.toMatchObject({ code: TRANSCRIPT_NOT_CONTINUABLE_ERROR_CODE, role: "assistant", }); }); }); describe("agentLoop streaming updates", () => { it("rebuilds assistant message snapshots for text deltas without partial snapshots", async () => { const streamFn: StreamFn = async () => { const stream = createAssistantMessageEventStream(); const startMessage: AssistantMessage = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: 1, }; const textStartMessage: AssistantMessage = { ...startMessage, content: [] }; const finalMessage: AssistantMessage = { ...startMessage, content: [{ type: "text", text: "Hello world" }], }; queueMicrotask(() => { stream.push({ type: "start", partial: startMessage }); stream.push({ type: "text_start", contentIndex: 0, partial: textStartMessage }); stream.push({ type: "text_delta", contentIndex: 0, delta: "Hello" }); stream.push({ type: "text_delta", contentIndex: 0, delta: " world" }); stream.push({ type: "text_end", contentIndex: 0, content: "Hello world", partial: finalMessage, }); stream.push({ type: "done", reason: "stop", message: finalMessage }); }); return stream; }; const stream = agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [] }, config, undefined, streamFn, ); const events = await collectEvents(stream); const deltaUpdates = events.filter( (event): event is Extract => event.type === "message_update" && event.assistantMessageEvent.type === "text_delta", ); expect(deltaUpdates).toHaveLength(2); expect(deltaUpdates.map((event) => event.message)).toMatchObject([ { role: "assistant", content: [{ type: "text", text: "Hello" }] }, { role: "assistant", content: [{ type: "text", text: "Hello world" }] }, ]); for (const update of deltaUpdates) { expect(update.assistantMessageEvent).not.toHaveProperty("partial"); } }); it("does not execute tool calls from a max-token-truncated assistant turn", async () => { const execute = vi.fn( async (): Promise> => ({ content: [{ type: "text", text: "should not run" }], details: {}, }), ); const contexts: Context[] = []; let streamCalls = 0; const streamFn: StreamFn = async (_model, context) => { contexts.push(context); streamCalls += 1; const stream = createAssistantMessageEventStream(); if (streamCalls > 1) { const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "continued" }], api: model.api, provider: model.provider, model: model.id, usage: TEST_USAGE, stopReason: "stop", timestamp: 2, }; queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message }); }); return stream; } const toolCall = { type: "toolCall" as const, id: "call-truncated-spawn", name: "sessions_spawn", arguments: {}, }; const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "spawning" }, toolCall], api: model.api, provider: model.provider, model: model.id, usage: TEST_USAGE, stopReason: "length", timestamp: 1, }; queueMicrotask(() => { stream.push({ type: "start", partial: { ...message, content: [] } }); stream.push({ type: "toolcall_start", contentIndex: 1, partial: message }); stream.push({ type: "toolcall_end", contentIndex: 1, toolCall, partial: message, }); stream.push({ type: "done", reason: "length", message }); }); return stream; }; const stream = agentLoop( [{ role: "user", content: "spawn specialists", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [ { name: "sessions_spawn", label: "sessions_spawn", description: "Spawn a child session", parameters: Type.Object({}, { additionalProperties: false }), execute, }, ], }, { ...config, getFollowUpMessages: async () => streamCalls === 1 ? [{ role: "user", content: "continue", timestamp: 2 }] : [], }, undefined, streamFn, ); const events = await collectEvents(stream); const messages = await stream.result(); const truncatedMessageEnd = events.find( (event): event is Extract => event.type === "message_end" && event.message.role === "assistant" && event.message.stopReason === "length", ); const replayedTruncatedMessage = contexts[1]?.messages[1]; if (!truncatedMessageEnd || !replayedTruncatedMessage) { throw new Error("expected the truncated assistant message to be emitted and replayed"); } expect(execute).not.toHaveBeenCalled(); expect(events.some((event) => event.type === "tool_execution_start")).toBe(false); expect(messages.map((message) => message.role)).toEqual([ "user", "assistant", "user", "assistant", ]); expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "length" }); expect(messages[1]).not.toMatchObject({ content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]), }); expect(truncatedMessageEnd.message).not.toMatchObject({ content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]), }); expect(replayedTruncatedMessage).toMatchObject({ role: "assistant", stopReason: "length" }); expect(replayedTruncatedMessage).not.toMatchObject({ content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]), }); }); }); describe("runAgentLoop deferred tool hydration", () => { it("hydrates an authorized deferred tool for execution and the continuation", async () => { const execute = vi.fn( async (): Promise> => ({ content: [{ type: "text", text: "hidden ok" }], details: { ok: true }, }), ); const hiddenTool: AgentTool = { name: "hidden_search", label: "hidden_search", description: "Hidden search tool", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], }, execute, }; const contexts: Context[] = []; let streamCalls = 0; const streamFn: StreamFn = (_model, context) => { contexts.push({ ...context, tools: context.tools?.slice() }); const stream = createAssistantMessageEventStream(); queueMicrotask(() => { streamCalls += 1; const message = streamCalls === 1 ? { role: "assistant" as const, content: [ { type: "toolCall" as const, id: "call-hidden", name: "hidden_search", arguments: { query: "penguin" }, }, ], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "toolUse" as const, timestamp: Date.now(), } : { role: "assistant" as const, content: [{ type: "text" as const, text: "done" }], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "stop" as const, timestamp: Date.now(), }; stream.push({ type: "done", reason: message.stopReason === "toolUse" ? "toolUse" : "stop", message, }); }); return stream; }; const resolveDeferredTool = vi.fn(() => hiddenTool); const messages = await runAgentLoop( [{ role: "user", content: "search penguin", timestamp: Date.now() }], { systemPrompt: "test", messages: [], tools: [] }, { model, convertToLlm: (agentMessages: AgentMessage[]) => agentMessages as never, resolveDeferredTool, }, (_event: AgentEvent) => {}, undefined, streamFn, ); expect(resolveDeferredTool).toHaveBeenCalledTimes(1); expect(execute).toHaveBeenCalledWith( "call-hidden", { query: "penguin" }, undefined, expect.any(Function), ); expect(contexts.map((context) => context.tools?.map((tool) => tool.name) ?? [])).toEqual([ [], ["hidden_search"], ]); expect(messages.some((message) => message.role === "toolResult")).toBe(true); }); it("resolves a missing deferred tool once across pre-scan and preparation", async () => { let streamCalls = 0; const streamFn: StreamFn = () => { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { streamCalls += 1; const message = streamCalls === 1 ? { role: "assistant" as const, content: [ { type: "toolCall" as const, id: "call-missing", name: "missing_deferred", arguments: {}, }, ], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "toolUse" as const, timestamp: Date.now(), } : { role: "assistant" as const, content: [{ type: "text" as const, text: "done" }], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "stop" as const, timestamp: Date.now(), }; stream.push({ type: "done", reason: message.stopReason === "toolUse" ? "toolUse" : "stop", message, }); }); return stream; }; const resolveDeferredTool = vi.fn(() => undefined); const messages = await runAgentLoop( [{ role: "user", content: "call missing tool", timestamp: Date.now() }], { systemPrompt: "test", messages: [], tools: [] }, { model, convertToLlm: (agentMessages: AgentMessage[]) => agentMessages as never, resolveDeferredTool, }, (_event: AgentEvent) => {}, undefined, streamFn, ); expect(resolveDeferredTool).toHaveBeenCalledTimes(1); expect(messages).toContainEqual( expect.objectContaining({ role: "toolResult", toolName: "missing_deferred", isError: true, }), ); }); it("converts deferred resolver failures into one error tool result", async () => { let streamCalls = 0; const streamFn: StreamFn = () => { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { streamCalls += 1; const message = streamCalls === 1 ? { role: "assistant" as const, content: [ { type: "toolCall" as const, id: "call-failing-deferred", name: "failing_deferred", arguments: {}, }, ], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "toolUse" as const, timestamp: Date.now(), } : { role: "assistant" as const, content: [{ type: "text" as const, text: "done" }], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "stop" as const, timestamp: Date.now(), }; stream.push({ type: "done", reason: message.stopReason, message }); }); return stream; }; const resolveDeferredTool = vi.fn(async () => { throw new Error("deferred hydration failed"); }); const messages = await runAgentLoop( [{ role: "user", content: "call failing tool", timestamp: Date.now() }], { systemPrompt: "test", messages: [], tools: [] }, { model, convertToLlm: (agentMessages: AgentMessage[]) => agentMessages as never, resolveDeferredTool, }, (_event: AgentEvent) => {}, undefined, streamFn, ); expect(resolveDeferredTool).toHaveBeenCalledTimes(1); expect(messages).toContainEqual( expect.objectContaining({ role: "toolResult", toolName: "failing_deferred", isError: true, content: [{ type: "text", text: "deferred hydration failed" }], }), ); }); it("rejects deferred tools whose names differ from the requested call", async () => { const execute = vi.fn( async (): Promise> => ({ content: [{ type: "text", text: "wrong tool ran" }], details: { ok: true }, }), ); const mismatchedTool: AgentTool = { name: "other_deferred", label: "other_deferred", description: "Different deferred tool", parameters: Type.Object({}, { additionalProperties: false }), execute, }; const contexts: Context[] = []; let streamCalls = 0; const streamFn: StreamFn = (_model, context) => { contexts.push({ ...context, tools: context.tools?.slice() }); const stream = createAssistantMessageEventStream(); queueMicrotask(() => { streamCalls += 1; const message = streamCalls === 1 ? { role: "assistant" as const, content: [ { type: "toolCall" as const, id: "call-requested-deferred", name: "requested_deferred", arguments: {}, }, ], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "toolUse" as const, timestamp: Date.now(), } : { role: "assistant" as const, content: [{ type: "text" as const, text: "done" }], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "stop" as const, timestamp: Date.now(), }; stream.push({ type: "done", reason: message.stopReason, message }); }); return stream; }; const messages = await runAgentLoop( [{ role: "user", content: "call requested tool", timestamp: Date.now() }], { systemPrompt: "test", messages: [], tools: [] }, { model, convertToLlm: (agentMessages: AgentMessage[]) => agentMessages as never, resolveDeferredTool: () => mismatchedTool, }, (_event: AgentEvent) => {}, undefined, streamFn, ); expect(execute).not.toHaveBeenCalled(); expect(contexts.map((context) => context.tools?.map((tool) => tool.name) ?? [])).toEqual([ [], [], ]); expect(messages).toContainEqual( expect.objectContaining({ role: "toolResult", toolName: "requested_deferred", isError: true, content: [ { type: "text", text: 'Deferred tool resolver returned "other_deferred" for requested "requested_deferred"', }, ], }), ); }); it("hydrates sequential deferred tools before choosing the executor", async () => { let activeExecutions = 0; let maxActiveExecutions = 0; const execute = vi.fn(async (): Promise> => { activeExecutions += 1; maxActiveExecutions = Math.max(maxActiveExecutions, activeExecutions); await new Promise((resolve) => { setTimeout(resolve, 5); }); activeExecutions -= 1; return { content: [{ type: "text", text: "hidden ok" }], details: { ok: true }, }; }); const hiddenTool: AgentTool = { name: "hidden_serial", label: "hidden_serial", description: "Hidden sequential tool", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], }, executionMode: "sequential", execute, }; let streamCalls = 0; const streamFn: StreamFn = () => { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { streamCalls += 1; const message = streamCalls === 1 ? { role: "assistant" as const, content: [ { type: "toolCall" as const, id: "call-hidden-1", name: "hidden_serial", arguments: { query: "one" }, }, { type: "toolCall" as const, id: "call-hidden-2", name: "hidden_serial", arguments: { query: "two" }, }, ], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "toolUse" as const, timestamp: Date.now(), } : { role: "assistant" as const, content: [{ type: "text" as const, text: "done" }], api: "faux", provider: "faux", model: "faux-1", usage: TEST_USAGE, stopReason: "stop" as const, timestamp: Date.now(), }; stream.push({ type: "done", reason: message.stopReason, message }); }); return stream; }; const resolveDeferredTool = vi.fn(() => hiddenTool); await runAgentLoop( [{ role: "user", content: "search twice", timestamp: Date.now() }], { systemPrompt: "test", messages: [], tools: [] }, { model, convertToLlm: (agentMessages: AgentMessage[]) => agentMessages as never, resolveDeferredTool, }, (_event: AgentEvent) => {}, undefined, streamFn, ); expect(resolveDeferredTool).toHaveBeenCalledTimes(1); expect(execute).toHaveBeenCalledTimes(2); expect(maxActiveExecutions).toBe(1); }); }); describe("agentLoop tool termination", () => { function makeAssistantMessage(content: AssistantMessage["content"]): AssistantMessage { return { role: "assistant", content, api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop", timestamp: 1, }; } function makeTool(name: string, executed: string[]): AgentTool { return { name, label: name, description: name, parameters: Type.Object({}, { additionalProperties: false }), execute: async () => { executed.push(name); return { content: [{ type: "text", text: `${name} result` }], details: { name }, }; }, }; } it("marks lifecycle events from the concrete hidden tool instance", async () => { let turn = 0; const streamFn: StreamFn = () => { turn += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = turn === 1 ? makeAssistantMessage([ { type: "toolCall", id: "call-wait", name: "wait", arguments: {} }, ]) : makeAssistantMessage([{ type: "text", text: "done" }]); stream.push({ type: "done", reason: message.stopReason === "toolUse" ? "toolUse" : "stop", message, }); stream.end(); }); return stream; }; const hiddenTool: AgentTool = { ...makeTool("wait", []), hideFromChannelProgress: true, execute: async (_toolCallId, _args, _signal, onUpdate) => { onUpdate?.({ content: [{ type: "text", text: "still waiting" }], details: { status: "waiting" }, }); return { content: [{ type: "text", text: "resumed" }], details: { status: "completed" }, }; }, }; const events = await collectEvents( agentLoop( [{ role: "user", content: "resume", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [hiddenTool] }, { ...config, toolExecution: "sequential" }, undefined, streamFn, ), ); const lifecycleEvents = events.filter((event) => event.type.startsWith("tool_execution_")); expect(lifecycleEvents.map((event) => event.type)).toEqual([ "tool_execution_start", "tool_execution_update", "tool_execution_end", ]); expect( lifecycleEvents.every( (event) => "hideFromChannelProgress" in event && event.hideFromChannelProgress === true, ), ).toBe(true); }); it("ignores progress updates after a tool execution settles", async () => { let delayedUpdate: ((result: AgentToolResult) => void) | undefined; const tool: AgentTool = { name: "delayed_tool", label: "delayed_tool", description: "captures progress callbacks", parameters: Type.Object({}, { additionalProperties: false }), execute: async (_toolCallId, _args, _signal, onUpdate) => { delayedUpdate = onUpdate; onUpdate?.({ content: [{ type: "text", text: "running" }], details: { status: "running" }, }); return { content: [{ type: "text", text: "done" }], details: { status: "done" }, terminate: true, }; }, }; const streamFn: StreamFn = () => { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = makeAssistantMessage([ { type: "toolCall", id: "call-delayed", name: tool.name, arguments: {} }, ]); stream.push({ type: "done", reason: "toolUse", message }); stream.end(); }); return stream; }; const events = await collectEvents( agentLoop( [{ role: "user", content: "run", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [tool] }, { ...config, toolExecution: "sequential" }, undefined, streamFn, ), ); const countAfterRun = events.length; delayedUpdate?.({ content: [{ type: "text", text: "late" }], details: { status: "late" }, }); await new Promise((resolve) => { setTimeout(resolve, 0); }); expect(events).toHaveLength(countAfterRun); expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1); }); it("continues after a side-effect tool result when afterToolCall records it without terminate", async () => { const executed: string[] = []; let turn = 0; const streamFn: StreamFn = () => { turn += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = turn === 1 ? makeAssistantMessage([ { type: "toolCall", id: "call-message", name: "message", arguments: {} }, ]) : turn === 2 ? makeAssistantMessage([ { type: "toolCall", id: "call-exec", name: "exec", arguments: {} }, ]) : makeAssistantMessage([{ type: "text", text: "done" }]); stream.push({ type: "done", reason: message.stopReason === "toolUse" ? "toolUse" : "stop", message, }); stream.end(); }); return stream; }; let recordedSideEffect = false; const stream = agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [makeTool("message", executed), makeTool("exec", executed)], }, { ...config, afterToolCall: async ({ toolCall }) => { if (toolCall.name === "message") { recordedSideEffect = true; } return undefined; }, }, undefined, streamFn, ); const events = await collectEvents(stream); expect(recordedSideEffect).toBe(true); expect(turn).toBe(3); expect(executed).toEqual(["message", "exec"]); expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(2); expect( events .filter( (event): event is Extract => event.type === "tool_execution_end", ) .map((event) => event.executionStarted), ).toEqual([true, true]); expect(events.at(-1)).toMatchObject({ type: "agent_end" }); }); it("marks policy-blocked tool calls as not executed", async () => { const executed: string[] = []; let turn = 0; const streamFn: StreamFn = () => { turn += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = turn === 1 ? makeAssistantMessage([ { type: "toolCall", id: "call-cron", name: "cron", arguments: {} }, ]) : makeAssistantMessage([{ type: "text", text: "done" }]); stream.push({ type: "done", reason: message.stopReason === "toolUse" ? "toolUse" : "stop", message, }); stream.end(); }); return stream; }; const stream = agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [makeTool("cron", executed)], }, { ...config, beforeToolCall: async () => ({ block: true, reason: "blocked" }), }, undefined, streamFn, ); const events = await collectEvents(stream); const endEvent = events.find( (event): event is Extract => event.type === "tool_execution_end", ); expect(executed).toEqual([]); expect(endEvent?.executionStarted).toBe(false); }); it("marks argument validation failures with typed provenance", async () => { const executed: string[] = []; let turn = 0; const streamFn: StreamFn = () => { turn += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = turn === 1 ? makeAssistantMessage([ { type: "toolCall", id: "call-edit", name: "edit", arguments: {} }, ]) : makeAssistantMessage([{ type: "text", text: "done" }]); stream.push({ type: "done", reason: message.stopReason === "toolUse" ? "toolUse" : "stop", message, }); stream.end(); }); return stream; }; const tool: AgentTool = { ...makeTool("edit", executed), parameters: Type.Object({ path: Type.String() }, { additionalProperties: false }), }; const events = await collectEvents( agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [tool] }, config, undefined, streamFn, ), ); const endEvent = events.find( (event): event is Extract => event.type === "tool_execution_end", ); expect(executed).toEqual([]); expect(endEvent).toMatchObject({ executionStarted: false, errorKind: "argument-validation", }); }); it("stops after a tool result only when the finalized result explicitly terminates", async () => { const executed: string[] = []; let turn = 0; const streamFn: StreamFn = () => { turn += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = turn === 1 ? makeAssistantMessage([ { type: "toolCall", id: "call-message", name: "message", arguments: {} }, ]) : makeAssistantMessage([ { type: "toolCall", id: "call-exec", name: "exec", arguments: {} }, ]); stream.push({ type: "done", reason: "toolUse", message }); stream.end(); }); return stream; }; const stream = agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [makeTool("message", executed), makeTool("exec", executed)], }, { ...config, afterToolCall: async ({ toolCall }) => toolCall.name === "message" ? { terminate: true } : undefined, }, undefined, streamFn, ); const events = await collectEvents(stream); expect(turn).toBe(1); expect(executed).toEqual(["message"]); expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1); expect(events.at(-1)).toMatchObject({ type: "agent_end" }); }); it("does not request another model turn after a tool aborts the run", async () => { const controller = new AbortController(); let streamCalls = 0; const streamFn: StreamFn = () => { streamCalls += 1; if (streamCalls > 1) { throw new Error("model was called after abort"); } const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = makeAssistantMessage([ { type: "toolCall", id: "call-abort", name: "abort_tool", arguments: {} }, ]); stream.push({ type: "done", reason: "toolUse", message }); stream.end(); }); return stream; }; const abortTool: AgentTool = { name: "abort_tool", label: "abort_tool", description: "Abort the active run", parameters: Type.Object({}, { additionalProperties: false }), execute: async () => { controller.abort(new Error("user aborted")); return { content: [{ type: "text", text: "aborted" }], details: { aborted: true }, }; }, }; const events: AgentEvent[] = []; const messages = await runAgentLoop( [{ role: "user", content: "abort during tool", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [abortTool], }, config, (event) => { events.push(event); }, controller.signal, streamFn, ); expect(streamCalls).toBe(1); expect(messages.map((message) => message.role)).toEqual([ "user", "assistant", "toolResult", "assistant", "custom", ]); expect(messages.at(-2)).toMatchObject({ role: "assistant", stopReason: "aborted" }); expect(messages.at(-1)).toMatchObject({ role: "custom", customType: "openclaw:turn-aborted", display: false, content: expect.stringContaining("may have partially executed"), }); expect(events.map((event) => event.type)).toEqual([ "agent_start", "turn_start", "message_start", "message_end", "message_start", "message_end", "tool_execution_start", "tool_execution_end", "message_start", "message_end", "turn_end", "turn_start", "message_start", "message_end", "turn_end", "message_start", "message_end", "agent_end", ]); expect(events.at(-1)).toMatchObject({ type: "agent_end" }); }); it("skips interrupted-turn guidance when the abort reason marks a turn handoff", async () => { const controller = new AbortController(); let streamCalls = 0; const streamFn: StreamFn = () => { streamCalls += 1; if (streamCalls > 1) { throw new Error("model was called after abort"); } const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = makeAssistantMessage([ { type: "toolCall", id: "call-yield", name: "yield_tool", arguments: {} }, ]); stream.push({ type: "done", reason: "toolUse", message }); stream.end(); }); return stream; }; const yieldTool: AgentTool = { name: "yield_tool", label: "yield_tool", description: "Yield the active run as a clean handoff", parameters: Type.Object({}, { additionalProperties: false }), execute: async () => { controller.abort({ code: "sessions_yield", turnHandoff: true }); return { content: [{ type: "text", text: "yielded" }], details: { yielded: true }, }; }, }; const messages = await runAgentLoop( [{ role: "user", content: "yield during tool", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [yieldTool], }, config, () => {}, controller.signal, streamFn, ); expect(streamCalls).toBe(1); expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" }); expect(messages.some((message) => message.role === "custom")).toBe(false); }); it("does not start prepared parallel tools after the run aborts mid-batch", async () => { const controller = new AbortController(); const executed: string[] = []; const afterToolCall = vi.fn(async () => undefined); const streamFn: StreamFn = () => { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "toolUse", message: makeAssistantMessage([ { type: "toolCall", id: "call-paid", name: "paid", arguments: {} }, { type: "toolCall", id: "call-gated", name: "gated", arguments: {} }, ]), }); stream.end(); }); return stream; }; const events: AgentEvent[] = []; await runAgentLoop( [{ role: "user", content: "abort during parallel tool preparation", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [makeTool("paid", executed), makeTool("gated", executed)], }, { ...config, toolExecution: "parallel", beforeToolCall: async ({ toolCall }) => { if (toolCall.name === "gated") { await Promise.resolve(); controller.abort(new Error("user aborted")); } return undefined; }, afterToolCall, }, (event) => { events.push(event); }, controller.signal, streamFn, ); const endEvents = events.filter( (event): event is Extract => event.type === "tool_execution_end", ); expect(executed).toEqual([]); expect(afterToolCall).not.toHaveBeenCalled(); expect(endEvents).toHaveLength(2); expect(endEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "paid", isError: true, executionStarted: false, result: expect.objectContaining({ content: [{ type: "text", text: "Operation aborted" }], }), }), expect.objectContaining({ toolName: "gated", isError: true, executionStarted: false, result: expect.objectContaining({ content: [{ type: "text", text: "Operation aborted" }], }), }), ]), ); }); it("does not request another model turn when an async turn hook aborts the run", async () => { const controller = new AbortController(); let streamCalls = 0; const streamFn: StreamFn = () => { streamCalls += 1; if (streamCalls > 1) { throw new Error("model was called after abort"); } const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message = makeAssistantMessage([ { type: "toolCall", id: "call-hook-abort", name: "hook_abort", arguments: {} }, ]); stream.push({ type: "done", reason: "toolUse", message }); stream.end(); }); return stream; }; const events: AgentEvent[] = []; const messages = await runAgentLoop( [{ role: "user", content: "abort from hook", timestamp: 1 }], { systemPrompt: "", messages: [], tools: [makeTool("hook_abort", [])], }, { ...config, prepareNextTurn: async () => { await Promise.resolve(); controller.abort(new Error("user aborted")); return undefined; }, }, (event) => { events.push(event); }, controller.signal, streamFn, ); expect(streamCalls).toBe(1); expect(messages.map((message) => message.role)).toEqual([ "user", "assistant", "toolResult", "assistant", "custom", ]); expect(messages.at(-2)).toMatchObject({ role: "assistant", stopReason: "aborted" }); expect(messages.at(-1)).toMatchObject({ role: "custom", customType: "openclaw:turn-aborted", }); expect(events.map((event) => event.type)).toEqual([ "agent_start", "turn_start", "message_start", "message_end", "message_start", "message_end", "tool_execution_start", "tool_execution_end", "message_start", "message_end", "turn_end", "turn_start", "message_start", "message_end", "turn_end", "message_start", "message_end", "agent_end", ]); expect(events.at(-1)).toMatchObject({ type: "agent_end" }); }); }); describe("agentLoop thinking state", () => { function makeAssistantMessage( activeModel: Model, content: AssistantMessage["content"], ): AssistantMessage { return { role: "assistant", content, api: activeModel.api, provider: activeModel.provider, model: activeModel.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop", timestamp: 1, }; } it.each([ { name: "disables reasoning after leaving Fable", initialModel: { ...model, id: "claude-fable-5", thinkingLevelMap: { off: "low" } }, nextModel: model, expected: ["low", undefined], }, { name: "uses Fable's low fallback after entering Fable", initialModel: model, nextModel: { ...model, id: "claude-fable-5", thinkingLevelMap: { off: "low" } }, expected: [undefined, "low"], }, ])("$name", async ({ initialModel, nextModel, expected }) => { const observedReasoning: Array = []; let callCount = 0; const streamFn: StreamFn = (activeModel, _context, options) => { observedReasoning.push(options?.reasoning); callCount += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const content: AssistantMessage["content"] = callCount === 1 ? [{ type: "toolCall", id: "tool-1", name: "missing_tool", arguments: {} }] : [{ type: "text", text: "done" }]; stream.push({ type: "done", reason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop", message: makeAssistantMessage(activeModel, content), }); stream.end(); }); return stream; }; let prepared = false; const stream = agentLoop( [{ role: "user", content: "hello", timestamp: 1 }], { systemPrompt: "", messages: [] }, { ...config, model: initialModel, thinkingLevel: "off", reasoning: initialModel.thinkingLevelMap?.off === "low" ? "low" : undefined, prepareNextTurn: () => { if (prepared) { return undefined; } prepared = true; return { model: nextModel }; }, }, undefined, streamFn, ); await collectEvents(stream); expect(observedReasoning).toEqual(expected); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */