From 111b9d95f40f3f5d524611e6123e852090b249a9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 10:39:28 +0700 Subject: [PATCH] feat: carry archive UI label hard cut updates --- packages/app/src/app/h/[serverId]/index.tsx | 2 +- packages/app/src/contexts/session-context.tsx | 1 - packages/app/src/hooks/use-all-agents-list.ts | 2 +- packages/app/src/runtime/host-runtime.test.ts | 2 +- packages/app/src/runtime/host-runtime.ts | 2 +- .../src/screens/agent/agent-ready-screen.tsx | 17 --- .../src/screens/agent/draft-agent-screen.tsx | 1 - .../workspace-agent-visibility.test.ts | 137 ++++++++++++++++++ .../workspace/workspace-agent-visibility.ts | 68 +++++++++ .../workspace/workspace-draft-agent-tab.tsx | 1 - .../screens/workspace/workspace-screen.tsx | 58 +++----- packages/cli/src/cli.ts | 1 - packages/cli/src/commands/agent/index.ts | 1 - packages/cli/src/commands/agent/run.ts | 9 +- packages/cli/tests/05-agent-run.test.ts | 12 ++ packages/cli/tests/16-agent-update.test.ts | 2 +- .../server/scripts/voice-mcp-agent-smoke.ts | 4 +- .../server/src/client/daemon-client.test.ts | 2 +- .../server/src/server/agent/agent-manager.ts | 2 +- .../src/server/agent/mcp-server.test.ts | 4 +- .../src/server/client-activity.e2e.test.ts | 38 ++--- .../src/server/daemon-client.e2e.test.ts | 64 +++++++- packages/server/src/server/session.ts | 136 +++++++++-------- .../src/server/session.workspaces.test.ts | 2 +- .../websocket-server.notifications.test.ts | 5 +- .../server/src/server/websocket-server.ts | 10 -- 26 files changed, 408 insertions(+), 175 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-agent-visibility.test.ts create mode 100644 packages/app/src/screens/workspace/workspace-agent-visibility.ts diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 20f7d9ffd..52b994602 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -21,7 +21,7 @@ export default function HostIndexRoute() { const visibleAgents = sessionAgents ? Array.from(sessionAgents.values()).filter( - (agent) => !agent.archivedAt && agent.labels.ui === "true" + (agent) => !agent.archivedAt ) : []; visibleAgents.sort( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index ed703cf00..cb364e6c7 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1550,7 +1550,6 @@ function SessionProviderInternal({ } return client.createAgent({ config, - labels: { ui: "true" }, ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(git ? { git } : {}), diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 319b889cd..cbb68103d 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -74,7 +74,7 @@ export function useAllAgentsList(options?: { serverId, serverLabel, }); - if (aggregated.labels.ui !== "true") { + if (aggregated.archivedAt) { continue; } list.push(aggregated); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 1a6abf61f..4d032eaea 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -743,7 +743,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ - filter: { labels: { ui: "true" } }, + filter: { includeArchived: true }, subscribe: { subscriptionId: "app:srv_test" }, page: { limit: 200 }, }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 252b012d0..af669b9a6 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1113,7 +1113,7 @@ export class HostRuntimeStore { controller.markAgentDirectorySyncLoading(); try { const payload = await client.fetchAgents({ - filter: input.filter ?? { labels: { ui: "true" }, includeArchived: true }, + filter: input.filter ?? { includeArchived: true }, ...(input.subscribe ? { subscribe: input.subscribe } : {}), ...(input.page ? { page: input.page } : {}), }); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 463477999..abed3ef18 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -589,23 +589,6 @@ function AgentScreenContent({ resolvedAgentId && isArchivingAgent({ serverId, agentId: resolvedAgentId }) ); - const hasRedirectedArchivedAgentRef = useRef(false); - - useEffect(() => { - if (!resolvedAgentId) { - hasRedirectedArchivedAgentRef.current = false; - return; - } - if (!agent?.archivedAt) { - hasRedirectedArchivedAgentRef.current = false; - return; - } - if (hasRedirectedArchivedAgentRef.current) { - return; - } - hasRedirectedArchivedAgentRef.current = true; - router.replace(buildHostAgentDraftRoute(serverId) as any); - }, [agent?.archivedAt, resolvedAgentId, router, serverId]); useEffect(() => { if (!resolvedAgentId) { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 8ca8b40e7..3b608ee39 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1017,7 +1017,6 @@ function DraftAgentScreenContent({ const imagesData = await encodeImages(images) const result = await createAgentClient.createAgent({ config, - labels: { ui: 'true' }, initialPrompt: trimmedPrompt, clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts new file mode 100644 index 000000000..5ae2b2143 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@/stores/session-store"; +import { + canOpenAgentTabFromRoute, + deriveWorkspaceAgentVisibility, +} from "@/screens/workspace/workspace-agent-visibility"; + +function makeAgent(input: { + id: string; + cwd: string; + archivedAt?: Date | null; + createdAt?: Date; + lastActivityAt?: Date; +}): Agent { + const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z"); + const lastActivityAt = input.lastActivityAt ?? createdAt; + return { + serverId: "srv", + id: input.id, + provider: "codex", + status: "idle", + createdAt, + updatedAt: createdAt, + lastUserMessageAt: null, + lastActivityAt, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + title: null, + cwd: input.cwd, + model: null, + thinkingOptionId: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: input.archivedAt ?? null, + }; +} + +describe("workspace agent visibility", () => { + it("keeps archived agents hidden from visible list but present in workspace lookup", () => { + const workspaceId = "/repo/worktree"; + const visible = makeAgent({ + id: "visible-agent", + cwd: workspaceId, + createdAt: new Date("2026-03-04T00:00:00.000Z"), + }); + const archived = makeAgent({ + id: "archived-agent", + cwd: workspaceId, + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + createdAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const otherWorkspace = makeAgent({ + id: "other-workspace-agent", + cwd: "/repo/other", + }); + + const sessionAgents = new Map([ + [visible.id, visible], + [archived.id, archived], + [otherWorkspace.id, otherWorkspace], + ]); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceId, + }); + + expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["visible-agent"]); + expect(result.lookupById.has("visible-agent")).toBe(true); + expect(result.lookupById.has("archived-agent")).toBe(true); + expect(result.lookupById.has("other-workspace-agent")).toBe(false); + }); + + it("allows explicit route open for archived agent once agents are hydrated", () => { + const archivedAgent = makeAgent({ + id: "archived-agent", + cwd: "/repo/worktree", + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const lookup = new Map([[archivedAgent.id, archivedAgent]]); + + expect( + canOpenAgentTabFromRoute({ + agentId: "archived-agent", + agentsHydrated: true, + workspaceAgentLookup: lookup, + }) + ).toBe(true); + }); + + it("sorts same-createdAt agents by lastActivityAt descending", () => { + const workspaceId = "/repo/worktree"; + const createdAt = new Date("2026-03-04T00:00:00.000Z"); + const newerActivity = makeAgent({ + id: "newer-activity", + cwd: workspaceId, + createdAt, + lastActivityAt: new Date("2026-03-04T00:05:00.000Z"), + }); + const olderActivity = makeAgent({ + id: "older-activity", + cwd: workspaceId, + createdAt, + lastActivityAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const sessionAgents = new Map([ + [olderActivity.id, olderActivity], + [newerActivity.id, newerActivity], + ]); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceId, + }); + + expect(result.visibleAgents.map((agent) => agent.id)).toEqual([ + "newer-activity", + "older-activity", + ]); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts new file mode 100644 index 000000000..c2dbdec3a --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -0,0 +1,68 @@ +import type { Agent } from "@/stores/session-store"; + +function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { + const sorted = [...agents]; + sorted.sort((left, right) => { + const createdAtDelta = right.createdAt.getTime() - left.createdAt.getTime(); + if (createdAtDelta !== 0) { + return createdAtDelta; + } + return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); + }); + return sorted; +} + +function trimNonEmpty(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function deriveWorkspaceAgentVisibility(input: { + sessionAgents: Map | undefined; + workspaceId: string; +}): { + visibleAgents: Agent[]; + lookupById: Map; +} { + const { sessionAgents, workspaceId } = input; + if (!sessionAgents || !workspaceId) { + return { + visibleAgents: [], + lookupById: new Map(), + }; + } + + const lookupById = new Map(); + const visible: Agent[] = []; + for (const agent of sessionAgents.values()) { + if ((trimNonEmpty(agent.cwd) ?? "") !== workspaceId) { + continue; + } + lookupById.set(agent.id, agent); + if (!agent.archivedAt) { + visible.push(agent); + } + } + + return { + visibleAgents: sortAgentsByCreatedAtDescending(visible), + lookupById, + }; +} + +export function canOpenAgentTabFromRoute(input: { + agentId: string; + agentsHydrated: boolean; + workspaceAgentLookup: Map; +}): boolean { + if (!input.agentId.trim()) { + return false; + } + if (!input.agentsHydrated) { + return true; + } + return input.workspaceAgentLookup.has(input.agentId); +} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index b0d9474d7..f2a6cb17e 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -257,7 +257,6 @@ export function WorkspaceDraftAgentTab({ const imagesData = await encodeImages(images); const result = await client.createAgent({ config, - labels: { ui: "true" }, initialPrompt: trimmedPrompt, clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index eb9f3baf4..14014f588 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -86,6 +86,10 @@ import { resolveWorkspaceHeader, shouldRenderMissingWorkspaceDescriptor, } from "@/screens/workspace/workspace-header-source"; +import { + canOpenAgentTabFromRoute, + deriveWorkspaceAgentVisibility, +} from "@/screens/workspace/workspace-agent-visibility"; const TERMINALS_QUERY_STALE_TIME = 5_000; const DROPDOWN_WIDTH = 220; @@ -191,17 +195,6 @@ function resolveTabAvailability(input: { return input.terminalIds.has(input.tab.terminalId) ? "available" : "invalid"; } -function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { - return [...agents].sort((left, right) => { - const createdAtDelta = - right.createdAt.getTime() - left.createdAt.getTime(); - if (createdAtDelta !== 0) { - return createdAtDelta; - } - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); - }); -} - export function WorkspaceScreen({ serverId, workspaceId, @@ -238,24 +231,15 @@ function WorkspaceScreenContent({ const sessionAgents = useSessionStore( (state) => state.sessions[normalizedServerId]?.agents ); - const workspaceAgents = useMemo(() => { - if (!sessionAgents || !normalizedWorkspaceId) { - return [] as Agent[]; - } - - const collected: Agent[] = []; - for (const agent of sessionAgents.values()) { - if (agent.archivedAt) { - continue; - } - if ((trimNonEmpty(agent.cwd) ?? "") !== normalizedWorkspaceId) { - continue; - } - collected.push(agent); - } - - return sortAgentsByCreatedAtDescending(collected); - }, [normalizedWorkspaceId, sessionAgents]); + const workspaceAgentVisibility = useMemo( + () => + deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceId: normalizedWorkspaceId, + }), + [normalizedWorkspaceId, sessionAgents] + ); + const workspaceAgents = workspaceAgentVisibility.visibleAgents; const terminalsQueryKey = useMemo( () => ["terminals", normalizedServerId, normalizedWorkspaceId] as const, @@ -482,13 +466,7 @@ function WorkspaceScreenContent({ return () => handler.remove(); }, [closeToAgent, isExplorerOpen]); - const agentsById = useMemo(() => { - const map = new Map(); - for (const agent of workspaceAgents) { - map.set(agent.id, agent); - } - return map; - }, [workspaceAgents]); + const agentsById = workspaceAgentVisibility.lookupById; const terminalIds = useMemo(() => { const set = new Set(); @@ -575,7 +553,13 @@ function WorkspaceScreenContent({ if (normalized.startsWith("agent_")) { const agentId = normalized.slice("agent_".length).trim(); if (agentId) { - if (areWorkspaceAgentsHydrated && !agentsById.has(agentId)) { + if ( + !canOpenAgentTabFromRoute({ + agentId, + agentsHydrated: areWorkspaceAgentsHydrated, + workspaceAgentLookup: agentsById, + }) + ) { return; } const tabId = openOrFocusTab({ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3864401f0..900f849ee 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -83,7 +83,6 @@ export function createCli(): Command { .option('--image ', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, []) .option('--cwd ', 'Working directory (default: current)') .option('--label ', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) - .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') .option('--output-schema ', 'Output JSON matching the provided schema file path or inline JSON schema') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 9b0b7afad..ab94a7c95 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -44,7 +44,6 @@ export function createAgentCommand(): Command { .option('--mode ', 'Provider-specific mode (e.g., plan, default, bypass)') .option('--cwd ', 'Working directory (default: current)') .option('--label ', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) - .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') .option('--output-schema ', 'Output JSON matching the provided schema file path or inline JSON schema') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 436d68a62..85df55174 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -43,7 +43,6 @@ export interface AgentRunOptions extends CommandOptions { image?: string[] cwd?: string label?: string[] - ui?: boolean outputSchema?: string } @@ -265,9 +264,7 @@ export async function runRunCommand( } : undefined - // Build labels from --label and --ui flags - // --ui is syntactic sugar for --label ui=true - // If explicit --label ui=... is provided, it takes precedence over --ui + // Build labels from --label flags const labels: Record = {} if (options.label) { for (const labelStr of options.label) { @@ -285,10 +282,6 @@ export async function runRunCommand( labels[key] = value } } - // Add ui=true if --ui flag is set and ui label not already set - if (options.ui && !('ui' in labels)) { - labels['ui'] = 'true' - } if (outputSchema) { let structuredAgent: AgentSnapshotPayload | null = null diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index b9688072b..99ac41503 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -61,6 +61,7 @@ try { assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option') assert(result.stdout.includes('--host'), 'help should mention --host option') assert(result.stdout.includes(''), 'help should mention prompt argument') + assert(!result.stdout.includes('--ui'), 'help should not mention removed --ui option') console.log('✓ run --help shows options\n') } @@ -206,6 +207,17 @@ try { assert(result.stdout.includes('run'), 'help should mention run command') console.log('✓ paseo --help shows run command\n') } + + // Test 14: run --ui is rejected (flag removed) + { + console.log('Test 14: run --ui is rejected') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow() + assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag') + const output = result.stdout + result.stderr + assert(output.includes('unknown option'), 'should report unknown option for --ui') + console.log('✓ run --ui is rejected\n') + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }) diff --git a/packages/cli/tests/16-agent-update.test.ts b/packages/cli/tests/16-agent-update.test.ts index 78f6cbc85..fde9b4c84 100644 --- a/packages/cli/tests/16-agent-update.test.ts +++ b/packages/cli/tests/16-agent-update.test.ts @@ -89,7 +89,7 @@ try { { console.log('Test 5: agent update accepts multi-label syntax') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label ui=true,area=frontend --label priority=high`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow() const output = result.stdout + result.stderr assert(!output.includes('unknown option'), 'should accept --label flag') assert(!output.includes('error: option'), 'should not have option parsing error') diff --git a/packages/server/scripts/voice-mcp-agent-smoke.ts b/packages/server/scripts/voice-mcp-agent-smoke.ts index 41faa4eb4..af5f0e6f3 100644 --- a/packages/server/scripts/voice-mcp-agent-smoke.ts +++ b/packages/server/scripts/voice-mcp-agent-smoke.ts @@ -114,7 +114,7 @@ async function main(): Promise { enableVoiceTools: false, resolveSpeakHandler, resolveCallerContext: () => ({ - childAgentDefaultLabels: { ui: "true" }, + childAgentDefaultLabels: { source: "voice-smoke" }, allowCustomCwd: true, enableVoiceTools: true, }), @@ -156,7 +156,7 @@ async function main(): Promise { try { const created = await agentManager.createAgent(config, agentId, { - labels: { surface: "voice-smoke", ui: "true" }, + labels: { surface: "voice-smoke" }, }); logger.info({ provider: opts.provider, agentId: created.id }, "Created smoke agent"); diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 440e82a03..5a385a9f4 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -838,7 +838,7 @@ describe('DaemonClient', () => { await connectPromise const promise = client.fetchAgents({ - filter: { labels: { ui: 'true' } }, + filter: { labels: { surface: 'workspace' } }, sort: [ { key: 'status_priority', direction: 'asc' }, { key: 'created_at', direction: 'desc' }, diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index faa08dfe2..689ab962c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -168,7 +168,7 @@ type ManagedAgentBase = { */ internal?: boolean; /** - * User-defined labels for categorizing agents (e.g., { ui: "true" }). + * User-defined labels for categorizing agents (e.g., { surface: "workspace" }). */ labels: Record; }; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 8deba1996..a0a9cbb16 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -197,7 +197,7 @@ describe("create_agent MCP tool", () => { agentStorage, callerAgentId: "voice-agent", resolveCallerContext: () => ({ - childAgentDefaultLabels: { ui: "true" }, + childAgentDefaultLabels: { source: "voice" }, allowCustomCwd: true, }), logger, @@ -216,7 +216,7 @@ describe("create_agent MCP tool", () => { cwd: subdir, }), undefined, - { labels: { ui: "true" } } + { labels: { source: "voice" } } ); await rm(baseDir, { recursive: true, force: true }); }); diff --git a/packages/server/src/server/client-activity.e2e.test.ts b/packages/server/src/server/client-activity.e2e.test.ts index 16f2894ab..98c6ba336 100644 --- a/packages/server/src/server/client-activity.e2e.test.ts +++ b/packages/server/src/server/client-activity.e2e.test.ts @@ -59,7 +59,7 @@ describe("client activity tracking", () => { return client; } - async function createUiAgent(params: { + async function createAgent(params: { client: DaemonClient; title: string; }): Promise { @@ -68,7 +68,7 @@ describe("client activity tracking", () => { model: TEST_MODEL, cwd: TEST_CWD, title: params.title, - labels: { ui: "true" }, + labels: { surface: "activity-test" }, }); } @@ -103,7 +103,7 @@ describe("client activity tracking", () => { test("no notification when actively focused on agent", async () => { client1 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Active Focus Test", }); @@ -129,9 +129,9 @@ describe("client activity tracking", () => { test("notification when focused on different agent", async () => { client1 = await createClient(); - const agent1 = await createUiAgent({ client: client1, title: "Agent 1" }); + const agent1 = await createAgent({ client: client1, title: "Agent 1" }); - const agent2 = await createUiAgent({ client: client1, title: "Agent 2" }); + const agent2 = await createAgent({ client: client1, title: "Agent 2" }); // User is looking at agent2, not agent1 client1.sendHeartbeat({ @@ -155,7 +155,7 @@ describe("client activity tracking", () => { test("no notification when app is not visible but activity is recent (user just switched tabs)", async () => { client1 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "App Hidden Test", }); @@ -182,7 +182,7 @@ describe("client activity tracking", () => { test("notification when activity is stale (user walked away for 2+ minutes)", async () => { client1 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Stale Activity Test", }); @@ -210,7 +210,7 @@ describe("client activity tracking", () => { test("notification when no heartbeat received (legacy/new client)", async () => { client1 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "No Heartbeat Test", }); @@ -236,7 +236,7 @@ describe("client activity tracking", () => { client1 = await createClient(); client2 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Two Tabs Test", }); @@ -277,7 +277,7 @@ describe("client activity tracking", () => { client1 = await createClient(); client2 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Both Inactive Test", }); @@ -325,7 +325,7 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Web Active Test", }); @@ -366,7 +366,7 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Mobile Active Test", }); @@ -407,7 +407,7 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Web Stale Test", }); @@ -448,9 +448,9 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - const agent1 = await createUiAgent({ client: client1, title: "Agent 1" }); + const agent1 = await createAgent({ client: client1, title: "Agent 1" }); - const agent2 = await createUiAgent({ client: client1, title: "Agent 2" }); + const agent2 = await createAgent({ client: client1, title: "Agent 2" }); // Web: active but looking at agent2 client1.sendHeartbeat({ @@ -489,7 +489,7 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Both Inactive Test", }); @@ -539,7 +539,7 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - no heartbeat - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Mobile No Heartbeat Test", }); @@ -575,7 +575,7 @@ describe("client activity tracking", () => { test("no notification when app not visible but activity recent (switched tabs recently)", async () => { client1 = await createClient(); - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Tab Switch Test", }); @@ -603,7 +603,7 @@ describe("client activity tracking", () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - const agent = await createUiAgent({ + const agent = await createAgent({ client: client1, title: "Both Recent Activity Test", }); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a114f4327..db7dbfc10 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -236,7 +236,7 @@ describe("daemon client E2E", () => { } }, 60000); - test("rejects send_agent_message for archived agents", async () => { + test("send_agent_message auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { const created = await ctx.client.createAgent({ @@ -247,13 +247,67 @@ describe("daemon client E2E", () => { }); await ctx.client.archiveAgent(created.id); - await expect( - ctx.client.sendMessage(created.id, "Say hello and nothing else") - ).rejects.toThrow("archived"); + await ctx.client.sendMessage(created.id, "Say hello and nothing else"); + const finalState = await ctx.client.waitForFinish(created.id, 120000); + expect(finalState.status).toBe("idle"); + + const refreshed = await ctx.client.fetchAgent(created.id); + expect(refreshed).not.toBeNull(); + expect(refreshed?.agent.archivedAt).toBeNull(); } finally { rmSync(cwd, { recursive: true, force: true }); } - }, 30000); + }, 180000); + + test("refresh_agent auto-unarchives archived agents", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + await ctx.client.archiveAgent(created.id); + await ctx.client.refreshAgent(created.id); + + const refreshed = await ctx.client.fetchAgent(created.id); + expect(refreshed).not.toBeNull(); + expect(refreshed?.agent.archivedAt).toBeNull(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 120000); + + test("resume_agent auto-unarchives archived agents", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const agentBeforeArchive = await ctx.client.fetchAgent(created.id); + expect(agentBeforeArchive?.agent.persistence).toBeTruthy(); + await ctx.client.archiveAgent(created.id); + + const handle = agentBeforeArchive?.agent.persistence; + if (!handle) { + throw new Error("Expected persistence handle for resume test"); + } + const resumed = await ctx.client.resumeAgent(handle); + const resumedDetails = await ctx.client.fetchAgent(resumed.id); + expect(resumedDetails).not.toBeNull(); + expect(resumedDetails?.agent.archivedAt).toBeNull(); + + if (resumed.id !== created.id) { + await ctx.client.deleteAgent(resumed.id); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 180000); test("returns home-scoped directory suggestions", async () => { const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4f94730dc..d542e2538 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1808,35 +1808,7 @@ export class Session { private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) - if (this.agentManager.getAgent(agentId)) { - await this.interruptAgentIfRunning(agentId) - } - - const archivedAt = new Date().toISOString() - - const existing = await this.agentStorage.get(agentId) - let archivedRecord: StoredAgentRecord | null = existing - if (!archivedRecord) { - const liveAgent = this.agentManager.getAgent(agentId) - if (!liveAgent) { - throw new Error(`Agent not found: ${agentId}`) - } - - await this.agentStorage.applySnapshot(liveAgent, { - internal: liveAgent.internal, - }) - archivedRecord = await this.agentStorage.get(agentId) - if (!archivedRecord) { - throw new Error(`Agent not found in storage after snapshot: ${agentId}`) - } - } - - archivedRecord = { - ...archivedRecord, - archivedAt, - } - await this.agentStorage.upsert(archivedRecord) - this.agentManager.notifyAgentState(agentId) + const { archivedAt, archivedRecord } = await this.archiveAgentState(agentId) this.emit({ type: 'agent_archived', @@ -1854,9 +1826,75 @@ export class Session { }) } - private async getArchivedAt(agentId: string): Promise { + private async archiveAgentState(agentId: string): Promise<{ + archivedAt: string + archivedRecord: StoredAgentRecord + }> { + if (this.agentManager.getAgent(agentId)) { + await this.interruptAgentIfRunning(agentId) + await this.agentManager.clearAgentAttention(agentId).catch(() => undefined) + } + + const archivedAt = new Date().toISOString() + const existing = await this.agentStorage.get(agentId) + let archivedRecord: StoredAgentRecord | null = existing + if (!archivedRecord) { + const liveAgent = this.agentManager.getAgent(agentId) + if (!liveAgent) { + throw new Error(`Agent not found: ${agentId}`) + } + + await this.agentStorage.applySnapshot(liveAgent, { + internal: liveAgent.internal, + }) + archivedRecord = await this.agentStorage.get(agentId) + if (!archivedRecord) { + throw new Error(`Agent not found in storage after snapshot: ${agentId}`) + } + } + + const normalizedStatus = + archivedRecord.lastStatus === 'running' || archivedRecord.lastStatus === 'initializing' + ? 'idle' + : archivedRecord.lastStatus + + const nextRecord: StoredAgentRecord = { + ...archivedRecord, + archivedAt, + lastStatus: normalizedStatus, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + } + await this.agentStorage.upsert(nextRecord) + this.agentManager.notifyAgentState(agentId) + return { archivedAt, archivedRecord: nextRecord } + } + + private async unarchiveAgentState(agentId: string): Promise { const record = await this.agentStorage.get(agentId) - return record?.archivedAt ?? null + if (!record || !record.archivedAt) { + return false + } + await this.agentStorage.upsert({ + ...record, + archivedAt: null, + }) + this.agentManager.notifyAgentState(agentId) + return true + } + + private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise { + const records = await this.agentStorage.list() + const matched = records.find( + (record) => + record.persistence?.provider === handle.provider && + record.persistence?.sessionId === handle.sessionId + ) + if (!matched) { + return + } + await this.unarchiveAgentState(matched.id) } private async handleUpdateAgentRequest( @@ -2443,6 +2481,8 @@ export class Session { `Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}` ) + await this.unarchiveAgentState(agentId) + try { await this.ensureAgentLoaded(agentId) } catch (error) { @@ -2450,16 +2490,6 @@ export class Session { return } - const archivedAt = await this.getArchivedAt(agentId) - if (archivedAt) { - this.handleAgentRunError( - agentId, - new Error(`Agent ${agentId} is archived`), - 'Refusing to send prompt to archived agent' - ) - return - } - try { await this.interruptAgentIfRunning(agentId) } catch (error) { @@ -2645,7 +2675,9 @@ export class Session { `Resuming agent ${handle.sessionId} (${handle.provider})` ) try { + await this.unarchiveAgentByHandle(handle) const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides) + await this.unarchiveAgentState(snapshot.id) await this.agentManager.hydrateTimelineFromProvider(snapshot.id) await this.forwardAgentUpdate(snapshot) const timelineSize = this.agentManager.getTimeline(snapshot.id).length @@ -2686,6 +2718,7 @@ export class Session { this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`) try { + await this.unarchiveAgentState(agentId) let snapshot: ManagedAgent const existing = this.agentManager.getAgent(agentId) if (existing) { @@ -5318,9 +5351,7 @@ export class Session { } private async listWorkspaceDescriptors(): Promise { - const agents = await this.listAgentPayloads({ - labels: { ui: 'true' }, - }) + const agents = await this.listAgentPayloads() const descriptorsByWorkspaceId = new Map() const placementByWorkspaceId = new Map>() @@ -6021,20 +6052,7 @@ export class Session { try { const agentId = resolved.agentId - - const archivedAt = await this.getArchivedAt(agentId) - if (archivedAt) { - this.emit({ - type: 'send_agent_message_response', - payload: { - requestId: msg.requestId, - agentId, - accepted: false, - error: `Agent ${agentId} is archived`, - }, - }) - return - } + await this.unarchiveAgentState(agentId) await this.ensureAgentLoaded(agentId) await this.interruptAgentIfRunning(agentId) @@ -6589,7 +6607,7 @@ export class Session { }) this.registerVoiceCallerContext?.(agentId, { - childAgentDefaultLabels: { ui: 'true' }, + childAgentDefaultLabels: {}, allowCustomCwd: false, enableVoiceTools: true, }) diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 22e657e25..9fd82fcaa 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -45,7 +45,7 @@ function makeAgent(input: { sessionId: null, }, title: null, - labels: { ui: 'true' }, + labels: {}, requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: null, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 483a02523..f57845f90 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -97,7 +97,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", - labels: { ui: "true" }, timeline: [ { type: "assistant_message", @@ -128,7 +127,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { ); }); - it("skips push notifications for non-UI agents", () => { + it("sends push notifications regardless of UI label presence", () => { const { server } = createServer({ getAgent: vi.fn(() => ({ config: { title: null }, @@ -150,6 +149,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { reason: "finished", }); - expect(pushMocks.sendPush).not.toHaveBeenCalled(); + expect(pushMocks.sendPush).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 39a0ba68a..81bef7464 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1074,16 +1074,6 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); const agent = this.agentManager.getAgent(params.agentId); - if (agent?.labels?.ui !== "true") { - this.logger.debug( - { - agentId: params.agentId, - labels: agent?.labels ?? null, - }, - "Skipping attention notification for non-UI agent" - ); - return; - } const notification = buildAgentAttentionNotificationPayload({ reason: params.reason, serverId: this.serverId,