From 638c633a21f6fed3190565ea6a6cb12ded8113dc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 25 Mar 2026 16:46:23 +0700 Subject: [PATCH] Fix Claude agent interrupt race and refactor mode color tiers - Fix race in agent-manager where pendingRun wasn't fully cleaned up before the next streamAgent call, causing "already has an active run" - Fix Claude agent query restart: null out query/input before awaiting old iterator return so the old pump skips failActiveTurns - Reset pendingInterruptAbort on new foreground turn - Add system error assertion to send-during-tool-call e2e test - Rename mode color tiers: drop "default", rename "readonly" to "planning", update color assignments across providers and UI --- .../app/src/components/active-processes.tsx | 10 ++++++---- .../app/src/components/agent-status-bar.tsx | 7 ++----- .../server/src/server/agent/agent-manager.ts | 7 +++++++ .../src/server/agent/provider-manifest.ts | 10 +++++----- .../server/agent/providers/claude-agent.ts | 19 +++++++++++++------ ...d-during-tool-call-claude.real.e2e.test.ts | 6 ++++++ 6 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx index 84a55954a..8827186e4 100644 --- a/packages/app/src/components/active-processes.tsx +++ b/packages/app/src/components/active-processes.tsx @@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]): function getModeColor(modeId?: string): string { if (!modeId) return "#9ca3af"; // gray - // Color based on common mode types - if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission - if (modeId.includes("code")) return "#22c55e"; // green - writes code - if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans + if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous + if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits")) + return "#3b82f6"; // blue - build/auto + if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning + if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default") + return "#22c55e"; // green - safest return "#9ca3af"; // gray - unknown } diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index c2317653a..3e79d1123 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -106,21 +106,18 @@ function getModeIconColor( palette: { blue: { 500: string }; green: { 500: string }; - amber: { 500: string }; red: { 500: string }; purple: { 500: string }; }, ): string { switch (colorTier) { - case "default": - return palette.blue[500]; case "safe": return palette.green[500]; case "moderate": - return palette.amber[500]; + return palette.blue[500]; case "dangerous": return palette.red[500]; - case "readonly": + case "planning": return palette.purple[500]; default: return palette.blue[500]; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5080cfb45..819e74a5b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1383,6 +1383,13 @@ export class AgentManager { timeout, ]); } + // The waiter settling wakes up the streamForwarder generator, but its + // finally block (which deletes the pendingForegroundRun) runs asynchronously. + // Wait for the pending run to be fully cleaned up so the next streamAgent + // call doesn't see a stale entry and reject with "already has an active run". + if (pendingRun && !pendingRun.settled) { + await Promise.race([pendingRun.settledPromise, timeout]); + } } else if (pendingRun) { const timeout = new Promise((resolve) => setTimeout(resolve, 2000)); await Promise.race([pendingRun.settledPromise, timeout]); diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 0e2d7e0a9..bc0b8d89c 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { AgentMode } from "./agent-sdk-types.js"; -export type AgentModeColorTier = "default" | "safe" | "moderate" | "dangerous" | "readonly"; +export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning"; export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff"; export interface AgentModeVisuals { @@ -44,7 +44,7 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [ label: "Plan Mode", description: "Analyze the codebase without executing tools or edits", icon: "ShieldCheck", - colorTier: "readonly", + colorTier: "planning", }, { id: "bypassPermissions", @@ -62,7 +62,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [ description: "Read files and answer questions. Manual approval required for edits, commands, or network ops.", icon: "ShieldCheck", - colorTier: "readonly", + colorTier: "safe", }, { id: "auto", @@ -86,14 +86,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [ label: "Build", description: "Allows edits and tool execution for implementation work", icon: "ShieldCheck", - colorTier: "default", + colorTier: "moderate", }, { id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", icon: "ShieldCheck", - colorTier: "readonly", + colorTier: "planning", }, ]; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index a7f277589..c42e9a8ce 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1325,6 +1325,7 @@ class ClaudeAgentSession implements AgentSession { const turnId = this.createTurnId("foreground"); this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; + this.pendingInterruptAbort = false; this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); @@ -1343,6 +1344,7 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", reason: "Interrupted", }); + this.queryRestartNeeded = true; void this.interruptActiveTurn().catch((error) => { this.logger.warn({ err: error }, "Failed to interrupt during cancel"); }); @@ -1780,16 +1782,21 @@ class ClaudeAgentSession implements AgentSession { } if (this.queryRestartNeeded && this.query) { - this.input?.end(); - this.query.close?.(); + const oldQuery = this.query; + const oldInput = this.input; + // Null out query/input BEFORE awaiting the old iterator's return so the + // old pump sees this.query !== activeQuery and skips failActiveTurns. + this.query = null; + this.input = null; + this.queryPumpPromise = null; + this.queryRestartNeeded = false; + oldInput?.end(); + oldQuery.close?.(); try { - await this.query.return?.(); + await oldQuery.return?.(); } catch { /* ignore */ } - this.query = null; - this.input = null; - this.queryRestartNeeded = false; } const input = createAsyncMessageInput(); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 401761f28..0fb177929 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -113,6 +113,12 @@ describe("daemon E2E (real claude) - send message during tool call", () => { return item.text; }); + // No system error messages should leak into the timeline + const hasSystemError = assistantTexts.some((text) => + text.includes("[System Error]"), + ); + expect(hasSystemError).toBe(false); + const responded = assistantTexts.some((text) => text.toUpperCase().includes("INTERRUPT_RECEIVED"), );