Skip to content

Commit bf75ca3

Browse files
committed
fix: pause compatibility commands before admission
1 parent 7bfbffa commit bf75ca3

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

‎dist/server.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3185,6 +3185,24 @@ async function setupV2(context) {
31853185
});
31863186
}
31873187
}));
3188+
registrations.push(await context.session.hook("prompt", async (input) => {
3189+
const pauseTemplate = goalStatusCommandTemplate("pause_goal");
3190+
const resumeTemplate = goalStatusCommandTemplate("resume_goal");
3191+
const template = input.prompt.text.startsWith(pauseTemplate) ? pauseTemplate : input.prompt.text.startsWith(resumeTemplate) ? resumeTemplate : null;
3192+
if (!template)
3193+
return;
3194+
input.prompt.text = template;
3195+
delete input.prompt.files;
3196+
delete input.prompt.agents;
3197+
delete input.prompt.skills;
3198+
if (template !== pauseTemplate)
3199+
return;
3200+
const goal = await getGoal(input.sessionID);
3201+
if (goal?.status === "active")
3202+
await setGoalStatus(input.sessionID, "paused");
3203+
cancelScheduledContinuation(input.sessionID);
3204+
clearTurnWatchdog(input.sessionID);
3205+
}));
31883206
}
31893207
registrations.push(await context.tool.transform((draft) => {
31903208
for (const tool of goalToolsV2(goalServices))

‎src/server.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2235,6 +2235,30 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
22352235
}
22362236
}),
22372237
)
2238+
2239+
// V1-compatible config commands bypass V2 command executors, so enforce
2240+
// their lifecycle action again at the shared prompt-admission boundary.
2241+
registrations.push(
2242+
await context.session.hook("prompt", async (input) => {
2243+
const pauseTemplate = goalStatusCommandTemplate("pause_goal")
2244+
const resumeTemplate = goalStatusCommandTemplate("resume_goal")
2245+
const template = input.prompt.text.startsWith(pauseTemplate)
2246+
? pauseTemplate
2247+
: input.prompt.text.startsWith(resumeTemplate)
2248+
? resumeTemplate
2249+
: null
2250+
if (!template) return
2251+
input.prompt.text = template
2252+
delete input.prompt.files
2253+
delete input.prompt.agents
2254+
delete input.prompt.skills
2255+
if (template !== pauseTemplate) return
2256+
const goal = await getGoal(input.sessionID)
2257+
if (goal?.status === "active") await setGoalStatus(input.sessionID, "paused")
2258+
cancelScheduledContinuation(input.sessionID)
2259+
clearTurnWatchdog(input.sessionID)
2260+
}),
2261+
)
22382262
}
22392263

22402264
registrations.push(

‎test/server-v2.test.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,57 @@ test("V2 setup preserves existing commands and configured command-name collision
399399
await cleanup()
400400
})
401401

402+
test("V2 prompt hook pauses compatibility commands before admission", async () => {
403+
const mock = makeMockContext({ auto_continue: false }, ["goal", "pause_goal", "resume_goal"])
404+
const cleanup = await setupPlugin(mock as never)
405+
await createGoalViaV2Tool(mock, "pause before acknowledgement")
406+
const v1 = await plugin.server({ client: { session: { promptAsync: async () => {} } } } as never, {
407+
auto_continue: false,
408+
})
409+
const config = {} as { command?: Record<string, { template: string }> }
410+
await v1.config?.(config as never)
411+
const pauseTemplate = config.command?.pause_goal?.template
412+
const resumeTemplate = config.command?.resume_goal?.template
413+
if (!pauseTemplate) throw new Error("expected pause_goal compatibility command")
414+
if (!resumeTemplate) throw new Error("expected resume_goal compatibility command")
415+
await v1.dispose?.()
416+
const input = {
417+
sessionID: "ses_v2",
418+
messageID: "msg_pause",
419+
prompt: {
420+
text: `${pauseTemplate}\nuntrusted arguments`,
421+
files: [{ uri: "file:///tmp/untrusted.txt" }],
422+
agents: [{ name: "plan" }],
423+
skills: [{ id: "untrusted" }],
424+
},
425+
delivery: "steer",
426+
}
427+
428+
await mock.hooks.prompt?.(input)
429+
430+
expect(await getGoal("ses_v2")).toMatchObject({ status: "paused", objective: "pause before acknowledgement" })
431+
expect(input.prompt.text).toBe(pauseTemplate)
432+
expect(input.prompt.files).toBeUndefined()
433+
expect(input.prompt.agents).toBeUndefined()
434+
expect(input.prompt.skills).toBeUndefined()
435+
436+
const resumeInput = {
437+
sessionID: "ses_v2",
438+
messageID: "msg_resume",
439+
prompt: {
440+
text: `${resumeTemplate}\nuntrusted arguments`,
441+
files: [{ uri: "file:///tmp/untrusted.txt" }],
442+
},
443+
delivery: "steer",
444+
}
445+
await mock.hooks.prompt?.(resumeInput)
446+
expect((await getGoal("ses_v2"))?.status).toBe("paused")
447+
expect(resumeInput.prompt.text).toBe(resumeTemplate)
448+
expect(resumeInput.prompt.files).toBeUndefined()
449+
mock.stream.end()
450+
await cleanup()
451+
})
452+
402453
test("V2 command transform remains stable when the registry replays it", async () => {
403454
const mock = makeMockContext({ auto_continue: false })
404455
let transform: ((draft: MockCommandDraft) => void) | undefined
@@ -463,6 +514,7 @@ test("V2 setup skips command registration when register_command is false", async
463514

464515
expect(mock.commands).toHaveLength(0)
465516
expect(mock.disposals).not.toContain("command.transform")
517+
expect(mock.hooks.prompt).toBeUndefined()
466518

467519
mock.stream.end()
468520
await cleanup()
@@ -757,7 +809,14 @@ test("V2 cleanup disposes registrations and stops the event consumer", async ()
757809
await cleanup()
758810

759811
expect(mock.disposals).toEqual(
760-
expect.arrayContaining(["command.transform", "tool.transform", "tool.hook:execute.before", "tool.hook:execute.after", "session.hook:context"]),
812+
expect.arrayContaining([
813+
"command.transform",
814+
"session.hook:prompt",
815+
"tool.transform",
816+
"tool.hook:execute.before",
817+
"tool.hook:execute.after",
818+
"session.hook:context",
819+
]),
761820
)
762821
// Events pushed after cleanup must not throw or mutate state.
763822
mock.stream.push({

0 commit comments

Comments
 (0)