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:
co-authored by
Claude Sonnet 4.6
parent
b0063a08f0
commit
416c9947bd
@@ -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 () => {},
|
||||
|
||||
Reference in New Issue
Block a user