mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +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
157 lines
5.0 KiB
TypeScript
157 lines
5.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
|
import { render } from "../../output/index.js";
|
|
import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
|
|
|
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
|
|
|
function createPagingClient() {
|
|
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
|
|
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
|
|
provider: "codex",
|
|
title: `agent ${n}`,
|
|
status: "closed",
|
|
archivedAt: null,
|
|
cwd: "/tmp/project",
|
|
createdAt: `2026-10-0${n}T00:00:00.000Z`,
|
|
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
|
|
labels: {},
|
|
model: null,
|
|
lastUserMessageAt: null,
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
}));
|
|
|
|
// The fake daemon pages two agents at a time, newest first by the requested key
|
|
// (updated_at by default), and agent 1 is updated right after the first page is served.
|
|
const fetchAgents = async (options?: {
|
|
sort?: { key: string }[];
|
|
page?: { cursor?: string };
|
|
}) => {
|
|
const key: "createdAt" | "updatedAt" =
|
|
options?.sort?.[0]?.key === "created_at" ? "createdAt" : "updatedAt";
|
|
const cursor = options?.page?.cursor;
|
|
const ordered = daemonAgents
|
|
.filter((agent) => cursor === undefined || agent[key] < cursor)
|
|
.sort((left, right) => right[key].localeCompare(left[key]));
|
|
const page = ordered.slice(0, 2);
|
|
const hasMore = ordered.length > 2;
|
|
if (cursor === undefined) {
|
|
daemonAgents[2]!.updatedAt = "2026-10-09T00:00:00.000Z";
|
|
}
|
|
return {
|
|
entries: page.map((agent) => ({ agent })),
|
|
pageInfo: { nextCursor: hasMore ? page[page.length - 1]![key] : null },
|
|
};
|
|
};
|
|
return { fetchAgents, close: async () => {} };
|
|
}
|
|
|
|
describe("buildAgentLsFetchOptions", () => {
|
|
it("fetches active agents by default", () => {
|
|
expect(buildAgentLsFetchOptions({})).toEqual({
|
|
scope: "active",
|
|
});
|
|
});
|
|
|
|
it("keeps label and thinking filters within the active scope", () => {
|
|
expect(
|
|
buildAgentLsFetchOptions({
|
|
label: ["surface=workspace"],
|
|
thinking: " medium ",
|
|
}),
|
|
).toEqual({
|
|
scope: "active",
|
|
filter: {
|
|
labels: { surface: "workspace" },
|
|
thinkingOptionId: "medium",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("asks the daemon for internal agents only with --internal", () => {
|
|
expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({
|
|
filter: { includeBackground: true },
|
|
});
|
|
});
|
|
|
|
it("fetches global non-archived agents for -g", () => {
|
|
expect(buildAgentLsFetchOptions({ global: true })).toEqual({});
|
|
});
|
|
|
|
it("includes archived and background agents with -a", () => {
|
|
expect(buildAgentLsFetchOptions({ all: true })).toEqual({
|
|
filter: {
|
|
includeArchived: true,
|
|
includeBackground: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("fetches all global agents for -a -g", () => {
|
|
expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({
|
|
filter: {
|
|
includeArchived: true,
|
|
includeBackground: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("applies filters to global queries", () => {
|
|
expect(
|
|
buildAgentLsFetchOptions({
|
|
global: true,
|
|
label: ["surface=workspace"],
|
|
thinking: " medium ",
|
|
}),
|
|
).toEqual({
|
|
filter: {
|
|
labels: { surface: "workspace" },
|
|
thinkingOptionId: "medium",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("runLsCommand", () => {
|
|
it("lists agents on every page, including one updated during the listing", async () => {
|
|
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
|
|
createPagingClient(),
|
|
);
|
|
|
|
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
|
});
|
|
|
|
it("marks background agents in a BACKGROUND column only for inclusive listings", async () => {
|
|
const client = createPagingClient();
|
|
const fetchAgents: typeof client.fetchAgents = async (options) => {
|
|
const page = await client.fetchAgents(options);
|
|
return {
|
|
...page,
|
|
entries: page.entries.map(({ agent }) => ({
|
|
agent: agent.title === "agent 2" ? { ...agent, background: true } : agent,
|
|
})),
|
|
};
|
|
};
|
|
const connect = async () => ({ ...client, fetchAgents });
|
|
|
|
const inclusive = await listAgents({ daemonTarget, all: true, global: true }, connect);
|
|
const rows = render(inclusive, { noColor: true }).split("\n");
|
|
expect(rows[0]).toMatch(/BACKGROUND\s*$/);
|
|
expect(rows.slice(1).map((row) => row.trim().split(/\s+/).at(-1))).toEqual(["-", "yes", "-"]);
|
|
|
|
const ordinary = await listAgents({ daemonTarget, global: true }, connect);
|
|
expect(render(ordinary, { noColor: true })).not.toContain("BACKGROUND");
|
|
});
|
|
});
|