From 416c9947bdbfdd367a5e1fa50ee8b0f2cd11c18a Mon Sep 17 00:00:00 2001 From: Theo Mathieu Date: Sun, 7 Jun 2026 20:37:21 +0200 Subject: [PATCH] fix(acp): finish prompt turn when extension/custom command is handled locally Extension commands (e.g. /sonnet) and TypeScript custom commands that consume the input without calling the LLM return early from session.prompt() with no agent turn. In ACP mode this left the pending prompt promise unresolved, hanging the client forever. Change session.prompt() to return Promise: true when the LLM was invoked, false when the command was fully handled locally. #runPromptOrCommand calls #finishPrompt immediately on a false return so the ACP turn completes. Co-Authored-By: Claude Sonnet 4.6 --- packages/coding-agent/src/modes/acp/acp-agent.ts | 8 +++++++- .../coding-agent/src/session/agent-session.ts | 16 ++++++++++++---- .../test/agent-session-empty-stop-guard.test.ts | 2 +- .../coding-agent/test/issue-927-repro.test.ts | 2 +- .../test/main-interactive-input.test.ts | 6 +++--- .../test/task/executor-wall-clock.test.ts | 6 ++++-- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 86d2dca49..966bedcd8 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -703,7 +703,13 @@ export class AcpAgent implements Agent { return; } - await record.session.prompt(text, { images }); + const agentInvoked = await record.session.prompt(text, { images }); + // Extension and custom-TS commands are handled locally inside session.prompt() + // without calling the LLM, so no agent_end event fires and the turn would hang. + // Finish it here when the session confirms no agent was invoked. + if (!agentInvoked) { + this.#finishPrompt(record, { stopReason: "end_turn" }); + } } async #tryRunSkillCommand(record: ManagedSessionRecord, text: string): Promise { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a86fce63f..9200b0f0b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4398,21 +4398,28 @@ export class AgentSession { * @throws Error if streaming and no streamingBehavior specified * @throws Error if no model selected or no API key available (when not streaming) */ - async prompt(text: string, options?: PromptOptions): Promise { + /** + * Returns `false` when the command was fully handled locally (extension or + * custom-TS command consumed without calling the LLM). Returns `true` when + * the prompt was forwarded to the agent — either directly or queued as a + * steer/follow-up. Callers that render a UI or manage turn lifecycle (e.g. + * the ACP agent) use this to know whether to expect an `agent_end` event. + */ + async prompt(text: string, options?: PromptOptions): Promise { const expandPromptTemplates = options?.expandPromptTemplates ?? true; // Handle extension commands first (execute immediately, even during streaming) if (expandPromptTemplates && text.startsWith("/")) { const handled = await this.#tryExecuteExtensionCommand(text); if (handled) { - return; + return false; } // Try custom commands (TypeScript slash commands) const customResult = await this.#tryExecuteCustomCommand(text); if (customResult !== null) { if (customResult === "") { - return; + return false; } text = customResult; } @@ -4446,7 +4453,7 @@ export class AgentSession { for (const notice of keywordNotices) { await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior }); } - return; + return true; } // Skip eager todo prelude when the user has already queued a directive @@ -4486,6 +4493,7 @@ export class AgentSession { if (!options?.synthetic) { await this.#enforcePlanModeToolDecision(); } + return true; } async promptCustomMessage( diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index eb9495ef0..b96391ae4 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -136,7 +136,7 @@ function reminderMessages(messages: AgentMessage[]): AgentMessage[] { }); } -async function expectPromptCompletes(prompt: Promise): Promise { +async function expectPromptCompletes(prompt: Promise): Promise { await Promise.race([ prompt, Bun.sleep(1_000).then(() => { diff --git a/packages/coding-agent/test/issue-927-repro.test.ts b/packages/coding-agent/test/issue-927-repro.test.ts index 5e1c9c5c5..25fa740dd 100644 --- a/packages/coding-agent/test/issue-927-repro.test.ts +++ b/packages/coding-agent/test/issue-927-repro.test.ts @@ -44,7 +44,7 @@ describe("issue #927 optimistic pending spinner", () => { settings: Settings.isolated(), modelRegistry, }); - vi.spyOn(session, "prompt").mockResolvedValue(undefined); + vi.spyOn(session, "prompt").mockResolvedValue(true); mode = new InteractiveMode(session, "test"); mode.addMessageToChat = vi.fn(); mode.ui.requestRender = vi.fn(); diff --git a/packages/coding-agent/test/main-interactive-input.test.ts b/packages/coding-agent/test/main-interactive-input.test.ts index 835a97a13..754d91ea2 100644 --- a/packages/coding-agent/test/main-interactive-input.test.ts +++ b/packages/coding-agent/test/main-interactive-input.test.ts @@ -21,7 +21,7 @@ describe("submitInteractiveInput", () => { checkShutdownRequested: vi.fn(async () => {}), }; const session = { - prompt: vi.fn(async () => {}), + prompt: vi.fn(async () => true), promptCustomMessage: vi.fn(async () => {}), }; const input = createInput({ text: "resume now", started: true, synthetic: true }); @@ -42,7 +42,7 @@ describe("submitInteractiveInput", () => { checkShutdownRequested: vi.fn(async () => {}), }; const session = { - prompt: vi.fn(async () => {}), + prompt: vi.fn(async () => true), promptCustomMessage: vi.fn(async () => {}), }; const input = createInput(); @@ -63,7 +63,7 @@ describe("submitInteractiveInput", () => { checkShutdownRequested: vi.fn(async () => {}), }; const session = { - prompt: vi.fn(async () => {}), + prompt: vi.fn(async () => true), promptCustomMessage: vi.fn(async () => {}), }; const input = createInput({ text: "continue goal", customType: "goal-continuation" }); diff --git a/packages/coding-agent/test/task/executor-wall-clock.test.ts b/packages/coding-agent/test/task/executor-wall-clock.test.ts index fed13d140..1a5f6ec56 100644 --- a/packages/coding-agent/test/task/executor-wall-clock.test.ts +++ b/packages/coding-agent/test/task/executor-wall-clock.test.ts @@ -41,6 +41,7 @@ function createHangingSession(): HangingSessionHandle { subscribe: (_listener: (event: AgentSessionEvent) => void) => () => {}, prompt: async (_text: string, _options?: PromptOptions) => { await hang; + return true; }, waitForIdle: async () => { await hang; @@ -140,7 +141,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { }); return () => {}; }, - prompt: async () => {}, + prompt: async () => true, waitForIdle: async () => {}, getLastAssistantMessage: () => undefined, abort: async () => {}, @@ -218,6 +219,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { }, prompt: async (_text: string, _options?: PromptOptions) => { await hang; + return true; }, waitForIdle: async () => { await hang; @@ -294,7 +296,7 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { }); return () => {}; }, - prompt: async () => {}, + prompt: async () => true, waitForIdle: async () => {}, getLastAssistantMessage: () => undefined, abort: async () => {},