mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
* 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
352 lines
9.1 KiB
TypeScript
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"),
|
|
});
|
|
});
|
|
});
|