Dismiss a failed agent's red dot by reading it (#8831)

* fix(sidebar): clear the failed status once the error is read

Reading an errored agent already cleared its error attention, but the
state bucket also mapped lifecycle status "error" to failed. Lifecycle
only leaves "error" when the next turn starts, so the red dot and the
Failed group stuck until the agent was prompted again. The failed bucket
now follows unread error attention, the same way the green dot follows
unread finished attention; lifecycle status stays "error".

Because failed now depends on attention, a failure must replace unread
finished attention, otherwise a reload that fails on an unread agent
would show green.

* fix(sidebar): keep failed subagents red and let read failures be marked unread

The failed bucket now follows unread error attention, which broke three
places that relied on lifecycle "error" alone:

- Subagent track rows and provider-subagent tabs have no read state of
  their own, so failed children lost their red mark. They now map the
  child's lifecycle directly.
- A read errored workspace became "done" and offered Mark as unread, but
  the daemon only accepted idle or closed agents. Marking it unread now
  restores error attention.
- A reload that failed again while the lifecycle was already "error"
  raised no new attention. A failed reload now raises error attention
  directly instead of relying on a lifecycle transition.
This commit is contained in:
Mohamed Boudra
2026-11-05 18:49:12 +07:00
committed by GitHub
parent cdd7800fed
commit 59be8975a4
12 changed files with 267 additions and 39 deletions
@@ -39,6 +39,28 @@ describe("deriveAgentStateBucket", () => {
).toBe("failed");
});
it("treats an errored agent whose error was read as done", () => {
expect(
deriveAgentStateBucket({
status: "error",
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
}),
).toBe("done");
});
it("treats an errored agent with unread error attention as failed", () => {
expect(
deriveAgentStateBucket({
status: "error",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "error",
}),
).toBe("failed");
});
it("treats unread finished agents as attention", () => {
expect(
deriveAgentStateBucket({
@@ -83,10 +105,21 @@ describe("getAgentStatusPriority", () => {
it("prioritizes pending permissions before errors and running agents", () => {
const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 });
expect(permission).toBeLessThan(
getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }),
getAgentStatusPriority({
status: "error",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "error",
}),
);
expect(permission).toBeLessThan(
getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }),
);
});
it("ranks an errored agent with a read error alongside completed agents", () => {
expect(getAgentStatusPriority({ status: "error", requiresAttention: false })).toBe(
getAgentStatusPriority({ status: "idle" }),
);
});
});
+8 -2
View File
@@ -19,11 +19,17 @@ const WORKSPACE_STATE_BUCKET_PRIORITY = {
done: 4,
} as const satisfies Record<WorkspaceStateBucket, number>;
// An error is a notification like a finish: once the user has seen it, the agent stops showing as
// failed even though its lifecycle stays "error" until the next turn starts.
function hasUnreadError(input: AgentStateBucketInput): boolean {
return input.requiresAttention === true && input.attentionReason === "error";
}
export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket {
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
return "needs_input";
}
if (input.status === "error" || input.attentionReason === "error") {
if (hasUnreadError(input)) {
return "failed";
}
if (input.status === "running") {
@@ -43,7 +49,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number {
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
return 0;
}
if (input.status === "error" || input.attentionReason === "error") {
if (hasUnreadError(input)) {
return 1;
}
if (input.status === "running") {