From bf75ca388e4cad362145c7c2c31ad985bfc81e38 Mon Sep 17 00:00:00 2001 From: Daniel Saldarriaga Date: Fri, 4 Sep 2026 13:07:09 +0200 Subject: [PATCH] fix: pause compatibility commands before admission --- dist/server.js | 18 +++++++++++++ src/server.ts | 24 +++++++++++++++++ test/server-v2.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/dist/server.js b/dist/server.js index 2c453e6..ae58093 100644 --- a/dist/server.js +++ b/dist/server.js @@ -3185,6 +3185,24 @@ async function setupV2(context) { }); } })); + registrations.push(await context.session.hook("prompt", async (input) => { + const pauseTemplate = goalStatusCommandTemplate("pause_goal"); + const resumeTemplate = goalStatusCommandTemplate("resume_goal"); + const template = input.prompt.text.startsWith(pauseTemplate) ? pauseTemplate : input.prompt.text.startsWith(resumeTemplate) ? resumeTemplate : null; + if (!template) + return; + input.prompt.text = template; + delete input.prompt.files; + delete input.prompt.agents; + delete input.prompt.skills; + if (template !== pauseTemplate) + return; + const goal = await getGoal(input.sessionID); + if (goal?.status === "active") + await setGoalStatus(input.sessionID, "paused"); + cancelScheduledContinuation(input.sessionID); + clearTurnWatchdog(input.sessionID); + })); } registrations.push(await context.tool.transform((draft) => { for (const tool of goalToolsV2(goalServices)) diff --git a/src/server.ts b/src/server.ts index b56ee3f..d03735d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2235,6 +2235,30 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { + const pauseTemplate = goalStatusCommandTemplate("pause_goal") + const resumeTemplate = goalStatusCommandTemplate("resume_goal") + const template = input.prompt.text.startsWith(pauseTemplate) + ? pauseTemplate + : input.prompt.text.startsWith(resumeTemplate) + ? resumeTemplate + : null + if (!template) return + input.prompt.text = template + delete input.prompt.files + delete input.prompt.agents + delete input.prompt.skills + if (template !== pauseTemplate) return + const goal = await getGoal(input.sessionID) + if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused") + cancelScheduledContinuation(input.sessionID) + clearTurnWatchdog(input.sessionID) + }), + ) } registrations.push( diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 2460168..b22d993 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -399,6 +399,57 @@ test("V2 setup preserves existing commands and configured command-name collision await cleanup() }) +test("V2 prompt hook pauses compatibility commands before admission", async () => { + const mock = makeMockContext({ auto_continue: false }, ["goal", "pause_goal", "resume_goal"]) + const cleanup = await setupPlugin(mock as never) + await createGoalViaV2Tool(mock, "pause before acknowledgement") + const v1 = await plugin.server({ client: { session: { promptAsync: async () => {} } } } as never, { + auto_continue: false, + }) + const config = {} as { command?: Record } + await v1.config?.(config as never) + const pauseTemplate = config.command?.pause_goal?.template + const resumeTemplate = config.command?.resume_goal?.template + if (!pauseTemplate) throw new Error("expected pause_goal compatibility command") + if (!resumeTemplate) throw new Error("expected resume_goal compatibility command") + await v1.dispose?.() + const input = { + sessionID: "ses_v2", + messageID: "msg_pause", + prompt: { + text: `${pauseTemplate}\nuntrusted arguments`, + files: [{ uri: "file:///tmp/untrusted.txt" }], + agents: [{ name: "plan" }], + skills: [{ id: "untrusted" }], + }, + delivery: "steer", + } + + await mock.hooks.prompt?.(input) + + expect(await getGoal("ses_v2")).toMatchObject({ status: "paused", objective: "pause before acknowledgement" }) + expect(input.prompt.text).toBe(pauseTemplate) + expect(input.prompt.files).toBeUndefined() + expect(input.prompt.agents).toBeUndefined() + expect(input.prompt.skills).toBeUndefined() + + const resumeInput = { + sessionID: "ses_v2", + messageID: "msg_resume", + prompt: { + text: `${resumeTemplate}\nuntrusted arguments`, + files: [{ uri: "file:///tmp/untrusted.txt" }], + }, + delivery: "steer", + } + await mock.hooks.prompt?.(resumeInput) + expect((await getGoal("ses_v2"))?.status).toBe("paused") + expect(resumeInput.prompt.text).toBe(resumeTemplate) + expect(resumeInput.prompt.files).toBeUndefined() + mock.stream.end() + await cleanup() +}) + test("V2 command transform remains stable when the registry replays it", async () => { const mock = makeMockContext({ auto_continue: false }) let transform: ((draft: MockCommandDraft) => void) | undefined @@ -463,6 +514,7 @@ test("V2 setup skips command registration when register_command is false", async expect(mock.commands).toHaveLength(0) expect(mock.disposals).not.toContain("command.transform") + expect(mock.hooks.prompt).toBeUndefined() mock.stream.end() await cleanup() @@ -757,7 +809,14 @@ test("V2 cleanup disposes registrations and stops the event consumer", async () await cleanup() expect(mock.disposals).toEqual( - expect.arrayContaining(["command.transform", "tool.transform", "tool.hook:execute.before", "tool.hook:execute.after", "session.hook:context"]), + expect.arrayContaining([ + "command.transform", + "session.hook:prompt", + "tool.transform", + "tool.hook:execute.before", + "tool.hook:execute.after", + "session.hook:context", + ]), ) // Events pushed after cleanup must not throw or mutate state. mock.stream.push({