From 4d9b7946d7a55b317cb6b9102aaab96f90030fd6 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 23:18:37 +0000 Subject: [PATCH] feat(opencode): add opt-in assistant auto-continue --- packages/opencode/src/cli/cmd/run.ts | 34 +++- packages/opencode/src/config/config.ts | 51 ++++++ packages/opencode/src/project/bootstrap.ts | 2 + packages/opencode/src/session/autocontinue.ts | 91 +++++++++++ packages/opencode/test/config/config.test.ts | 62 ++++++++ .../test/session/autocontinue.test.ts | 147 ++++++++++++++++++ 6 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/session/autocontinue.ts create mode 100644 packages/opencode/test/session/autocontinue.test.ts diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 61bc609bb7c..1ee846ce581 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -218,6 +218,13 @@ function normalizePath(input?: string) { return input } +function auth() { + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${btoa(`${username}:${password}`)}` +} + export const RunCommand = cmd({ command: "run [message..]", describe: "run opencode with a message", @@ -648,7 +655,18 @@ export const RunCommand = cmd({ } if (args.attach) { - const sdk = createOpencodeClient({ baseUrl: args.attach, directory }) + const authorization = auth() + const sdk = createOpencodeClient({ + baseUrl: args.attach, + directory, + ...(authorization + ? { + headers: { + authorization, + }, + } + : {}), + }) return await execute(sdk) } @@ -657,7 +675,19 @@ export const RunCommand = cmd({ const request = new Request(input, init) return Server.App().fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const authorization = auth() + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory: process.cwd(), + ...(authorization + ? { + headers: { + authorization, + }, + } + : {}), + }) await execute(sdk) }) }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b4242a225a..8fadff79d6f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -654,6 +654,54 @@ export namespace Config { }) export type Command = z.infer + const AutoContinuePattern = z.string().refine( + (value) => { + try { + new RegExp(value, "i") + return true + } catch { + return false + } + }, + { + message: "Invalid regex pattern", + }, + ) + + export const AutoContinue = z + .union([ + z.boolean(), + z.object({ + prompt: z.string().min(1).optional().describe("Message to auto-send when a match is detected"), + patterns: z + .array(AutoContinuePattern) + .min(1) + .optional() + .describe("Case-insensitive regex patterns matched against the assistant's closing paragraph"), + }), + ]) + .transform((value) => { + const prompt = typeof value === "object" && value.prompt ? value.prompt : "Yes. Do this." + const patterns = + typeof value === "object" && value.patterns + ? value.patterns + : [ + "\\bif you want\\b", + "\\bif you like\\b", + "\\bif you(?:'d| would)? like\\b", + "\\blet me know if you(?:'d| would)? like me to continue\\b", + ] + return { + enabled: value === true || typeof value === "object", + prompt, + patterns, + } + }) + .meta({ + ref: "AutoContinueConfig", + }) + export type AutoContinue = z.infer + export const Skills = z.object({ paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), urls: z @@ -1150,6 +1198,9 @@ export namespace Config { experimental: z .object({ disable_paste_summary: z.boolean().optional(), + auto_continue: AutoContinue.optional().describe( + "Automatically continue when the assistant ends with a continuation prompt", + ), batch_tool: z.boolean().optional().describe("Enable the batch tool"), openTelemetry: z .boolean() diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f85..fba8f0a00f2 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { AutoContinue } from "@/session/autocontinue" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -24,6 +25,7 @@ export async function InstanceBootstrap() { Vcs.init() Snapshot.init() Truncate.init() + AutoContinue.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/session/autocontinue.ts b/packages/opencode/src/session/autocontinue.ts new file mode 100644 index 00000000000..992d2a046f2 --- /dev/null +++ b/packages/opencode/src/session/autocontinue.ts @@ -0,0 +1,91 @@ +import { Bus } from "@/bus" +import { Config } from "@/config/config" +import { Instance } from "@/project/instance" +import { SessionPrompt } from "@/session/prompt" +import { Log } from "@/util/log" +import { MessageV2 } from "./message-v2" + +export namespace AutoContinue { + const log = Log.create({ service: "session.autocontinue" }) + + const state = Instance.state(() => ({ + init: false, + seen: new Set(), + })) + + export function init() { + const s = state() + if (s.init) return + s.init = true + Bus.subscribe(MessageV2.Event.Updated, async (event) => { + await update(event.properties.info) + }) + } + + export async function update(info: MessageV2.Info) { + if (info.role !== "assistant" || info.error) return + if (!info.finish || info.finish === "tool-calls" || info.finish === "unknown") return + + const cfg = (await Config.get()).experimental?.auto_continue + if (!cfg?.enabled) return + + const s = state() + if (s.seen.has(info.id)) return + s.seen.add(info.id) + if ((await latest(info.sessionID))?.info.id !== info.id) { + s.seen.delete(info.id) + return + } + + const msg = await MessageV2.get({ + sessionID: info.sessionID, + messageID: info.id, + }) + const text = tail(msg.parts) + if (!text || !match(text, cfg)) { + s.seen.delete(info.id) + return + } + + log.info("continuing", { sessionID: info.sessionID, messageID: info.id }) + void SessionPrompt.prompt({ + sessionID: info.sessionID, + parts: [ + { + type: "text", + text: cfg.prompt, + synthetic: true, + }, + ], + }).catch((err) => { + log.error("failed to continue", { sessionID: info.sessionID, messageID: info.id, error: err }) + }) + } + + export function match(text: string, cfg: Config.AutoContinue) { + return cfg.patterns.some((pattern) => new RegExp(pattern, "i").test(text)) + } + + export function tail(parts: MessageV2.Part[]) { + const text = parts + .filter((part) => part.type === "text") + .map((part) => part.text.trim()) + .filter(Boolean) + .join("\n") + .trim() + if (!text) return "" + return ( + text + .split(/\n\s*\n/g) + .map((part) => part.trim()) + .filter(Boolean) + .pop() ?? text + ) + } + + async function latest(sessionID: string) { + for await (const msg of MessageV2.stream(sessionID)) { + return msg + } + } +} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 96fac8cca2e..995f2aed85c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1994,3 +1994,65 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { } }) }) + +describe("experimental.auto_continue", () => { + test("normalizes boolean config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + experimental: { + auto_continue: true, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + expect(cfg.experimental?.auto_continue).toEqual({ + enabled: true, + prompt: "Yes. Do this.", + patterns: [ + "\\bif you want\\b", + "\\bif you like\\b", + "\\bif you(?:'d| would)? like\\b", + "\\blet me know if you(?:'d| would)? like me to continue\\b", + ], + }) + }, + }) + }) + + test("normalizes custom config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + experimental: { + auto_continue: { + prompt: "Keep going.", + patterns: ["continue please"], + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + expect(cfg.experimental?.auto_continue).toEqual({ + enabled: true, + prompt: "Keep going.", + patterns: ["continue please"], + }) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/autocontinue.test.ts b/packages/opencode/test/session/autocontinue.test.ts new file mode 100644 index 00000000000..3f944d34045 --- /dev/null +++ b/packages/opencode/test/session/autocontinue.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, mock, test } from "bun:test" +import path from "path" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +const calls: unknown[] = [] + +mock.module("../../src/session/prompt", () => ({ + SessionPrompt: { + prompt(input: unknown) { + calls.push(input) + return Promise.resolve(undefined) + }, + }, +})) + +describe("AutoContinue", () => { + test("matches the closing paragraph", async () => { + const { AutoContinue } = await import("../../src/session/autocontinue") + expect( + AutoContinue.match("If you'd like, I can continue from here.", { + enabled: true, + prompt: "Yes. Do this.", + patterns: ["\\bif you(?:'d| would)? like\\b"], + }), + ).toBe(true) + expect( + AutoContinue.tail([ + { + id: "part_1", + messageID: "message_1", + sessionID: "session_1", + type: "text", + text: "Finished step 2.\n\nIf you'd like, I can continue.", + }, + ]), + ).toBe("If you'd like, I can continue.") + }) + + test("submits one synthetic follow-up for matching assistant replies", async () => { + calls.length = 0 + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + experimental: { + auto_continue: true, + }, + }), + ) + }, + }) + const [{ Instance }, { Session }, { Identifier }, { AutoContinue }] = await Promise.all([ + import("../../src/project/instance"), + import("../../src/session"), + import("../../src/id/id"), + import("../../src/session/autocontinue"), + ]) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + AutoContinue.init() + + const session = await Session.create({}) + const user = Identifier.ascending("message") + const aid = Identifier.ascending("message") + + await Session.updateMessage({ + id: user, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "test", modelID: "test" }, + } as any) + + await Session.updateMessage({ + id: aid, + parentID: user, + sessionID: session.id, + role: "assistant", + mode: "build", + agent: "build", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test", + providerID: "test", + time: { created: Date.now() }, + } as any) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: aid, + sessionID: session.id, + type: "text", + text: "I finished step 2 of 11. If you'd like, I can continue.", + } as any) + await Session.updateMessage({ + id: aid, + parentID: user, + sessionID: session.id, + role: "assistant", + mode: "build", + agent: "build", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test", + providerID: "test", + finish: "stop", + time: { created: Date.now(), completed: Date.now() }, + } as any) + await Session.updateMessage({ + id: aid, + parentID: user, + sessionID: session.id, + role: "assistant", + mode: "build", + agent: "build", + path: { cwd: tmp.path, root: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test", + providerID: "test", + finish: "stop", + time: { created: Date.now(), completed: Date.now() }, + } as any) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Yes. Do this.", + synthetic: true, + }, + ], + }) + }, + }) + }) +})