From 6d4a345d693bdcb1fc5b1ff9845eee8fb5d8b547 Mon Sep 17 00:00:00 2001 From: Brent <67750428+eggpeat@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:51:08 +0000 Subject: [PATCH] fix(coding-agent): defer ACP reserve confirmation --- packages/coding-agent/src/main.ts | 4 +- packages/coding-agent/src/sdk.ts | 7 +++- .../test/acp-lazy-startup.test.ts | 6 +++ .../test/sdk-model-selection.test.ts | 37 +++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 65887bc08..db8278447 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -391,7 +391,9 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess authStorage: args.authStorage, modelRegistry: args.modelRegistry, agentId, - hasUI: false, + // Preserve reserve-policy confirmation until ACP capabilities are known + // without enabling AskTool or other UI-only session behavior. + deferUsageReserveConfirmation: true, enableMCP: false, titleSystemPrompt, }); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 8d81f6653..8284ace4b 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -520,6 +520,11 @@ export interface CreateAgentSessionOptions { /** Whether UI is available (enables interactive tools like ask). Default: false */ hasUI?: boolean; + /** + * Defer `confirm` reserve-policy fallback until AgentSession prompt-time UI is configured. + * ACP uses this while capabilities are negotiated without enabling UI-only tools. + */ + deferUsageReserveConfirmation?: boolean; /** * Opt-in OpenTelemetry instrumentation forwarded to the underlying Agent. @@ -2205,7 +2210,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} `Usage reserve reached for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - if (usageReservePolicy === "auto" || !options.hasUI) { + if (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) { usageFallbackTriggered = true; continue; } diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 3f9f3cc75..eb855020c 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -492,6 +492,8 @@ describe("ACP lazy startup", () => { const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); const { createAgentSession } = await import("@oh-my-pi/pi-coding-agent/sdk"); let session: AgentSession | undefined; + let sessionHasUI: boolean | undefined; + let deferredUsageReserveConfirmation: boolean | undefined; const stopped = runRootCommand( { @@ -514,6 +516,8 @@ describe("ACP lazy startup", () => { discoverAuthStorage: async () => authStorage, createAgentSession: options => { const sessionOptions = options ?? {}; + sessionHasUI = sessionOptions.hasUI; + deferredUsageReserveConfirmation = sessionOptions.deferUsageReserveConfirmation; return createAgentSession({ ...sessionOptions, workspaceTree: sessionOptions.workspaceTree ?? emptyWorkspaceTree(sessionOptions.cwd ?? cwd), @@ -533,6 +537,8 @@ describe("ACP lazy startup", () => { } expect(session.model.provider).toBe("runtime-provider"); expect(await session.modelRegistry.getApiKey(session.model)).toBe("cli-runtime-key"); + expect(sessionHasUI).toBe(false); + expect(deferredUsageReserveConfirmation).toBe(true); await session.dispose(); } finally { authStorage.close(); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 682a2649b..67549e7c8 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -459,6 +459,43 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("defers ACP reserve fallback until prompt-time capabilities are configured", async () => { + const settings = Settings.isolated({ + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + }); + settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); + const options = await buildSessionOptions("task"); + vi.spyOn(options.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, healthOptions) => + healthOptions.modelId === "runtime-model" + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [{ credentialId: 2, credentialType: "oauth", state: "healthy" }] }, + ); + const { session } = await createAgentSession({ + ...options, + modelPatternFallbackRole: "subagent:usage-aware-acp", + settings, + hasUI: false, + deferUsageReserveConfirmation: true, + }); + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-model"); + } finally { + await session.dispose(); + } + }); + test("enforces fail-closed reserve policy without requiring a fallback candidate", async () => { const settings = Settings.isolated({ "retry.usageAwareFallback": true,