mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
Give plugins an agent's background work count and a background flag on hooks (#9362)
* feat(plugins): give plugins the agent's sidebar indicator and background flag Plugin UI re-derived agent state from status and attention fields and could not see background work, so it showed agents as done while a shell they left running was still live. Agent snapshots now carry the sidebar's own bucket as indicator/indicatorLabel, computed by the same function the sidebar uses so the two cannot drift. Lifecycle hooks and the agent.open.before request now carry `background`, so a hook can skip a hidden helper agent without a lookup. A hook cannot change it; an omitted value means unchanged, like metadata. `finished` attention still means the turn finished; background work is the indicator's concern. * fix(plugins): keep session-open hooks that omit background typechecking A hook that returns its own object instead of spreading the request has no background field; the result type now treats it as optional, like metadata, matching the runtime where an omitted value means unchanged. * refactor(plugins): give agent snapshots the background work count instead of an indicator * fix(plugins): keep legacy session-open hooks that omit background typechecking
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { fr } from "@/i18n/resources/fr";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
createPluginAgentSnapshot,
|
||||
createPluginWorkspaceSnapshot,
|
||||
@@ -109,6 +110,26 @@ describe("sidebar entity snapshots", () => {
|
||||
).toBe(after);
|
||||
});
|
||||
|
||||
it("carries the background work count the host publishes for the agent", () => {
|
||||
// Round-trip through the wire payload, the way the app receives each agent update.
|
||||
const published = (backgroundWork: Agent["backgroundWork"]) =>
|
||||
createPluginAgentSnapshot(
|
||||
normalizeAgentSnapshot({ ...projectAgentSnapshot(agent), backgroundWork }, agent.serverId),
|
||||
workspace.id,
|
||||
).backgroundWork;
|
||||
expect(published(undefined)).toEqual({ count: 0 });
|
||||
expect(published({ count: 2 })).toEqual({ count: 2 });
|
||||
expect(published({ count: 0 })).toEqual({ count: 0 });
|
||||
expect(Object.isFrozen(published({ count: 1 }))).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a background agent", () => {
|
||||
expect(createPluginAgentSnapshot(agent, workspace.id).background).toBe(false);
|
||||
expect(createPluginAgentSnapshot({ ...agent, background: true }, workspace.id).background).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads projects without requiring a workspace", () => {
|
||||
const project = {
|
||||
projectId: "empty",
|
||||
|
||||
@@ -86,6 +86,8 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
|
||||
workspaceId,
|
||||
provider: agent.provider,
|
||||
status: agent.status,
|
||||
backgroundWork: Object.freeze({ count: agent.backgroundWork?.count ?? 0 }),
|
||||
background: agent.background ?? false,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastActivityAt: agent.lastActivityAt.toISOString(),
|
||||
|
||||
@@ -51,6 +51,8 @@ const agent: PluginAgentSnapshot = {
|
||||
workspaceId: workspace.id,
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
backgroundWork: { count: 0 },
|
||||
background: false,
|
||||
createdAt: "2026-08-16T10:00:00.000Z",
|
||||
updatedAt: "2026-08-16T10:00:00.000Z",
|
||||
lastActivityAt: "2026-08-16T10:00:00.000Z",
|
||||
|
||||
@@ -370,6 +370,17 @@ describe("plugin agent tools SDK boundary", () => {
|
||||
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
} };
|
||||
});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
const background: boolean = request.background;
|
||||
if (background) return;
|
||||
// A hook written before background and metadata returns its own object without them.
|
||||
return {
|
||||
agentId: request.agentId, workspaceId: request.workspaceId, provider: request.provider,
|
||||
cwd: request.cwd, reason: request.reason, purpose: request.purpose, title: request.title,
|
||||
labels: request.labels, tools: request.tools, env: request.env,
|
||||
mcpServers: request.mcpServers,
|
||||
};
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`),
|
||||
@@ -429,6 +440,12 @@ describe("lifecycle hook SDK types", () => {
|
||||
// @ts-expect-error A prepare handler must return the whole request or undefined.
|
||||
server.addHook("agent.create.before", async () => ({ invalid: true }));
|
||||
server.before("agent.session_open", ({ request }) => request);
|
||||
server.before("agent.session_open", ({ request }) => ({
|
||||
agentId: request.agentId, workspaceId: request.workspaceId, provider: request.provider,
|
||||
cwd: request.cwd, reason: request.reason, purpose: request.purpose, title: request.title,
|
||||
labels: request.labels, metadata: request.metadata, tools: request.tools, env: request.env,
|
||||
mcpServers: request.mcpServers,
|
||||
}));
|
||||
server.on("agent.turn_ended", () => {});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ export interface PluginAgentSnapshot {
|
||||
readonly workspaceId: string;
|
||||
readonly provider: string;
|
||||
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
/** Shells and monitors the agent left running after its turn. 0 when none are live. */
|
||||
readonly backgroundWork: { readonly count: number };
|
||||
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
|
||||
readonly background: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly lastActivityAt: string;
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface PluginHookAgent {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
|
||||
readonly background: boolean;
|
||||
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
@@ -83,6 +85,8 @@ export interface PluginSessionOpenRequest {
|
||||
reason: "create" | "resume" | "refresh" | "import";
|
||||
purpose: "interactive" | "history";
|
||||
readonly title: string | null;
|
||||
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
|
||||
readonly background: boolean;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
tools: AgentToolDefinition[];
|
||||
@@ -134,7 +138,9 @@ export interface PluginHookRequests {
|
||||
|
||||
/** What a hook may return. An omitted `metadata` stays as it was. */
|
||||
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
|
||||
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
|
||||
// Read-only fields added after the first SDK stay optional in a result; omitted means unchanged.
|
||||
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata" | "background"> & {
|
||||
readonly background?: boolean;
|
||||
metadata?: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
};
|
||||
@@ -184,7 +190,7 @@ export interface PluginLifecycleRegistration {
|
||||
handler: (
|
||||
input: { request: PluginBeforeRequests[Name] },
|
||||
context: PluginHookContext,
|
||||
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
|
||||
) => PluginBeforeResults[Name] | void | Promise<PluginBeforeResults[Name] | void>,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
@@ -229,6 +235,9 @@ export type PluginLifecycleEvents = {
|
||||
export type PluginBeforeRequests = {
|
||||
[Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]];
|
||||
};
|
||||
type PluginBeforeResults = {
|
||||
[Name in keyof typeof legacyRequests]: PluginHookRequestResults[(typeof legacyRequests)[Name]];
|
||||
};
|
||||
|
||||
/** @internal SDK adapter for the daemon's hook registry. */
|
||||
export function createLifecycleRegistration(
|
||||
|
||||
@@ -13225,6 +13225,7 @@ test.each([
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: null,
|
||||
background: false,
|
||||
metadata: {},
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
|
||||
@@ -1566,6 +1566,7 @@ export class AgentManager {
|
||||
purpose: "interactive",
|
||||
workspaceId: options.workspaceId ?? null,
|
||||
title: options.initialTitle ?? storedConfig.title ?? null,
|
||||
background: options.background,
|
||||
labels: options.labels ?? {},
|
||||
metadata: options.metadata ?? {},
|
||||
},
|
||||
@@ -1700,6 +1701,7 @@ export class AgentManager {
|
||||
purpose,
|
||||
workspaceId,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
background: options?.background,
|
||||
...agentEntityData(record ?? options),
|
||||
},
|
||||
);
|
||||
@@ -1881,6 +1883,7 @@ export class AgentManager {
|
||||
purpose: "interactive",
|
||||
workspaceId: existing.workspaceId,
|
||||
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
||||
background: existing.background,
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
},
|
||||
@@ -6101,6 +6104,7 @@ export class AgentManager {
|
||||
purpose: PluginSessionOpenRequest["purpose"];
|
||||
workspaceId?: string | null;
|
||||
title?: string | null;
|
||||
background?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
},
|
||||
@@ -6120,6 +6124,7 @@ export class AgentManager {
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
background: opening?.background === true,
|
||||
...agentEntityData(opening),
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
|
||||
@@ -323,6 +323,88 @@ export default function contribute(server) {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("hooks tell a background agent from a normal one", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-background-hooks-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "background-hooks",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
const log = (hook, agentId, background) =>
|
||||
console.log(JSON.stringify({ hook, agentId, background }));
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
log("agent.open.before", request.agentId, request.background);
|
||||
return { ...request, background: request.background };
|
||||
});
|
||||
for (const hook of ["agent.create.after", "agent.turn.before", "agent.turn.after", "agent.archive.after"]) {
|
||||
server.addHook(hook, async ({ agent }) => log(hook, agent.id, agent.background));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
const workspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
});
|
||||
const workspaceId = workspace.workspace!.id;
|
||||
const helper = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId,
|
||||
background: true,
|
||||
});
|
||||
const visible = await client.createAgent({ provider: "claude", cwd: directory, workspaceId });
|
||||
expect(helper.background).toBe(true);
|
||||
for (const agent of [helper, visible]) {
|
||||
await client.sendMessage(agent.id, "hello");
|
||||
await client.waitForFinish(agent.id, 10_000);
|
||||
await client.archiveAgent(agent.id);
|
||||
}
|
||||
const hooks = [
|
||||
"agent.open.before",
|
||||
"agent.create.after",
|
||||
"agent.turn.before",
|
||||
"agent.turn.after",
|
||||
"agent.archive.after",
|
||||
];
|
||||
const seen = (logs: { message: string }[], agentId: string) =>
|
||||
logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message))
|
||||
.filter((entry) => entry.agentId === agentId)
|
||||
.map(({ hook, background }) => ({ hook, background }));
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await client.getPluginLogs("background-hooks");
|
||||
return {
|
||||
helper: new Set(seen(logs, helper.id).map((entry) => JSON.stringify(entry))),
|
||||
visible: new Set(seen(logs, visible.id).map((entry) => JSON.stringify(entry))),
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
helper: new Set(hooks.map((hook) => JSON.stringify({ hook, background: true }))),
|
||||
visible: new Set(hooks.map((hook) => JSON.stringify({ hook, background: false }))),
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
|
||||
|
||||
@@ -160,7 +160,7 @@ test("a workspace create hook that omits metadata passes on what an earlier hook
|
||||
).resolves.toMatchObject({ metadata: {} });
|
||||
});
|
||||
|
||||
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
|
||||
test("a session-open hook that returns its own object without metadata or background leaves them unchanged", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.open.before", async ({ request }) => ({
|
||||
agentId: request.agentId,
|
||||
@@ -184,6 +184,7 @@ test("a session-open hook that returns its own object without metadata leaves me
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: null,
|
||||
background: true,
|
||||
labels: {},
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
tools: [],
|
||||
@@ -196,11 +197,12 @@ test("a session-open hook that returns its own object without metadata leaves me
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
env: { HOOK: "1" },
|
||||
background: true,
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
test.each([{ title: "Changed" }, { labels: { changed: "true" } }, { background: true }])(
|
||||
"session-open targeting metadata is read-only: %j",
|
||||
async (patch) => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
@@ -215,6 +217,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: "Original",
|
||||
background: false,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
|
||||
@@ -103,6 +103,8 @@ const beforeSchemas = {
|
||||
reason: z.enum(["create", "resume", "refresh", "import"]),
|
||||
purpose: z.enum(["interactive", "history"]),
|
||||
title: z.string().nullable(),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
|
||||
@@ -166,6 +168,7 @@ export function describeHookAgent(agent: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title?: string | null;
|
||||
background?: boolean;
|
||||
labels: Record<string, string>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}): PluginHookAgent {
|
||||
@@ -176,6 +179,7 @@ export function describeHookAgent(agent: {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title ?? null,
|
||||
background: agent.background === true,
|
||||
metadata: agent.metadata,
|
||||
};
|
||||
}
|
||||
@@ -219,6 +223,24 @@ export function publishAgentStream(
|
||||
}
|
||||
}
|
||||
|
||||
type SessionOpenRequest = z.infer<(typeof beforeSchemas)["agent.open.before"]>;
|
||||
|
||||
// Omitted `background` and `metadata` mean unchanged, so a hook returning its own object keeps working.
|
||||
function changesSessionOpenTarget(previous: SessionOpenRequest, next: SessionOpenRequest): boolean {
|
||||
return (
|
||||
previous.agentId !== next.agentId ||
|
||||
previous.workspaceId !== next.workspaceId ||
|
||||
previous.provider !== next.provider ||
|
||||
previous.cwd !== next.cwd ||
|
||||
previous.reason !== next.reason ||
|
||||
previous.purpose !== next.purpose ||
|
||||
previous.title !== next.title ||
|
||||
(next.background !== undefined && previous.background !== next.background) ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels) ||
|
||||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
|
||||
name: Name,
|
||||
input: PluginWireBeforeRequests[Name],
|
||||
@@ -228,20 +250,10 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
if (name === "agent.open.before") {
|
||||
const previous = beforeSchemas["agent.open.before"].parse(input);
|
||||
const next = beforeSchemas["agent.open.before"].parse(result);
|
||||
if (
|
||||
previous.agentId !== next.agentId ||
|
||||
previous.workspaceId !== next.workspaceId ||
|
||||
previous.provider !== next.provider ||
|
||||
previous.cwd !== next.cwd ||
|
||||
previous.reason !== next.reason ||
|
||||
previous.purpose !== next.purpose ||
|
||||
previous.title !== next.title ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels) ||
|
||||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
|
||||
) {
|
||||
if (changesSessionOpenTarget(previous, next)) {
|
||||
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
}
|
||||
return { ...result, metadata: previous.metadata };
|
||||
return { ...result, background: previous.background, metadata: previous.metadata };
|
||||
}
|
||||
if (name === "agent.create.before") {
|
||||
const previous = beforeSchemas["agent.create.before"].parse(input);
|
||||
|
||||
@@ -169,6 +169,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
title: z.string().nullable(),
|
||||
background: z.boolean(),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()),
|
||||
})
|
||||
.strict(),
|
||||
|
||||
Reference in New Issue
Block a user