Merge branch 'archive-ui-label-hard-cut-impl'

# Conflicts:
#	packages/app/src/screens/agent/agent-ready-screen.tsx
This commit is contained in:
Mohamed Boudra
2026-03-04 10:39:55 +07:00
25 changed files with 408 additions and 158 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ export default function HostIndexRoute() {
const visibleAgents = sessionAgents const visibleAgents = sessionAgents
? Array.from(sessionAgents.values()).filter( ? Array.from(sessionAgents.values()).filter(
(agent) => !agent.archivedAt && agent.labels.ui === "true" (agent) => !agent.archivedAt
) )
: []; : [];
visibleAgents.sort( visibleAgents.sort(
@@ -1550,7 +1550,6 @@ function SessionProviderInternal({
} }
return client.createAgent({ return client.createAgent({
config, config,
labels: { ui: "true" },
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(git ? { git } : {}), ...(git ? { git } : {}),
@@ -74,7 +74,7 @@ export function useAllAgentsList(options?: {
serverId, serverId,
serverLabel, serverLabel,
}); });
if (aggregated.labels.ui !== "true") { if (aggregated.archivedAt) {
continue; continue;
} }
list.push(aggregated); list.push(aggregated);
@@ -743,7 +743,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({ expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { labels: { ui: "true" } }, filter: { includeArchived: true },
subscribe: { subscriptionId: "app:srv_test" }, subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 }, page: { limit: 200 },
}); });
+1 -1
View File
@@ -1113,7 +1113,7 @@ export class HostRuntimeStore {
controller.markAgentDirectorySyncLoading(); controller.markAgentDirectorySyncLoading();
try { try {
const payload = await client.fetchAgents({ const payload = await client.fetchAgents({
filter: input.filter ?? { labels: { ui: "true" }, includeArchived: true }, filter: input.filter ?? { includeArchived: true },
...(input.subscribe ? { subscribe: input.subscribe } : {}), ...(input.subscribe ? { subscribe: input.subscribe } : {}),
...(input.page ? { page: input.page } : {}), ...(input.page ? { page: input.page } : {}),
}); });
@@ -1017,7 +1017,6 @@ function DraftAgentScreenContent({
const imagesData = await encodeImages(images) const imagesData = await encodeImages(images)
const result = await createAgentClient.createAgent({ const result = await createAgentClient.createAgent({
config, config,
labels: { ui: 'true' },
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
clientMessageId: attempt.clientMessageId, clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -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<string, Agent>([
[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<string, Agent>([[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<string, Agent>([
[olderActivity.id, olderActivity],
[newerActivity.id, newerActivity],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId,
});
expect(result.visibleAgents.map((agent) => agent.id)).toEqual([
"newer-activity",
"older-activity",
]);
});
});
@@ -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<string, Agent> | undefined;
workspaceId: string;
}): {
visibleAgents: Agent[];
lookupById: Map<string, Agent>;
} {
const { sessionAgents, workspaceId } = input;
if (!sessionAgents || !workspaceId) {
return {
visibleAgents: [],
lookupById: new Map<string, Agent>(),
};
}
const lookupById = new Map<string, Agent>();
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<string, Agent>;
}): boolean {
if (!input.agentId.trim()) {
return false;
}
if (!input.agentsHydrated) {
return true;
}
return input.workspaceAgentLookup.has(input.agentId);
}
@@ -257,7 +257,6 @@ export function WorkspaceDraftAgentTab({
const imagesData = await encodeImages(images); const imagesData = await encodeImages(images);
const result = await client.createAgent({ const result = await client.createAgent({
config, config,
labels: { ui: "true" },
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
clientMessageId: attempt.clientMessageId, clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -78,6 +78,10 @@ import {
resolveWorkspaceHeader, resolveWorkspaceHeader,
shouldRenderMissingWorkspaceDescriptor, shouldRenderMissingWorkspaceDescriptor,
} from "@/screens/workspace/workspace-header-source"; } from "@/screens/workspace/workspace-header-source";
import {
canOpenAgentTabFromRoute,
deriveWorkspaceAgentVisibility,
} from "@/screens/workspace/workspace-agent-visibility";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -196,17 +200,6 @@ function resolveTabAvailability(input: {
return input.terminalIds.has(input.tab.terminalId) ? "available" : "invalid"; 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({ export function WorkspaceScreen({
serverId, serverId,
workspaceId, workspaceId,
@@ -243,24 +236,15 @@ function WorkspaceScreenContent({
const sessionAgents = useSessionStore( const sessionAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.agents (state) => state.sessions[normalizedServerId]?.agents
); );
const workspaceAgents = useMemo(() => { const workspaceAgentVisibility = useMemo(
if (!sessionAgents || !normalizedWorkspaceId) { () =>
return [] as Agent[]; deriveWorkspaceAgentVisibility({
} sessionAgents,
workspaceId: normalizedWorkspaceId,
const collected: Agent[] = []; }),
for (const agent of sessionAgents.values()) { [normalizedWorkspaceId, sessionAgents]
if (agent.archivedAt) { );
continue; const workspaceAgents = workspaceAgentVisibility.visibleAgents;
}
if ((trimNonEmpty(agent.cwd) ?? "") !== normalizedWorkspaceId) {
continue;
}
collected.push(agent);
}
return sortAgentsByCreatedAtDescending(collected);
}, [normalizedWorkspaceId, sessionAgents]);
const terminalsQueryKey = useMemo( const terminalsQueryKey = useMemo(
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const, () => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
@@ -487,13 +471,7 @@ function WorkspaceScreenContent({
return () => handler.remove(); return () => handler.remove();
}, [closeToAgent, isExplorerOpen]); }, [closeToAgent, isExplorerOpen]);
const agentsById = useMemo(() => { const agentsById = workspaceAgentVisibility.lookupById;
const map = new Map<string, Agent>();
for (const agent of workspaceAgents) {
map.set(agent.id, agent);
}
return map;
}, [workspaceAgents]);
const terminalIds = useMemo(() => { const terminalIds = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -580,7 +558,13 @@ function WorkspaceScreenContent({
if (normalized.startsWith("agent_")) { if (normalized.startsWith("agent_")) {
const agentId = normalized.slice("agent_".length).trim(); const agentId = normalized.slice("agent_".length).trim();
if (agentId) { if (agentId) {
if (areWorkspaceAgentsHydrated && !agentsById.has(agentId)) { if (
!canOpenAgentTabFromRoute({
agentId,
agentsHydrated: areWorkspaceAgentsHydrated,
workspaceAgentLookup: agentsById,
})
) {
return; return;
} }
const tabId = openOrFocusTab({ const tabId = openOrFocusTab({
-1
View File
@@ -83,7 +83,6 @@ export function createCli(): Command {
.option('--image <path>', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, []) .option('--image <path>', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, [])
.option('--cwd <path>', 'Working directory (default: current)') .option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--label <key=value>', '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 <schema>', 'Output JSON matching the provided schema file path or inline JSON schema') .option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
-1
View File
@@ -44,7 +44,6 @@ export function createAgentCommand(): Command {
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)') .option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--cwd <path>', 'Working directory (default: current)') .option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--label <key=value>', '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 <schema>', 'Output JSON matching the provided schema file path or inline JSON schema') .option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
+1 -8
View File
@@ -43,7 +43,6 @@ export interface AgentRunOptions extends CommandOptions {
image?: string[] image?: string[]
cwd?: string cwd?: string
label?: string[] label?: string[]
ui?: boolean
outputSchema?: string outputSchema?: string
} }
@@ -265,9 +264,7 @@ export async function runRunCommand(
} }
: undefined : undefined
// Build labels from --label and --ui flags // Build labels from --label flags
// --ui is syntactic sugar for --label ui=true
// If explicit --label ui=... is provided, it takes precedence over --ui
const labels: Record<string, string> = {} const labels: Record<string, string> = {}
if (options.label) { if (options.label) {
for (const labelStr of options.label) { for (const labelStr of options.label) {
@@ -285,10 +282,6 @@ export async function runRunCommand(
labels[key] = value 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) { if (outputSchema) {
let structuredAgent: AgentSnapshotPayload | null = null let structuredAgent: AgentSnapshotPayload | null = null
+12
View File
@@ -61,6 +61,7 @@ try {
assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option') 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('--host'), 'help should mention --host option')
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument') assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
assert(!result.stdout.includes('--ui'), 'help should not mention removed --ui option')
console.log('✓ run --help shows options\n') console.log('✓ run --help shows options\n')
} }
@@ -206,6 +207,17 @@ try {
assert(result.stdout.includes('run'), 'help should mention run command') assert(result.stdout.includes('run'), 'help should mention run command')
console.log('✓ paseo --help shows run command\n') 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 { } finally {
// Clean up temp directory // Clean up temp directory
await rm(paseoHome, { recursive: true, force: true }) await rm(paseoHome, { recursive: true, force: true })
+1 -1
View File
@@ -89,7 +89,7 @@ try {
{ {
console.log('Test 5: agent update accepts multi-label syntax') console.log('Test 5: agent update accepts multi-label syntax')
const result = 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 const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --label flag') assert(!output.includes('unknown option'), 'should accept --label flag')
assert(!output.includes('error: option'), 'should not have option parsing error') assert(!output.includes('error: option'), 'should not have option parsing error')
@@ -114,7 +114,7 @@ async function main(): Promise<void> {
enableVoiceTools: false, enableVoiceTools: false,
resolveSpeakHandler, resolveSpeakHandler,
resolveCallerContext: () => ({ resolveCallerContext: () => ({
childAgentDefaultLabels: { ui: "true" }, childAgentDefaultLabels: { source: "voice-smoke" },
allowCustomCwd: true, allowCustomCwd: true,
enableVoiceTools: true, enableVoiceTools: true,
}), }),
@@ -156,7 +156,7 @@ async function main(): Promise<void> {
try { try {
const created = await agentManager.createAgent(config, agentId, { 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"); logger.info({ provider: opts.provider, agentId: created.id }, "Created smoke agent");
@@ -838,7 +838,7 @@ describe('DaemonClient', () => {
await connectPromise await connectPromise
const promise = client.fetchAgents({ const promise = client.fetchAgents({
filter: { labels: { ui: 'true' } }, filter: { labels: { surface: 'workspace' } },
sort: [ sort: [
{ key: 'status_priority', direction: 'asc' }, { key: 'status_priority', direction: 'asc' },
{ key: 'created_at', direction: 'desc' }, { key: 'created_at', direction: 'desc' },
@@ -168,7 +168,7 @@ type ManagedAgentBase = {
*/ */
internal?: boolean; internal?: boolean;
/** /**
* User-defined labels for categorizing agents (e.g., { ui: "true" }). * User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/ */
labels: Record<string, string>; labels: Record<string, string>;
}; };
@@ -197,7 +197,7 @@ describe("create_agent MCP tool", () => {
agentStorage, agentStorage,
callerAgentId: "voice-agent", callerAgentId: "voice-agent",
resolveCallerContext: () => ({ resolveCallerContext: () => ({
childAgentDefaultLabels: { ui: "true" }, childAgentDefaultLabels: { source: "voice" },
allowCustomCwd: true, allowCustomCwd: true,
}), }),
logger, logger,
@@ -216,7 +216,7 @@ describe("create_agent MCP tool", () => {
cwd: subdir, cwd: subdir,
}), }),
undefined, undefined,
{ labels: { ui: "true" } } { labels: { source: "voice" } }
); );
await rm(baseDir, { recursive: true, force: true }); await rm(baseDir, { recursive: true, force: true });
}); });
@@ -59,7 +59,7 @@ describe("client activity tracking", () => {
return client; return client;
} }
async function createUiAgent(params: { async function createAgent(params: {
client: DaemonClient; client: DaemonClient;
title: string; title: string;
}): Promise<AgentSnapshotPayload> { }): Promise<AgentSnapshotPayload> {
@@ -68,7 +68,7 @@ describe("client activity tracking", () => {
model: TEST_MODEL, model: TEST_MODEL,
cwd: TEST_CWD, cwd: TEST_CWD,
title: params.title, 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 () => { test("no notification when actively focused on agent", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Active Focus Test", title: "Active Focus Test",
}); });
@@ -129,9 +129,9 @@ describe("client activity tracking", () => {
test("notification when focused on different agent", async () => { test("notification when focused on different agent", async () => {
client1 = await createClient(); 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 // User is looking at agent2, not agent1
client1.sendHeartbeat({ 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 () => { test("no notification when app is not visible but activity is recent (user just switched tabs)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "App Hidden Test", 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 () => { test("notification when activity is stale (user walked away for 2+ minutes)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Stale Activity Test", title: "Stale Activity Test",
}); });
@@ -210,7 +210,7 @@ describe("client activity tracking", () => {
test("notification when no heartbeat received (legacy/new client)", async () => { test("notification when no heartbeat received (legacy/new client)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "No Heartbeat Test", title: "No Heartbeat Test",
}); });
@@ -236,7 +236,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); client1 = await createClient();
client2 = await createClient(); client2 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Two Tabs Test", title: "Two Tabs Test",
}); });
@@ -277,7 +277,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); client1 = await createClient();
client2 = await createClient(); client2 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Both Inactive Test", title: "Both Inactive Test",
}); });
@@ -325,7 +325,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Web Active Test", title: "Web Active Test",
}); });
@@ -366,7 +366,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Mobile Active Test", title: "Mobile Active Test",
}); });
@@ -407,7 +407,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Web Stale Test", title: "Web Stale Test",
}); });
@@ -448,9 +448,9 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile 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 // Web: active but looking at agent2
client1.sendHeartbeat({ client1.sendHeartbeat({
@@ -489,7 +489,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Both Inactive Test", title: "Both Inactive Test",
}); });
@@ -539,7 +539,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile - no heartbeat client2 = await createClient(); // mobile - no heartbeat
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Mobile No Heartbeat Test", 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 () => { test("no notification when app not visible but activity recent (switched tabs recently)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Tab Switch Test", title: "Tab Switch Test",
}); });
@@ -603,7 +603,7 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await createUiAgent({ const agent = await createAgent({
client: client1, client: client1,
title: "Both Recent Activity Test", title: "Both Recent Activity Test",
}); });
@@ -236,7 +236,7 @@ describe("daemon client E2E", () => {
} }
}, 60000); }, 60000);
test("rejects send_agent_message for archived agents", async () => { test("send_agent_message auto-unarchives archived agents", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
try { try {
const created = await ctx.client.createAgent({ const created = await ctx.client.createAgent({
@@ -247,13 +247,67 @@ describe("daemon client E2E", () => {
}); });
await ctx.client.archiveAgent(created.id); await ctx.client.archiveAgent(created.id);
await expect( await ctx.client.sendMessage(created.id, "Say hello and nothing else");
ctx.client.sendMessage(created.id, "Say hello and nothing else") const finalState = await ctx.client.waitForFinish(created.id, 120000);
).rejects.toThrow("archived"); expect(finalState.status).toBe("idle");
const refreshed = await ctx.client.fetchAgent(created.id);
expect(refreshed).not.toBeNull();
expect(refreshed?.agent.archivedAt).toBeNull();
} finally { } finally {
rmSync(cwd, { recursive: true, force: true }); 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 () => { test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
+77 -59
View File
@@ -1808,35 +1808,7 @@ export class Session {
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> { private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`)
if (this.agentManager.getAgent(agentId)) { const { archivedAt, archivedRecord } = await this.archiveAgentState(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)
this.emit({ this.emit({
type: 'agent_archived', type: 'agent_archived',
@@ -1854,9 +1826,75 @@ export class Session {
}) })
} }
private async getArchivedAt(agentId: string): Promise<string | null> { 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<boolean> {
const record = await this.agentStorage.get(agentId) 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<void> {
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( 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)` : ''}` `Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
) )
await this.unarchiveAgentState(agentId)
try { try {
await this.ensureAgentLoaded(agentId) await this.ensureAgentLoaded(agentId)
} catch (error) { } catch (error) {
@@ -2450,16 +2490,6 @@ export class Session {
return 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 { try {
await this.interruptAgentIfRunning(agentId) await this.interruptAgentIfRunning(agentId)
} catch (error) { } catch (error) {
@@ -2645,7 +2675,9 @@ export class Session {
`Resuming agent ${handle.sessionId} (${handle.provider})` `Resuming agent ${handle.sessionId} (${handle.provider})`
) )
try { try {
await this.unarchiveAgentByHandle(handle)
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides) const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides)
await this.unarchiveAgentState(snapshot.id)
await this.agentManager.hydrateTimelineFromProvider(snapshot.id) await this.agentManager.hydrateTimelineFromProvider(snapshot.id)
await this.forwardAgentUpdate(snapshot) await this.forwardAgentUpdate(snapshot)
const timelineSize = this.agentManager.getTimeline(snapshot.id).length const timelineSize = this.agentManager.getTimeline(snapshot.id).length
@@ -2686,6 +2718,7 @@ export class Session {
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`) this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`)
try { try {
await this.unarchiveAgentState(agentId)
let snapshot: ManagedAgent let snapshot: ManagedAgent
const existing = this.agentManager.getAgent(agentId) const existing = this.agentManager.getAgent(agentId)
if (existing) { if (existing) {
@@ -5318,9 +5351,7 @@ export class Session {
} }
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> { private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
const agents = await this.listAgentPayloads({ const agents = await this.listAgentPayloads()
labels: { ui: 'true' },
})
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>() const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>()
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>() const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>()
@@ -6021,20 +6052,7 @@ export class Session {
try { try {
const agentId = resolved.agentId const agentId = resolved.agentId
await this.unarchiveAgentState(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.ensureAgentLoaded(agentId) await this.ensureAgentLoaded(agentId)
await this.interruptAgentIfRunning(agentId) await this.interruptAgentIfRunning(agentId)
@@ -6589,7 +6607,7 @@ export class Session {
}) })
this.registerVoiceCallerContext?.(agentId, { this.registerVoiceCallerContext?.(agentId, {
childAgentDefaultLabels: { ui: 'true' }, childAgentDefaultLabels: {},
allowCustomCwd: false, allowCustomCwd: false,
enableVoiceTools: true, enableVoiceTools: true,
}) })
@@ -45,7 +45,7 @@ function makeAgent(input: {
sessionId: null, sessionId: null,
}, },
title: null, title: null,
labels: { ui: 'true' }, labels: {},
requiresAttention: input.requiresAttention ?? false, requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null, attentionReason: input.attentionReason ?? null,
attentionTimestamp: null, attentionTimestamp: null,
@@ -97,7 +97,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
getAgent: vi.fn(() => ({ getAgent: vi.fn(() => ({
config: { title: null }, config: { title: null },
cwd: "/tmp/worktree", cwd: "/tmp/worktree",
labels: { ui: "true" },
timeline: [ timeline: [
{ {
type: "assistant_message", 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({ const { server } = createServer({
getAgent: vi.fn(() => ({ getAgent: vi.fn(() => ({
config: { title: null }, config: { title: null },
@@ -150,6 +149,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished", reason: "finished",
}); });
expect(pushMocks.sendPush).not.toHaveBeenCalled(); expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -1074,16 +1074,6 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state); const allStates = clientEntries.map((e) => e.state);
const agent = this.agentManager.getAgent(params.agentId); 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({ const notification = buildAgentAttentionNotificationPayload({
reason: params.reason, reason: params.reason,
serverId: this.serverId, serverId: this.serverId,