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 () => {},