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
This commit is contained in:
Mohamed Boudra
2026-03-25 16:46:23 +07:00
parent 935e55eb27
commit 638c633a21
6 changed files with 39 additions and 20 deletions
@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
function getModeColor(modeId?: string): string { function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray if (!modeId) return "#9ca3af"; // gray
// Color based on common mode types if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
if (modeId.includes("code")) return "#22c55e"; // green - writes code return "#3b82f6"; // blue - build/auto
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans 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 return "#9ca3af"; // gray - unknown
} }
@@ -106,21 +106,18 @@ function getModeIconColor(
palette: { palette: {
blue: { 500: string }; blue: { 500: string };
green: { 500: string }; green: { 500: string };
amber: { 500: string };
red: { 500: string }; red: { 500: string };
purple: { 500: string }; purple: { 500: string };
}, },
): string { ): string {
switch (colorTier) { switch (colorTier) {
case "default":
return palette.blue[500];
case "safe": case "safe":
return palette.green[500]; return palette.green[500];
case "moderate": case "moderate":
return palette.amber[500]; return palette.blue[500];
case "dangerous": case "dangerous":
return palette.red[500]; return palette.red[500];
case "readonly": case "planning":
return palette.purple[500]; return palette.purple[500];
default: default:
return palette.blue[500]; return palette.blue[500];
@@ -1383,6 +1383,13 @@ export class AgentManager {
timeout, 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) { } else if (pendingRun) {
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000)); const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
await Promise.race([pendingRun.settledPromise, timeout]); await Promise.race([pendingRun.settledPromise, timeout]);
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js"; 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 type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff";
export interface AgentModeVisuals { export interface AgentModeVisuals {
@@ -44,7 +44,7 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
label: "Plan Mode", label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits", description: "Analyze the codebase without executing tools or edits",
icon: "ShieldCheck", icon: "ShieldCheck",
colorTier: "readonly", colorTier: "planning",
}, },
{ {
id: "bypassPermissions", id: "bypassPermissions",
@@ -62,7 +62,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
description: description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.", "Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck", icon: "ShieldCheck",
colorTier: "readonly", colorTier: "safe",
}, },
{ {
id: "auto", id: "auto",
@@ -86,14 +86,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
label: "Build", label: "Build",
description: "Allows edits and tool execution for implementation work", description: "Allows edits and tool execution for implementation work",
icon: "ShieldCheck", icon: "ShieldCheck",
colorTier: "default", colorTier: "moderate",
}, },
{ {
id: "plan", id: "plan",
label: "Plan", label: "Plan",
description: "Read-only planning mode that avoids file edits", description: "Read-only planning mode that avoids file edits",
icon: "ShieldCheck", icon: "ShieldCheck",
colorTier: "readonly", colorTier: "planning",
}, },
]; ];
@@ -1325,6 +1325,7 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.createTurnId("foreground"); const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId; this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false; this.foregroundHasVisibleActivity = false;
this.pendingInterruptAbort = false;
this.transitionTurnState("foreground", "foreground turn started"); this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr(); this.clearRecentStderr();
@@ -1343,6 +1344,7 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude", provider: "claude",
reason: "Interrupted", reason: "Interrupted",
}); });
this.queryRestartNeeded = true;
void this.interruptActiveTurn().catch((error) => { void this.interruptActiveTurn().catch((error) => {
this.logger.warn({ err: error }, "Failed to interrupt during cancel"); this.logger.warn({ err: error }, "Failed to interrupt during cancel");
}); });
@@ -1780,16 +1782,21 @@ class ClaudeAgentSession implements AgentSession {
} }
if (this.queryRestartNeeded && this.query) { if (this.queryRestartNeeded && this.query) {
this.input?.end(); const oldQuery = this.query;
this.query.close?.(); 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 { try {
await this.query.return?.(); await oldQuery.return?.();
} catch { } catch {
/* ignore */ /* ignore */
} }
this.query = null;
this.input = null;
this.queryRestartNeeded = false;
} }
const input = createAsyncMessageInput<SDKUserMessage>(); const input = createAsyncMessageInput<SDKUserMessage>();
@@ -113,6 +113,12 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
return item.text; 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) => const responded = assistantTexts.some((text) =>
text.toUpperCase().includes("INTERRUPT_RECEIVED"), text.toUpperCase().includes("INTERRUPT_RECEIVED"),
); );