Files
oh-my-pi/packages/agent/test/agent-loop.test.ts
T
Christian Stewart d363ca0968 feat(agent): wake steering on an event instead of polling for it
While a turn is running, the loop watched for out-of-band steering by
re-checking the queue on a fixed interval. The interval sets a floor on how long
a message waits and burns a wakeup on every tick that finds nothing, and the
delay is worst exactly when it matters: a cancellation or a correction issued
during a long tool loop sits until the next tick.

Wait on the queue instead. `Agent` keeps a set of steering waiters and notifies
them whenever a message is enqueued, and the loop awaits
`waitForSteeringMessages`, re-checking only when woken. The timer path remains
for the IRC interrupt queue, which is session-owned and has no wake callback, so
nothing regresses where no event source exists.

Every wait races against local abort. The callback contract does not require an
implementation to observe the signal, and one that resolves only on the next
queue event would otherwise never settle once a batch finished, so awaiting it
during teardown would hang a batch that simply had no steer.

Which controllers a steer raises is unchanged: this is a timing change, not a
change to what an interrupt does.

Signed-off-by: Christian Stewart <christian@aperture.us>
2026-07-25 03:54:46 -07:00

4080 lines
140 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
agentLoop,
agentLoopContinue,
agentLoopDetailed,
TERMINAL_TOOL_RESULT_ABORT_REASON,
} from "@oh-my-pi/pi-agent-core/agent-loop";
import type {
AgentContext,
AgentEvent,
AgentLoopConfig,
AgentMessage,
AgentTool,
AgentToolContext,
ToolCallContext,
} from "@oh-my-pi/pi-agent-core/types";
import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { type } from "arktype";
import { createAssistantMessage, createUserMessage } from "./helpers";
declare module "@oh-my-pi/pi-agent-core/types" {
interface CustomAgentMessages {
advisor: {
role: "custom";
customType: "advisor";
content: string;
display: boolean;
attribution: "agent";
timestamp: number;
};
}
}
// Simple identity converter for tests - just passes through standard messages
function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
}
describe("agentLoop with AgentMessage", () => {
it("should emit events with AgentMessage types", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel({ responses: [{ content: ["Hi there!"] }] });
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
// Should have user message and assistant message
expect(messages.length).toBe(2);
expect(messages[0].role).toBe("user");
expect(messages[1].role).toBe("assistant");
// Verify event sequence
const eventTypes = events.map(e => e.type);
expect(eventTypes).toContain("agent_start");
expect(eventTypes).toContain("turn_start");
expect(eventTypes).toContain("message_start");
expect(eventTypes).toContain("message_end");
expect(eventTypes).toContain("turn_end");
expect(eventTypes).toContain("agent_end");
});
it("ends gracefully without a provider call after the deadline", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const prompt = createUserMessage("Hello");
const mock = createMockModel({ responses: [{ content: ["Too late"] }] });
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
deadline: Date.now() - 1,
};
const events: AgentEvent[] = [];
const stream = agentLoop([prompt], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
expect(await stream.result()).toEqual([prompt]);
expect(mock.calls).toHaveLength(0);
expect(events.map(event => event.type)).toContain("agent_end");
});
it("returns detailed telemetry when awaiting detailed() directly", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel({ responses: [{ content: ["Hi there!"] }] });
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const { detailed } = agentLoopDetailed([createUserMessage("Hello")], context, config, undefined, mock.stream);
const result = await detailed();
expect(result.messages).toHaveLength(2);
expect(result.telemetry?.stepCount).toBe(1);
expect(result.telemetry?.chats.total).toBe(1);
expect(result.coverage?.modelsUsed).toEqual([mock.model.id]);
});
it("re-samples when an assistant turn ends with a pause_turn stop", async () => {
const context: AgentContext = { systemPrompt: ["You are helpful."], messages: [], tools: [] };
const secondCallRoles: string[] = [];
const mock = createMockModel({
responses: [
{ content: ["Scanning the repo first."], stopReason: "stop", stopDetails: { type: "pause_turn" } },
context => {
secondCallRoles.push(...context.messages.map(m => m.role));
return { content: ["All done."] };
},
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
// The pause re-samples with the commentary committed to history and no
// tool results in between; the second response ends the run.
expect(mock.calls).toHaveLength(2);
expect(messages.map(m => m.role)).toEqual(["user", "assistant", "assistant"]);
const [paused, final] = messages.slice(1) as AssistantMessage[];
expect(paused.content).toEqual([{ type: "text", text: "Scanning the repo first." }]);
expect(final.content).toEqual([{ type: "text", text: "All done." }]);
// The follow-up request replayed the paused commentary, with no user or
// tool-result message appended in between.
expect(secondCallRoles).toEqual(["user", "assistant"]);
// One turn_start per sampling round: the continuation ran as a fresh turn.
expect(events.filter(e => e.type === "turn_start")).toHaveLength(2);
});
it("caps consecutive pause_turn continuations", async () => {
const context: AgentContext = { systemPrompt: ["You are helpful."], messages: [], tools: [] };
function* pauseForever(): Generator<MockResponse> {
while (true) {
yield { content: ["still working"], stopReason: "stop", stopDetails: { type: "pause_turn" } };
}
}
const mock = createMockModel({ responses: pauseForever() });
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop([createUserMessage("Hello")], context, config, undefined, mock.stream).result();
// Initial sample + MAX_PAUSED_TURN_CONTINUATIONS (8), then the loop stops
// cleanly instead of spinning on a backend that never stops pausing.
expect(mock.calls).toHaveLength(9);
expect(messages.at(-1)?.role).toBe("assistant");
});
it("retries when harmony leakage reaches the committed assistant message (openai-codex)", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
// First response leaks a harmony payload as visible assistant text; the
// retry is clean. Mitigation only engages for openai-codex.
const leak = "Some prose. analysis to=functions.edit code 大发官网";
const mock = createMockModel({
provider: "openai-codex",
responses: [{ content: [leak] }, { content: ["clean retry response"] }],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
// The leaked attempt was retried, not committed.
expect(mock.calls).toHaveLength(2);
expect(messages).toHaveLength(2);
const final = messages[1];
if (final.role !== "assistant") throw new Error("expected assistant message");
expect(final.content).toEqual([{ type: "text", text: "clean retry response" }]);
expect(JSON.stringify(messages)).not.toContain("to=functions.");
});
it("does not hard-abort a codex tool call whose argument legitimately carries the marker", async () => {
// A legit edit of a file (e.g. these harmony fixtures) whose content carries
// `to=functions.*` next to a channel word + non-Latin script. tool_arg is
// gated on the trailing-garbage `T` co-signal, and the loop supplies no parse
// boundary, so the call commits + executes once instead of being detected as
// a leak and retried/escalated.
const toolSchema = type({ input: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { input: string }> = {
name: "edit",
label: "Edit",
description: "Edit tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.input);
return { content: [{ type: "text", text: "ok" }], details: { input: params.input } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const leakyArg = "@fixtures/corpus.json\n+\tanalysis to=functions.edit code 大发官网\n";
const mock = createMockModel({
provider: "openai-codex",
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "edit", arguments: { input: leakyArg } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const stream = agentLoop([createUserMessage("edit a fixture")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
// The tool ran on the original (unmodified) argument and the turn was not
// retried — a hard-abort would have left `executed` empty and consumed the
// "done" response as a clean retry instead.
expect(executed).toEqual([leakyArg]);
expect(mock.calls).toHaveLength(2);
});
it("emits an aborted assistant message when cancellation happens before provider events", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const controller = new AbortController();
// The mock provider would reject without a configured response; we want the
// agent's abort path to kick in before any event is emitted. Use a raw stream
// that never emits anything.
const streamFn = () => new AssistantMessageEventStream();
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn);
queueMicrotask(() => controller.abort());
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
const finalMessage = messages[messages.length - 1];
expect(finalMessage.role).toBe("assistant");
if (finalMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(finalMessage.stopReason).toBe("aborted");
expect(finalMessage.errorMessage).toBe("Request was aborted");
expect(events.map(event => event.type)).toContain("agent_end");
});
it("does not wait for provider iterator cleanup when aborting a stalled response", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const controller = new AbortController();
let returnCalled = false;
const streamFn = () =>
({
result: () => Promise.withResolvers<AssistantMessage>().promise,
[Symbol.asyncIterator]: () => ({
next: () => Promise.withResolvers<IteratorResult<AssistantMessageEvent>>().promise,
return: () => {
returnCalled = true;
return Promise.withResolvers<IteratorResult<AssistantMessageEvent>>().promise;
},
}),
}) as AssistantMessageEventStream;
const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn);
queueMicrotask(() => controller.abort("stop now"));
const messages = await stream.result();
expect(returnCalled).toBe(true);
const finalMessage = messages[messages.length - 1];
expect(finalMessage.role).toBe("assistant");
if (finalMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(finalMessage.stopReason).toBe("aborted");
expect(finalMessage.errorMessage).toBe("stop now");
});
it("surfaces a custom abort reason on the synthesized aborted message", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const controller = new AbortController();
const streamFn = () => new AssistantMessageEventStream();
const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn);
// Abort with a reason (as the coding agent does for a user Esc interrupt).
queueMicrotask(() => controller.abort("Interrupted by user"));
for await (const _event of stream) {
// drain
}
const messages = await stream.result();
const finalMessage = messages[messages.length - 1];
expect(finalMessage.role).toBe("assistant");
if (finalMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(finalMessage.stopReason).toBe("aborted");
// The reason rides AbortController.abort(reason) onto the message verbatim,
// instead of the generic "Request was aborted" default.
expect(finalMessage.errorMessage).toBe("Interrupted by user");
});
it("should handle custom message types via convertToLlm", async () => {
// Create a custom message type
interface CustomNotification {
role: "notification";
text: string;
timestamp: number;
}
const notification: CustomNotification = {
role: "notification",
text: "This is a notification",
timestamp: Date.now(),
};
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [notification as unknown as AgentMessage], // Custom message in context
tools: [],
};
let convertedMessages: Message[] = [];
const mock = createMockModel({ responses: [{ content: ["Response"] }] });
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: messages => {
// Filter out notifications, convert rest
convertedMessages = messages
.filter(m => (m as { role: string }).role !== "notification")
.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
return convertedMessages;
},
};
const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
// The notification should have been filtered out in convertToLlm
expect(convertedMessages.length).toBe(1); // Only user message
expect(convertedMessages[0].role).toBe("user");
});
it("should apply transformContext before convertToLlm", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [
createUserMessage("old message 1"),
createAssistantMessage([{ type: "text", text: "old response 1" }]),
createUserMessage("old message 2"),
createAssistantMessage([{ type: "text", text: "old response 2" }]),
],
tools: [],
};
let transformedMessages: AgentMessage[] = [];
let convertedMessages: Message[] = [];
const mock = createMockModel({ responses: [{ content: ["Response"] }] });
const config: AgentLoopConfig = {
model: mock.model,
transformContext: async messages => {
// Keep only last 2 messages (prune old ones)
transformedMessages = messages.slice(-2);
return transformedMessages;
},
convertToLlm: messages => {
convertedMessages = messages.filter(
m => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
) as Message[];
return convertedMessages;
},
};
const stream = agentLoop([createUserMessage("new message")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
// transformContext should have been called first, keeping only last 2
expect(transformedMessages.length).toBe(2);
// Then convertToLlm receives the pruned messages
expect(convertedMessages.length).toBe(2);
});
it("provides tool call batch context", async () => {
const toolSchema = type({ value: "string" });
const contexts: ToolCallContext[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const toolCall = (ctx as { toolCall?: ToolCallContext })?.toolCall;
if (toolCall) {
contexts.push(toolCall);
}
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "world" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
getToolContext: toolCall => ({ toolCall }) as AgentToolContext,
};
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
expect(contexts).toHaveLength(2);
expect(contexts[0]?.batchId).toBe(contexts[1]?.batchId);
expect(contexts[0]?.total).toBe(2);
expect(contexts[0]?.toolCalls).toEqual([
{ id: "tool-1", name: "echo" },
{ id: "tool-2", name: "echo" },
]);
expect(contexts[0]?.index).toBe(0);
expect(contexts[1]?.index).toBe(1);
});
it("should handle tool calls and results", async () => {
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// Tool should have been executed
expect(executed).toEqual(["hello"]);
// Should have tool execution events
const toolStart = events.find(e => e.type === "tool_execution_start");
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolStart).toBeDefined();
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBeFalsy();
}
});
it("surfaces validation error for malformed JSON parse sentinels without leaking __rawJson", async () => {
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute() {
return { content: [] };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const rawJsonPayload = `{"i": Finding getAvailable definition, "value": "hello"}${"A".repeat(1000)}`;
const mock = createMockModel({
responses: [
{
content: [
{
type: "toolCall",
id: "tool-1",
name: "echo",
arguments: {
__parseError: "Unexpected token F in JSON at position 6",
__rawJson: rawJsonPayload,
},
},
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("run echo")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// Validation should have failed and reported the parse error & truncated JSON
const toolResultMsg = events.find(e => e.type === "message_start" && e.message.role === "toolResult") as any;
expect(toolResultMsg).toBeDefined();
const resultText = toolResultMsg.message.content[0].text;
expect(resultText).toContain("Tool call arguments are not valid JSON.");
expect(resultText).toContain("Unexpected token F");
expect(resultText).toContain("[truncated");
// Should have paired start and end events
const toolStart = events.find(e => e.type === "tool_execution_start") as any;
const toolEnd = events.find(e => e.type === "tool_execution_end") as any;
expect(toolStart).toBeDefined();
expect(toolEnd).toBeDefined();
// Start args must not include __rawJson
expect(toolStart.args).toBeDefined();
expect(toolStart.args.__rawJson).toBeUndefined();
expect(toolStart.args.__parseError).toBeDefined(); // keeps __parseError for visibility of parse failure
});
it("runs completed tool calls after a transient stream_read_error", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
stopReason: "error",
errorMessage: "Error Code stream_read_error: stream_read_error",
},
{ content: ["done after recovery"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run echo")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([{ value: "hello" }]);
expect(mock.calls).toHaveLength(2);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const recoveredTurn = messages[1] as AssistantMessage;
expect(recoveredTurn.stopReason).toBe("toolUse");
expect(recoveredTurn.stopDetails?.type).toBe("stream_interrupted_after_content");
const finalTurn = messages[3] as AssistantMessage;
expect(finalTurn.content).toContainEqual({ type: "text", text: "done after recovery" });
});
it("runs completed tool calls after a transient stream JSON parse error", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
stopReason: "error",
errorMessage: "JSON Parse error: Unterminated string",
},
{ content: ["done after parse recovery"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run echo")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([{ value: "hello" }]);
expect(mock.calls).toHaveLength(2);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const recoveredTurn = messages[1] as AssistantMessage;
expect(recoveredTurn.stopReason).toBe("toolUse");
expect(recoveredTurn.stopDetails?.type).toBe("stream_interrupted_after_content");
const finalTurn = messages[3] as AssistantMessage;
expect(finalTurn.content).toContainEqual({ type: "text", text: "done after parse recovery" });
});
it("recovers only completed calls when a stream parse error interrupts the next call", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const completedCall = {
type: "toolCall" as const,
id: "tool-complete",
name: "echo",
arguments: { value: "complete" },
};
const incompleteCall = {
type: "toolCall" as const,
id: "tool-incomplete",
name: "echo",
arguments: { value: "incomplete" },
};
const mock = createMockModel({ responses: [{ content: ["done after partial parse recovery"] }] });
let streamCalls = 0;
const streamFn: typeof mock.stream = (model, callContext, options) => {
streamCalls++;
if (streamCalls > 1) return mock.stream(model, callContext, options);
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial: AssistantMessage = {
...createAssistantMessage([completedCall, incompleteCall], "error"),
errorMessage: "provider stream parse failed",
stopDetails: {
type: "parse_error",
explanation: "JSON Parse error: Unterminated string",
},
};
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: completedCall, partial });
stream.push({ type: "toolcall_start", contentIndex: 1, partial });
stream.push({ type: "toolcall_delta", contentIndex: 1, delta: '{"value":"incomplete', partial });
stream.push({ type: "error", reason: "error", error: partial });
});
return stream;
};
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop([createUserMessage("run echo")], context, config, undefined, streamFn).result();
expect(executedParams).toEqual([{ value: "complete" }]);
expect(streamCalls).toBe(2);
expect(mock.calls).toHaveLength(1);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const recoveredTurn = messages[1] as AssistantMessage;
expect(recoveredTurn.stopReason).toBe("toolUse");
expect(recoveredTurn.content.filter(block => block.type === "toolCall").map(block => block.id)).toEqual([
"tool-complete",
]);
expect(messages.some(message => message.role === "toolResult" && message.toolCallId === "tool-incomplete")).toBe(
false,
);
});
it("does not recover a wrapped refusal after dropping an incomplete sibling", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return { content: [], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const completedCall = {
type: "toolCall" as const,
id: "tool-complete",
name: "echo",
arguments: { value: "complete" },
};
const incompleteCall = {
type: "toolCall" as const,
id: "tool-incomplete",
name: "echo",
arguments: { value: "incomplete" },
};
const mock = createMockModel({ responses: [{ content: ["should not continue"] }] });
let streamCalls = 0;
const streamFn: typeof mock.stream = (model, callContext, options) => {
streamCalls++;
if (streamCalls > 1) return mock.stream(model, callContext, options);
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial: AssistantMessage = {
...createAssistantMessage([completedCall, incompleteCall], "error"),
errorMessage: "provider refused output",
stopDetails: { type: "refusal", explanation: "Unexpected end of JSON input" },
};
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: completedCall, partial });
stream.push({ type: "toolcall_start", contentIndex: 1, partial });
stream.push({ type: "toolcall_delta", contentIndex: 1, delta: '{"value":"incomplete', partial });
stream.push({ type: "error", reason: "error", error: partial });
});
return stream;
};
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop([createUserMessage("run echo")], context, config, undefined, streamFn).result();
expect(executedParams).toEqual([]);
expect(streamCalls).toBe(1);
expect(mock.calls).toHaveLength(0);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.content.filter(block => block.type === "toolCall").map(block => block.id)).toEqual([
"tool-complete",
]);
expect(errorTurn.stopDetails).toEqual({
type: "stream_interrupted_after_content",
category: "refusal",
explanation: "Unexpected end of JSON input",
});
});
it("does not recover a mixed known and unknown completed tool turn", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return { content: [], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-known", name: "echo", arguments: { value: "known" } },
{ type: "toolCall", id: "tool-unknown", name: "missing", arguments: { value: "unknown" } },
],
stopReason: "error",
errorMessage: "JSON Parse error: Unterminated string",
},
{ content: ["should not continue"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run mixed tools")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([]);
expect(mock.calls).toHaveLength(1);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("JSON Parse error: Unterminated string");
});
it("does not recover content-only transient stream parse errors", async () => {
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [] };
const mock = createMockModel({
responses: [
{
content: ["partial response"],
stopReason: "error",
errorMessage: "JSON Parse error: Unterminated string",
},
{ content: ["should not continue"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("answer once")],
context,
config,
undefined,
mock.stream,
).result();
expect(mock.calls).toHaveLength(1);
expect(messages.map(message => message.role)).toEqual(["user", "assistant"]);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("JSON Parse error: Unterminated string");
});
it("does not recover ordinary transient errors or terminal stops quoting parse diagnostics", async () => {
for (const stopDetails of [
undefined,
{ type: "refusal", explanation: "Unexpected end of JSON input" },
{ type: "sensitive", explanation: "Unexpected end of JSON input" },
]) {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
stopReason: "error",
stopDetails,
errorMessage: "rate_limit_error",
},
{ content: ["should not continue"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run echo")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([]);
expect(mock.calls).toHaveLength(1);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult"]);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("rate_limit_error");
expect(errorTurn.stopDetails).toEqual(stopDetails);
}
});
it("labels the synthetic tool result for a provider-error turn as not-executed and preserves the upstream error", async () => {
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "edit",
label: "Edit",
description: "Edit tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `edited: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", id: "tool-1", name: "edit", arguments: { value: "hello" } }],
stopReason: "error",
errorMessage: "Codex websocket transport error: websocket closed (1000)",
},
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("edit thing")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
expect(messages.map(m => m.role)).toEqual(["user", "assistant", "toolResult"]);
const toolResult = messages[2] as ToolResultMessage;
expect(toolResult.isError).toBe(true);
const text = toolResult.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("");
// The message must make it obvious the local tool never ran — this is the
// bug in #4321: the previous wording ("Tool execution failed due to an
// error: <upstream>") was indistinguishable from a real local tool
// failure.
expect(text).not.toContain("Tool execution failed due to an error");
expect(text.toLowerCase()).toContain("not executed");
expect(text.toLowerCase()).toContain("provider");
expect(text).toContain("Codex websocket transport error: websocket closed (1000)");
// Structured details on the synthetic result let downstream consumers
// (UI, telemetry, ACP) distinguish it from a real tool failure without
// string-matching the content.
const details = toolResult.details as {
__synthetic?: boolean;
source?: string;
executed?: boolean;
upstreamError?: string;
};
expect(details.__synthetic).toBe(true);
expect(details.source).toBe("assistant_stop_error");
expect(details.executed).toBe(false);
expect(details.upstreamError).toBe("Codex websocket transport error: websocket closed (1000)");
const endEvent = events.find(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> =>
e.type === "tool_execution_end" && e.toolCallId === "tool-1",
);
expect(endEvent).toBeDefined();
expect(endEvent?.isError).toBe(true);
// The event's result carries the same discriminator so the UI can
// render "provider transport failed, tool not executed" instead of a
// generic "Edit tool failed" panel.
expect(endEvent?.result?.details?.__synthetic).toBe(true);
expect(endEvent?.result?.details?.source).toBe("assistant_stop_error");
expect(endEvent?.result?.details?.executed).toBe(false);
});
it("recovers completed custom-wire tool calls after stream_read_error", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
customWireName: "echo_wire",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", id: "tool-1", name: "echo_wire", arguments: { value: "hello" } }],
stopReason: "error",
errorMessage: "Error Code stream_read_error: stream_read_error",
},
{ content: ["done after custom recovery"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run echo")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([{ value: "hello" }]);
expect(mock.calls).toHaveLength(2);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const finalTurn = messages[3] as AssistantMessage;
expect(finalTurn.content).toContainEqual({ type: "text", text: "done after custom recovery" });
});
it("routes unadvertised tool calls through resolveFallbackTool", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const deviceTool: AgentTool<typeof toolSchema, { value: string }> = {
name: "browser",
label: "Browser",
description: "Mounted device tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `device: ${params.value}` }],
details: { value: params.value },
};
},
};
// The device is NOT in the advertised tool set.
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "browser", arguments: { value: "open" } },
{ type: "toolCall", id: "tool-2", name: "nonexistent", arguments: {} },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
resolveFallbackTool: name => (name === "browser" ? deviceTool : undefined),
};
const messages = await agentLoop(
[createUserMessage("use the device")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([{ value: "open" }]);
const results = messages.filter((m): m is ToolResultMessage => m.role === "toolResult");
const deviceResult = results.find(r => r.toolCallId === "tool-1");
expect(deviceResult?.isError).toBeFalsy();
expect(deviceResult?.content).toContainEqual({ type: "text", text: "device: open" });
// Names the resolver does not know keep the "not found" failure.
const missingResult = results.find(r => r.toolCallId === "tool-2");
expect(missingResult?.isError).toBe(true);
expect(missingResult?.content.some(c => c.type === "text" && c.text.includes("Tool nonexistent not found"))).toBe(
true,
);
});
it("injects and strips intent when intent tracing is enabled", async () => {
const toolSchema = type({ value: "string" });
const executedParams: Record<string, unknown>[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params as Record<string, unknown>);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{
type: "toolCall",
id: "tool-1",
name: "echo",
arguments: { value: "hello", [INTENT_FIELD]: "Read one file" },
},
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
intentTracing: true,
};
const stream = agentLoop([createUserMessage("run")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
const messages = await stream.result();
const assistantWithToolCall = messages.find(
message => message.role === "assistant" && message.content.some(content => content.type === "toolCall"),
) as AssistantMessage | undefined;
const tracedToolCall = assistantWithToolCall?.content.find(content => content.type === "toolCall");
const firstRequestToolSchema = mock.calls[0]?.context.tools?.[0]?.parameters as
| { properties?: Record<string, unknown>; required?: string[] }
| undefined;
expect(firstRequestToolSchema?.properties).toMatchObject({
value: { type: "string" },
[INTENT_FIELD]: { type: "string" },
});
expect(firstRequestToolSchema?.required).toEqual(expect.arrayContaining([INTENT_FIELD]));
expect(executedParams).toEqual([{ value: "hello" }]);
expect(tracedToolCall?.type).toBe("toolCall");
if (tracedToolCall?.type === "toolCall") {
expect(tracedToolCall.intent).toBe("Read one file");
}
});
it("runs shared tools in parallel and emits completion-ordered results", async () => {
const toolSchema = type({ value: "string" });
const startTimes: Record<string, number> = {};
const finishTimes: Record<string, number> = {};
const { promise: slowContinue, resolve: slowResolve } = Promise.withResolvers<void>();
const { promise: slowStarted, resolve: slowStartedResolve } = Promise.withResolvers<void>();
const { promise: fastFinished, resolve: fastFinishedResolve } = Promise.withResolvers<void>();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
if (params.value === "slow") {
startTimes.slow = Bun.nanoseconds();
slowStartedResolve();
await slowContinue;
finishTimes.slow = Bun.nanoseconds();
} else {
await slowStarted;
startTimes.fast = Bun.nanoseconds();
finishTimes.fast = Bun.nanoseconds();
fastFinishedResolve();
}
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "slow" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "fast" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
const streamTask = (async () => {
for await (const event of stream) {
events.push(event);
}
})();
await fastFinished;
slowResolve();
await streamTask;
expect(startTimes.fast).toBeDefined();
expect(startTimes.slow).toBeDefined();
expect(finishTimes.fast).toBeDefined();
expect(finishTimes.slow).toBeDefined();
expect(startTimes.fast).toBeLessThan(finishTimes.slow);
expect(finishTimes.fast).toBeLessThan(finishTimes.slow);
const toolResultStarts = events.filter(
(e): e is Extract<AgentEvent, { type: "message_start" }> =>
e.type === "message_start" && e.message.role === "toolResult",
);
expect(toolResultStarts).toHaveLength(2);
expect((toolResultStarts[0].message as ToolResultMessage).toolCallId).toBe("tool-2");
expect((toolResultStarts[1].message as ToolResultMessage).toolCallId).toBe("tool-1");
const turnEndEvent = events.find((e): e is Extract<AgentEvent, { type: "turn_end" }> => e.type === "turn_end");
expect(turnEndEvent).toBeDefined();
if (!turnEndEvent) return;
expect(turnEndEvent.toolResults.map(result => result.toolCallId)).toEqual(["tool-2", "tool-1"]);
});
it("resolves function-form concurrency per call", async () => {
const toolSchema = type({ value: "string", exclusive: "boolean?" });
const startTimes: Record<string, number> = {};
const finishTimes: Record<string, number> = {};
const { promise: slowContinue, resolve: slowResolve } = Promise.withResolvers<void>();
const { promise: slowStarted, resolve: slowStartedResolve } = Promise.withResolvers<void>();
const { promise: fastFinished, resolve: fastFinishedResolve } = Promise.withResolvers<void>();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: args => (args.exclusive === true ? "exclusive" : "shared"),
async execute(_toolCallId, params) {
if (params.value === "slow") {
startTimes.slow = Bun.nanoseconds();
slowStartedResolve();
await slowContinue;
finishTimes.slow = Bun.nanoseconds();
} else if (params.value === "fast") {
await slowStarted;
startTimes.fast = Bun.nanoseconds();
finishTimes.fast = Bun.nanoseconds();
fastFinishedResolve();
} else {
startTimes.exclusive = Bun.nanoseconds();
finishTimes.exclusive = Bun.nanoseconds();
}
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "slow" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "fast" } },
{
type: "toolCall",
id: "tool-3",
name: "echo",
arguments: { value: "last", exclusive: true },
},
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
const streamTask = (async () => {
for await (const event of stream) {
events.push(event);
}
})();
await fastFinished;
slowResolve();
await streamTask;
// Both shared calls overlapped: fast started and finished while slow was running.
expect(startTimes.fast).toBeLessThan(finishTimes.slow);
expect(finishTimes.fast).toBeLessThan(finishTimes.slow);
// The exclusive call waited for every shared call to finish.
expect(startTimes.exclusive).toBeGreaterThan(finishTimes.slow);
expect(startTimes.exclusive).toBeGreaterThan(finishTimes.fast);
});
it("drops incomplete tool calls when assistant aborts before toolcall_end", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const abortController = new AbortController();
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
// Custom stream: emit a partial assistant that already contains a tool
// call, then abort before any `toolcall_end` event proves that the args
// completed. The agent must not synthesize a toolResult for that partial
// call; replaying it would preserve unsafe/incomplete arguments.
const streamFn = () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial = createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "yield", arguments: { data: { ok: true } } }],
"toolUse",
);
stream.push({ type: "start", partial });
setTimeout(() => {
abortController.abort();
stream.push({ type: "done", reason: "toolUse", message: partial });
}, 0);
});
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, abortController.signal, streamFn);
for await (const event of stream) {
events.push(event);
}
const toolResultEvent = events.find(
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "toolResult",
);
expect(toolResultEvent).toBeUndefined();
const assistantEnd = events.find(
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "assistant",
);
expect(assistantEnd).toBeDefined();
if (assistantEnd?.message.role !== "assistant") return;
expect(assistantEnd.message.stopReason).toBe("aborted");
expect(assistantEnd.message.content.some(block => block.type === "toolCall")).toBe(false);
});
it("drops incomplete tool calls on labeled user interrupts", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const abortController = new AbortController();
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const streamFn = () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial = createAssistantMessage(
[{ type: "toolCall", id: "", name: "browser", arguments: {} }],
"toolUse",
);
stream.push({ type: "start", partial });
setTimeout(() => {
abortController.abort("Interrupted by user");
stream.push({ type: "done", reason: "toolUse", message: partial });
}, 0);
});
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, abortController.signal, streamFn);
for await (const event of stream) events.push(event);
expect(events.some(event => event.type === "message_end" && event.message.role === "toolResult")).toBe(false);
const assistantEnd = events.find(
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "assistant",
);
expect(assistantEnd?.message.role).toBe("assistant");
if (assistantEnd?.message.role !== "assistant") return;
expect(assistantEnd.message.errorMessage).toBe("Interrupted by user");
expect(assistantEnd.message.content.some(block => block.type === "toolCall")).toBe(false);
});
it("should skip remaining tool calls when steering is queued", async () => {
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `ok:${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const queuedUserMessage = createUserMessage("interrupt");
let queuedDelivered = false;
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => executed.length >= 1 && !queuedDelivered,
getSteeringMessages: async () => {
// Deliver the steering message at the injection boundary after
// tool execution has started
if (executed.length >= 1 && !queuedDelivered) {
queuedDelivered = true;
return [queuedUserMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// Only the first tool should execute; the second is skipped after steering is queued.
expect(executed).toEqual(["first"]);
const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
);
expect(toolEnds.length).toBe(2);
expect(toolEnds[0].isError).toBe(false);
expect(toolEnds[1].isError).toBe(true);
const skippedContent = toolEnds[1].result.content[0];
expect(skippedContent?.type).toBe("text");
if (skippedContent?.type !== "text") throw new Error("skipped tool result must be text");
expect(skippedContent.text).toContain("Skipped due to queued user message");
expect(skippedContent.text).toContain("Do not count this skipped result as completed work");
expect(skippedContent.text).toContain("retry the skipped tool if it is still needed");
// Queued message should appear in events after the tool results and before the next model call.
const eventSequence = events.flatMap(event => {
if (event.type !== "message_start") return [];
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
if (event.message.role === "user" && typeof event.message.content === "string") {
return [event.message.content];
}
return [];
});
expect(eventSequence).toContain("interrupt");
expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt"));
expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt"));
// Interrupt message should be in context when second LLM call is made
const sawInterruptInContext = mock.calls[1]?.context.messages.some(
m => m.role === "user" && typeof m.content === "string" && m.content === "interrupt",
);
expect(sawInterruptInContext).toBe(true);
});
it("should skip remaining tool calls with system advisory wording when advisor steering is queued", async () => {
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `ok:${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const advisorMessage: AgentMessage = {
role: "custom",
customType: "advisor",
content: "pause before continuing",
display: true,
attribution: "agent",
timestamp: Date.now(),
};
let advisorDelivered = false;
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => {
if (executed.length < 1 || advisorDelivered) {
return { queued: false };
}
return { queued: true, source: "system" };
},
getSteeringMessages: async () => {
if (executed.length >= 1 && !advisorDelivered) {
advisorDelivered = true;
return [advisorMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
expect(executed).toEqual(["first"]);
const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
);
expect(toolEnds.length).toBe(2);
expect(toolEnds[0].isError).toBe(false);
expect(toolEnds[1].isError).toBe(true);
const skippedContent = toolEnds[1].result.content[0];
expect(skippedContent?.type).toBe("text");
if (skippedContent?.type !== "text") throw new Error("skipped tool result must be text");
expect(skippedContent.text).toContain("Skipped due to pending system advisory");
expect(skippedContent.text).not.toContain("queued user message");
expect(skippedContent.text).toContain("Do not count this skipped result as completed work");
expect(skippedContent.text).toContain("retry the skipped tool if it is still needed");
const advisorInjected = events.some(
event =>
event.type === "message_start" &&
event.message.role === "custom" &&
event.message.customType === "advisor" &&
event.message.content === "pause before continuing",
);
expect(advisorInjected).toBe(true);
});
it("drains queued steering by aborting an interruptible tool mid-wait", async () => {
const toolSchema = type({});
let steerReady = false;
let drained = false;
let observedAbort = false;
let resolvedByTimeout = false;
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "wait",
label: "Wait",
description: "Blocks until aborted (mimics a job poll)",
parameters: toolSchema,
interruptible: true,
async execute(_toolCallId, _params, signal) {
steerReady = true;
const { promise, resolve } = Promise.withResolvers<void>();
if (signal?.aborted) {
resolve();
} else {
const timer = setTimeout(() => {
resolvedByTimeout = true;
resolve();
}, 2000);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
}
await promise;
observedAbort = signal?.aborted === true;
return { content: [{ type: "text", text: "waited" }], details: {} };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "wait", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => steerReady && !drained,
getSteeringMessages: async () => {
if (steerReady && !drained) {
drained = true;
return [createUserMessage("interrupt")];
}
return [];
},
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("start")], context, config, undefined, mock.stream)) {
events.push(event);
}
expect(observedAbort).toBe(true);
expect(resolvedByTimeout).toBe(false);
expect(drained).toBe(true);
expect(
events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"),
).toBe(true);
});
it("keeps a completed error result instead of clobbering it into skipped when a steer aborts the signal (#4752)", async () => {
// A steer lands while an interruptible tool is in flight, aborting its shared
// signal via the mid-batch watch poll. The tool nonetheless runs to completion
// and returns a genuine error result (e.g. `ls` on a missing path exiting
// non-zero). Its real output MUST survive — not be replaced by the "Skipped due
// to queued user message" placeholder, which discards work the tool performed.
const toolSchema = type({});
let steerReady = false;
let drained = false;
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "lslike",
label: "Lslike",
description: "Completes with an error result even after its signal aborts",
parameters: toolSchema,
interruptible: true,
async execute(_toolCallId, _params, signal) {
steerReady = true;
// Wait for the steering-watch poll to abort our signal, then finish
// anyway with a real error result (no wall-clock sleep — await the abort).
if (!signal?.aborted) {
const { promise, resolve } = Promise.withResolvers<void>();
signal?.addEventListener("abort", () => resolve(), { once: true });
await promise;
}
expect(signal?.aborted).toBe(true);
return {
content: [{ type: "text", text: "ls: cannot access X: No such file or directory" }],
details: {},
isError: true,
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "lslike", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => steerReady && !drained,
getSteeringMessages: async () => {
if (steerReady && !drained) {
drained = true;
return [createUserMessage("interrupt")];
}
return [];
},
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("start")], context, config, undefined, mock.stream)) {
events.push(event);
}
const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
);
expect(toolEnds.length).toBe(1);
const content = toolEnds[0].result.content[0];
expect(content?.type).toBe("text");
if (content?.type !== "text") throw new Error("tool result must be text");
expect(content.text).toContain("No such file or directory");
expect(content.text).not.toContain("Skipped due to queued user message");
expect(toolEnds[0].isError).toBe(true);
// The steer is still delivered at the injection boundary.
expect(
events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"),
).toBe(true);
});
it("drains queued IRC interrupts by aborting an interruptible tool mid-wait", async () => {
const toolSchema = type({});
let ircReady = false;
let ircDrained = false;
let observedAbort = false;
let resolvedByTimeout = false;
const ircMessage = createUserMessage("irc interrupt");
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "wait",
label: "Wait",
description: "Blocks until aborted (mimics a job poll)",
parameters: toolSchema,
interruptible: true,
async execute(_toolCallId, _params, signal) {
ircReady = true;
const { promise, resolve } = Promise.withResolvers<void>();
if (signal?.aborted) {
resolve();
} else {
const timer = setTimeout(() => {
resolvedByTimeout = true;
resolve();
}, 1000);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
}
await promise;
observedAbort = signal?.aborted === true;
return { content: [{ type: "text", text: "waited" }], details: {} };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "wait", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasIrcInterrupts: () => ircReady && !ircDrained,
getAsideMessages: async () => {
if (ircReady && !ircDrained) {
ircDrained = true;
return [() => ircMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("start")], context, config, undefined, mock.stream)) {
events.push(event);
}
expect(observedAbort).toBe(true);
expect(resolvedByTimeout).toBe(false);
expect(ircDrained).toBe(true);
expect(
events.some(
e => e.type === "message_start" && e.message.role === "user" && e.message.content === "irc interrupt",
),
).toBe(true);
});
it("keeps legacy steering queued until the injection boundary when no non-consuming peek exists", async () => {
const toolSchema = type({ value: "string" });
const executed: string[] = [];
let steerReady = false;
let steeringDrained = false;
const steeringMessage = createUserMessage("queued steering");
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
if (params.value === "first") steerReady = true;
return {
content: [{ type: "text", text: `ok:${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
// Legacy integrations may only have the consuming dequeue. The
// mid-batch poll must not call it; the boundary below owns the drain.
getSteeringMessages: async () => {
if (steerReady && !steeringDrained) {
steeringDrained = true;
return [steeringMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("start")], context, config, undefined, mock.stream)) {
events.push(event);
}
expect(executed).toEqual(["first", "second"]);
expect(steeringDrained).toBe(true);
expect(
events.some(
e => e.type === "message_start" && e.message.role === "user" && e.message.content === "queued steering",
),
).toBe(true);
expect(
mock.calls[1]?.context.messages.some(
m => m.role === "user" && typeof m.content === "string" && m.content === "queued steering",
),
).toBe(true);
});
it("does not abort a non-interruptible foreground tool when only IRC is queued", async () => {
const toolSchema = type({});
let ircReady = false;
let ircDrained = false;
let bgSignalAborted = true;
let bgCompleted = false;
let waitObservedAbort = false;
const bgStarted = Promise.withResolvers<void>();
const waitFinished = Promise.withResolvers<void>();
const ircMessage = createUserMessage("peer irc");
const bg: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "bg",
label: "Bg",
description: "Foreground non-interruptible work (mimics bash)",
parameters: toolSchema,
async execute(_toolCallId, _params, signal) {
bgStarted.resolve();
// Hold until the interruptible wait sibling has observed the IRC abort,
// then finish normally. Reads `signal.aborted` at completion so the
// assertion sees whether an IRC-only interrupt clobbered this signal.
await waitFinished.promise;
bgSignalAborted = signal?.aborted === true;
bgCompleted = true;
return { content: [{ type: "text", text: "bg done" }], details: {} };
},
};
const wait: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "wait",
label: "Wait",
description: "Interruptible wait (mimics a job poll)",
parameters: toolSchema,
interruptible: true,
async execute(_toolCallId, _params, signal) {
await bgStarted.promise;
ircReady = true;
const { promise, resolve } = Promise.withResolvers<void>();
const timer = setTimeout(resolve, 2000);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
waitObservedAbort = true;
resolve();
},
{ once: true },
);
await promise;
waitFinished.resolve();
return { content: [{ type: "text", text: "waited" }], details: {} };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [bg, wait] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "call-bg", name: "bg", arguments: {} },
{ type: "toolCall", id: "call-wait", name: "wait", arguments: {} },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasIrcInterrupts: () => ircReady && !ircDrained,
getAsideMessages: async () => {
if (ircReady && !ircDrained) {
ircDrained = true;
return [() => ircMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("start")], context, config, undefined, mock.stream)) {
events.push(event);
}
expect(waitObservedAbort).toBe(true);
expect(bgCompleted).toBe(true);
expect(bgSignalAborted).toBe(false);
expect(ircDrained).toBe(true);
const bgEnd = events.find(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> =>
e.type === "tool_execution_end" && e.toolCallId === "call-bg",
);
expect(bgEnd?.isError).toBe(false);
if (bgEnd?.result.content[0]?.type === "text") {
expect(bgEnd.result.content[0].text).toContain("bg done");
}
});
it("does not abort a tool when its interruptibility resolver rejects the call", async () => {
const toolSchema = type({ op: "'start' | 'wait'" });
let steerReady = false;
let drained = false;
let observedAbort = false;
let resolvedByTimeout = false;
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "wait",
label: "Wait",
description: "Blocks on its own window (mimics a side-effecting start)",
parameters: toolSchema,
interruptible: params => params.op === "wait",
async execute(_toolCallId, _params, signal) {
steerReady = true;
const { promise, resolve } = Promise.withResolvers<void>();
if (signal?.aborted) {
resolve();
} else {
const timer = setTimeout(() => {
resolvedByTimeout = true;
resolve();
}, 300);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
}
await promise;
observedAbort = signal?.aborted === true;
return { content: [{ type: "text", text: "waited" }], details: {} };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "wait", arguments: { op: "start" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => steerReady && !drained,
getSteeringMessages: async () => {
if (steerReady && !drained) {
drained = true;
return [createUserMessage("interrupt")];
}
return [];
},
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("start")], context, config, undefined, mock.stream)) {
events.push(event);
}
expect(observedAbort).toBe(false);
expect(resolvedByTimeout).toBe(true);
expect(drained).toBe(true);
expect(
events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"),
).toBe(true);
});
it("leaves steering queued when the run is aborted while interrupted tools settle", async () => {
// Regression: the mid-batch steering poll used to DEQUEUE the message into
// a loop-local variable. An external abort while the in-flight tools were
// still settling then injected it into history right before the run died —
// the message showed as "sent" but the agent never responded, and queue
// consumers (clearAllQueues/hasQueuedMessages) could no longer see it.
// The poll must only peek; an abort must leave the queue untouched.
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const abortController = new AbortController();
const steerTriggered = Promise.withResolvers<void>();
let steerReady = false;
let drained = false;
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "shared",
async execute(_toolCallId, params) {
executed.push(params.value);
if (params.value === "fast") {
steerReady = true;
} else {
// Slow tool: keep settling until the steering interrupt has
// fired, then abort the whole run before resolving.
await steerTriggered.promise;
abortController.abort();
await Bun.sleep(1);
}
return {
content: [{ type: "text", text: `ok:${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "fast" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "slow" } },
],
},
{ content: ["never reached"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => {
if (!steerReady) return false;
steerTriggered.resolve();
return true;
},
getSteeringMessages: async () => {
if (!steerReady) return [];
drained = true;
return [createUserMessage("interrupt")];
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, abortController.signal, mock.stream);
for await (const event of stream) {
events.push(event);
}
// The queue was never drained into the dying run...
expect(drained).toBe(false);
// ...and the steering message never landed in history.
const steerInjected = events.some(
e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt",
);
expect(steerInjected).toBe(false);
const steerInContext = context.messages.some(m => m.role === "user" && m.content === "interrupt");
expect(steerInContext).toBe(false);
});
it("injects nothing when steering is retracted between the interrupt and the boundary", async () => {
// The interrupt poll peeks; the queue owner may still cancel (Esc/Alt+Up
// pulls the message back into the editor) before the loop reaches the
// injection boundary. The boundary dequeue must then find nothing and the
// loop must keep going without a phantom user message.
const toolSchema = type({ value: "string" });
const executed: string[] = [];
let steerReady = false;
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `ok:${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => {
if (executed.length >= 1) {
steerReady = true;
return true;
}
return false;
},
// Retraction: by the time the loop dequeues, the queue is empty again.
getSteeringMessages: async () => [],
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// The interrupt fired (second tool skipped), but no user message appeared.
expect(steerReady).toBe(true);
expect(executed).toEqual(["first"]);
const userInjected = events.some(
e => e.type === "message_start" && e.message.role === "user" && e.message.content !== "start",
);
expect(userInjected).toBe(false);
// The loop still completed the turn normally.
const finalAssistant = events.findLast(
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "assistant",
);
expect(finalAssistant).toBeDefined();
if (finalAssistant?.message.role !== "assistant") return;
expect(finalAssistant.message.stopReason).toBe("stop");
});
it("injects aside messages at the step boundary without interrupting tools", async () => {
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const asideMessage = createUserMessage("bg-job-complete");
let asideDelivered = false;
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
getAsideMessages: async () => {
if (!asideDelivered && executed.length >= 1) {
asideDelivered = true;
return [asideMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// Asides are non-interrupting: BOTH tools in the batch run (steering would skip the 2nd).
expect(executed).toEqual(["first", "second"]);
// The aside lands after the tool results, before the next model call.
const seq = events.flatMap(event => {
if (event.type !== "message_start") return [];
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
if (event.message.role === "user" && typeof event.message.content === "string") {
return [event.message.content];
}
return [];
});
expect(seq).toContain("bg-job-complete");
expect(seq.indexOf("tool:tool-2")).toBeLessThan(seq.indexOf("bg-job-complete"));
// The model saw it on the very next request — delivered mid-run, no yield required.
const sawAsideInContext = mock.calls[1]?.context.messages.some(
m => m.role === "user" && typeof m.content === "string" && m.content === "bg-job-complete",
);
expect(sawAsideInContext).toBe(true);
});
it("evaluates aside thunks at injection and skips ones that return null", async () => {
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [] };
const mock = createMockModel({ responses: [{ content: ["done"] }] });
let polls = 0;
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
// A lazy aside that decides, at injection time, NOT to inject (e.g. superseded).
getAsideMessages: async () => {
polls++;
return [() => null];
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("hi")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// The thunk was consulted...
expect(polls).toBeGreaterThan(0);
// ...but a null result injects nothing and triggers no wasted continuation turn.
const userStarts = events.filter(e => e.type === "message_start" && e.message.role === "user");
expect(userStarts).toHaveLength(1); // only the original prompt
expect(mock.calls).toHaveLength(1);
});
});
it("refreshes tools and system prompt between same-turn model calls", async () => {
const toolSchema = type({ value: "string" });
let activeSystemPrompt = "prompt-one";
let activeTools: Array<AgentTool<typeof toolSchema, { value: string }>> = [];
const betaTool: AgentTool<typeof toolSchema, { value: string }> = {
name: "beta",
label: "Beta",
description: "Beta tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `beta:${params.value}` }],
details: { value: params.value },
};
},
};
const alphaTool: AgentTool<typeof toolSchema, { value: string }> = {
name: "alpha",
label: "Alpha",
description: "Alpha tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
activeSystemPrompt = "prompt-two";
activeTools = [alphaTool, betaTool];
return {
content: [{ type: "text", text: `alpha:${params.value}` }],
details: { value: params.value },
};
},
};
activeTools = [alphaTool];
const context: AgentContext = {
systemPrompt: [activeSystemPrompt],
messages: [],
tools: activeTools,
};
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "alpha", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
syncContextBeforeModelCall: async currentContext => {
currentContext.systemPrompt = [activeSystemPrompt];
currentContext.tools = activeTools;
},
};
const stream = agentLoop([createUserMessage("refresh tools")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
expect(mock.calls).toHaveLength(2);
expect(mock.calls[0]?.context.systemPrompt).toEqual(["prompt-one"]);
expect(mock.calls[0]?.context.tools?.map(tool => tool.name)).toEqual(["alpha"]);
expect(mock.calls[1]?.context.systemPrompt).toEqual(["prompt-two"]);
expect(mock.calls[1]?.context.tools?.map(tool => tool.name)).toEqual(["alpha", "beta"]);
});
it("recovers from provider whitespace loop recovery without duplicating assistant messages", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const customStreamFn = (model: any, _context: any, _options: any) => {
const stream = new AssistantMessageEventStream();
const run = async () => {
try {
const mockAssistantMsg = (): 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: Date.now(),
});
// First Attempt
const output1 = mockAssistantMsg();
stream.push({ type: "start", partial: output1 });
output1.content.push({ type: "thinking", thinking: "stale thoughts" });
stream.push({ type: "thinking_start", contentIndex: 0, partial: output1 });
stream.push({ type: "thinking_delta", contentIndex: 0, delta: "stale thoughts", partial: output1 });
stream.push({ type: "thinking_end", contentIndex: 0, content: "stale thoughts", partial: output1 });
output1.content.push({ type: "toolCall", id: "fc_1", name: "todo", arguments: {} });
stream.push({ type: "toolcall_start", contentIndex: 1, partial: output1 });
// Wait a tick to simulate async stream delivery
await Promise.resolve();
// Recovery event: push a start with reset content!
const output2 = mockAssistantMsg();
stream.push({ type: "start", partial: output2 });
// Second Attempt
output2.content.push({ type: "text", text: "Hello!" });
stream.push({ type: "text_start", contentIndex: 0, partial: output2 });
stream.push({ type: "text_delta", contentIndex: 0, delta: "Hello!", partial: output2 });
stream.push({ type: "text_end", contentIndex: 0, content: "Hello!", partial: output2 });
stream.push({
type: "done",
reason: "stop",
message: output2,
});
} catch (err) {
stream.fail(err);
}
};
void run();
return stream;
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, customStreamFn as any);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
// The final messages list should only contain one assistant message!
const assistantMessages = messages.filter(m => m.role === "assistant");
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].content).toHaveLength(1);
expect(assistantMessages[0].content[0].type).toBe("text");
// Event sequence checks
const messageStarts = events.filter(e => e.type === "message_start");
// Should only have 1 message_start for the user message and 1 for the assistant message
expect(messageStarts).toHaveLength(2);
expect(messageStarts[0].message.role).toBe("user");
expect(messageStarts[1].message.role).toBe("assistant");
// Should have message_updates
const updates = events.filter(e => e.type === "message_update");
expect(updates.length).toBeGreaterThan(0);
});
describe("agentLoop event-driven steering watch", () => {
it("wakes on a steering event instead of polling for it", async () => {
const executed: string[] = [];
let steerReady = false;
let drained = false;
let waitCalls = 0;
let wake: (() => void) | undefined;
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
// Make a steer available and wake the watcher, the way a real queue
// does on enqueue. No timer is involved.
if (params.value === "first") {
steerReady = true;
wake?.();
}
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => (steerReady && !drained ? { queued: true, source: "user" } : { queued: false }),
waitForSteeringMessages: async signal => {
waitCalls++;
if (steerReady) return;
const waiter = Promise.withResolvers<void>();
wake = waiter.resolve;
signal?.addEventListener("abort", () => waiter.resolve(), { once: true });
await waiter.promise;
},
getSteeringMessages: async () => {
if (steerReady && !drained) {
drained = true;
return [createUserMessage("interrupt")];
}
return [];
},
};
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
// The watcher woke on the event and stopped the batch before the second call.
expect(executed).toEqual(["first"]);
expect(waitCalls).toBeGreaterThan(0);
});
it("does not miss steering queued between the state check and subscription", async () => {
const executed: string[] = [];
let steerReady = false;
let waitTimedOut = false;
const firstToolStarted = Promise.withResolvers<void>();
const checkStarted = Promise.withResolvers<void>();
const checkRelease = Promise.withResolvers<void>();
let checking = false;
let drained = false;
let wake: (() => void) | undefined;
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
interruptible: true,
async execute(_toolCallId, params, signal) {
executed.push(params.value);
if (params.value === "first") {
firstToolStarted.resolve();
const waiter = Promise.withResolvers<void>();
const timer = setTimeout(() => {
waitTimedOut = true;
waiter.resolve();
}, 500);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
waiter.resolve();
},
{ once: true },
);
await waiter.promise;
}
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: async () => {
const queued = steerReady && !drained;
if (!checking) {
checking = true;
checkStarted.resolve();
await checkRelease.promise;
}
return queued ? { queued: true, source: "user" } : { queued: false };
},
waitForSteeringMessages: async signal => {
// This queue emits future transitions only. Queue state belongs to
// hasSteeringMessages, so subscribing late cannot recover this edge.
const waiter = Promise.withResolvers<void>();
wake = waiter.resolve;
signal?.addEventListener("abort", () => waiter.resolve(), { once: true });
await waiter.promise;
},
getSteeringMessages: async () => {
if (!steerReady || drained) return [];
drained = true;
return [createUserMessage("arrived on the edge")];
},
};
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
const completion = (async () => {
for await (const _event of stream) {
// drain
}
})();
await Promise.all([firstToolStarted.promise, checkStarted.promise]);
steerReady = true;
wake?.();
checkRelease.resolve();
await completion;
expect(waitTimedOut).toBe(false);
expect(drained).toBe(true);
expect(executed).not.toContain("second");
});
it("completes cleanly when canceling a rejecting wait after queued steering", async () => {
let drained = false;
let watchCanceled = false;
const unhandledRejections: unknown[] = [];
const captureUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason);
process.on("unhandledRejection", captureUnhandledRejection);
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "only" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => (drained ? { queued: false } : { queued: true, source: "user" }),
waitForSteeringMessages: signal => {
const waiter = Promise.withResolvers<void>();
signal?.addEventListener(
"abort",
() => {
watchCanceled = true;
waiter.reject(new Error("watch canceled"));
},
{ once: true },
);
return waiter.promise;
},
getSteeringMessages: async () => {
if (drained) return [];
drained = true;
return [createUserMessage("already queued")];
},
};
try {
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
await Bun.sleep(0);
expect(drained).toBe(true);
expect(watchCanceled).toBe(true);
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", captureUnhandledRejection);
}
});
it("does not hang teardown when the steering wait ignores its signal", async () => {
const executed: string[] = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "only" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => ({ queued: false }),
// Never settles and never observes the signal: a shape an integration can
// legitimately write, since the contract does not require honouring it.
waitForSteeringMessages: () => Promise.withResolvers<void>().promise,
getSteeringMessages: async () => [],
};
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
expect(executed).toEqual(["only"]);
});
it("uses the IRC timer without polling the steering queue", async () => {
const secondIrcCheck = Promise.withResolvers<void>();
let steeringChecks = 0;
let ircChecks = 0;
let steeringChecksAtIrcTimer: number | undefined;
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
await secondIrcCheck.promise;
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "only" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => {
steeringChecks++;
return false;
},
waitForSteeringMessages: () => Promise.withResolvers<void>().promise,
hasIrcInterrupts: () => {
ircChecks++;
if (ircChecks === 2) {
steeringChecksAtIrcTimer = steeringChecks;
secondIrcCheck.resolve();
}
return false;
},
getSteeringMessages: async () => [],
};
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
expect(steeringChecksAtIrcTimer).toBe(1);
expect(ircChecks).toBeGreaterThanOrEqual(2);
});
it("does not hang teardown when the steering check ignores cancellation", async () => {
const executed: string[] = [];
const check = Promise.withResolvers<boolean>();
const checkStarted = Promise.withResolvers<void>();
let checkCalls = 0;
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
await checkStarted.promise;
executed.push(params.value);
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "only" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => {
checkCalls++;
if (checkCalls !== 1) return false;
checkStarted.resolve();
return check.promise;
},
waitForSteeringMessages: () => Promise.withResolvers<void>().promise,
getSteeringMessages: async () => [],
};
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
const drain = (async () => {
for await (const _event of stream) {
// drain
}
})();
const completed = await Promise.race([drain.then(() => true), Bun.sleep(1000).then(() => false)]);
try {
expect(completed).toBe(true);
expect(executed).toEqual(["only"]);
} finally {
check.resolve(false);
await drain;
}
});
it("stops watching after a steering subscription rejects", async () => {
let waitCalls = 0;
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
concurrency: "exclusive",
async execute(_toolCallId, params) {
await Bun.sleep(0);
return { content: [{ type: "text", text: `ok:${params.value}` }], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "only" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => ({ queued: false }),
waitForSteeringMessages: () => {
waitCalls++;
return Promise.reject(new Error("subscription unavailable"));
},
getSteeringMessages: async () => [],
};
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
expect(waitCalls).toBe(1);
});
});
describe("agentLoop useless-flag propagation", () => {
async function runProbe(toolReturn: unknown): Promise<ToolResultMessage> {
const toolSchema = type({});
const tool: AgentTool<typeof toolSchema> = {
name: "probe",
label: "Probe",
description: "Probe tool",
parameters: toolSchema,
async execute() {
return toolReturn as never;
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "probe", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("go")], context, config, undefined, mock.stream)) {
events.push(event);
}
const message = events
.filter(e => e.type === "message_end")
.map(e => (e as { message: AgentMessage }).message)
.find(m => m.role === "toolResult");
expect(message).toBeDefined();
return message as ToolResultMessage;
}
it("copies a tool-declared useless flag onto the emitted tool result message", async () => {
const message = await runProbe({
content: [{ type: "text", text: "No matches found" }],
details: {},
useless: true,
});
expect(message.useless).toBe(true);
expect(message.isError).toBe(false);
});
it("drops the useless flag when the tool also reports an error", async () => {
const message = await runProbe({
content: [{ type: "text", text: "failed but flagged" }],
details: {},
isError: true,
useless: true,
});
expect(message.isError).toBe(true);
expect(message.useless).toBeUndefined();
});
});
describe("agentLoopContinue with AgentMessage", () => {
it("should throw when context has no messages", () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
expect(() => agentLoopContinue(context, config)).toThrow("Cannot continue: no messages in context");
});
it("should continue from existing context without emitting user message events", async () => {
const userMessage = createUserMessage("Hello");
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [userMessage],
tools: [],
};
const mock = createMockModel({ responses: [{ content: ["Response"] }] });
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoopContinue(context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
// Should only return the new assistant message (not the existing user message)
expect(messages.length).toBe(1);
expect(messages[0].role).toBe("assistant");
// Should NOT have user message events (that's the key difference from agentLoop)
const messageEndEvents = events.filter(e => e.type === "message_end");
expect(messageEndEvents.length).toBe(1);
const firstEnd = messageEndEvents[0];
if (firstEnd?.type !== "message_end") throw new Error("Expected message_end");
expect(firstEnd.message.role).toBe("assistant");
});
it("should allow custom message types as last message (caller responsibility)", async () => {
// Custom message that will be converted to user message by convertToLlm
interface HookMessage {
role: "hookMessage";
text: string;
timestamp: number;
}
const hookMessage: HookMessage = {
role: "hookMessage",
text: "Hook content",
timestamp: Date.now(),
};
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [hookMessage as unknown as AgentMessage],
tools: [],
};
const mock = createMockModel({ responses: [{ content: ["Response to hook"] }] });
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: messages => {
// Convert hookMessage to user message
return messages
.map(m => {
const candidate = m as unknown as Partial<HookMessage>;
if (candidate.role === "hookMessage") {
return {
role: "user" as const,
content: candidate.text ?? "",
timestamp: candidate.timestamp ?? Date.now(),
};
}
return m;
})
.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
},
};
// Should not throw - the hookMessage will be converted to user message
const stream = agentLoopContinue(context, config, undefined, mock.stream);
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
expect(messages.length).toBe(1);
expect(messages[0].role).toBe("assistant");
});
it("blocks tool execution when beforeToolCall returns block", async () => {
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
beforeToolCall: async () => ({ block: true, reason: "policy: blocked" }),
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
expect(executed).toEqual([]);
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(true);
expect(JSON.stringify(toolEnd.result)).toContain("policy: blocked");
}
});
it("passes beforeToolCall args mutations into tool.execute without revalidation", async () => {
const toolSchema = type({ value: "string" });
const executed: Array<string | number> = [];
const tool: AgentTool<typeof toolSchema, { value: string | number }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value as string | number);
return {
content: [{ type: "text", text: `echoed: ${String(params.value)}` }],
details: { value: params.value as string | number },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
beforeToolCall: async ({ args }) => {
(args as { value: string | number }).value = 123;
return undefined;
},
};
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
expect(executed).toEqual([123]);
});
it("afterToolCall overrides content and isError on the emitted tool result", async () => {
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `original: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const seen: Array<{ args: unknown; isError: boolean }> = [];
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
afterToolCall: async ({ args, isError }) => {
seen.push({ args, isError });
return {
content: [{ type: "text", text: "rewritten" }],
isError: true,
};
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
expect(seen).toEqual([{ args: { value: "hello" }, isError: false }]);
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(true);
expect(toolEnd.result.content).toEqual([{ type: "text", text: "rewritten" }]);
// details preserved when override omits the field
expect(toolEnd.result.details).toEqual({ value: "hello" });
}
const toolResultMessage = events
.filter(e => e.type === "message_start")
.map(e => (e.type === "message_start" ? e.message : undefined))
.find((m): m is AgentMessage => m !== undefined && m.role === "toolResult");
expect(toolResultMessage).toBeDefined();
if (toolResultMessage && toolResultMessage.role === "toolResult") {
expect(toolResultMessage.isError).toBe(true);
expect(toolResultMessage.content).toEqual([{ type: "text", text: "rewritten" }]);
}
});
it("fails closed when afterToolCall returns malformed computer provider metadata", async () => {
const toolSchema = type({});
const tool: AgentTool<typeof toolSchema> = {
name: "probe",
label: "Probe",
description: "Probe tool",
parameters: toolSchema,
async execute() {
return { content: [], details: {} };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-metadata", name: "probe", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
afterToolCall: async () =>
({
providerMetadata: {
type: "computer",
screenshot: {
type: "computer_screenshot",
image_url: "data:image/png;base64,AAEC",
file_id: "file_conflicting_ref",
},
acknowledgedSafetyChecks: [{ id: 42 }],
},
}) as never,
};
const events: AgentEvent[] = [];
for await (const event of agentLoop([createUserMessage("go")], context, config, undefined, mock.stream)) {
events.push(event);
}
const result = events
.filter(event => event.type === "message_end" && event.message.role === "toolResult")
.map(event =>
event.type === "message_end" && event.message.role === "toolResult" ? event.message : undefined,
)[0];
expect(result?.isError).toBe(true);
expect(result?.providerMetadata).toBeUndefined();
expect(JSON.stringify(result?.content)).toContain("computer providerMetadata had an unsupported shape");
});
it("runs afterToolCall for a completed result even when the run aborts before the hook", async () => {
const toolSchema = type({ value: "string" });
const controller = new AbortController();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
controller.abort("stop after tool");
return {
content: [{ type: "text", text: `original: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
let hookSawAbortedSignal = false;
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
afterToolCall: async (_context, signal) => {
hookSawAbortedSignal = signal?.aborted === true;
return { content: [{ type: "text", text: "rewritten after abort" }] };
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream);
for await (const event of stream) {
events.push(event);
}
expect(hookSawAbortedSignal).toBe(true);
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(false);
expect(toolEnd.result.content).toEqual([{ type: "text", text: "rewritten after abort" }]);
}
});
it("stops after a post-tool hook marks the completed result terminal", async () => {
const toolSchema = type({ value: "string" });
const controller = new AbortController();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: params.value }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-terminal", name: "echo", arguments: { value: "done" } }] },
{ content: ["must not be reached"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
afterToolCall: async () => {
controller.abort(TERMINAL_TOOL_RESULT_ABORT_REASON);
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream);
for await (const event of stream) events.push(event);
expect(mock.calls).toHaveLength(1);
expect(events.some(event => event.type === "tool_execution_end")).toBe(true);
expect(
events.some(
event =>
event.type === "message_end" &&
event.message.role === "assistant" &&
event.message.stopReason === "aborted",
),
).toBe(false);
});
it("preserves an external abort boundary when a completed tool ignores cancellation", async () => {
const toolSchema = type({ value: "string" });
const controller = new AbortController();
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
started.resolve();
await release.promise;
return {
content: [{ type: "text", text: params.value }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-abort", name: "echo", arguments: { value: "done" } }] },
{ content: ["must not be observed"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream);
const consuming = (async () => {
for await (const event of stream) events.push(event);
})();
await started.promise;
controller.abort("Stopped by user");
release.resolve();
await consuming;
expect(mock.calls).toHaveLength(2);
const aborted = events.find(
event =>
event.type === "message_end" &&
event.message.role === "assistant" &&
event.message.stopReason === "aborted",
);
expect(aborted).toBeDefined();
if (aborted?.type !== "message_end" || aborted.message.role !== "assistant") {
throw new Error("Expected an aborted assistant message");
}
expect(aborted.message.errorMessage).toBe("Stopped by user");
});
it("surfaces afterToolCall errors as a tool error result", async () => {
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo",
parameters: toolSchema,
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `ok: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
afterToolCall: async () => {
throw new Error("hook exploded");
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(true);
expect(JSON.stringify(toolEnd.result)).toContain("hook exploded");
}
});
it("runs onBeforeYield before polling follow-up messages", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const queuedFollowUps: AgentMessage[] = [];
let hookCalls = 0;
const mock = createMockModel({
responses: [{ content: ["first"] }, { content: ["second"] }],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
onBeforeYield: () => {
hookCalls++;
if (hookCalls === 1) {
queuedFollowUps.push(createUserMessage("follow-up"));
}
},
getFollowUpMessages: async () => queuedFollowUps.splice(0),
};
const stream = agentLoop([createUserMessage("initial")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
const messages = await stream.result();
expect(hookCalls).toBe(2);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "user", "assistant"]);
expect(messages[2]).toMatchObject({ role: "user", content: "follow-up" });
});
it("skips tool calls when the assistant turn was truncated by max_tokens (stop_reason: length) and tells the model to chunk", async () => {
// Regression for issue #1785 (`write` tool crash on >1020-line content).
// When a model emits a `write` call whose `content` argument exceeds the
// model's `max_tokens` output cap, the provider cuts the stream off mid-
// arguments and reports `stop_reason: length`. The agent must NOT execute
// the truncated call (its `content` is a partial string), AND the synthetic
// tool result must guide the model towards a chunked retry — otherwise the
// auto-continue loop re-emits the same oversized payload and the file never
// gets written ("write tool crash" from the reporter's POV).
const writeSchema = type({ path: "string", content: "string" });
const executed: { path: string; content: string }[] = [];
const writeTool: AgentTool<typeof writeSchema, { path: string }> = {
name: "write",
label: "Write",
description: "Write tool",
parameters: writeSchema,
async execute(_id, params) {
executed.push({ path: params.path, content: params.content });
return { content: [{ type: "text", text: "ok" }], details: { path: params.path } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [writeTool] };
// The model emits one write tool call, then the stream ends with
// stop_reason: "length". The arguments field carries a truncated content
// payload — exactly what the streaming JSON parser produces when the
// closing quote/brace never arrive.
const truncatedContent = "line 1\nline 2\n... (cut off mid-string"; // no closing quote
const mock = createMockModel({
responses: [
{
content: [
{
type: "toolCall",
id: "tc-write-1",
name: "write",
arguments: { path: "/tmp/huge.ts", content: truncatedContent },
},
],
stopReason: "length",
},
{
content: ["ok, I will split the write into smaller chunks"],
},
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const stream = agentLoop([createUserMessage("write huge file")], context, config, undefined, mock.stream);
for await (const _event of stream) {
// drain
}
const messages = await stream.result();
// The tool MUST NOT have been executed — the arguments are mid-string and
// running them would persist a half-written file.
expect(executed).toEqual([]);
// The synthetic tool result must surface the truncation cause so the model
// can recover by chunking instead of re-emitting the same payload.
const toolResult = messages.find(m => m.role === "toolResult");
expect(toolResult).toBeDefined();
if (toolResult?.role !== "toolResult") throw new Error("expected tool result");
expect(toolResult.toolCallId).toBe("tc-write-1");
expect(toolResult.isError).toBe(true);
const text = toolResult.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("\n");
expect(text).toContain("stop_reason: length");
expect(text).toMatch(/split|chunk/i);
});
it("fills whitespace-only error tool results so Anthropic does not 400", async () => {
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute() {
return {
content: [{ type: "text", text: "\n\n\n\n\n" }],
isError: true,
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(true);
expect(toolEnd.result.content).toEqual([{ type: "text", text: "Tool failed with no output." }]);
}
});
it("aborts pending tool calls instead of running them when the deadline is crossed during the request", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
deadline: Date.now() + 10_000,
};
// The provider returns a runnable tool call, but the wall clock crosses the
// deadline while the request is in flight (simulated by moving the deadline
// into the past mid-call). The loop must NOT execute the tool — it pairs each
// call with an aborted placeholder so the tool_use/tool_result contract stays
// valid for any later replay.
const toolCall = { type: "toolCall" as const, id: "tc-late-1", name: "some-tool", arguments: {} };
const streamFn = () => {
config.deadline = Date.now() - 1;
const stream = new AssistantMessageEventStream();
const partial = createAssistantMessage([toolCall], "toolUse");
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_start", contentIndex: 0, partial });
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: "{}", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial });
stream.push({ type: "done", reason: "toolUse", message: partial });
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("Run helper")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
const messages = await stream.result();
expect(messages.map(m => m.role)).toEqual(["user", "assistant", "toolResult"]);
const toolResult = messages[2] as ToolResultMessage;
expect(toolResult.toolCallId).toBe("tc-late-1");
expect(toolResult.isError).toBe(true);
const text = toolResult.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("\n");
expect(text).toContain("Deadline exceeded");
expect(events.map(event => event.type)).toContain("agent_end");
});
it("does not dequeue follow-up messages when the deadline is crossed during onBeforeYield", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel({ responses: [{ content: ["Hi"] }] });
const queuedFollowUps = [createUserMessage("follow-up")];
let followUpPolls = 0;
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
deadline: Date.now() + 10_000,
onBeforeYield: () => {
// The wall clock crosses the deadline while this hook runs.
config.deadline = Date.now() - 1;
},
getFollowUpMessages: async () => {
followUpPolls++;
return queuedFollowUps.splice(0);
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// The post-onBeforeYield deadline guard must exit before the queue drain, so
// the follow-up is never dequeued (and therefore never silently dropped).
expect(followUpPolls).toBe(0);
expect(queuedFollowUps).toHaveLength(1);
expect(events.map(event => event.type)).toContain("agent_end");
});
it("aborts the in-flight provider request when the deadline timer fires", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
deadline: Date.now() + 50,
};
// A stalled provider that never emits; it only observes the abort signal the
// loop hands it. The merged deadline signal must fire and cancel the request,
// surfacing the deadline reason on the synthesized aborted message.
let providerSignalAborted = false;
let providerSignalReason: unknown;
const stream = agentLoop([createUserMessage("Wait")], context, config, undefined, (_model, _context, options) => {
options?.signal?.addEventListener("abort", () => {
providerSignalAborted = true;
providerSignalReason = options.signal?.reason;
});
return new AssistantMessageEventStream();
});
const messages = await stream.result();
expect(providerSignalAborted).toBe(true);
if (!(providerSignalReason instanceof DOMException)) throw new Error("Expected a DOMException deadline reason");
expect(providerSignalReason.name).toBe("TimeoutError");
expect(providerSignalReason.message).toBe("Deadline exceeded");
const finalMessage = messages[messages.length - 1];
expect(finalMessage.role).toBe("assistant");
if (finalMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(finalMessage.stopReason).toBe("aborted");
expect(finalMessage.errorMessage).toBe("Deadline exceeded");
});
});
describe("agentLoop streaming snapshots", () => {
it("deep-clones tool-call arguments into message_update snapshots, copying only own enumerable properties", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
};
// Arguments carry a nested object, a nested array, primitives, and an
// INHERITED enumerable property. The snapshot must deep-clone the own
// nested structures (fresh references), pass primitives through by value,
// and carry only OWN enumerable data — the inherited key must never leak
// into the immutable view subscribers receive.
const inheritedProto = { inheritedKey: "from-prototype" };
const innerArray = [2, 3];
const base: Record<string, unknown> = Object.create(inheritedProto);
const sourceArgs: Record<string, unknown> = Object.assign(base, {
nestedObj: { a: 1, b: "two" },
nestedArr: [1, innerArray, { c: 4 }],
num: 42,
str: "hi",
flag: true,
nul: null,
});
const toolCall = { type: "toolCall" as const, id: "tc-clone", name: "noop", arguments: sourceArgs };
// Turn 0 streams the tool call; the unknown tool produces an error result
// and the loop calls the model again — turn 1 returns plain text so the
// loop terminates instead of spinning forever.
let turn = 0;
const streamFn = () => {
const stream = new AssistantMessageEventStream();
if (turn++ === 0) {
const partial = createAssistantMessage([toolCall], "toolUse");
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_start", contentIndex: 0, partial });
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: "{}", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial });
stream.push({ type: "done", reason: "toolUse", message: partial });
} else {
const partial = createAssistantMessage([{ type: "text", text: "done" }], "stop");
stream.push({ type: "start", partial });
stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial });
stream.push({ type: "done", reason: "stop", message: partial });
}
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("call noop")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
const toolUpdate = events.find(
(e): e is Extract<AgentEvent, { type: "message_update" }> =>
e.type === "message_update" &&
e.message.role === "assistant" &&
e.message.content.some(c => c.type === "toolCall"),
);
expect(toolUpdate).toBeDefined();
if (toolUpdate?.message.role !== "assistant") throw new Error("missing tool-call update");
const block = toolUpdate.message.content.find(c => c.type === "toolCall");
if (block?.type !== "toolCall") throw new Error("missing tool-call block");
const cloned: Record<string, unknown> = block.arguments;
// Fresh top-level object, not the source reference.
expect(cloned).not.toBe(sourceArgs);
// Own enumerable keys only — the inherited property must not appear.
expect("inheritedKey" in cloned).toBe(false);
expect(Object.hasOwn(cloned, "inheritedKey")).toBe(false);
// Nested object: deep-cloned (equal value, distinct reference).
expect(cloned.nestedObj).toEqual({ a: 1, b: "two" });
expect(cloned.nestedObj).not.toBe(sourceArgs.nestedObj);
// Nested array: deep-cloned recursively (distinct references at every level).
expect(Array.isArray(cloned.nestedArr)).toBe(true);
expect(cloned.nestedArr).toEqual([1, [2, 3], { c: 4 }]);
expect(cloned.nestedArr).not.toBe(sourceArgs.nestedArr);
expect((cloned.nestedArr as unknown[])[1]).not.toBe(innerArray);
// Primitives pass through by value.
expect(cloned.num).toBe(42);
expect(cloned.str).toBe("hi");
expect(cloned.flag).toBe(true);
expect(cloned.nul).toBeNull();
});
it("shares one immutable snapshot between message and assistantMessageEvent.partial on message_update", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
};
const livePartial = createAssistantMessage([{ type: "text", text: "Hi" }], "stop");
const streamFn = () => {
const stream = new AssistantMessageEventStream();
stream.push({ type: "start", partial: livePartial });
stream.push({ type: "text_start", contentIndex: 0, partial: livePartial });
stream.push({ type: "text_delta", contentIndex: 0, delta: "Hi", partial: livePartial });
stream.push({ type: "text_end", contentIndex: 0, content: "Hi", partial: livePartial });
stream.push({ type: "done", reason: "stop", message: livePartial });
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("say hi")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
const update = events.find(
(e): e is Extract<AgentEvent, { type: "message_update" }> => e.type === "message_update",
);
expect(update).toBeDefined();
if (!update) throw new Error("missing message_update");
const ame = update.assistantMessageEvent;
expect("partial" in ame).toBe(true);
if (!("partial" in ame)) throw new Error("expected a partial-bearing assistant event");
// Alias contract: `message` and `assistantMessageEvent.partial` are ONE
// shared snapshot...
expect(update.message).toBe(ame.partial);
// ...and that snapshot is an independent deep clone of the live streaming
// partial, never the mutable partial object itself.
expect(update.message).not.toBe(livePartial);
});
});
describe("agentLoop kCursorExecResolved (issue #4348)", () => {
it("skips execute for a toolCall block marked as already run by Cursor's exec channel", async () => {
const { kCursorExecResolved } = await import("@oh-my-pi/pi-ai/utils/block-symbols");
const toolSchema = type({ command: "string" });
let executeCalls = 0;
const tool: AgentTool<typeof toolSchema, { command: string }> = {
name: "bash",
label: "Bash",
description: "Run shell commands",
parameters: toolSchema,
async execute(_id, params) {
executeCalls += 1;
return {
content: [{ type: "text", text: `local run: ${params.command}` }],
details: { command: params.command },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
};
// Simulate the shape the Cursor provider now emits after
// `synthesizeCursorExecToolCall`: a `toolCall` content block stamped
// with `kCursorExecResolved` because the server-driven exec channel
// already ran the tool. `agent-loop.ts` MUST NOT invoke `tool.execute`
// again — that would double-execute bash/write/delete/etc. and append a
// second `toolResult` with the same `toolCallId`.
const streamFn = () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const resolvedBlock = {
type: "toolCall" as const,
id: "cursor-exec-tc-1",
name: "bash",
arguments: { command: "rm -rf /tmp/cursor-once" },
[kCursorExecResolved]: true as const,
};
const finalMessage: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Ran the command." }, resolvedBlock],
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
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: Date.now(),
};
stream.push({ type: "start", partial: finalMessage });
stream.push({ type: "done", reason: "stop", message: finalMessage });
});
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("run bash")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
// Tool MUST NOT have been executed — Cursor already ran it server-side.
expect(executeCalls).toBe(0);
// No `tool_execution_start`/`tool_execution_end` from agent-loop —
// those live-events belong to the bridge that already ran the tool.
const startFromLoop = events.find(e => e.type === "tool_execution_start");
const endFromLoop = events.find(e => e.type === "tool_execution_end");
expect(startFromLoop).toBeUndefined();
expect(endFromLoop).toBeUndefined();
// And no duplicate `toolResult` message emitted for the resolved block:
// the buffered result flows via the Agent-class path, not agent-loop.
const orphanToolResult = events.find(
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "toolResult",
);
expect(orphanToolResult).toBeUndefined();
});
it("still runs a normal, unmarked toolCall block in the same turn", async () => {
// Guards against the filter over-matching: a mixed turn where only
// SOME blocks are Cursor-resolved must still execute the unmarked one.
const { kCursorExecResolved } = await import("@oh-my-pi/pi-ai/utils/block-symbols");
const toolSchema = type({ value: "string" });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_id, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
};
let turn = 0;
const streamFn = () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
if (turn++ === 0) {
const resolvedBlock = {
type: "toolCall" as const,
id: "resolved-1",
name: "bash",
arguments: { command: "true" },
[kCursorExecResolved]: true as const,
};
const runnableBlock = {
type: "toolCall" as const,
id: "runnable-1",
name: "echo",
arguments: { value: "hi" },
};
const partial: AssistantMessage = {
role: "assistant",
content: [resolvedBlock, runnableBlock],
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
stream.push({ type: "start", partial });
stream.push({ type: "done", reason: "toolUse", message: partial });
} else {
// Second turn: replay `done` closes the loop.
const partial: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "finished" }],
api: "cursor-agent",
provider: "cursor",
model: "cursor-composer-2.5",
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: Date.now(),
};
stream.push({ type: "start", partial });
stream.push({ type: "done", reason: "stop", message: partial });
}
});
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("mixed")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
expect(executed).toEqual(["hi"]);
const executionStarts = events.filter(e => e.type === "tool_execution_start");
// Exactly one execution: the unmarked `echo` block. The resolved
// `bash` block is passed through untouched.
expect(executionStarts).toHaveLength(1);
if (executionStarts[0]?.type !== "tool_execution_start") throw new Error("expected tool_execution_start");
expect(executionStarts[0].toolCallId).toBe("runnable-1");
expect(executionStarts[0].toolName).toBe("echo");
});
});