diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc987..12512fe099cd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -320,6 +320,12 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + const hook = await Plugin.trigger("session.stopping", { sessionID }, { stop: true, message: undefined as string | undefined }) + if (!hook.stop && hook.message) { + log.info("session.stopping hook prevented stop", { sessionID }) + await createUserMessage({ sessionID, parts: [{ type: "text", text: hook.message }] }) + continue + } log.info("exiting loop", { sessionID }) break } diff --git a/packages/opencode/test/plugin/session-stopping.test.ts b/packages/opencode/test/plugin/session-stopping.test.ts new file mode 100644 index 000000000000..83704bd126b6 --- /dev/null +++ b/packages/opencode/test/plugin/session-stopping.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Plugin } from "../../src/plugin" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" + +describe("session.stopping hook", () => { + test("plugin with session.stopping hook loads and triggers correctly", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + await Bun.write( + path.join(pluginDir, "stop-hook.ts"), + [ + "export default async () => ({", + ' "session.stopping": async (input, output) => {', + " output.stop = false", + ' output.message = "workflow gate"', + " },", + "})", + ].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + const out = await Plugin.trigger("session.stopping", { sessionID: "test-session" }, { stop: true, message: undefined as string | undefined }) + expect(out.stop).toBe(false) + expect(out.message).toBe("workflow gate") + }, + }) + }, 30000) + + test("no plugin installed — stop stays true", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + const out = await Plugin.trigger("session.stopping", { sessionID: "test-session" }, { stop: true, message: undefined as string | undefined }) + expect(out.stop).toBe(true) + expect(out.message).toBeUndefined() + }, + }) + }, 30000) + + test("stop=false without message does not satisfy re-entry condition", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + await Bun.write( + path.join(pluginDir, "no-msg.ts"), + ["export default async () => ({", ' "session.stopping": async (_input, output) => {', " output.stop = false", " },", "})"].join("\n"), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + const out = await Plugin.trigger("session.stopping", { sessionID: "test-session" }, { stop: true, message: undefined as string | undefined }) + expect(out.stop).toBe(false) + expect(out.message).toBeUndefined() + }, + }) + }, 30000) + + test("hook message is persisted as a user message in the session", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + await Bun.write( + path.join(pluginDir, "gate.ts"), + [ + "export default async () => ({", + ' "session.stopping": async (_input, output) => {', + " output.stop = false", + ' output.message = "resume from gate"', + " },", + "})", + ].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + const session = await Session.create({}) + + const out = await Plugin.trigger("session.stopping", { sessionID: session.id }, { stop: true, message: undefined as string | undefined }) + expect(out.stop).toBe(false) + expect(out.message).toBe("resume from gate") + + const msg = await SessionPrompt.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: "text", text: out.message! }], + }) + + expect(msg.info.role).toBe("user") + const text = msg.parts.find((p) => p.type === "text" && !p.synthetic) + expect(text?.type === "text" && text.text).toBe("resume from gate") + + await Session.remove(session.id) + }, + }) + }, 30000) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 76370d1d5a7f..5fb0b1c37b06 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -231,4 +231,12 @@ export interface Hooks { * Modify tool definitions (description and parameters) sent to LLM */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + /** + * Called before the agent loop exits. Set `output.stop = false` and + * provide `output.message` to inject a user message and continue the loop. + */ + "session.stopping"?: ( + input: { sessionID: string }, + output: { stop: boolean; message?: string }, + ) => Promise }