diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 6e4501885..f45c36438 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,6 +1,6 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; export function derivePendingPermissionKey( agentId: string, @@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: ? new Date(snapshot.attentionTimestamp) : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; - const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL]; - const parentAgentId = - typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0 - ? parentAgentLabel.trim() - : null; + const parentAgentId = getParentAgentIdFromLabels(snapshot.labels); return { serverId, diff --git a/packages/protocol/src/agent-labels.test.ts b/packages/protocol/src/agent-labels.test.ts new file mode 100644 index 000000000..c57db3a09 --- /dev/null +++ b/packages/protocol/src/agent-labels.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { + getParentAgentIdFromLabels, + isDelegatedAgent, + PARENT_AGENT_ID_LABEL, +} from "./agent-labels.js"; + +describe("agent label policy", () => { + test("treats a non-empty parent agent label as delegation", () => { + const labels = { [PARENT_AGENT_ID_LABEL]: " parent-agent \n" }; + + expect(getParentAgentIdFromLabels(labels)).toBe("parent-agent"); + expect(isDelegatedAgent({ labels })).toBe(true); + }); + + test("ignores missing, empty, and non-string parent agent labels", () => { + expect(isDelegatedAgent({ labels: {} })).toBe(false); + expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false); + expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false); + }); +}); diff --git a/packages/protocol/src/agent-labels.ts b/packages/protocol/src/agent-labels.ts index 9cc4180d4..3ad40022e 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -1 +1,16 @@ export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id"; + +export interface AgentLabelSource { + labels?: Record | null; +} + +export function getParentAgentIdFromLabels(labels: Record | null | undefined) { + const parentAgentId = labels?.[PARENT_AGENT_ID_LABEL]; + return typeof parentAgentId === "string" && parentAgentId.trim().length > 0 + ? parentAgentId.trim() + : null; +} + +export function isDelegatedAgent(agent: AgentLabelSource): boolean { + return getParentAgentIdFromLabels(agent.labels) !== null; +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index d4824e0f8..465f98a4b 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3892,6 +3892,39 @@ test("onAgentAttention is not called for internal agents", async () => { expect(attentionCalls).toHaveLength(0); }); +test("onAgentAttention is not called for delegated child agents", async () => { + const childAgentId = "00000000-0000-4000-8000-000000000112"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const attentionCalls: string[] = []; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => childAgentId, + onAgentAttention: ({ agentId }) => { + attentionCalls.push(agentId); + }, + }); + + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Delegated Child Agent", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } }, + ); + + await manager.runAgent(agent.id, "hello"); + + expect(attentionCalls).toEqual([]); +}); + test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 1e8fe4ba8..fe2988d89 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -5,7 +5,7 @@ import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus, } from "@getpaseo/protocol/agent-lifecycle"; -import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -3368,6 +3368,10 @@ export class AgentManager { agent: ManagedAgent, reason: "finished" | "error" | "permission", ): void { + if (isDelegatedAgent(agent)) { + return; + } + this.onAgentAttention?.({ agentId: agent.id, provider: agent.provider, diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts new file mode 100644 index 000000000..5456a656f --- /dev/null +++ b/packages/server/src/server/workspace-directory.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "vitest"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { createTestLogger } from "../test-utils/test-logger.js"; +import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js"; +import { WorkspaceDirectory } from "./workspace-directory.js"; +import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; + +const NOW = "2026-03-01T12:00:00.000Z"; + +class WorkspaceStatus { + private readonly project: PersistedProjectRecord = { + projectId: "project-1", + rootPath: "/workspace/project", + kind: "git", + displayName: "project", + customName: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; + + private readonly workspace: PersistedWorkspaceRecord = { + workspaceId: "workspace-1", + projectId: this.project.projectId, + cwd: this.project.rootPath, + kind: "local_checkout", + displayName: "main", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; + + private readonly agents: AgentSnapshotPayload[] = []; + private readonly directory = new WorkspaceDirectory({ + logger: createTestLogger(), + projectRegistry: { list: async () => [this.project] }, + workspaceRegistry: { list: async () => [this.workspace] }, + listAgentPayloads: async () => this.agents, + isProviderVisibleToClient: () => true, + buildWorkspaceDescriptor: async ({ workspace }) => ({ + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: "project", + projectCustomName: null, + projectRootPath: this.project.rootPath, + workspaceDirectory: workspace.cwd, + projectKind: "git", + workspaceKind: workspace.kind, + name: workspace.displayName, + archivingAt: null, + status: "done", + activityAt: null, + diffStat: null, + scripts: [], + gitRuntime: null, + githubRuntime: null, + }), + }); + + hasRootAgent(input: AgentState): void { + this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd })); + } + + hasDelegatedAgent(input: AgentState): void { + this.agents.push( + createAgent({ + ...input, + cwd: this.workspace.cwd, + labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" }, + }), + ); + } + + async workspaceStatus(): Promise { + const entries = await this.directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "workspace-status", + }); + return entries.entries[0]?.status ?? "done"; + } +} + +interface AgentState { + id: string; + status: AgentSnapshotPayload["status"]; + pendingPermissionCount?: number; + requiresAttention?: boolean; + attentionReason?: AgentSnapshotPayload["attentionReason"]; +} + +function createAgent(input: AgentState & { cwd: string; labels?: Record }) { + const pendingPermissionCount = input.pendingPermissionCount ?? 0; + return { + id: input.id, + provider: "codex", + cwd: input.cwd, + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: NOW, + updatedAt: NOW, + lastUserMessageAt: null, + status: input.status, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({ + id: `permission-${input.id}-${index}`, + provider: "codex", + name: "tool", + kind: "tool" as const, + })), + persistence: null, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + title: null, + labels: input.labels ?? {}, + requiresAttention: input.requiresAttention ?? false, + attentionReason: input.attentionReason ?? null, + attentionTimestamp: null, + archivedAt: null, + } satisfies AgentSnapshotPayload; +} + +describe("WorkspaceDirectory", () => { + test("uses root agent activity, not delegated child activity, for workspace status", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasRootAgent({ id: "root-agent", status: "running" }); + workspace.hasDelegatedAgent({ + id: "child-needs-input", + status: "idle", + pendingPermissionCount: 1, + }); + workspace.hasDelegatedAgent({ + id: "child-error", + status: "error", + requiresAttention: true, + attentionReason: "error", + }); + + await expect(workspace.workspaceStatus()).resolves.toBe("running"); + }); +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 84c143b98..0d37eb256 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -11,6 +11,7 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; +import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; @@ -187,6 +188,9 @@ export class WorkspaceDirectory { if (!this.deps.isProviderVisibleToClient(agent.provider)) { continue; } + if (isDelegatedAgent(agent)) { + continue; + } const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)); if (workspaceId === undefined) {