feat: carry archive UI label hard cut updates
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("client activity tracking", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
async function createUiAgent(params: {
|
||||
async function createAgent(params: {
|
||||
client: DaemonClient;
|
||||
title: string;
|
||||
}): Promise<AgentSnapshotPayload> {
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -1808,35 +1808,7 @@ export class Session {
|
||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
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<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)
|
||||
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(
|
||||
@@ -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<WorkspaceDescriptorPayload[]> {
|
||||
const agents = await this.listAgentPayloads({
|
||||
labels: { ui: 'true' },
|
||||
})
|
||||
const agents = await this.listAgentPayloads()
|
||||
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>()
|
||||
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>()
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user