From c0037e56afddc609fee299220202125a81a02da8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 22:52:06 +0800 Subject: [PATCH] refactor: share agent state priority (#846) --- packages/app/src/utils/sidebar-agent-state.ts | 43 ++------- packages/server/src/server/session.ts | 26 ++---- .../server/src/server/workspace-directory.ts | 43 +++------ .../src/shared/agent-state-bucket.test.ts | 92 +++++++++++++++++++ .../server/src/shared/agent-state-bucket.ts | 55 +++++++++++ 5 files changed, 177 insertions(+), 82 deletions(-) create mode 100644 packages/server/src/shared/agent-state-bucket.test.ts create mode 100644 packages/server/src/shared/agent-state-bucket.ts diff --git a/packages/app/src/utils/sidebar-agent-state.ts b/packages/app/src/utils/sidebar-agent-state.ts index d1e77b9ed..91b79153c 100644 --- a/packages/app/src/utils/sidebar-agent-state.ts +++ b/packages/app/src/utils/sidebar-agent-state.ts @@ -1,41 +1,16 @@ -import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; - -export type SidebarAttentionReason = "finished" | "error" | "permission" | null | undefined; +import { + deriveAgentStateBucket, + type AgentAttentionReason, + type AgentStateBucketInput, +} from "@server/shared/agent-state-bucket"; export type SidebarStateBucket = "needs_input" | "failed" | "running" | "attention" | "done"; +export type SidebarAttentionReason = AgentAttentionReason; -export function deriveSidebarStateBucket(input: { - status: AgentLifecycleStatus; - pendingPermissionCount?: number; - requiresAttention?: boolean; - attentionReason?: SidebarAttentionReason; -}): SidebarStateBucket { - if ((input.pendingPermissionCount ?? 0) > 0) { - return "needs_input"; - } - // Legacy fallback for snapshots persisted before permission state was decoupled - // from unread attention. - if (input.attentionReason === "permission") { - return "needs_input"; - } - if (input.status === "error" || input.attentionReason === "error") { - return "failed"; - } - if (input.status === "running") { - return "running"; - } - if (input.requiresAttention) { - // Unread/attention-needed completed agents are active in sidebar logic. - return "attention"; - } - return "done"; +export function deriveSidebarStateBucket(input: AgentStateBucketInput): SidebarStateBucket { + return deriveAgentStateBucket(input); } -export function isSidebarActiveAgent(input: { - status: AgentLifecycleStatus; - pendingPermissionCount?: number; - requiresAttention?: boolean; - attentionReason?: SidebarAttentionReason; -}): boolean { +export function isSidebarActiveAgent(input: AgentStateBucketInput): boolean { return deriveSidebarStateBucket(input) !== "done"; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5a5057810..34ba31db3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -81,6 +81,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto import type { DaemonConfigStore } from "./daemon-config-store.js"; import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js"; +import { getAgentStatusPriority } from "../shared/agent-state-bucket.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; @@ -5754,24 +5755,6 @@ export class Session { return this.isProviderVisibleToClient(payload.provider) ? payload : null; } - private getStatusPriority(agent: AgentSnapshotPayload): number { - const attentionReason = agent.attentionReason ?? null; - const hasPendingPermission = (agent.pendingPermissions?.length ?? 0) > 0; - if (hasPendingPermission || attentionReason === "permission") { - return 0; - } - if (agent.status === "error" || attentionReason === "error") { - return 1; - } - if (agent.status === "running") { - return 2; - } - if (agent.status === "initializing") { - return 3; - } - return 4; - } - private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise< Map > { @@ -5935,7 +5918,12 @@ export class Session { getSortValue: (agent, key): number | string => { switch (key) { case "status_priority": - return this.getStatusPriority(agent); + return getAgentStatusPriority({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions?.length ?? 0, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason ?? null, + }); case "created_at": return Date.parse(agent.createdAt); case "updated_at": diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 229adac5e..a0e8e636c 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -6,8 +6,11 @@ import type { SessionInboundMessage, SessionOutboundMessage, WorkspaceDescriptorPayload, - WorkspaceStateBucket, } from "./messages.js"; +import { + deriveAgentStateBucket, + getWorkspaceStateBucketPriority, +} from "../shared/agent-state-bucket.js"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; @@ -90,14 +93,6 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable(); - private readonly workspaceStatePriority: Record = { - needs_input: 0, - failed: 1, - running: 2, - attention: 3, - done: 4, - }; - private readonly pager = new SortablePager< WorkspaceDescriptorPayload, FetchWorkspacesRequestSort["key"] @@ -109,7 +104,7 @@ export class WorkspaceDirectory { getSortValue: (workspace, key) => { switch (key) { case "status_priority": - return this.workspaceStatePriority[workspace.status]; + return getWorkspaceStateBucketPriority(workspace.status); case "activity_at": return workspace.activityAt ? Date.parse(workspace.activityAt) : null; case "name": @@ -202,8 +197,15 @@ export class WorkspaceDirectory { continue; } - const bucket = this.deriveStateBucket(agent); - if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) { + const bucket = deriveAgentStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions?.length ?? 0, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason ?? null, + }); + if ( + getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) + ) { existing.status = bucket; } } @@ -329,21 +331,4 @@ export class WorkspaceDirectory { }, }; } - - private deriveStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket { - const pendingPermissionCount = agent.pendingPermissions?.length ?? 0; - if (pendingPermissionCount > 0 || agent.attentionReason === "permission") { - return "needs_input"; - } - if (agent.status === "error" || agent.attentionReason === "error") { - return "failed"; - } - if (agent.status === "running") { - return "running"; - } - if (agent.requiresAttention) { - return "attention"; - } - return "done"; - } } diff --git a/packages/server/src/shared/agent-state-bucket.test.ts b/packages/server/src/shared/agent-state-bucket.test.ts new file mode 100644 index 000000000..1a778d39d --- /dev/null +++ b/packages/server/src/shared/agent-state-bucket.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + deriveAgentStateBucket, + getAgentStatusPriority, + getWorkspaceStateBucketPriority, +} from "./agent-state-bucket.js"; + +describe("deriveAgentStateBucket", () => { + it("prioritizes pending permissions as needs_input", () => { + expect( + deriveAgentStateBucket({ + status: "idle", + pendingPermissionCount: 1, + requiresAttention: false, + attentionReason: null, + }), + ).toBe("needs_input"); + }); + + it("keeps legacy permission attention in needs_input", () => { + expect( + deriveAgentStateBucket({ + status: "idle", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "permission", + }), + ).toBe("needs_input"); + }); + + it("prioritizes error attention before running status", () => { + expect( + deriveAgentStateBucket({ + status: "running", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "error", + }), + ).toBe("failed"); + }); + + it("treats unread finished agents as attention", () => { + expect( + deriveAgentStateBucket({ + status: "idle", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "finished", + }), + ).toBe("attention"); + }); + + it("treats initializing agents as done for workspace buckets", () => { + expect( + deriveAgentStateBucket({ + status: "initializing", + pendingPermissionCount: 0, + requiresAttention: false, + attentionReason: null, + }), + ).toBe("done"); + }); +}); + +describe("getWorkspaceStateBucketPriority", () => { + it("orders active buckets before done", () => { + expect( + ["done", "attention", "running", "failed", "needs_input"].sort( + (left, right) => + getWorkspaceStateBucketPriority(left) - getWorkspaceStateBucketPriority(right), + ), + ).toEqual(["needs_input", "failed", "running", "attention", "done"]); + }); +}); + +describe("getAgentStatusPriority", () => { + it("keeps initializing agents ahead of completed agents in agent lists", () => { + expect(getAgentStatusPriority({ status: "initializing" })).toBeLessThan( + getAgentStatusPriority({ status: "idle" }), + ); + }); + + it("prioritizes pending permissions before errors and running agents", () => { + const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 }); + expect(permission).toBeLessThan( + getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }), + ); + expect(permission).toBeLessThan( + getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }), + ); + }); +}); diff --git a/packages/server/src/shared/agent-state-bucket.ts b/packages/server/src/shared/agent-state-bucket.ts new file mode 100644 index 000000000..0273562e9 --- /dev/null +++ b/packages/server/src/shared/agent-state-bucket.ts @@ -0,0 +1,55 @@ +import type { AgentLifecycleStatus } from "./agent-lifecycle.js"; +import type { WorkspaceStateBucket } from "./messages.js"; + +export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined; + +export interface AgentStateBucketInput { + status: AgentLifecycleStatus; + pendingPermissionCount?: number; + requiresAttention?: boolean; + attentionReason?: AgentAttentionReason; +} + +const WORKSPACE_STATE_BUCKET_PRIORITY = { + needs_input: 0, + failed: 1, + running: 2, + attention: 3, + done: 4, +} as const satisfies Record; + +export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket { + if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") { + return "needs_input"; + } + if (input.status === "error" || input.attentionReason === "error") { + return "failed"; + } + if (input.status === "running") { + return "running"; + } + if (input.requiresAttention) { + return "attention"; + } + return "done"; +} + +export function getWorkspaceStateBucketPriority(bucket: WorkspaceStateBucket): number { + return WORKSPACE_STATE_BUCKET_PRIORITY[bucket]; +} + +export function getAgentStatusPriority(input: AgentStateBucketInput): number { + if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") { + return 0; + } + if (input.status === "error" || input.attentionReason === "error") { + return 1; + } + if (input.status === "running") { + return 2; + } + if (input.status === "initializing") { + return 3; + } + return 4; +}