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<boolean>: 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 <noreply@anthropic.com>
This commit is contained in:
Theo Mathieu
2026-06-10 08:26:07 +02:00
committed by can1357
co-authored by Claude Sonnet 4.6
parent b0063a08f0
commit 416c9947bd
6 changed files with 28 additions and 12 deletions
@@ -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<boolean> {
@@ -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<void> {
/**
* 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<boolean> {
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<T = unknown>(
@@ -136,7 +136,7 @@ function reminderMessages(messages: AgentMessage[]): AgentMessage[] {
});
}
async function expectPromptCompletes(prompt: Promise<void>): Promise<void> {
async function expectPromptCompletes(prompt: Promise<boolean>): Promise<void> {
await Promise.race([
prompt,
Bun.sleep(1_000).then(() => {
@@ -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();
@@ -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" });
@@ -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 () => {},