mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
fix(plugins): an agent.open.before result may omit metadata
The result schema required metadata, so a plugin written before metadata existed that returns its own object instead of spreading the request failed validation and the session open failed, even for agents with no metadata. An omitted metadata now means unchanged and is filled back in from the request so later plugins in the chain still see it; a present value that differs is still rejected. The restart test now sets metadata through the PaseoApi handles and checks that agent.open.before sees the patched value after the restart.
This commit is contained in:
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
|
||||
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
|
||||
@@ -325,14 +326,39 @@ export default function contribute(server) {
|
||||
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-"));
|
||||
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-restart",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
|
||||
const { metadata, ...old } = request;
|
||||
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
|
||||
const firstApi = createPaseoApi(firstClient);
|
||||
let agentId: string;
|
||||
let workspaceId: string;
|
||||
try {
|
||||
await firstClient.connect();
|
||||
await firstClient.fetchAgents({ subscribe: {} });
|
||||
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await firstClient.installDirectoryPlugin(pluginDirectory);
|
||||
const workspace = await firstClient.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
|
||||
@@ -345,19 +371,18 @@ test("metadata set at creation and through setMetadata survives a daemon restart
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
|
||||
});
|
||||
agentId = agent.id;
|
||||
expect(
|
||||
await firstClient.setAgentMetadata(agentId, { "b.agent": null, "c.agent": true }),
|
||||
).toEqual({
|
||||
const agentHandle = firstApi.agents.ref(agentId);
|
||||
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
|
||||
await firstApi.workspaces.ref(workspaceId).setMetadata({
|
||||
"b.workspace": null,
|
||||
"c.workspace": [],
|
||||
});
|
||||
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
expect(
|
||||
await firstClient.setWorkspaceMetadata(workspaceId, {
|
||||
"b.workspace": null,
|
||||
"c.workspace": [],
|
||||
}),
|
||||
).toEqual({ "a.workspace": 1, "c.workspace": [] });
|
||||
} finally {
|
||||
await firstApi.dispose();
|
||||
await firstClient.close();
|
||||
await first.close();
|
||||
}
|
||||
@@ -373,6 +398,25 @@ test("metadata set at creation and through setMetadata survives a daemon restart
|
||||
"a.workspace": 1,
|
||||
"c.workspace": [],
|
||||
});
|
||||
await secondClient.fetchAgents({ subscribe: {} });
|
||||
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await secondClient.installDirectoryPlugin(pluginDirectory);
|
||||
await secondClient.refreshAgent(agentId);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await secondClient.getPluginLogs("metadata-restart");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toContainEqual({
|
||||
hook: "agent.open.before",
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
|
||||
});
|
||||
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await secondClient.close();
|
||||
await second.close();
|
||||
@@ -380,6 +424,7 @@ test("metadata set at creation and through setMetadata survives a daemon restart
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rm(pluginDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -101,6 +101,46 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
).rejects.toThrow("agent.open.before hooks can only change env");
|
||||
});
|
||||
|
||||
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.open.before", async ({ 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,
|
||||
tools: request.tools,
|
||||
env: { HOOK: "1" },
|
||||
mcpServers: request.mcpServers,
|
||||
}));
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.open.before",
|
||||
{
|
||||
agentId: "agent",
|
||||
workspaceId: null,
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
reason: "resume",
|
||||
purpose: "interactive",
|
||||
},
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
env: { HOOK: "1" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
"session-open targeting metadata is read-only: %j",
|
||||
async (patch) => {
|
||||
|
||||
@@ -104,7 +104,8 @@ const beforeSchemas = {
|
||||
purpose: z.enum(["interactive", "history"]),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
|
||||
tools: z.array(agentToolMetadataSchema),
|
||||
env: z.record(z.string(), z.string()),
|
||||
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
|
||||
@@ -236,10 +237,11 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
previous.purpose !== next.purpose ||
|
||||
previous.title !== next.title ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels) ||
|
||||
!isDeepStrictEqual(previous.metadata, next.metadata)
|
||||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
|
||||
) {
|
||||
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
}
|
||||
return { ...result, metadata: previous.metadata };
|
||||
}
|
||||
if (name === "agent.create.before") {
|
||||
const previous = beforeSchemas["agent.create.before"].parse(input);
|
||||
|
||||
Reference in New Issue
Block a user