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