From 21365c571b3a198640d854f54dd5852bd40f21d3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 03:42:12 +0100 Subject: [PATCH] fix(plugins): omitted metadata in a hook result means unchanged An agent.create.before hook that returned its own { config, env } dropped metadata that an earlier hook in the chain had added, because only the caller's original metadata was restored at the end. The omitted field is now carried forward at each hook handoff, as agent.open.before already does. The agent.open.before result type also required metadata, so an older TypeScript plugin that returned its own object no longer compiled even though the daemon accepted it. The result type now makes it optional. --- packages/plugin/src/boundaries.test.ts | 4 ++++ packages/plugin/src/server/lifecycle.ts | 9 ++++++- .../server/plugins/lifecycle/handlers.test.ts | 24 +++++++++++++++++++ .../src/server/plugins/lifecycle/index.ts | 2 ++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index b0834956d6..6d15281c6b 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -283,6 +283,10 @@ describe("lifecycle hook SDK types", () => { server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" })); server.addHook("workspace.create.before", async () => {}); server.addHook("agent.open.before", async () => {}); + server.addHook("agent.open.before", async ({ request }) => { + const { metadata, ...withoutMetadata } = request; + return withoutMetadata; + }); server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => { const kind: "completed" | "failed" | "canceled" = outcome.kind; // @ts-expect-error Notification payloads have no creation request. diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 256ff45c0f..89f1b62412 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -132,10 +132,17 @@ export interface PluginHookRequests { "workspace.create.before": Omit; } +/** What a hook may return: a field it leaves out stays as it was. */ +type PluginHookRequestResults = Omit & { + "agent.open.before": Omit & { + metadata?: Readonly>; + }; +}; + export type PluginHooks = { [Name in keyof PluginHookRequests]: { request: PluginHookRequests[Name]; - result: PluginHookRequests[Name]; + result: PluginHookRequestResults[Name]; }; } & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & { "worktree.create.after": { payload: { worktree: PluginHookWorktree } }; diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index 5b9996d4af..e9b3ab4d74 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -101,6 +101,30 @@ test("session-open hooks reject changes to session identity instead of silently ).rejects.toThrow("agent.open.before hooks can only change env"); }); +test("a create hook that omits metadata passes on what an earlier hook added", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.addHook("agent.create.before", async ({ request }) => ({ + ...request, + metadata: { ...request.metadata, "new.plugin": { ticket: 42 } }, + })); + hooks.addHook("agent.create.before", async ({ request }) => ({ + config: request.config, + env: request.env, + })); + const seen: unknown[] = []; + hooks.addHook("agent.create.before", async ({ request }) => { + seen.push(request.metadata); + }); + const result = await hooks.invoke( + "operation", + "agent.create.before", + { config: { provider: "codex", cwd: "/project" }, env: {}, metadata: { original: true } }, + paseo, + ); + expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } }); + expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]); +}); + 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 }) => ({ diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index e78226f61b..eb6684a2fa 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -249,6 +249,8 @@ export async function validateBeforeResult