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.
This commit is contained in:
Mohamed Boudra
2026-11-02 03:42:12 +01:00
parent f4dea7413e
commit 21365c571b
4 changed files with 38 additions and 1 deletions
+4
View File
@@ -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.
+8 -1
View File
@@ -132,10 +132,17 @@ export interface PluginHookRequests {
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
}
/** What a hook may return: a field it leaves out stays as it was. */
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
metadata?: Readonly<Record<string, JsonValue>>;
};
};
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 } };
@@ -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 }) => ({
@@ -249,6 +249,8 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
if (previous.config.cwd !== next.config.cwd) {
throw new Error("agent.create.before hooks cannot change the workspace directory");
}
// Omitted means unchanged: the next hook sees what this one was handed.
return { ...result, metadata: next.metadata ?? previous.metadata };
}
if (name === "worktree.create.before") {
const previous = beforeSchemas["worktree.create.before"].parse(input);