diff --git a/packages/agent/README.md b/packages/agent/README.md index bc8205b..7b7404c 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -85,6 +85,32 @@ active tool selection, compaction/tree workflows, and higher-level queue events. `CuaAgentHarness` extends pi `AgentHarness`, installs CUA defaults, and refreshes provider-specific runtime state when `setModel()` changes models. +## Empty Response Recovery + +pi treats an assistant message with empty content and a successful `stop` reason +as a completed turn. CUA preserves that behavior by default. Applications that +want another model turn can opt into a bounded continuation: + +```ts +const agent = new CuaAgent({ + browser, + client, + initialState: { model: "openai:gpt-5.5" }, + emptyResponseRecovery: { + followUp: "Continue working on the task.", + maxAttempts: 1, + }, +}); +``` + +Recovery uses pi's public `followUp()` queue. It preserves the empty assistant +message, appends the configured user message, and starts another model turn. +Each attempt therefore adds provider latency and a billed provider call. A +`maxAttempts` value of `1` is recommended when recovery is needed; omission or +`0` keeps stock pi completion semantics. Provider history converters and +response-ID threading may represent the preserved empty turn differently in a +later wire request. + ## Core Concepts ### Class-First API diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 6b18861..ce62089 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -122,6 +122,14 @@ type CuaAgentInitialState = Omit, "mod model: CuaRuntimeInput; }; +/** Explicit opt-in policy for continuing after a successful exact-empty assistant response. */ +export interface CuaEmptyResponseRecoveryOptions { + /** User message appended to request another model turn. */ + followUp: string; + /** Maximum recovery continuations per top-level prompt. */ + maxAttempts: number; +} + /** * Constructor options for {@link CuaAgent}. * @@ -144,6 +152,8 @@ export type CuaAgentOptions = Omit & { nativeTool?: CuaNativeToolSpec; /** Expose a tool that runs Playwright code against the browser session. */ playwright?: boolean; + /** Explicitly continue successful exact-empty responses with pi's follow-up queue. */ + emptyResponseRecovery?: CuaEmptyResponseRecoveryOptions; /** Maximum tool-result images included from message history per provider request. Defaults to 4; false disables projection. */ toolResultImageReplayLimit?: ToolResultImageReplayLimit; /** Chain OpenAI and Tzafon requests through provider-stored response state. Defaults to true. */ @@ -188,6 +198,8 @@ export type CuaAgentHarnessOptions< playwright?: boolean; /** Optional payload hook composed after the provider-specific CUA payload hook. */ onPayload?: SimpleStreamOptions["onPayload"]; + /** Explicitly continue successful exact-empty responses with pi's follow-up queue. */ + emptyResponseRecovery?: CuaEmptyResponseRecoveryOptions; /** Maximum tool-result images included from message history per provider request. Defaults to 4; false disables projection. */ toolResultImageReplayLimit?: ToolResultImageReplayLimit; /** Chain OpenAI and Tzafon requests through provider-stored response state. Defaults to true. */ @@ -347,6 +359,23 @@ class CuaRuntimeController { /** Default stream path: the shared CUA `Models` collection. */ const defaultCuaStream: StreamFn = (model, context, options) => cuaModels().streamSimple(model, context, options); +function resolveEmptyResponseRecovery( + options: CuaEmptyResponseRecoveryOptions | undefined, +): CuaEmptyResponseRecoveryOptions | undefined { + if (!options) return undefined; + if (options.followUp.trim().length === 0) { + throw new Error("emptyResponseRecovery.followUp must not be blank"); + } + if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 0) { + throw new Error("emptyResponseRecovery.maxAttempts must be a non-negative finite integer"); + } + return { followUp: options.followUp, maxAttempts: options.maxAttempts }; +} + +function isEmptyAssistantResponse(message: AgentMessage): boolean { + return message.role === "assistant" && message.stopReason === "stop" && message.content.length === 0; +} + function resolveResponseThreading(responseThreading: boolean | undefined): boolean { if (responseThreading !== undefined && typeof responseThreading !== "boolean") { throw new TypeError("responseThreading must be a boolean"); @@ -423,6 +452,7 @@ export class CuaAgent extends Agent { private readonly runtime: CuaRuntimeController; private readonly ownsSystemPrompt: boolean; private runtimeDirty = false; + private emptyResponseRecoveryAttempts = 0; private stateProxy?: CuaAgentState; private stateProxyTarget?: AgentState; @@ -439,11 +469,13 @@ export class CuaAgent extends Agent { mode, nativeTool, playwright, + emptyResponseRecovery, toolResultImageReplayLimit, responseThreading, retry, ...agentOptions } = options; + const recovery = resolveEmptyResponseRecovery(emptyResponseRecovery); const imageReplayLimit = resolveToolResultImageReplayLimit(toolResultImageReplayLimit); const useResponseThreading = resolveResponseThreading(responseThreading); const runtime = new CuaRuntimeController({ @@ -489,6 +521,16 @@ export class CuaAgent extends Agent { this.runtime = runtime; this.ownsSystemPrompt = initialState.systemPrompt === undefined; + if (recovery && recovery.maxAttempts > 0) { + this.subscribe((event, signal) => { + if (event.type === "agent_start") { + this.emptyResponseRecoveryAttempts = 0; + return; + } + if (event.type !== "turn_end" || !isEmptyAssistantResponse(event.message)) return; + this.recoverFromEmptyResponse(recovery, signal); + }); + } /** * pi's loop only re-reads model/tools/prompt between provider requests * through `prepareNextTurn`. The wrapper stays pass-through (returning @@ -563,6 +605,18 @@ export class CuaAgent extends Agent { return this.runtime.mode; } + private recoverFromEmptyResponse(recovery: CuaEmptyResponseRecoveryOptions, signal: AbortSignal): void { + if (signal.aborted || this.emptyResponseRecoveryAttempts >= recovery.maxAttempts || this.hasQueuedMessages()) { + return; + } + super.followUp({ + role: "user", + content: [{ type: "text", text: recovery.followUp }], + timestamp: Date.now(), + }); + this.emptyResponseRecoveryAttempts += 1; + } + private applyRuntime(model: CuaRuntimeInput): void { this.runtime.setModel(model); this.runtimeDirty = true; @@ -590,6 +644,8 @@ export class CuaAgentHarness< > extends AgentHarness { private readonly runtime: CuaRuntimeController; private requestedActiveToolNames?: string[]; + private emptyResponseRecoveryAttempts = 0; + private hasPendingActiveQueue = false; constructor(options: CuaAgentHarnessOptions) { const { @@ -604,11 +660,13 @@ export class CuaAgentHarness< systemPrompt, onPayload, activeToolNames, + emptyResponseRecovery, toolResultImageReplayLimit, responseThreading, retry, ...harnessOptions } = options; + const recovery = resolveEmptyResponseRecovery(emptyResponseRecovery); const imageReplayLimit = resolveToolResultImageReplayLimit(toolResultImageReplayLimit); const useResponseThreading = resolveResponseThreading(responseThreading); const runtime = new CuaRuntimeController({ @@ -639,6 +697,21 @@ export class CuaAgentHarness< this.runtime = runtime; this.requestedActiveToolNames = activeToolNames; + if (recovery && recovery.maxAttempts > 0) { + this.on("before_agent_start", () => { + this.emptyResponseRecoveryAttempts = 0; + this.hasPendingActiveQueue = false; + return undefined; + }); + this.subscribe(async (event, signal) => { + if (event.type === "queue_update") { + this.hasPendingActiveQueue = event.steer.length > 0 || event.followUp.length > 0; + return; + } + if (event.type !== "turn_end" || !isEmptyAssistantResponse(event.message)) return; + await this.recoverFromEmptyResponse(recovery, signal); + }); + } this.on("before_provider_payload", async ({ model, payload }: { model: Model; payload: unknown }) => { const onPayload = this.runtime.onPayload(); if (!onPayload) return { payload }; @@ -646,6 +719,17 @@ export class CuaAgentHarness< }); } + private async recoverFromEmptyResponse( + recovery: CuaEmptyResponseRecoveryOptions, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted || this.emptyResponseRecoveryAttempts >= recovery.maxAttempts || this.hasPendingActiveQueue) { + return; + } + await super.followUp(recovery.followUp); + this.emptyResponseRecoveryAttempts += 1; + } + /** * Mirror pi `AgentHarness.setModel()` while accepting CUA model refs. * diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 197b490..220fff4 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -20,6 +20,7 @@ export type { CuaAgentHarnessOptions, CuaAgentOptions, CuaAgentState, + CuaEmptyResponseRecoveryOptions, ToolResultImageReplayLimit, } from "./agent"; export type { CuaRetryOptions } from "./provider-retry"; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index cc02565..b1d8683 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -68,6 +68,55 @@ function createCustomTool(name = "custom"): AgentTool { }; } +function finishMessage(message: AssistantMessage, text?: string): AssistantMessage { + if (text !== undefined) message.content = [{ type: "text", text }]; + return message; +} + +function createScriptedStream(texts: Array, contexts?: Array<{ messages: Array<{ role: string }> }>) { + let providerCalls = 0; + const streamFn: StreamFn = (model, context) => { + contexts?.push(context as never); + const stream = createAssistantMessageEventStream(); + const message = finishMessage(createAssistantMessage(model), texts[providerCalls]); + providerCalls += 1; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return stream; + }; + return { streamFn, calls: () => providerCalls }; +} + +function createModelsFromStream(streamFn: StreamFn) { + const models = createCuaModels(); + models.setProvider({ + id: "openai", + name: "scripted openai", + auth: { + apiKey: { + name: "test key", + resolve: async () => ({ auth: { apiKey: "test-key" } }), + }, + }, + getModels: () => [], + stream: streamFn, + streamSimple: streamFn, + } as never); + return models; +} + +function createScriptedModels( + texts: Array, + contexts?: Array<{ messages: Array<{ role: string }> }>, +) { + const scripted = createScriptedStream(texts, contexts); + return { + models: createModelsFromStream(scripted.streamFn), + calls: scripted.calls, + }; +} + describe("CuaAgent", () => { it("extends pi Agent and resolves model refs in initialState", () => { const runtime = resolveCuaRuntimeSpec("openai:gpt-5.5"); @@ -449,6 +498,7 @@ describe("CuaAgent", () => { browser, client: screenshotClient, streamFn, + emptyResponseRecovery: { followUp: "continue", maxAttempts: 1 }, initialState: { model: "openai:gpt-5.5" }, }); @@ -663,9 +713,636 @@ describe("CuaAgent", () => { })).toThrow(new TypeError("toolResultImageReplayLimit must be a finite non-negative integer or false")); } }); + + const recovery = { followUp: " Please continue exactly. ", maxAttempts: 1 }; + + it("preserves pi completion semantics when recovery is omitted", async () => { + const scripted = createScriptedStream([undefined]); + const agent = new CuaAgent({ + browser, + client, + streamFn: scripted.streamFn, + initialState: { model: "openai:gpt-5.5" }, + }); + + await agent.prompt("finish the task"); + + expect(scripted.calls()).toBe(1); + expect(agent.state.messages.at(-1)?.content).toEqual([]); + expect(agent.state.messages.filter((message) => message.role === "user")).toHaveLength(1); + }); + + it("uses pi followUp for explicit empty-response recovery", async () => { + const contexts: Array<{ + messages: Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + }> = []; + const scripted = createScriptedStream([undefined, "finished"], contexts); + const agent = new CuaAgent({ + browser, + client, + streamFn: scripted.streamFn, + emptyResponseRecovery: recovery, + initialState: { model: "openai:gpt-5.5" }, + }); + + await agent.prompt("finish the task"); + + expect(scripted.calls()).toBe(2); + const messages = contexts[1]!.messages; + expect(messages.at(-2)).toMatchObject({ role: "assistant", content: [] }); + expect(messages.at(-1)).toMatchObject({ + role: "user", + content: [{ type: "text", text: recovery.followUp }], + }); + }); + + it("enforces and resets the configured recovery budget", async () => { + const scripted = createScriptedStream([undefined, undefined, undefined, "done"]); + const agent = new CuaAgent({ + browser, + client, + streamFn: scripted.streamFn, + emptyResponseRecovery: recovery, + initialState: { model: "openai:gpt-5.5" }, + }); + + await agent.prompt("first"); + expect(scripted.calls()).toBe(2); + await agent.prompt("second"); + expect(scripted.calls()).toBe(4); + }); + + it("makes exactly one additional call per configured attempt", async () => { + const scripted = createScriptedStream([undefined, undefined, undefined]); + const agent = new CuaAgent({ + browser, + client, + streamFn: scripted.streamFn, + emptyResponseRecovery: { followUp: "continue", maxAttempts: 2 }, + initialState: { model: "openai:gpt-5.5" }, + }); + + await agent.prompt("task"); + expect(scripted.calls()).toBe(3); + }); + + it("recovers exact-empty content regardless of usage accounting", async () => { + let calls = 0; + const streamFn: StreamFn = (model) => { + const stream = createAssistantMessageEventStream(); + const message = finishMessage(createAssistantMessage(model), calls++ === 0 ? undefined : "done"); + if (message.content.length === 0) { + message.usage.output = 7; + message.usage.reasoning = 5; + } + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return stream; + }; + const agent = new CuaAgent({ + browser, + client, + streamFn, + emptyResponseRecovery: recovery, + initialState: { model: "openai:gpt-5.5" }, + }); + + await agent.prompt("task"); + expect(calls).toBe(2); + }); + + it.each([ + ["whitespace text", [{ type: "text", text: " " }], "stop"], + ["thinking content", [{ type: "thinking", thinking: "working" }], "stop"], + ["length stop", [], "length"], + ["error stop", [], "error"], + ["aborted stop", [], "aborted"], + ] as const)("does not recover %s", async (_name, content, stopReason) => { + let calls = 0; + const streamFn: StreamFn = (model) => { + calls += 1; + const stream = createAssistantMessageEventStream(); + const message = createAssistantMessage(model); + message.content = [...content] as AssistantMessage["content"]; + message.stopReason = stopReason; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: stopReason, message }); + stream.end(message); + return stream; + }; + const agent = new CuaAgent({ + browser, + client, + streamFn, + emptyResponseRecovery: recovery, + initialState: { model: "openai:gpt-5.5" }, + }); + + await agent.prompt("task"); + expect(calls).toBe(1); + }); + + it.each([[-1], [1.5], [Number.POSITIVE_INFINITY], [Number.NaN]])("rejects invalid maxAttempts %s", (maxAttempts) => { + expect( + () => + new CuaAgent({ + browser, + client, + emptyResponseRecovery: { followUp: "continue", maxAttempts }, + initialState: { model: "openai:gpt-5.5" }, + }), + ).toThrow(/non-negative finite integer/); + }); + + it("rejects blank follow-up text and accepts zero attempts", async () => { + expect( + () => + new CuaAgent({ + browser, + client, + emptyResponseRecovery: { followUp: " ", maxAttempts: 1 }, + initialState: { model: "openai:gpt-5.5" }, + }), + ).toThrow(/must not be blank/); + const scripted = createScriptedStream([undefined]); + const agent = new CuaAgent({ + browser, + client, + streamFn: scripted.streamFn, + emptyResponseRecovery: { followUp: "continue", maxAttempts: 0 }, + initialState: { model: "openai:gpt-5.5" }, + }); + await agent.prompt("task"); + expect(scripted.calls()).toBe(1); + }); + + it.each(["one-at-a-time", "all"] as const)( + "lets queued caller work drain before recovery in %s mode", + async (mode) => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const contexts: Array<{ + messages: Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + }> = []; + let calls = 0; + const streamFn: StreamFn = (model, context) => { + contexts.push(context as never); + const stream = createAssistantMessageEventStream(); + const message = finishMessage(createAssistantMessage(model), calls === 2 ? "recovered" : undefined); + calls += 1; + void (async () => { + if (calls === 1) await gate; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + })(); + return stream; + }; + const agent = new CuaAgent({ + browser, + client, + streamFn, + followUpMode: mode, + emptyResponseRecovery: recovery, + initialState: { model: "openai:gpt-5.5" }, + }); + const prompt = agent.prompt("task"); + await vi.waitFor(() => expect(calls).toBe(1)); + agent.followUp({ + role: "user", + content: [{ type: "text", text: "caller" }], + timestamp: Date.now(), + }); + release(); + await prompt; + + expect(calls).toBe(3); + expect(contexts[1]!.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ text: "caller" }], + }); + expect(contexts[2]!.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ text: recovery.followUp }], + }); + }, + ); + + it("does not recover when the active signal is aborted", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const streamFn: StreamFn = (model) => { + calls += 1; + const stream = createAssistantMessageEventStream(); + const message = createAssistantMessage(model); + void gate.then(() => { + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + }); + return stream; + }; + const agent = new CuaAgent({ + browser, + client, + streamFn, + emptyResponseRecovery: recovery, + initialState: { model: "openai:gpt-5.5" }, + }); + const prompt = agent.prompt("task"); + await vi.waitFor(() => expect(calls).toBe(1)); + agent.abort(); + release(); + await prompt; + expect(calls).toBe(1); + }); }); describe("CuaAgentHarness", () => { + const recovery = { followUp: " Please continue exactly. ", maxAttempts: 1 }; + + it("preserves pi completion semantics when recovery is omitted", async () => { + const scripted = createScriptedModels([undefined]); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: scripted.models, + }); + + const response = await harness.prompt("finish the task"); + expect(scripted.calls()).toBe(1); + expect(response.content).toEqual([]); + }); + + it("uses pi followUp for explicit empty-response recovery", async () => { + const contexts: Array<{ + messages: Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + }> = []; + const scripted = createScriptedModels([undefined, "finished"], contexts); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: scripted.models, + emptyResponseRecovery: recovery, + }); + + const response = await harness.prompt("finish the task"); + + expect(scripted.calls()).toBe(2); + expect(response.content).toEqual([{ type: "text", text: "finished" }]); + expect(contexts[1]!.messages.at(-2)).toMatchObject({ + role: "assistant", + content: [], + }); + expect(contexts[1]!.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ type: "text", text: recovery.followUp }], + }); + }); + + it("enforces and resets the configured recovery budget", async () => { + const scripted = createScriptedModels([undefined, undefined, undefined, "done"]); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: scripted.models, + emptyResponseRecovery: recovery, + }); + + await harness.prompt("first"); + expect(scripted.calls()).toBe(2); + await harness.prompt("second"); + expect(scripted.calls()).toBe(4); + }); + + it("clears stale queue snapshots before a new prompt starts", async () => { + const scripted = createScriptedModels([undefined, "finished"]); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: scripted.models, + emptyResponseRecovery: recovery, + }); + + (harness as unknown as { hasPendingActiveQueue: boolean }).hasPendingActiveQueue = true; + const response = await harness.prompt("finish the task"); + + expect(scripted.calls()).toBe(2); + expect(response.content).toEqual([{ type: "text", text: "finished" }]); + }); + + it("makes exactly one additional call per configured attempt", async () => { + const scripted = createScriptedModels([undefined, undefined, undefined]); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: scripted.models, + emptyResponseRecovery: { followUp: "continue", maxAttempts: 2 }, + }); + + await harness.prompt("task"); + expect(scripted.calls()).toBe(3); + }); + + it("recovers exact-empty content with nonzero usage", async () => { + let calls = 0; + const streamFn: StreamFn = (model) => { + const stream = createAssistantMessageEventStream(); + const message = finishMessage(createAssistantMessage(model), calls++ === 0 ? undefined : "done"); + if (message.content.length === 0) { + message.usage.output = 9; + message.usage.reasoning = 6; + } + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return stream; + }; + const models = createModelsFromStream(streamFn); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models, + emptyResponseRecovery: recovery, + }); + + await harness.prompt("task"); + expect(calls).toBe(2); + }); + + it.each([ + ["whitespace text", [{ type: "text", text: " " }], "stop"], + ["thinking content", [{ type: "thinking", thinking: "working" }], "stop"], + ["length stop", [], "length"], + ["error stop", [], "error"], + ["aborted stop", [], "aborted"], + ] as const)("does not recover %s", async (_name, content, stopReason) => { + let calls = 0; + const streamFn: StreamFn = (model) => { + calls += 1; + const stream = createAssistantMessageEventStream(); + const message = createAssistantMessage(model); + message.content = [...content] as AssistantMessage["content"]; + message.stopReason = stopReason; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: stopReason, message }); + stream.end(message); + return stream; + }; + const models = createModelsFromStream(streamFn); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models, + emptyResponseRecovery: recovery, + }); + + await harness.prompt("task"); + expect(calls).toBe(1); + }); + + it("does not treat a tool-use turn as an empty response", async () => { + let calls = 0; + const streamFn: StreamFn = (model) => { + const stream = createAssistantMessageEventStream(); + const message = createAssistantMessage(model); + if (calls++ === 0) { + message.content = [{ type: "toolCall", id: "tool-1", name: "custom", arguments: {} }]; + message.stopReason = "toolUse"; + } else { + message.content = [{ type: "text", text: "done" }]; + } + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: message.stopReason, message }); + stream.end(message); + return stream; + }; + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: createModelsFromStream(streamFn), + extraTools: [createCustomTool()], + emptyResponseRecovery: recovery, + }); + + await harness.prompt("task"); + expect(calls).toBe(2); + }); + + it.each([[-1], [1.5], [Number.POSITIVE_INFINITY], [Number.NaN]])( + "rejects invalid maxAttempts %s", + async (maxAttempts) => { + const services = await createHarnessServices(); + expect( + () => + new CuaAgentHarness({ + ...services, + browser, + client, + model: "openai:gpt-5.5", + emptyResponseRecovery: { followUp: "continue", maxAttempts }, + }), + ).toThrow(/non-negative finite integer/); + }, + ); + + it("rejects blank follow-up text and accepts zero attempts", async () => { + const services = await createHarnessServices(); + expect( + () => + new CuaAgentHarness({ + ...services, + browser, + client, + model: "openai:gpt-5.5", + emptyResponseRecovery: { followUp: " ", maxAttempts: 1 }, + }), + ).toThrow(/must not be blank/); + const scripted = createScriptedModels([undefined]); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models: scripted.models, + emptyResponseRecovery: { followUp: "continue", maxAttempts: 0 }, + }); + await harness.prompt("task"); + expect(scripted.calls()).toBe(1); + }); + + it.each(["one-at-a-time", "all"] as const)( + "lets queued caller work drain before recovery in %s mode", + async (mode) => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const contexts: Array<{ + messages: Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + }> = []; + let calls = 0; + const streamFn: StreamFn = (model, context) => { + contexts.push(context as never); + const stream = createAssistantMessageEventStream(); + const message = finishMessage(createAssistantMessage(model), calls === 2 ? "recovered" : undefined); + calls += 1; + void (async () => { + if (calls === 1) await gate; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + })(); + return stream; + }; + const models = createModelsFromStream(streamFn); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models, + emptyResponseRecovery: recovery, + }); + await harness.setFollowUpMode(mode); + const prompt = harness.prompt("task"); + await vi.waitFor(() => expect(calls).toBe(1)); + await harness.followUp("caller"); + release(); + await prompt; + + expect(calls).toBe(3); + expect(contexts[1]!.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ text: "caller" }], + }); + expect(contexts[2]!.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ text: recovery.followUp }], + }); + }, + ); + + it("does not let nextTurn suppress active-run recovery", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const contexts: Array<{ + messages: Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + }> = []; + let calls = 0; + const streamFn: StreamFn = (model, context) => { + contexts.push(context as never); + const stream = createAssistantMessageEventStream(); + const message = finishMessage(createAssistantMessage(model), calls++ === 0 ? undefined : "done"); + void (async () => { + if (calls === 1) await gate; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + })(); + return stream; + }; + const models = createModelsFromStream(streamFn); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models, + emptyResponseRecovery: recovery, + }); + const prompt = harness.prompt("first"); + await vi.waitFor(() => expect(calls).toBe(1)); + await harness.nextTurn("later"); + release(); + await prompt; + expect(calls).toBe(2); + expect(contexts[1]!.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ text: recovery.followUp }], + }); + + await harness.prompt("second"); + expect( + contexts[2]!.messages.some( + (message) => message.role === "user" && message.content.some((block) => block.text === "later"), + ), + ).toBe(true); + }); + + it("does not recover when the active signal is aborted", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const streamFn: StreamFn = (model) => { + calls += 1; + const stream = createAssistantMessageEventStream(); + const message = createAssistantMessage(model); + void gate.then(() => { + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + }); + return stream; + }; + const models = createModelsFromStream(streamFn); + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "openai:gpt-5.5", + models, + emptyResponseRecovery: recovery, + }); + const prompt = harness.prompt("task"); + await vi.waitFor(() => expect(calls).toBe(1)); + const abort = harness.abort(); + release(); + await abort; + await prompt; + expect(calls).toBe(1); + }); it.each([ { limit: 2 as const, expected: ["image-4", "image-5"] }, { limit: 0 as const, expected: [] },