From cba8780bca7072797475f3cd368e84025acc106c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Oct 2025 16:49:23 +0200 Subject: [PATCH] fix: use ACP protocol stopReason for proper agent completion detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace unreliable 2-second timeout heuristic with proper protocol-based completion detection using PromptResponse.stopReason from ACP SDK. Changes: - Capture and handle PromptResponse.stopReason in sendPrompt() - Set agent status based on stop reason: end_turn → completed, refusal → failed, cancelled → ready - Remove 2-second timeout completion heuristic (was causing drift) - Update SessionStateMessageSchema to match AgentInfo null types This ensures agent completion is detected via the protocol spec, not manual polling/timing. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../src/server/acp/agent-manager.ts | 42 +++++++++---------- .../voice-assistant/src/server/messages.ts | 8 ++-- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/voice-assistant/src/server/acp/agent-manager.ts b/packages/voice-assistant/src/server/acp/agent-manager.ts index 1358e1a0d..a2210fd13 100644 --- a/packages/voice-assistant/src/server/acp/agent-manager.ts +++ b/packages/voice-assistant/src/server/acp/agent-manager.ts @@ -225,7 +225,7 @@ export class AgentManager { this.notifySubscribers(agentId); try { - await agent.connection.prompt({ + const response = await agent.connection.prompt({ sessionId: agent.sessionId!, prompt: [ { @@ -234,6 +234,23 @@ export class AgentManager { }, ], }); + + // Handle completion based on stopReason from the protocol + console.log(`[Agent ${agentId}] Prompt completed with stopReason: ${response.stopReason}`); + + if (response.stopReason === "end_turn") { + agent.status = "completed"; + } else if (response.stopReason === "refusal") { + agent.status = "failed"; + agent.error = "Agent refused to process the prompt"; + } else if (response.stopReason === "cancelled") { + agent.status = "ready"; + } else { + // max_tokens, max_turn_requests - still completed but may be truncated + agent.status = "completed"; + } + + this.notifyStatusChange(agentId); } catch (error) { this.handleAgentError( agentId, @@ -435,29 +452,8 @@ export class AgentManager { console.log(`[Agent ${agentId}] Mode changed to: ${agent.currentModeId}`); } - // Track completion based on final message state - // ACP protocol indicates completion when the last agent_message_chunk arrives - // We detect this by tracking a completion timer that fires if no more updates arrive - const updateType = update.update.sessionUpdate; - - // Clear and reset completion timer on any update during processing - if (agent.status === "processing") { - if ((agent as any).completionTimer) { - clearTimeout((agent as any).completionTimer); - } - - // Set a new timer to mark as completed if no more updates arrive - // This is a heuristic - if no updates for 2 seconds, consider complete - (agent as any).completionTimer = setTimeout(() => { - if (agent.status === "processing") { - console.log(`[Agent ${agentId}] No more updates, marking as completed`); - agent.status = "completed"; - this.notifyStatusChange(agentId); - } - }, 2000); - } - // Log update for debugging + const updateType = update.update.sessionUpdate; console.log( `[Agent ${agentId}] Session update:`, updateType diff --git a/packages/voice-assistant/src/server/messages.ts b/packages/voice-assistant/src/server/messages.ts index 77fc89d7e..92f9fbefa 100644 --- a/packages/voice-assistant/src/server/messages.ts +++ b/packages/voice-assistant/src/server/messages.ts @@ -166,14 +166,14 @@ export const SessionStateMessageSchema = z.object({ status: z.string(), createdAt: z.date(), type: z.literal("claude"), - sessionId: z.string().optional(), - error: z.string().optional(), - currentModeId: z.string().optional(), + sessionId: z.string().nullable(), + error: z.string().nullable(), + currentModeId: z.string().nullable(), availableModes: z.array(z.object({ id: z.string(), name: z.string(), description: z.string().nullable().optional(), - })).optional(), + })).nullable(), }) ), commands: z.array(