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:
@@ -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"),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user