Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
120 changes: 120 additions & 0 deletions packages/opencode/test/plugin/session-stopping.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
8 changes: 8 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
/**
* 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<void>
}
Loading