Files
paseo/packages/app/src/utils/workspace-agent-activity.test.ts
T
Mohamed Boudra a880c9619d feat: background agents for hidden helpers (#8683)
* feat(server): add background agents

An agent can be created background in any workspace. It stays a durable,
addressable agent, but default discovery, workspace status and attention
leave it out. Effective background is agent.background ||
workspace.background.

- Protocol: `create_agent_request.background` now marks the agent itself
  and stays the workspace intent for a workspace the request creates.
  Snapshots carry `background`. The daemon advertises
  `features.backgroundAgents`. Clients advertise the `background_agents`
  capability.
- Daemon: the flag persists in the agent record, so restart and resume
  keep it. AgentDirectory owns visibility. Inclusive reads and
  subscriptions from sources without `background_agents` see only agents
  in background workspaces, because older apps would otherwise open a tab
  for each one.
- Inheritance: omitted on agent-scoped creation, children and new
  workspaces take the caller agent's background.
- MCP: create_agent accepts `background`.
- SDK: placing a background agent in an existing workspace, or under a
  caller, fails fast with "Update the host to use background agents." on
  older hosts.
- CLI: `ls -a` and `ls --background` add a BACKGROUND column.

* feat(app): hide background agents from tabs, track and status

* docs(plugins): point hidden helpers at background agents

* fix: keep background agents hidden across cache reload and connected hosts

* fix: keep remote creation working on older hosts and inherit foreign parents' background
2026-11-04 07:19:21 +07:00

352 lines
9.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { buildWorkspaceAgentActivityIndex } from "./workspace-agent-activity";
function agent(input: {
id: string;
workspaceId?: string;
status?: Agent["status"];
turn?: Agent["turn"];
updatedAt: string;
attentionTimestamp?: string | null;
requiresAttention?: boolean;
attentionReason?: Agent["attentionReason"];
pendingPermissionCount?: number;
archivedAt?: string | null;
parentAgentId?: string | null;
background?: boolean;
}): Agent {
return {
serverId: "host-a",
id: input.id,
provider: "codex",
status: input.status ?? "idle",
turn:
input.turn ??
(input.status === "running"
? {
phase: "open",
turnId: "turn-1",
startedAt: null,
cancellationRequestId: null,
}
: { phase: "idle", cancellationRequestId: null }),
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date(input.updatedAt),
lastUserMessageAt: null,
lastActivityAt: new Date(input.updatedAt),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: Array.from({ length: input.pendingPermissionCount ?? 0 }, (_, index) => ({
id: `permission-${index}`,
provider: "codex",
name: "shell",
kind: "tool",
input: {},
})),
persistence: null,
title: null,
cwd: "/repo",
workspaceId: input.workspaceId,
model: null,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
parentAgentId: input.parentAgentId ?? null,
background: input.background ?? false,
labels: {},
};
}
describe("workspace agent activity index", () => {
it("uses turn liveness for running while preserving protocol lifecycle states", () => {
const result = buildWorkspaceAgentActivityIndex(
new Map([
[
"open",
agent({
id: "open",
workspaceId: "workspace-open",
status: "idle",
turn: {
phase: "open",
turnId: null,
startedAt: null,
cancellationRequestId: null,
},
updatedAt: "2026-01-01T00:00:00.000Z",
}),
],
[
"idle-error",
agent({
id: "idle-error",
workspaceId: "workspace-error",
status: "error",
turn: { phase: "idle", cancellationRequestId: null },
updatedAt: "2026-01-01T00:00:00.000Z",
}),
],
]),
);
expect(result.get("workspace-open")?.status).toBe("running");
expect(result.get("workspace-error")?.status).toBe("failed");
});
it("keeps the latest active root agent for each workspace", () => {
const index = buildWorkspaceAgentActivityIndex(
new Map([
[
"older",
agent({
id: "older",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
[
"permission",
agent({
id: "permission",
workspaceId: "workspace-a",
updatedAt: "2026-06-01T10:01:00.000Z",
pendingPermissionCount: 1,
}),
],
[
"attention",
agent({
id: "attention",
workspaceId: "workspace-b",
updatedAt: "2026-06-01T10:00:00.000Z",
attentionTimestamp: "2026-06-01T10:02:00.000Z",
requiresAttention: true,
attentionReason: "finished",
}),
],
]),
);
expect(index).toEqual(
new Map([
[
"workspace-a",
{
agentId: "permission",
status: "needs_input",
enteredAt: new Date("2026-06-01T10:01:00.000Z"),
},
],
[
"workspace-b",
{
agentId: "attention",
status: "attention",
enteredAt: new Date("2026-06-01T10:02:00.000Z"),
},
],
]),
);
});
it("does not let a running background agent change root workspace activity", () => {
const index = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({ id: "root", workspaceId: "workspace-a", updatedAt: "2026-06-01T10:00:00.000Z" }),
],
[
"classifier",
agent({
id: "classifier",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:05:00.000Z",
background: true,
}),
],
]),
);
expect(index.get("workspace-a")).toEqual({
agentId: "root",
status: "done",
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
});
});
it("does not let archived or child agents change root workspace activity", () => {
const index = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
[
"child",
agent({
id: "child",
workspaceId: "workspace-a",
updatedAt: "2026-06-01T10:03:00.000Z",
pendingPermissionCount: 1,
parentAgentId: "root",
}),
],
[
"archived",
agent({
id: "archived",
workspaceId: "workspace-a",
updatedAt: "2026-06-01T10:04:00.000Z",
requiresAttention: true,
attentionReason: "error",
archivedAt: "2026-06-01T10:04:00.000Z",
}),
],
]),
);
expect(index.get("workspace-a")).toEqual({
agentId: "root",
status: "running",
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
});
});
it("treats a cross-workspace subagent as activity in its own workspace", () => {
const index = buildWorkspaceAgentActivityIndex(
new Map([
[
"parent",
agent({
id: "parent",
workspaceId: "workspace-a",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
[
"child",
agent({
id: "child",
workspaceId: "workspace-b",
status: "running",
updatedAt: "2026-06-01T10:03:00.000Z",
parentAgentId: "parent",
}),
],
]),
);
expect(index).toEqual(
new Map([
[
"workspace-a",
{
agentId: "parent",
status: "done",
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
},
],
[
"workspace-b",
{
agentId: "child",
status: "running",
enteredAt: new Date("2026-06-01T10:03:00.000Z"),
},
],
]),
);
});
it("preserves the activity index while the same agent remains in the same status", () => {
const previous = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
]),
);
const next = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:05:00.000Z",
}),
],
]),
previous,
);
expect(next).toBe(previous);
expect(next.get("workspace-a")?.enteredAt).toEqual(new Date("2026-06-01T10:00:00.000Z"));
});
it("records a new entry time when an agent changes status", () => {
const previous = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
]),
);
const next = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "idle",
updatedAt: "2026-06-01T10:05:00.000Z",
pendingPermissionCount: 1,
}),
],
]),
previous,
);
expect(next).not.toBe(previous);
expect(next.get("workspace-a")).toEqual({
agentId: "root",
status: "needs_input",
enteredAt: new Date("2026-06-01T10:05:00.000Z"),
});
});
});