From 7808e7ac98193c421156d27c2db4fcd853119300 Mon Sep 17 00:00:00 2001 From: Brendan DeBeasi Date: Thu, 12 Mar 2026 07:16:55 -0700 Subject: [PATCH 1/2] feat: add /btw background session command Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .opencode/command/btw.md | 13 + packages/opencode/src/session/prompt.ts | 216 +++++++++++++++++ packages/opencode/src/session/todo.ts | 24 ++ packages/opencode/test/session/btw.test.ts | 269 +++++++++++++++++++++ 4 files changed, 522 insertions(+) create mode 100644 .opencode/command/btw.md create mode 100644 packages/opencode/test/session/btw.test.ts diff --git a/.opencode/command/btw.md b/.opencode/command/btw.md new file mode 100644 index 00000000000..21a66a9ba1a --- /dev/null +++ b/.opencode/command/btw.md @@ -0,0 +1,13 @@ +--- +description: ask in background or append a todo +--- + +Background helper. + +Usage: + +- `/btw ask ` to start a background child session and post the result back here later. +- `/btw todo ` to append a todo to the current session immediately. +- `/btw status` to list child background tasks for the current session. + +If no subcommand is provided, treat the remaining text as `ask`. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 171c4b448fd..7f28d36811c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,6 +47,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { Todo } from "./todo" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1745,6 +1746,210 @@ NOTE: At any point in time through this workflow you should feel free to ask the const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g + + function text(parts: MessageV2.Part[]) { + return parts.findLast((part) => part.type === "text")?.text.trim() ?? "" + } + + function label(prefix: string, value: string) { + const next = value.trim() || prefix + return next.length > 80 ? next.slice(0, 77) + "..." : next + } + + async function idle(sessionID: SessionID) { + while (SessionStatus.get(sessionID).type !== "idle") { + await Bun.sleep(50) + } + } + + async function post(input: { + sessionID: SessionID + agent: string + model: { providerID: ProviderID; modelID: ModelID } + title: string + text: string + wait?: boolean + }) { + if (input.wait) await idle(input.sessionID) + const user: MessageV2.User = { + id: MessageID.ascending(), + sessionID: input.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: input.agent, + model: input.model, + } + await Session.updateMessage(user) + await Session.updatePart({ + id: PartID.ascending(), + messageID: user.id, + sessionID: input.sessionID, + type: "text", + text: input.title, + synthetic: true, + } satisfies MessageV2.TextPart) + + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: user.id, + mode: input.agent, + agent: input.agent, + cost: 0, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + role: "assistant", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model.modelID, + providerID: input.model.providerID, + } + await Session.updateMessage(assistant) + const part = { + id: PartID.ascending(), + messageID: assistant.id, + sessionID: input.sessionID, + type: "text", + text: input.text, + synthetic: true, + } satisfies MessageV2.TextPart + await Session.updatePart(part) + return { + info: assistant, + parts: [part], + } + } + + async function update(input: { part: MessageV2.TextPart; text: string; wait?: boolean }) { + if (input.wait) await idle(input.part.sessionID) + input.part.text = [input.part.text, input.text].filter(Boolean).join("\n\n") + await Session.updatePart(input.part) + } + + async function status(sessionID: SessionID) { + const kids = await Session.children(sessionID) + if (kids.length === 0) return "No background tasks yet." + + const lines = await Promise.all( + kids.map(async (child) => { + const state = SessionStatus.get(child.id) + if (state.type === "busy") return `- ${child.title} (\`${child.id}\`) - running` + if (state.type === "retry") return `- ${child.title} (\`${child.id}\`) - retry ${state.attempt}` + + const msgs = await Session.messages({ sessionID: child.id, limit: 3 }) + const assistant = msgs.find((msg) => msg.info.role === "assistant") + const value = assistant ? text(assistant.parts) || "completed" : "idle" + return `- ${child.title} (\`${child.id}\`) - ${value}` + }), + ) + + return lines.join("\n") + } + + export async function commandBtw(input: { + sessionID: SessionID + agent: string + model: { providerID: ProviderID; modelID: ModelID } + arguments: string + variant?: string + parts?: CommandInput["parts"] + run?: (input: PromptInput) => Promise + }) { + const run = input.run ?? prompt + const args = input.arguments.trim() + if (args === "status") { + return post({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + title: "Background status", + text: await status(input.sessionID), + }) + } + + const todo = args.startsWith("todo ") ? args.slice(5).trim() : "" + if (todo) { + Todo.append({ + sessionID: input.sessionID, + todo: { content: todo, status: "pending", priority: "medium" }, + }) + return post({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + title: `Background note: ${label("todo", todo)}`, + text: `Added todo: ${todo}`, + }) + } + + const query = args.startsWith("ask ") ? args.slice(4).trim() : args + if (!query) { + return post({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + title: "Background helper", + text: "Usage: `/btw ask `, `/btw todo `, or `/btw status`.", + }) + } + + const parent = await Session.get(input.sessionID) + const child = await Session.create({ + parentID: input.sessionID, + workspaceID: parent.workspaceID, + title: `BTW: ${label("background task", query)}`, + }) + + const note = await post({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + title: `Background task: ${label("ask", query)}`, + text: `Started background task in child session \`${child.id}\`. I'll post the result here when it finishes.`, + }) + + void run({ + sessionID: child.id, + model: input.model, + agent: input.agent, + variant: input.variant, + parts: [ + { + type: "text", + text: query, + }, + ...(input.parts ?? []), + ], + }) + .then((result) => + update({ + part: note.parts[0] as MessageV2.TextPart, + text: `Child session \`${child.id}\` completed.\n\n${text(result.parts) || "(no text output)"}`, + wait: true, + }), + ) + .catch((err) => + update({ + part: note.parts[0] as MessageV2.TextPart, + text: `Child session \`${child.id}\` failed: ${err instanceof Error ? err.message : String(err)}`, + wait: true, + }), + ) + + return note + } /** * Regular expression to match @ file references in text * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks @@ -1840,6 +2045,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } + if (input.command === "btw") { + return commandBtw({ + sessionID: input.sessionID, + agent: agent.name, + model: taskModel, + arguments: input.arguments, + variant: input.variant, + parts: input.parts, + }) + } + const templateParts = await resolvePromptParts(template) const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true const parts = isSubtask diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 02ad0d3b337..a456696bb9d 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -4,6 +4,7 @@ import { SessionID } from "./schema" import z from "zod" import { Database, eq, asc } from "../storage/db" import { TodoTable } from "./session.sql" +import { max } from "drizzle-orm" export namespace Todo { export const Info = z @@ -54,4 +55,27 @@ export namespace Todo { priority: row.priority, })) } + + export function append(input: { sessionID: SessionID; todo: Info }) { + Database.transaction((db) => { + const row = db + .select({ position: max(TodoTable.position) }) + .from(TodoTable) + .where(eq(TodoTable.session_id, input.sessionID)) + .get() + db.insert(TodoTable) + .values({ + session_id: input.sessionID, + content: input.todo.content, + status: input.todo.status, + priority: input.todo.priority, + position: (row?.position ?? -1) + 1, + }) + .run() + }) + Bus.publish(Event.Updated, { + sessionID: input.sessionID, + todos: get(input.sessionID), + }) + } } diff --git a/packages/opencode/test/session/btw.test.ts b/packages/opencode/test/session/btw.test.ts new file mode 100644 index 00000000000..97dc9338739 --- /dev/null +++ b/packages/opencode/test/session/btw.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { PartID, MessageID, SessionID } from "../../src/session/schema" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { Todo } from "../../src/session/todo" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const model = { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5.2"), +} + +async function parts(sessionID: SessionID) { + const msgs = await Session.messages({ sessionID }) + return msgs.flatMap((msg) => msg.parts.filter((part) => part.type === "text").map((part) => part.text)) +} + +async function wait(check: () => Promise) { + for (let i = 0; i < 20; i++) { + if (await check()) return + await Bun.sleep(5) + } + throw new Error("timed out waiting for background result") +} + +describe("session.prompt /btw", () => { + test("appends todos immediately", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const result = await SessionPrompt.commandBtw({ + sessionID: session.id, + agent: "build", + model, + arguments: "todo follow up on logs", + }) + + await SessionPrompt.commandBtw({ + sessionID: session.id, + agent: "build", + model, + arguments: "todo file a note", + }) + + expect(result.info.role).toBe("assistant") + expect(Todo.get(session.id)).toEqual([ + { + content: "follow up on logs", + status: "pending", + priority: "medium", + }, + { + content: "file a note", + status: "pending", + priority: "medium", + }, + ]) + expect(await parts(session.id)).toContain("Added todo: follow up on logs") + + await Session.remove(session.id) + }, + }) + }) + + test("creates a child session and reports completion back to parent", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const result = await SessionPrompt.commandBtw({ + sessionID: session.id, + agent: "build", + model, + arguments: "ask what changed?", + run: async (input) => ({ + info: { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: MessageID.ascending(), + mode: input.agent ?? "build", + agent: input.agent ?? "build", + cost: 0, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + role: "assistant", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model?.modelID ?? model.modelID, + providerID: input.model?.providerID ?? model.providerID, + } satisfies MessageV2.Assistant, + parts: [ + { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: input.sessionID, + type: "text", + text: "background answer", + } satisfies MessageV2.TextPart, + ], + }), + }) + + expect(result.info.role).toBe("assistant") + await wait( + async () => + (await Session.children(session.id)).length === 1 && + (await parts(session.id)).some((item) => item.includes("background answer")), + ) + + const kids = await Session.children(session.id) + expect(kids).toHaveLength(1) + const msgs = await Session.messages({ sessionID: session.id }) + expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) + const text = await parts(session.id) + expect(text.some((item) => item.includes("Started background task in child session"))).toBe(true) + expect(text.some((item) => item.includes("background answer"))).toBe(true) + + await Session.remove(session.id) + }, + }) + }) + + test("reports background child status", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + let done = false + + await SessionPrompt.commandBtw({ + sessionID: session.id, + agent: "build", + model, + arguments: "ask first", + run: async (input) => { + SessionStatus.set(input.sessionID, { type: "busy" }) + while (!done) await Bun.sleep(5) + SessionStatus.set(input.sessionID, { type: "idle" }) + return { + info: { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: MessageID.ascending(), + mode: input.agent ?? "build", + agent: input.agent ?? "build", + cost: 0, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + role: "assistant", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model?.modelID ?? model.modelID, + providerID: input.model?.providerID ?? model.providerID, + } satisfies MessageV2.Assistant, + parts: [ + { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: input.sessionID, + type: "text", + text: "done later", + } satisfies MessageV2.TextPart, + ], + } + }, + }) + + await SessionPrompt.commandBtw({ + sessionID: session.id, + agent: "build", + model, + arguments: "ask second", + run: async (input) => { + const info = { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: MessageID.ascending(), + mode: input.agent ?? "build", + agent: input.agent ?? "build", + cost: 0, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + role: "assistant", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model?.modelID ?? model.modelID, + providerID: input.model?.providerID ?? model.providerID, + } satisfies MessageV2.Assistant + const part = { + id: PartID.ascending(), + messageID: info.id, + sessionID: input.sessionID, + type: "text", + text: "finished already", + } satisfies MessageV2.TextPart + await Session.updateMessage(info) + await Session.updatePart(part) + return { + info, + parts: [part], + } + }, + }) + + await wait(async () => (await Session.children(session.id)).length === 2) + const result = await SessionPrompt.commandBtw({ + sessionID: session.id, + agent: "build", + model, + arguments: "status", + }) + + const value = result.parts.find((part) => part.type === "text") + expect(value?.text.includes("running")).toBe(true) + expect(value?.text.includes("finished already")).toBe(true) + + done = true + await wait(async () => (await parts(session.id)).some((item) => item.includes("done later"))) + await Session.remove(session.id) + }, + }) + }) +}) From 7f7e07d032bafea102d7765183b943365df47aa0 Mon Sep 17 00:00:00 2001 From: Brendan DeBeasi Date: Thu, 12 Mar 2026 07:17:15 -0700 Subject: [PATCH 2/2] fix: skip synthetic assistant parts in model messages Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- packages/opencode/src/session/message-v2.ts | 4 ++-- packages/opencode/test/session/message-v2.test.ts | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 03ccb44c1ad..e97c5f804ac 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -622,7 +622,7 @@ export namespace MessageV2 { parts: [], } for (const part of msg.parts) { - if (part.type === "text") + if (part.type === "text" && !part.synthetic) assistantMessage.parts.push({ type: "text", text: part.text, @@ -694,7 +694,7 @@ export namespace MessageV2 { } } if (assistantMessage.parts.length > 0) { - result.push(assistantMessage) + if (assistantMessage.parts.length > 0) result.push(assistantMessage) // Inject pending media as a user message for providers that don't support // media (images, PDFs) in tool results if (media.length > 0) { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729bb..54f794c3a96 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -153,7 +153,7 @@ describe("session.message-v2.toModelMessage", () => { expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) - test("includes synthetic text parts", () => { + test("keeps synthetic user text but drops synthetic assistant text", () => { const messageID = "m-user" const input: MessageV2.WithParts[] = [ @@ -186,10 +186,6 @@ describe("session.message-v2.toModelMessage", () => { role: "user", content: [{ type: "text", text: "hello" }], }, - { - role: "assistant", - content: [{ type: "text", text: "assistant" }], - }, ]) })