feat(mini): migrate mini to v2 (#34895)

feat(run): migrate non-interactive prompts to V2
feat(run): route mini prompts through V2
fix(run): use current session contracts
fix(run): fix V2 prompt turns
feat(cli): add mini subcommand
feat(run): use settled execution events
fix(run): handle remote prompt file attachments
feat(run): send prompt files as attachments
feat(run): use current APIs for run state
fix(run): adopt app-node runtime deps
feat(run): track subagent sessions
feat(run): move catalogs and default model onto current APIs
This commit is contained in:
Simon Klee 2026-07-02 12:06:49 +02:00 committed by GitHub
commit f016392368
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 5196 additions and 7098 deletions

View file

@ -311,7 +311,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) })
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) =>
raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
type Endpoint7_0Input = {

View file

@ -53,6 +53,8 @@ import type {
MessageListOutput,
ModelListInput,
ModelListOutput,
ModelDefaultInput,
ModelDefaultOutput,
GenerateTextInput,
GenerateTextOutput,
ProviderListInput,
@ -627,6 +629,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) =>
request<ModelDefaultOutput>(
{
method: "GET",
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
},
generate: {
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>

View file

@ -2217,6 +2217,67 @@ export type ModelListOutput = {
}>
}
export type ModelDefaultInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelDefaultOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly providerID: string
readonly family?: string
readonly name: string
readonly api:
| {
readonly id: string
readonly type: "aisdk"
readonly package: string
readonly url?: string
readonly settings?: { readonly [x: string]: JsonValue }
}
| {
readonly id: string
readonly type: "native"
readonly url?: string
readonly settings: { readonly [x: string]: JsonValue }
}
readonly capabilities: {
readonly tools: boolean
readonly input: ReadonlyArray<string>
readonly output: ReadonlyArray<string>
}
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly variant?: string
}
readonly variants: ReadonlyArray<{
readonly id: string
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}>
readonly time: { readonly released: number }
readonly cost: ReadonlyArray<{
readonly tier?: { readonly type: "context"; readonly size: number }
readonly input: number
readonly output: number
readonly cache: { readonly read: number; readonly write: number }
}>
readonly status: "alpha" | "beta" | "deprecated" | "active"
readonly enabled: boolean
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
} | null
}
export type GenerateTextInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@ -3533,6 +3594,19 @@ export type EventSubscribeOutput =
readonly delivery: "steer" | "queue"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.execution.settled"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly outcome: "success" | "failure" | "interrupted"
readonly error?: { readonly type: "unknown"; readonly message: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }

View file

@ -139,6 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.execution.settled": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({

View file

@ -8,7 +8,7 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
@ -387,7 +387,7 @@ const layer = Layer.effect(
)
})
const run = Effect.fn("SessionRunner.run")(function* (input: {
const drain = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
@ -418,6 +418,33 @@ const layer = Layer.effect(
}
})
const run = Effect.fn("SessionRunner.run")(
(input: { readonly sessionID: SessionSchema.ID; readonly force: boolean }) =>
drain(input).pipe(
Effect.onExit((exit) =>
Effect.gen(function* () {
const failure =
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
yield* events.publish(
SessionEvent.ExecutionSettled,
{
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error:
failure !== undefined
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
: undefined,
},
)
}).pipe(
Effect.catchCause(() => Effect.void),
Effect.asVoid,
),
),
),
)
return Service.of({
run,
})

View file

@ -43,32 +43,8 @@ export const AttachCommand = cmd({
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
const directory = (() => {
if (!args.dir) return undefined
try {
@ -80,32 +56,6 @@ export const AttachCommand = cmd({
}
})()
if (args.mini) {
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")

View file

@ -0,0 +1,172 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { resolveThreadDirectory } from "./tui"
type ReplayArgs = {
replay?: boolean
noReplay?: boolean
}
type MiniArgs = ReplayArgs & {
continue?: boolean
session?: string
fork?: boolean
replayLimit?: number
}
type MiniLocalArgs = MiniArgs & {
project?: string
model?: string
agent?: string
prompt?: string
demo?: boolean
}
type MiniAttachArgs = MiniArgs & {
url: string
dir?: string
password?: string
username?: string
}
function replay(args: ReplayArgs) {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return "invalid" as const
}
return args.replay === false || args.noReplay === true ? false : undefined
}
function miniOptions<T>(yargs: Argv<T>) {
return yargs
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
type: "string",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible replay to the newest N messages",
})
}
/** @internal Exported for CLI parser tests. */
export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
command: "$0 [project]",
describe: "start the minimal interactive interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: shouldReplay,
replayLimit: args.replayLimit,
demo: args.demo,
})
},
})
/** @internal Exported for CLI parser tests. */
export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
command: "attach <url>",
describe: "attach to a running opencode server with the minimal interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
describe: "directory on the remote server",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory: args.dir,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: shouldReplay,
replayLimit: args.replayLimit,
})
},
})
export const MiniCommand = cmd({
command: "mini",
describe: "start the minimal interactive interface",
builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
handler: async () => {},
})

View file

@ -1,13 +1,13 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
// CLI entry point for `opencode run` and `opencode --mini`.
// CLI entry point for `opencode run` and `opencode mini`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
// 2. Interactive local (`opencode mini`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
// 3. Interactive attach (`opencode mini attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
@ -25,6 +25,8 @@ import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { FormatError, FormatUnknownError } from "../error"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
import { isImageAttachment, isPdfAttachment } from "@/util/media"
import { loadRunAgents } from "./run/catalog.shared"
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined {
return value + "\n" + piped
}
function isBinaryContent(bytes: Uint8Array) {
if (bytes.length === 0) return false
if (bytes.includes(0)) return true
return (
bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
)
}
type FilePart = {
type: "file"
url: string
@ -68,6 +78,7 @@ type SessionInfo = {
id: string
title?: string
directory?: string
current?: boolean
}
function inline(info: Inline) {
@ -158,10 +169,6 @@ export const RunCommand = effectCmd({
describe: "fork the session before continuing (requires --continue or --session)",
type: "boolean",
})
.option("share", {
type: "boolean",
describe: "share the session",
})
.option("model", {
type: "string",
alias: ["m"],
@ -217,11 +224,6 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("mini", {
type: "boolean",
hidden: true,
default: false,
})
.option("replay", {
type: "boolean",
default: true,
@ -270,7 +272,7 @@ export const RunCommand = effectCmd({
const localInstance = yield* InstanceRef
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const interactive = args.mini
const interactive = (args as typeof args & { mini?: boolean }).mini === true
const auto = args.auto || args.yolo || args["dangerously-skip-permissions"]
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const die = (message: string): never => {
@ -290,23 +292,23 @@ export const RunCommand = effectCmd({
.join(" ")
if (interactive && args.command) {
die("--mini cannot be used with --command")
die("opencode mini cannot be used with --command")
}
if (interactive && args._?.[0] !== "mini") {
die("--mini must be used without the run subcommand")
die("opencode mini must be run with the mini command")
}
if (args.demo && !interactive) {
die("--demo requires --mini")
die("--demo requires opencode mini")
}
if (interactive && args.format === "json") {
die("--mini cannot be used with --format json")
die("opencode mini cannot be used with --format json")
}
if (args["replay-limit"] !== undefined && !interactive) {
die("--replay-limit requires --mini")
die("--replay-limit requires opencode mini")
}
if (
@ -317,7 +319,7 @@ export const RunCommand = effectCmd({
}
if (interactive && !process.stdout.isTTY) {
die("--mini requires a TTY stdout")
die("opencode mini requires a TTY stdout")
}
if (interactive) {
@ -355,6 +357,12 @@ export const RunCommand = effectCmd({
}
const files: FilePart[] = []
const fileInputs: Array<{
filePath: string
resolvedPath: string
stat: ReturnType<typeof Filesystem.stat>
isDirectory: boolean
}> = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@ -371,45 +379,7 @@ export const RunCommand = effectCmd({
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
process.exit(1)
}
const content = await (async () => {
if (!args.attach) return
const handle = await open(resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(resolvedPath)
const text = content?.toString("utf8")
const mime = !args.attach
? isDirectory
? "application/x-directory"
: "text/plain"
: content && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
fileInputs.push({ filePath, resolvedPath, stat, isDirectory })
}
}
@ -446,6 +416,53 @@ export const RunCommand = effectCmd({
pattern: "*",
},
]
const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory)
const inlineFiles = interactive || currentPrompt
for (const file of fileInputs) {
const content = await (async () => {
if (file.isDirectory || !inlineFiles) return
if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
process.exit(1)
}
const handle = await open(file.resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(file.resolvedPath)
const text = content?.toString("utf8")
const mime = file.isDirectory
? "application/x-directory"
: isImageAttachment(detected) || isPdfAttachment(detected)
? detected
: content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href,
filename: path.basename(file.resolvedPath),
mime,
})
}
function title() {
if (args.title === undefined) return
@ -453,58 +470,122 @@ export const RunCommand = effectCmd({
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await sdk.session
.get({
sessionID: args.session,
})
.catch(() => undefined)
async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
const listed = await sdk.v2.session
.list({
directory: await current(sdk),
limit: 50,
order: "desc",
})
.then((result) => result.data?.data.find((item) => item.id === sessionID))
.catch(() => undefined)
const selected =
listed ??
(await sdk.v2.session
.get({ sessionID })
.then((result) => result.data?.data)
.catch(() => undefined))
const legacy =
selected ??
(await sdk.session
.get({ sessionID })
.then((result) => result.data)
.catch(() => undefined))
const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID)
if (!legacy && transcript === "empty") {
return
}
if (interactive && transcript === "legacy") {
throw new Error("Mini cannot resume a legacy Session transcript")
}
if (!current?.data) {
UI.error("Session not found")
process.exit(1)
}
if (args.fork) {
const forked = await sdk.session.fork({
sessionID: args.session,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? current.data.title,
directory: forked.data?.directory ?? current.data.directory,
}
}
return {
id: legacy?.id ?? sessionID,
title: legacy?.title,
directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk),
current: transcript !== "legacy",
}
}
async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise<SessionInfo | undefined> {
if (session.current !== false) {
const forked = await sdk.v2.session.fork(
{ sessionID: session.id, messageID: undefined },
{ throwOnError: true },
)
await waitForFork(sdk, session.id, forked.data.data.id)
return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
id: forked.data.data.id,
title: forked.data.data.title,
directory: forked.data.data.location.directory,
current: true,
}
}
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
const forked = await sdk.session.fork({
sessionID: session.id,
})
const id = forked.data?.id
if (!id) {
return
}
if (base && args.fork) {
const forked = await sdk.session.fork({
sessionID: base.id,
})
const id = forked.data?.id
if (!id) {
return {
id,
title: forked.data?.title ?? session.title,
directory: forked.data?.directory ?? session.directory,
current: false,
}
}
async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) {
const parentHasMessages = await sdk.v2.session
.messages({ sessionID: parentID, limit: 1 })
.then((result) => (result.data?.data.length ?? 0) > 0)
.catch(() => false)
if (!parentHasMessages) {
return
}
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
const forkedHasMessages = await sdk.v2.session
.messages({ sessionID, limit: 1 })
.then((result) => (result.data?.data.length ?? 0) > 0)
.catch(() => false)
if (forkedHasMessages) {
return
}
return {
id,
title: forked.data?.title ?? base.title,
directory: forked.data?.directory ?? base.directory,
await Bun.sleep(25)
}
}
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await currentSession(sdk, args.session)
if (!current) {
UI.error("Session not found")
process.exit(1)
}
if (!interactive && !currentPrompt && current.current !== false) {
throw new Error("This operation is not available for a current Session transcript")
}
if (args.fork) {
return forkSession(sdk, current)
}
return current
}
const base = args.continue ? await currentRootSession(sdk) : undefined
if (base && !interactive && !currentPrompt && base.current !== false) {
throw new Error("This operation is not available for a current Session transcript")
}
if (base && args.fork) {
return forkSession(sdk, base)
}
if (base) {
@ -512,6 +593,23 @@ export const RunCommand = effectCmd({
id: base.id,
title: base.title,
directory: base.directory,
current: "current" in base ? base.current : false,
}
}
if (interactive || currentPrompt) {
const name = title()
const result = await sdk.v2.session.create({
location: { directory: await current(sdk) },
})
const created = result.data?.data
if (!created) return
if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name })
return {
id: created.id,
title: name ?? created.title,
directory: created.location.directory,
current: true,
}
}
@ -529,30 +627,45 @@ export const RunCommand = effectCmd({
id,
title: result.data?.title ?? name,
directory: result.data?.directory,
current: false,
}
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
async function currentRootSession(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
const response = await sdk.v2.session.list({
directory: await current(sdk),
limit: 50,
order: "desc",
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
const root = (response.data?.data ?? [])
.filter((session) => !session.parentID)
.toSorted((a, b) => b.time.updated - a.time.updated)[0]
if (!root) return
return currentSession(sdk, root.id)
}
async function transcriptKind(sdk: OpencodeClient, sessionID: string) {
const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0)
// Ordinary prompt flows assume a transcript with current messages is
// current-owned; only legacy-only modes (--command, directory
// attachments) still probe legacy history for mixed transcripts.
if (current && (interactive || currentPrompt)) return "current" as const
const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0)
if (current) {
if (legacy) throw new Error("Session contains mixed legacy and current transcripts")
return "current" as const
}
if (legacy) return "legacy" as const
return "empty" as const
}
async function createFreshSession(
sdk: OpencodeClient,
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
): Promise<SessionInfo> {
const result = await sdk.session.create({
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
const name = args.title !== undefined && args.title !== "" ? args.title : undefined
const result = await sdk.v2.session.create({
agent: input.agent,
model: input.model
? {
@ -561,17 +674,18 @@ export const RunCommand = effectCmd({
variant: input.variant,
}
: undefined,
permission: [...rules],
location: { directory: await current(sdk) },
})
const id = result.data?.id
const created = result.data?.data
const id = created?.id
if (!id) {
throw new Error("Failed to create session")
}
if (name) await sdk.v2.session.rename({ sessionID: id, title: name })
void share(sdk, id).catch(() => {})
return {
id,
title: result.data?.title,
title: name ?? created.title,
}
}
@ -580,8 +694,8 @@ export const RunCommand = effectCmd({
return directory ?? root
}
const next = await sdk.path
.get()
const next = await sdk.v2.location
.get(undefined, { throwOnError: true })
.then((x) => x.data?.directory)
.catch(() => undefined)
if (next) {
@ -622,10 +736,7 @@ export const RunCommand = effectCmd({
if (!args.agent) return undefined
const name = args.agent
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined)
if (!modes) {
UI.println(
@ -636,7 +747,7 @@ export const RunCommand = effectCmd({
return undefined
}
const agent = modes.find((a) => a.name === name)
const agent = modes.find((item) => item.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
@ -823,9 +934,33 @@ export const RunCommand = effectCmd({
// Validate agent if specified
const agent = await pickAgent(client)
await share(client, sessionID)
if (!interactive) {
if (currentPrompt && sess.current !== false) {
const model = pick(args.model)
const { runNonInteractivePrompt } = await import("./run/noninteractive")
try {
await runNonInteractivePrompt({
client,
sessionID,
message,
files,
agent,
model,
variant: args.variant,
thinking,
format: args.format === "json" ? "json" : "default",
dangerouslySkipPermissions: args["dangerously-skip-permissions"],
renderTool: tool,
renderToolError: toolError,
})
} catch (error) {
const output = error instanceof Error ? { type: "unknown", message: error.message } : error
if (!emit("error", { error: output })) UI.error(formatRunError(error))
process.exitCode = 1
}
return
}
const events = await client.event.subscribe()
const completed = loop(client, events).catch((e) => {
console.error(e)
@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({
directory: cwd,
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
resume: Boolean(args.session || args.continue),
replay,
replayLimit: args["replay-limit"],
agent,
@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({
fetch: fetchFn,
resolveAgent: localAgent,
session,
share,
createSession: createFreshSession,
agent: args.agent,
model,
@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) {
continue: input.continue,
session: input.session,
fork: input.fork,
share: undefined,
model: input.model,
agent: input.agent,
format: "default",
@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) {
"dangerously-skip-permissions": false,
dangerouslySkipPermissions: false,
demo: input.demo ?? false,
})
} as Parameters<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
}

View file

@ -0,0 +1,114 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
function location(directory: string) {
return {
location: {
directory,
},
}
}
function defaultCost(model: CurrentModel) {
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
if (!picked) {
return undefined
}
return {
...picked,
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
}
}
export function runAgent(input: CurrentAgent): RunAgent {
return {
name: input.id,
description: input.description,
mode: input.mode,
hidden: input.hidden,
}
}
export function runCommand(input: CurrentCommand): RunCommand {
return {
name: input.name,
description: input.description,
}
}
export function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.name,
description: input.description,
source: "skill",
}
}
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
const grouped = new Map<string, RunProvider>()
for (const provider of providers) {
grouped.set(provider.id, {
id: provider.id,
name: provider.name,
models: {},
})
}
for (const model of models) {
const provider = grouped.get(model.providerID) ?? {
id: model.providerID,
name: model.providerID,
models: {},
}
provider.models[model.id] = {
id: model.id,
providerID: model.providerID,
name: model.name,
capabilities: model.capabilities,
cost: defaultCost(model),
limit: model.limit,
status: model.status,
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])),
}
grouped.set(provider.id, provider)
}
return [...grouped.values()]
}
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).map(runAgent)
}
export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([
sdk.v2.command.list(location(directory), { throwOnError: true }),
sdk.v2.skill.list(location(directory), { throwOnError: true }),
])
return [
...(commands.data?.data ?? []).map(runCommand),
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
]
}
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).filter((reference) => !reference.hidden)
}
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
const [providers, models] = await Promise.all([
sdk.v2.provider.list(location(directory), { throwOnError: true }),
sdk.v2.model.list(location(directory), { throwOnError: true }),
])
return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
}

View file

@ -141,7 +141,9 @@ export function RunPermissionBody(props: {
const info = createMemo(() => permissionInfo(props.request))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() => permissionOptions(state().stage))
const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
)
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
if (state().stage === "always") {
@ -165,7 +167,7 @@ export function RunPermissionBody(props: {
})
const shift = (dir: -1 | 1) => {
setState((prev) => permissionShift(prev, dir))
setState((prev) => permissionShift(prev, dir, opts()))
}
const submit = async (next: PermissionReply) => {

View file

@ -1,7 +1,7 @@
// Prompt composer and its state machine for direct interactive mode.
//
// createPromptState() wires keymap command layers, history navigation, and
// `@` autocomplete for files, subagents, and MCP resources.
// `@` autocomplete for files, subagents, and project references.
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
@ -59,7 +59,7 @@ type PromptInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
resources: Accessor<RunResource[]>
references: Accessor<RunReference[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
state: Accessor<FooterState>
@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState {
},
}))
})
const resources = createMemo<Auto[]>(() => {
return input.resources().map((item) => ({
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
display: Locale.truncateMiddle("@" + item.name, width()),
value: item.name,
description: item.description,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
type: "file",
mime: item.mimeType ?? "text/plain",
mime: "application/x-directory",
filename: item.name,
url: item.uri,
url: pathToFileURL(item.path).href,
source: {
type: "resource",
clientName: item.client,
uri: item.uri,
type: "file",
path: item.name,
text: {
start: 0,
end: 0,
@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState {
},
{ initialValue: [] as Auto[] },
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState {
return [
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...files(),
...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
]
}

View file

@ -53,6 +53,9 @@ export function RunFooterSubagentBody(props: {
diffStyle?: RunDiffStyle
onCycle: (dir: -1 | 1) => void
onClose: () => void
// Formatted interrupt shortcut from the registered keymap binding; the
// command itself is dispatched through the keymap in footer.view.
interrupt?: () => string | undefined
}) {
const theme = createMemo(() => props.theme())
const footer = createMemo(() => theme().footer)
@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: {
))
let scroll: ScrollBoxRenderable | undefined
const interruptHint = createMemo(() => {
if (tab()?.status !== "running") return undefined
return props.interrupt?.()
})
useKeyboard((event) => {
if (!props.active()) {
return
@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: {
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
</Show>
</text>
<Show when={interruptHint()}>
{(hint) => (
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{hint()} interrupt
</text>
)}
</Show>
<Show when={props.total() > 1 && props.index() > 0}>
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{props.index()} of {props.total()}

View file

@ -55,7 +55,7 @@ import type {
RunInput,
RunPrompt,
RunProvider,
RunResource,
RunReference,
RunTuiConfig,
StreamCommit,
} from "./types"
@ -71,7 +71,7 @@ type RunFooterOptions = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
commands?: RunCommand[]
wrote?: boolean
sessionID: () => string | undefined
@ -97,6 +97,7 @@ type RunFooterOptions = {
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
treeSitterClient?: TreeSitterClient
}
@ -180,8 +181,8 @@ export class RunFooter implements FooterApi {
private rows = TEXTAREA_MIN_ROWS
private agents: Accessor<RunAgent[]>
private setAgents: Setter<RunAgent[]>
private resources: Accessor<RunResource[]>
private setResources: Setter<RunResource[]>
private references: Accessor<RunReference[]>
private setReferences: Setter<RunReference[]>
private commands: Accessor<RunCommand[] | undefined>
private setCommands: Setter<RunCommand[] | undefined>
private providers: Accessor<RunProvider[] | undefined>
@ -255,9 +256,9 @@ export class RunFooter implements FooterApi {
const [agents, setAgents] = createSignal(options.agents)
this.agents = agents
this.setAgents = setAgents
const [resources, setResources] = createSignal(options.resources)
this.resources = resources
this.setResources = setResources
const [references, setReferences] = createSignal(options.references)
this.references = references
this.setReferences = setReferences
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
this.commands = commands
this.setCommands = setCommands
@ -311,7 +312,7 @@ export class RunFooter implements FooterApi {
queuedPrompts: footer.queuedPrompts,
findFiles: options.findFiles,
agents: footer.agents,
resources: footer.resources,
references: footer.references,
commands: footer.commands,
providers: footer.providers,
currentModel: footer.currentModel,
@ -341,6 +342,7 @@ export class RunFooter implements FooterApi {
onLayout: footer.syncLayout,
onStatus: footer.setStatus,
onSubagentSelect: options.onSubagentSelect,
onSubagentInterrupt: options.onSubagentInterrupt,
onQueuedRemove: footer.handleQueuedRemove,
})
},
@ -411,7 +413,7 @@ export class RunFooter implements FooterApi {
}
this.setAgents(next.agents)
this.setResources(next.resources)
this.setReferences(next.references)
if (next.commands !== undefined) {
this.setCommands(next.commands)
}

View file

@ -50,7 +50,7 @@ import type {
RunInput,
RunPrompt,
RunProvider,
RunResource,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@ -74,7 +74,7 @@ type RunFooterViewProps = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: () => RunAgent[]
resources: () => RunResource[]
references: () => RunReference[]
commands: () => RunCommand[] | undefined
providers: () => RunProvider[] | undefined
currentModel: () => RunInput["model"]
@ -108,6 +108,7 @@ type RunFooterViewProps = {
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
onStatus: (text: string) => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
onQueuedRemove: (messageID: string) => Promise<boolean>
}
@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) {
directory: props.directory,
findFiles: props.findFiles,
agents: props.agents,
resources: props.resources,
references: props.references,
commands: props.commands,
tuiConfig: props.tuiConfig,
state: props.state,
@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) {
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
selectedTab()?.status === "running" &&
!!props.onSubagentInterrupt,
priority: 1,
commands: [
{
name: "subagent.interrupt",
title: "Interrupt subagent",
category: "Session",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
return
}
props.onSubagentInterrupt?.(current.sessionID)
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {
const current = route()
if (current.type !== "subagent") {
@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) {
diffStyle={props.diffStyle}
onCycle={cycleTab}
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}
/>
</box>
</Show>

View file

@ -0,0 +1,459 @@
import type {
OpencodeClient,
ReasoningPart,
StepFinishPart,
StepStartPart,
TextPart,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { EOL } from "node:os"
import { MessageID } from "@/session/schema"
import { UI } from "../../ui"
type Model = {
providerID: string
modelID: string
}
type File = {
url: string
filename: string
mime: string
}
type Input = {
client: OpencodeClient
sessionID: string
message: string
files: File[]
agent?: string
model?: Model
variant?: string
thinking: boolean
format: "default" | "json"
dangerouslySkipPermissions: boolean
renderTool: (part: ToolPart) => Promise<void>
renderToolError: (part: ToolPart) => Promise<void>
}
type StartedPart = {
id: string
timestamp: number
}
type ToolState = StartedPart & {
assistantMessageID: string
tool: string
input: Record<string, unknown>
raw?: string
provider?: unknown
}
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const events = await input.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
const connected = await stream.next()
if (connected.done) throw new Error("Event stream disconnected before prompt admission")
const messageID = MessageID.ascending()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
let submitted = false
let promoted = false
let emittedError = false
let questionRejected = false
let permissionRejected = false
let interrupted = false
let admission: AbortController | undefined
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
if (input.format !== "json") return false
process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL)
return true
}
const writeText = (part: TextPart, timestamp: number) => {
if (emit("text", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
return
}
UI.empty()
UI.println(text)
UI.empty()
}
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
if (!input.dangerouslySkipPermissions) {
permissionRejected = true
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
)
}
await input.client.v2.session.permission
.reply({
sessionID: input.sessionID,
requestID: request.id,
reply: input.dangerouslySkipPermissions ? "once" : "reject",
})
.catch(() => {})
if (!input.dangerouslySkipPermissions) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
if (event.type === "session.next.prompted") {
if (event.data.messageID === messageID) {
promoted = true
continue
}
if (promoted && event.data.delivery === "queue") return
}
if (
event.type === "session.next.execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected)
) {
return
}
if (!promoted) continue
if (event.type === "session.next.step.started") {
const part: StepStartPart = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "step-start",
snapshot: event.data.snapshot,
}
if (!emit("step_start", time, { part }) && input.format !== "json") {
UI.empty()
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
UI.empty()
}
continue
}
if (event.type === "session.next.text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.text.ended") {
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "text",
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
writeText(part, time)
continue
}
if (event.type === "session.next.reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID)
const part: ReasoningPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "reasoning",
text: event.data.text,
metadata: event.data.providerMetadata,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) {
process.stdout.write(line + EOL)
continue
}
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
if (event.type === "session.next.tool.input.started") {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
})
continue
}
if (event.type === "session.next.tool.input.ended") {
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.next.tool.called") {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.tool,
input: event.data.input,
raw: current?.raw,
provider: event.data.provider,
})
continue
}
if (event.type === "session.next.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
tool: current.tool,
state: {
status: "completed",
input: current.input,
output: event.data.content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n"),
title: current.tool,
metadata: {
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "session.next.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
tool: current.tool,
state: {
status: "error",
input: current.input,
error,
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) {
await input.renderToolError(part)
UI.error(error)
}
continue
}
if (event.type === "session.next.step.ended") {
const part: StepFinishPart = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "step-finish",
reason: event.data.finish,
snapshot: event.data.snapshot,
cost: event.data.cost,
tokens: event.data.tokens,
}
emit("step_finish", time, { part })
continue
}
if (event.type === "session.next.step.failed") {
if (interrupted || permissionRejected || questionRejected) continue
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.next.execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
emittedError = true
process.exitCode = 1
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message)
}
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
return
}
}
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
process.exitCode = 130
admission?.abort()
void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
process.on("SIGINT", interrupt)
let completed: Promise<void> | undefined
try {
if (input.agent) {
await input.client.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: input.agent },
{ throwOnError: true },
)
}
const selected = input.model
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
: input.variant
? await input.client.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true })
.then((result) => result.data.data.model)
.then(async (model) => {
if (model) return { ...model, variant: input.variant }
const result = await input.client.v2.model.default(undefined, { throwOnError: true })
const fallback = result.data.data
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
})
: undefined
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) {
await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true })
}
const prepared = await Promise.all(input.files.map(prepareFile))
if (interrupted) return
submitted = true
completed = consume()
admission = new AbortController()
const response = await input.client.v2.session
.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
},
delivery: "steer",
},
{ throwOnError: true, signal: admission.signal },
)
.catch(async (error) => {
if (interrupted) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
controller.abort()
await completed?.catch(() => {})
if (interrupted) return undefined
throw error
})
admission = undefined
if (!response) return
if (!response.data.data) throw new Error("Prompt was not admitted")
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
])
await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
])
await completed
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
}
}
function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function fallbackTool(event: {
id: string
data: { timestamp: number; assistantMessageID: string; callID: string }
}): ToolState {
return {
id: partID(event.id),
timestamp: toMillis(event.data.timestamp),
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
}
}
function toMillis(value: unknown) {
if (typeof value === "number") return value
if (typeof value === "string") return new Date(value).getTime()
return Date.now()
}
async function prepareFile(file: File) {
if (file.mime !== "text/plain") {
const uri = file.url.startsWith("data:")
? file.url
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
return { attachment: { uri, mime: file.mime, name: file.filename } }
}
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}

View file

@ -150,8 +150,11 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply
}
}
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
const list = permissionOptions(state.stage)
export function permissionShift(
state: PermissionBodyState,
dir: -1 | 1,
list = permissionOptions(state.stage),
): PermissionBodyState {
if (list.length === 0) {
return state
}

View file

@ -8,9 +8,12 @@
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { loadRunProviders } from "./catalog.shared"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
@ -95,20 +98,7 @@ const layer = Layer.effect(
directory: string,
model: RunInput["model"],
) {
const connected = yield* Effect.promise(() =>
sdk.config
.providers({ directory })
.then((item) => item.data?.providers)
.catch(() => undefined),
)
const providers = yield* Effect.promise(() =>
connected
? Promise.resolve(connected)
: sdk.provider
.list()
.then((item) => item.data?.all ?? [])
.catch(() => []),
)
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
const limits = Object.fromEntries(
providers.flatMap((provider) =>
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
@ -143,7 +133,7 @@ const layer = Layer.effect(
sessionID: string,
model: RunInput["model"],
) {
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
if (!session) {
return emptySessionInfo()
}
@ -172,7 +162,8 @@ const layer = Layer.effect(
}),
)
const runtime = makeRuntime(Service, layer)
const node = makeGlobalNode({ service: Service, layer, deps: [] })
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(

View file

@ -27,7 +27,7 @@ import type {
RunAgent,
RunInput,
RunPrompt,
RunResource,
RunReference,
RunTuiConfig,
} from "./types"
import { formatModelLabel } from "./variant.shared"
@ -55,7 +55,7 @@ export type LifecycleInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
sessionID: string
sessionTitle?: string
getSessionID?: () => string | undefined
@ -75,6 +75,7 @@ export type LifecycleInput = {
onInterrupt?: () => void
onBackground?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
export type Lifecycle = {
@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
references: input.references,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}
},
onSubagentSelect: input.onSubagentSelect,
onSubagentInterrupt: input.onSubagentInterrupt,
})
const sigint = () => {

View file

@ -1,7 +1,7 @@
import fs from "fs"
import * as tty from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
type InteractiveStdin = {
stdin: NodeJS.ReadStream

View file

@ -1,4 +1,4 @@
// Top-level orchestrator for `opencode --mini`.
// Top-level orchestrator for `opencode mini`.
//
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
// and prompt queue together into a single session loop. Two entry points:
@ -15,6 +15,7 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import { createRunDemo } from "./demo"
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
@ -62,7 +63,6 @@ type RunLocalInput = {
fetch: typeof globalThis.fetch
resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
createSession?: CreateSession
agent: RunInput["agent"]
model: RunInput["model"]
@ -77,7 +77,7 @@ type RunLocalInput = {
}
type StreamTransportModule = Pick<
Awaited<typeof import("./stream.transport")>,
Awaited<typeof import("./stream-v2.transport")>,
"createSessionTransport" | "formatUnknownError"
>
@ -164,11 +164,9 @@ async function resolveExitTitle(
return undefined
}
return ctx.sdk.session
.get({
sessionID: state.sessionID,
})
.then((x) => x.data?.title)
return ctx.sdk.v2.session
.get({ sessionID: state.sessionID })
.then((x) => x.data?.data.title)
.catch(() => undefined)
}
@ -233,7 +231,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
.then((x) => x.data ?? [])
.catch(() => []),
agents: [],
resources: [],
references: [],
sessionID: state.sessionID,
sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID,
@ -250,21 +248,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
log?.write("send.permission.reply", next)
await ctx.sdk.permission.reply(next)
await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next })
},
onQuestionReply: async (next) => {
if (state.demo?.questionReply(next)) {
return
}
await ctx.sdk.question.reply(next)
await ctx.sdk.v2.session.question.reply({
sessionID: state.sessionID,
requestID: next.requestID,
questionV2Reply: { answers: next.answers ?? [] },
})
},
onQuestionReject: async (next) => {
if (state.demo?.questionReject(next)) {
return
}
await ctx.sdk.question.reject(next)
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
@ -339,22 +341,30 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
},
onInterrupt: () => {
if (!hasSession(input, state) || state.aborting) {
return
return false
}
state.aborting = true
void ctx.sdk.session
.abort({
sessionID: state.sessionID,
})
void (state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
.catch(() => {})
.finally(() => {
state.aborting = false
})
return true
},
onBackground: () => {
if (!hasSession(input, state)) return
void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
if (!hasSession(input, state)) {
return
}
log?.write("send.background", { sessionID: state.sessionID })
void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
@ -373,19 +383,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
const [agents, resources, commands] = await Promise.all([
ctx.sdk.app
.agents({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
ctx.sdk.experimental.resource
.list({ directory: ctx.directory })
.then((x) => Object.values(x.data ?? {}))
.catch(() => []),
ctx.sdk.command
.list({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
const [agents, references, commands] = await Promise.all([
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
])
if (footer.isClosed) {
return
@ -394,7 +395,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer.event({
type: "catalog",
agents,
resources,
references,
commands,
})
}
@ -453,7 +454,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
})
const streamTask = deps.streamTransport ?? import("./stream.transport")
const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
const ensureStream = () => {
if (state.stream) {
return state.stream
@ -758,7 +759,6 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
throw new Error("Session not found")
}
void input.share(sdk, next.id).catch(() => {})
return {
sessionID: next.id,
sessionTitle: next.title,

View file

@ -5,9 +5,9 @@
// - FooterOutput: status bar patches and view transitions (permission, question)
//
// The reducer mutates SessionData in place for performance but has no
// external side effects -- no IO, no footer calls. The caller
// (stream.transport.ts) feeds events in and forwards output to the footer
// through stream.ts.
// external side effects -- no IO, no footer calls. The demo runtime
// (demo.ts) feeds events in and forwards output to the footer through
// stream.ts; the current transport reuses the blocker helpers below.
//
// Key design decisions:
//
@ -24,7 +24,7 @@
// `data.questions`. The footer shows whichever is first. When a reply
// event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view.
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@ -280,33 +280,6 @@ function remove(list: Array<{ id: string }>, id: string): boolean {
return true
}
export function bootstrapSessionData(input: {
data: SessionData
messages: Array<{
parts: Part[]
}>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}) {
for (const message of input.messages) {
for (const part of message.parts) {
if (part.type !== "tool") {
continue
}
input.data.call.set(key(part.messageID, part.callID), part.state.input)
}
}
for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
upsert(input.data.permissions, enrichPermission(input.data, request))
}
for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
upsert(input.data.questions, request)
}
}
function key(msg: string, call: string): string {
return `${msg}:${call}`
}
@ -740,26 +713,6 @@ function failTool(part: ToolPart, text: string): SessionCommit {
})
}
// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted.
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
for (const partID of data.part.keys()) {
if (data.ids.has(partID)) {
continue
}
const msg = data.msg.get(partID)
if (msg && data.role.get(msg) === "user" && !data.includeUserText) {
data.ids.add(partID)
drop(data, partID)
continue
}
flushPart(data, commits, partID, true)
data.ids.add(partID)
drop(data, partID)
}
}
// The main reducer. Takes one SDK event and returns scrollback commits and
// footer updates. Called once per event from the stream transport's watch loop.
//

View file

@ -1,374 +0,0 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import { messageTurnSummaryCommit } from "./turn-summary"
import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
permissions: PermissionRequest[]
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
providers?: RunProvider[]
}
type ReplayConfig = {
limits: Record<string, number>
providers?: RunProvider[]
summaries: ReadonlySet<string>
}
export type SessionReplay = {
data: SessionData
commits: StreamCommit[]
patch?: FooterPatch
}
type ReplayMessage = {
commits: StreamCommit[]
patch?: FooterPatch
}
const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user"
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
event,
sessionID,
thinking,
limits,
})
}
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
if (!left) {
return right
}
if (!right) {
return left
}
return {
...left,
...right,
}
}
function active(data: SessionData) {
return data.part.size > 0 || data.tools.size > 0
}
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
if (active(data)) {
if (!patch) {
return {
phase: "running",
} satisfies FooterPatch
}
return {
...patch,
phase: "running",
} satisfies FooterPatch
}
if (data.permissions.length > 0 || data.questions.length > 0) {
if (!patch) {
return {
phase: "idle",
} satisfies FooterPatch
}
return {
...patch,
phase: "idle",
} satisfies FooterPatch
}
if (!patch) {
return undefined
}
return {
...patch,
phase: "idle",
status: "",
} satisfies FooterPatch
}
function isShellSyntheticUser(message: SessionMessages[number]) {
if (message.info.role !== "user") {
return false
}
const prompt = messagePrompt(message)
return (
!prompt.text.trim() &&
prompt.parts.length === 0 &&
message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT)
)
}
function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet<string>) {
return (
message.info.role === "assistant" &&
shellParents.has(message.info.parentID) &&
message.parts.some((part) => part.type === "tool" && part.tool === "bash")
)
}
function summaryMessageIDs(messages: SessionMessages): ReadonlySet<string> {
const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
const parents = new Set<string>()
const summaries = new Set<string>()
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
const message = messages[idx]
if (!message || message.info.role !== "assistant") {
continue
}
if (isShellSyntheticAssistant(message, shellParents)) {
continue
}
if (parents.has(message.info.parentID)) {
continue
}
parents.add(message.info.parentID)
const completed = message.info.time.completed
if (typeof completed === "number" && completed > message.info.time.created) {
summaries.add(message.info.id)
}
}
return summaries
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
config: ReplayConfig,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
if (!prompt.text.trim()) {
return {
commits: [],
}
}
return {
commits: [
{
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: message.info.id,
},
],
}
}
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const info = apply(
data,
{
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: message.info.sessionID,
info: message.info,
},
},
message.info.sessionID,
thinking,
config.limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
for (const part of message.parts) {
const next = apply(
data,
{
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 0,
},
},
message.info.sessionID,
thinking,
config.limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
const summary = config.summaries.has(message.info.id)
? messageTurnSummaryCommit(message, config.providers)
: undefined
if (summary) {
commits.push(summary)
}
return {
commits,
patch,
}
}
export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const summaries = summaryMessageIDs(input.messages)
bootstrapSessionData({
data,
messages: input.messages,
permissions: input.permissions,
questions: input.questions,
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, {
limits: input.limits,
providers: input.providers,
summaries,
})
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
return {
data,
commits,
patch: replayPatch(data, patch),
}
}
export function replayLocalRows(
messages: SessionMessages,
commits: StreamCommit[],
rows: LocalReplayRow[],
): StreamCommit[] {
const persisted = new Set(messages.map((message) => message.info.id))
return rows.reduce((out, local) => {
const row = local.commit
if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) {
return out
}
if (!row.messageID) {
return [...out, row]
}
const exact = local.after
? out.findIndex(
(commit) =>
commit.kind === local.after?.kind &&
commit.text === local.after.text &&
commit.phase === local.after.phase &&
commit.toolState === local.after.toolState &&
(local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID),
)
: -1
const anchored =
exact !== -1
? exact
: local.after
? out.findLastIndex((commit) =>
local.after?.partID
? commit.partID === local.after.partID
: commit.kind === local.after?.kind && commit.messageID === local.after.messageID,
)
: -1
if (anchored !== -1) {
const commit = out[anchored]
const visible = local.after?.visible
if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) {
return [
...out.slice(0, anchored),
{ ...commit, text: visible },
row,
{ ...commit, text: commit.text.slice(visible.length) },
...out.slice(anchored + 1),
]
}
return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)]
}
const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID)
if (after !== -1) {
return [...out.slice(0, after + 1), row, ...out.slice(after + 1)]
}
const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID)
if (before === -1) {
return [...out, row]
}
return [...out.slice(0, before), row, ...out.slice(before)]
}, commits)
}
export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] {
return [...current.part.entries()].flatMap(([partID, kind]) => {
if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) {
return []
}
const text = current.text.get(partID) ?? ""
const existing = data.text.get(partID) ?? ""
const sent = current.sent.get(partID) ?? 0
const existingSent = data.sent.get(partID) ?? 0
const visible = current.visible.get(partID) ?? ""
const existingVisible = data.visible.get(partID) ?? ""
if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) {
return []
}
data.part.set(partID, kind)
data.text.set(partID, text)
data.sent.set(partID, sent)
data.visible.set(partID, visible)
const messageID = current.msg.get(partID)
if (messageID) {
data.msg.set(partID, messageID)
const role = current.role.get(messageID)
if (role) {
data.role.set(messageID, role)
}
}
const chunk = visible.slice(existingVisible.length)
if (!chunk) {
return []
}
return [
{
kind,
text: chunk,
phase: "progress",
source: kind,
...(messageID ? { messageID } : {}),
partID,
},
] satisfies StreamCommit[]
})
}

View file

@ -152,12 +152,52 @@ export function createSession(messages: SessionMessages): RunSession {
}
}
export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
const response = await sdk.session.messages({
sessionID,
limit,
})
return createSession(response.data ?? [])
export async function resolveCurrentSession(
sdk: RunInput["sdk"],
sessionID: string,
limit = LIMIT,
): Promise<RunSession> {
const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true })
const messages = response.data.data.toReversed()
const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
return {
first: messages.length === 0,
turns: messages.flatMap((message) => {
if (message.type !== "user") return []
return [
{
prompt: {
text: message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.uri,
mime: file.mime,
filename: file.name,
source: file.source
? {
type: "file" as const,
path: file.name ?? file.uri,
text: { start: file.source.start, end: file.source.end, value: file.source.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.source
? { start: agent.source.start, end: agent.source.end, value: agent.source.text }
: undefined,
})),
],
},
provider: session.data.data.model?.providerID,
model: session.data.data.model?.id,
variant: session.data.data.model?.variant,
},
]
}),
}
}
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {

View file

@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
lines,
body_left + label.length,
top + 1,
`opencode --mini -s ${meta.session_id}`,
`opencode mini -s ${meta.session_id}`,
right,
undefined,
TextAttributes.BOLD,

View file

@ -0,0 +1,698 @@
// Current-native subagent (child Session) tracking for the mini transport.
//
// Discovers child Sessions of the active parent from four current sources:
// 1. projected subagent tool output (`structured.sessionID`) during hydration
// 2. the current session list filtered by `parentID` during hydration
// 3. the process-local active-session map during hydration
// 4. live events from unknown sessions whose `parentID` matches the parent
//
// Tracks one footer tab per child and a detail transcript for the selected
// child, reduced from the same current live event stream the parent uses.
// Detail transcripts rebuild from projected messages on discovery, selection,
// and reconnect, then continue from live deltas using the same
// projected-prefix dedup the parent transport uses.
//
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type {
OpencodeClient,
SessionMessage,
SessionMessageAssistantTool,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
const DISCOVERY_BUFFER_LIMIT = 64
const FAMILY_LIST_LIMIT = 100
const FALLBACK_LABEL = "Subagent"
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
}
export function legacyTool(input: {
sessionID: string
messageID: string
callID: string
name: string
state: SessionMessageAssistantTool["state"]
time: SessionMessageAssistantTool["time"]
provider?: SessionMessageAssistantTool["provider"]
}): ToolPart {
const base = {
id: `prt_${input.callID}`,
sessionID: input.sessionID,
messageID: input.messageID,
type: "tool" as const,
callID: input.callID,
tool: input.name,
}
if (input.state.status === "pending") {
return {
...base,
state: { status: "pending", input: {}, raw: input.state.input },
}
}
if (input.state.status === "running") {
return {
...base,
state: {
status: "running",
input: input.state.input,
title: input.name,
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
time: { start: input.time.ran ?? input.time.created },
},
}
}
if (input.state.status === "completed") {
return {
...base,
state: {
status: "completed",
input: input.state.input,
output: outputText(input.state.content),
title: input.name,
metadata: {
structured: input.state.structured,
content: input.state.content,
outputPaths: input.state.outputPaths,
result: input.state.result,
providerCall: input.provider,
},
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
}
}
return {
...base,
state: {
status: "error",
input: input.state.input,
error: input.state.error.message,
metadata: {
structured: input.state.structured,
content: input.state.content,
result: input.state.result,
providerCall: input.provider,
},
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
}
}
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
const status = part.state.status
const text =
status === "running"
? part.tool === "task"
? "running task"
: `running ${part.tool}`
: status === "completed"
? part.state.output
: status === "error"
? part.state.error
: ""
return {
kind: "tool",
source: "tool",
text,
phase,
messageID: part.messageID,
partID: part.id,
tool: part.tool,
part,
toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running",
toolError: status === "error" ? part.state.error : undefined,
}
}
type Frame = {
key: string
commit: StreamCommit
}
type ToolTrack = {
name: string
input: Record<string, unknown>
started: number
}
type ChildState = {
sessionID: string
label: string
description: string
status: FooterSubagentTab["status"]
background: boolean
title?: string
callIDs: Set<string>
lastUpdatedAt: number
frames: Frame[]
text: Map<string, string>
projectedText: Map<string, string>
reasoning: Map<string, string>
projectedReasoning: Map<string, string>
tools: Map<string, ToolTrack>
finishedTools: Set<string>
messageIDs: Set<string>
hydrated: boolean
}
export type SubagentTrackerInput = {
sdk: OpencodeClient
sessionID: string
thinking: boolean
emit: () => void
}
export type SubagentTracker = {
main(event: V2Event): void
foreign(sessionID: string, event: V2Event): void
hydrate(next: { messages: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
select(sessionID: string | undefined): void
snapshot(): FooterSubagentState
}
function record(value: unknown): Record<string, unknown> | undefined {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
return undefined
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const next = value.trim()
return next || undefined
}
function childSessionID(structured: Record<string, unknown> | undefined) {
const sessionID = text(structured?.sessionID)
if (!sessionID || !sessionID.startsWith("ses")) return undefined
const status = structured?.status
if (status !== "running" && status !== "completed") return undefined
return { sessionID, running: status === "running" }
}
function tab(child: ChildState): FooterSubagentTab {
return {
sessionID: child.sessionID,
partID: `subagent:${child.sessionID}`,
callID: `subagent:${child.sessionID}`,
label: child.label,
description: child.description || child.title || "",
status: child.status,
background: child.background ? true : undefined,
title: child.title,
toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined,
lastUpdatedAt: child.lastUpdatedAt,
}
}
export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker {
const children = new Map<string, ChildState>()
// Live subagent tool calls in the parent, so tool.success structured output
// can be joined with the call's input metadata.
const pendingCalls = new Map<string, Record<string, unknown>>()
// Foreign sessions already resolved through session.get. Non-children stay
// cached so unrelated concurrent sessions are checked at most once.
const checked = new Set<string>()
// Foreign events buffered while a session.get discovery is in flight, so a
// fast child (including its settled event) is not lost mid-discovery.
const pendingEvents = new Map<string, V2Event[]>()
const hydrations = new Map<string, Promise<void>>()
let selected: string | undefined
const ensureChild = (sessionID: string): ChildState => {
const existing = children.get(sessionID)
const child: ChildState = existing ?? {
sessionID,
label: FALLBACK_LABEL,
description: "",
status: "running",
background: false,
callIDs: new Set(),
lastUpdatedAt: Date.now(),
frames: [],
text: new Map(),
projectedText: new Map(),
reasoning: new Map(),
projectedReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
messageIDs: new Set(),
hydrated: false,
}
if (!existing) children.set(sessionID, child)
// Adopting a child while its session.get discovery is still in flight:
// drain the buffered events now. They arrived before whatever the caller
// applies next, so replaying them first preserves bus order, and the
// resolved discovery can no longer replay stale events (e.g. step.started)
// after a terminal settled event was applied directly.
const buffered = pendingEvents.get(sessionID)
if (buffered) {
pendingEvents.delete(sessionID)
for (const event of buffered) reduce(child, event)
}
return child
}
const touch = (child: ChildState, timestamp?: number) => {
child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now())
}
const notifyDetail = (child: ChildState) => {
if (child.sessionID === selected) input.emit()
}
const setFrame = (child: ChildState, key: string, commit: StreamCommit) => {
const index = child.frames.findIndex((item) => item.key === key)
if (index === -1) {
child.frames.push({ key, commit })
if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT)
return
}
child.frames[index] = { key, commit }
}
const applyMeta = (child: ChildState, meta: Record<string, unknown> | undefined) => {
if (!meta) return
const agent = text(meta.agent)
if (agent) child.label = Locale.titlecase(agent)
const description = text(meta.description)
if (description) child.description = description
if (meta.background === true) child.background = true
}
const userFrame = (child: ChildState, messageID: string, value: string) => {
if (child.messageIDs.has(messageID)) return false
child.messageIDs.add(messageID)
setFrame(child, `user:${messageID}`, {
kind: "user",
source: "system",
text: value,
phase: "start",
messageID,
})
return true
}
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
const part = legacyTool({
sessionID: child.sessionID,
messageID,
callID: item.id,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
})
if (item.state.status === "pending") return
child.callIDs.add(item.id)
if (item.state.status === "running") {
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
return
}
child.finishedTools.add(item.id)
child.tools.delete(item.id)
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
}
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
child.frames = []
child.text.clear()
child.projectedText.clear()
child.reasoning.clear()
child.projectedReasoning.clear()
child.finishedTools.clear()
child.messageIDs.clear()
child.callIDs.clear()
for (const message of messages) {
if (message.type === "user") {
userFrame(child, message.id, message.text)
continue
}
if (message.type !== "assistant") continue
child.messageIDs.add(message.id)
for (const item of message.content) {
if (item.type === "text") {
child.text.set(item.id, item.text)
child.projectedText.set(item.id, item.text)
setFrame(child, `text:${item.id}`, {
kind: "assistant",
source: "assistant",
text: item.text,
phase: "progress",
messageID: message.id,
partID: item.id,
})
continue
}
if (item.type === "reasoning") {
child.reasoning.set(item.id, item.text)
child.projectedReasoning.set(item.id, item.text)
if (input.thinking)
setFrame(child, `reasoning:${item.id}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${item.text}`,
phase: "progress",
messageID: message.id,
partID: item.id,
})
continue
}
childTool(child, item, message.id)
}
if (message.error) {
setFrame(child, `error:${message.id}`, {
kind: "error",
source: "system",
text: message.error.message,
phase: "start",
messageID: message.id,
})
}
}
}
const hydrateChild = (child: ChildState): Promise<void> => {
const existing = hydrations.get(child.sessionID)
if (existing) return existing
const task = input.sdk.v2.session
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => {
rebuild(child, response.data.data.toReversed())
child.hydrated = true
notifyDetail(child)
})
.catch(() => {})
.finally(() => {
hydrations.delete(child.sessionID)
})
hydrations.set(child.sessionID, task)
return task
}
const discover = (sessionID: string) => {
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
checked.add(sessionID)
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
void input.sdk.v2.session
.get({ sessionID }, { throwOnError: true })
.then((response) => {
const session = response.data.data
const buffered = pendingEvents.get(sessionID) ?? []
pendingEvents.delete(sessionID)
if (session.parentID !== input.sessionID) return
const child = ensureChild(sessionID)
if (session.agent) child.label = Locale.titlecase(session.agent)
child.title = session.title
for (const event of buffered) reduce(child, event)
touch(child)
input.emit()
void hydrateChild(child)
})
.catch(() => {
// Allow a later event to retry discovery after transient failures.
pendingEvents.delete(sessionID)
checked.delete(sessionID)
})
}
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "session.next.prompted") {
if (userFrame(child, event.data.messageID, event.data.prompt.text)) {
touch(child, event.data.timestamp)
notifyDetail(child)
}
return
}
if (event.type === "session.next.step.started") {
touch(child, event.data.timestamp)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
if (child.status !== "running") child.status = "running"
input.emit()
return
}
if (event.type === "session.next.text.delta") {
const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
return
}
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
child.text.set(event.data.textID, next)
setFrame(child, `text:${event.data.textID}`, {
kind: "assistant",
source: "assistant",
text: next,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
})
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.text.ended") {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
kind: "assistant",
source: "assistant",
text: event.data.text,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
})
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
return
}
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
child.reasoning.set(event.data.reasoningID, next)
if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${next}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
})
notifyDetail(child)
return
}
if (event.type === "session.next.reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
})
notifyDetail(child)
return
}
if (event.type === "session.next.tool.input.started") {
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp })
return
}
if (event.type === "session.next.tool.called") {
const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, {
name: event.data.tool,
input: event.data.input,
started: current?.started ?? event.data.timestamp,
})
childTool(
child,
{
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp },
},
event.data.assistantMessageID,
)
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed"
childTool(
child,
{
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
state: failed
? {
status: "error",
input: current?.input ?? {},
structured: {},
content: [],
error: event.data.error,
result: event.data.result,
}
: {
status: "completed",
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: {
created: current?.started ?? event.data.timestamp,
ran: current?.started,
completed: event.data.timestamp,
},
},
event.data.assistantMessageID,
)
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error",
source: "system",
text: event.data.error.message,
phase: "start",
messageID: event.data.assistantMessageID,
})
touch(child, event.data.timestamp)
notifyDetail(child)
return
}
if (event.type === "session.next.execution.settled") {
child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.data.timestamp)
input.emit()
}
}
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
if (item.name !== "subagent" || item.state.status !== "completed") return
const found = childSessionID(record(item.state.structured))
if (!found) return
const child = ensureChild(found.sessionID)
applyMeta(child, record(item.state.input))
if (found.running) child.background = true
if (child.status === "running") {
const running = found.running && (!active || found.sessionID in active)
child.status = running ? "running" : "completed"
}
touch(child, item.time.completed ?? item.time.created)
}
return {
main(event) {
if (event.type === "session.next.tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
return
}
if (event.type === "session.next.tool.failed") {
pendingCalls.delete(event.data.callID)
return
}
if (event.type !== "session.next.tool.success") return
const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured))
if (!found) return
const child = ensureChild(found.sessionID)
applyMeta(child, pending)
if (found.running) {
child.background = true
child.status = "running"
}
if (!found.running && child.status === "running") child.status = "completed"
touch(child, event.data.timestamp)
input.emit()
if (!child.hydrated) void hydrateChild(child)
},
foreign(sessionID, event) {
const child = children.get(sessionID)
if (child) {
reduce(child, event)
return
}
discover(sessionID)
const buffered = pendingEvents.get(sessionID)
if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event)
},
async hydrate(next) {
for (const message of next.messages) {
if (message.type !== "assistant") continue
for (const item of message.content) {
if (item.type === "tool") mainTool(item, next.active)
}
}
// Family index: adopt children directly from the current session list so
// historical subagents beyond the projected message window still get tabs.
const family = await input.sdk.v2.session
.list({ limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => response.data.data.filter((session) => session.parentID === input.sessionID))
.catch(() => [])
for (const session of family) {
const child = ensureChild(session.id)
if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent)
if (!child.title) child.title = session.title
touch(child, session.time.updated)
}
for (const sessionID of Object.keys(next.active)) discover(sessionID)
for (const child of children.values()) {
// Reconnect can miss a child's settled event; the active map is the
// authoritative live signal for still-running children.
if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed"
}
const current = selected ? children.get(selected) : undefined
if (current) await hydrateChild(current)
if (children.size > 0) input.emit()
},
select(sessionID) {
selected = sessionID
const child = sessionID ? children.get(sessionID) : undefined
if (child && !child.hydrated) void hydrateChild(child)
input.emit()
},
snapshot() {
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
})
const child = selected ? children.get(selected) : undefined
const details: Record<string, FooterSubagentDetail> = child
? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } }
: {}
return { tabs, details, permissions: [], questions: [] }
},
}
}

View file

@ -0,0 +1,798 @@
import type {
OpencodeClient,
PermissionRequest,
PermissionV2Request,
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
LocalReplayAnchor,
LocalReplayRow,
RunFilePart,
RunInput,
RunPrompt,
RunPromptPart,
RunProvider,
StreamCommit,
} from "./types"
type Trace = {
write(type: string, data?: unknown): void
}
type StreamInput = {
sdk: OpencodeClient
directory?: string
sessionID: string
thinking: boolean
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
providers?: () => RunProvider[]
footer: FooterApi
trace?: Trace
signal?: AbortSignal
}
export type SessionTurnInput = {
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
prompt: RunPrompt
files: RunFilePart[]
includeFiles: boolean
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
signal?: AbortSignal
}
export type SessionResizeReplayInput = {
localRows: () => LocalReplayRow[]
reset: () => Promise<void>
}
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput): Promise<void>
interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
close(): Promise<void>
}
type Wait = {
messageID: string
promoted: boolean
interrupted: boolean
failureRendered: boolean
resolve: () => void
reject: (error: unknown) => void
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
}
type RunV2Event = V2Event
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = {
messageID: string
name: string
input: Record<string, unknown>
started: number
running: boolean
}
type State = {
permissions: PermissionRequest[]
questions: QuestionRequest[]
view: FooterView
messageIDs: Set<string>
text: Map<string, string>
projectedText: Map<string, string>
reasoning: Map<string, string>
projectedReasoning: Map<string, string>
tools: Map<string, ToolState>
finishedTools: Set<string>
wait?: Wait
connected: boolean
closed: boolean
initial: boolean
buffered?: RunV2Event[]
errors: Set<string>
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
export function formatUnknownError(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message || error.name
if (error && typeof error === "object") {
const message = Reflect.get(error, "message")
if (typeof message === "string" && message.trim()) return message
const tag = Reflect.get(error, "_tag")
if (typeof tag === "string" && tag.trim()) return tag
}
return "unknown error"
}
function permission(request: PermissionV2Request): PermissionRequest {
return {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: request.resources,
metadata: request.metadata ?? {},
always: request.save ?? [],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions,
tool: request.tool,
}
}
function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
}
function errorMessage(error: { message?: string; _tag?: string }) {
return error.message || error._tag || "Session execution failed"
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
async function prepareFile(file: RunFilePart) {
if (file.mime !== "text/plain") return { attachment: { uri: file.url, mime: file.mime, name: file.filename } }
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
function promptFileSource(part: PromptFilePart) {
if (!part.source?.text) return
return {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
}
function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnInput, "model" | "variant" | "signal">) {
if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
if (!next.variant) return
const session = await input.sdk.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data.model)
if (session) return { ...session, variant: next.variant }
const fallback = await input.sdk.v2.model
.default(undefined, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data)
if (!fallback) return
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
}
export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
const controller = new AbortController()
input.signal?.addEventListener("abort", () => controller.abort(), { once: true })
const state: State = {
permissions: [],
questions: [],
view: { type: "prompt" },
messageIDs: new Set(),
text: new Map(),
projectedText: new Map(),
reasoning: new Map(),
projectedReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
connected: false,
closed: false,
initial: true,
errors: new Set(),
}
let readyResolve!: () => void
let readyReject!: (error: unknown) => void
const ready = new Promise<void>((resolve, reject) => {
readyResolve = resolve
readyReject = reject
})
const abortReady = () => readyReject(new Error("Mini closed before the event stream connected"))
controller.signal.addEventListener("abort", abortReady, { once: true })
const offFooterClose = input.footer.onClose(() => controller.abort())
const subagents = createSubagentTracker({
sdk: input.sdk,
sessionID: input.sessionID,
thinking: input.thinking,
emit: () => {
if (state.closed || input.footer.isClosed) return
writeSessionOutput(
{ footer: input.footer, trace: input.trace },
{ commits: [], footer: { subagent: subagents.snapshot() } },
)
},
})
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
const visible = commits.at(-1)
if (visible) {
state.wait?.onVisibleOutput?.({
kind: visible.kind,
text: visible.text,
phase: visible.phase,
messageID: visible.messageID,
partID: visible.partID,
toolState: visible.toolState,
})
}
writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits, footer: patch ? { patch } : undefined })
}
const syncBlockers = () => {
const next = pickBlockerView({ permission: state.permissions[0], question: state.questions[0] })
if (next.type === "prompt" && state.view.type === "prompt") return
if (next.type !== "prompt" && state.view.type === next.type && next.request.id === state.view.request.id) return
state.view = next
writeSessionOutput(
{ footer: input.footer, trace: input.trace },
{ commits: [], footer: { view: next, patch: { status: blockerStatus(next) } } },
)
}
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
const part = legacyTool({
sessionID: input.sessionID,
messageID,
callID: item.id,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
})
if (item.state.status === "pending") return
if (item.state.status === "running") {
if (state.tools.get(item.id)?.running) return
state.tools.set(item.id, {
messageID,
name: item.name,
input: item.state.input,
started: item.time.ran ?? item.time.created,
running: true,
})
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
return
}
if (state.finishedTools.has(item.id)) return
if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")])
state.finishedTools.add(item.id)
state.tools.delete(item.id)
write([toolCommit(part, item.state.status === "completed" && part.state.status === "completed" && part.state.output ? "progress" : "final")])
}
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
if (message.type === "user") {
const waiting = state.wait?.messageID === message.id
if (waiting && state.wait) state.wait.promoted = true
if (!render || state.messageIDs.has(message.id)) return
state.messageIDs.add(message.id)
if (reuseVisibleWait && waiting) return
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
return
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
for (const item of message.content) {
if (item.type === "text") {
const key = streamPartKey(message.id, item.id)
const sent = state.text.get(key)?.length ?? 0
state.text.set(key, item.text)
if (render) state.projectedText.set(key, item.text)
if (render && item.text.length > sent)
write([
{
kind: "assistant",
source: "assistant",
text: item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
},
])
continue
}
if (item.type === "reasoning") {
const key = streamPartKey(message.id, item.id)
const sent = state.reasoning.get(key)?.length ?? 0
state.reasoning.set(key, item.text)
if (render) state.projectedReasoning.set(key, item.text)
if (render && input.thinking && item.text.length > sent)
write([
{
kind: "reasoning",
source: "reasoning",
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: item.id,
},
])
continue
}
if (render) renderTool(message.id, item)
}
if (render && message.error && !state.errors.has(message.id)) {
state.errors.add(message.id)
write([
{
kind: "error",
source: "system",
text: errorMessage(message.error),
phase: "start",
messageID: message.id,
},
])
}
}
const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => {
const [messages, permissions, questions, active] = await Promise.all([
input.sdk.v2.session.messages(
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
{ throwOnError: true },
),
input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.active({ throwOnError: true }),
])
const projected = messages.data.data.toReversed()
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.data.data.map(permission)
state.questions = questions.data.data.map(question)
syncBlockers()
await subagents.hydrate({ messages: projected, active: active.data.data })
const running = input.sessionID in active.data.data
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait
state.wait = undefined
current.resolve()
}
}
const apply = (event: RunV2Event) => {
const source = sessionID(event)
if (source !== input.sessionID) {
if (source) subagents.foreign(source, event)
return
}
input.trace?.write("recv.event", event)
subagents.main(event)
if (event.type === "session.next.prompted") {
if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true
state.messageIDs.add(event.data.messageID)
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.next.step.started") {
write([], { phase: "running", status: "assistant responding" })
return
}
if (event.type === "session.next.text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
state.projectedText.set(key, projected.slice(covered + event.data.delta.length))
return
}
const previous = state.text.get(key) ?? ""
state.text.set(key, previous + event.data.delta)
write([
{
kind: "assistant",
source: "assistant",
text: event.data.delta,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
},
])
return
}
if (event.type === "session.next.text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
write([
{
kind: "assistant",
source: "assistant",
text: event.data.text.slice(previous.length),
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.textID,
},
])
state.text.set(key, event.data.text)
state.projectedText.delete(key)
return
}
if (event.type === "session.next.reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
state.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
return
}
const previous = state.reasoning.get(key) ?? ""
state.reasoning.set(key, previous + event.data.delta)
if (input.thinking)
write([
{
kind: "reasoning",
source: "reasoning",
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
},
])
return
}
if (event.type === "session.next.reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length)
write([
{
kind: "reasoning",
source: "reasoning",
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: event.data.reasoningID,
},
])
state.reasoning.set(key, event.data.text)
state.projectedReasoning.delete(key)
return
}
if (event.type === "session.next.tool.input.started") {
state.tools.set(event.data.callID, {
messageID: event.data.assistantMessageID,
name: event.data.name,
input: {},
started: event.data.timestamp,
running: false,
})
return
}
if (event.type === "session.next.tool.called") {
if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp },
}
renderTool(event.data.assistantMessageID, item)
return
}
if (event.type === "session.next.tool.progress") return
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
const current = state.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed"
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
state: failed
? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result }
: {
status: "completed",
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: { created: current?.started ?? event.data.timestamp, ran: current?.started, completed: event.data.timestamp },
}
renderTool(event.data.assistantMessageID, item)
return
}
if (event.type === "permission.v2.asked") {
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data))
syncBlockers()
return
}
if (event.type === "permission.v2.replied") {
state.permissions = state.permissions.filter((item) => item.id !== event.data.requestID)
syncBlockers()
return
}
if (event.type === "question.v2.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
syncBlockers()
return
}
if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
state.questions = state.questions.filter((item) => item.id !== event.data.requestID)
syncBlockers()
return
}
if (event.type === "session.next.step.ended") {
const total =
event.data.tokens.input +
event.data.tokens.output +
event.data.tokens.reasoning +
event.data.tokens.cache.read +
event.data.tokens.cache.write
const usage = total > 0 ? total.toLocaleString() : ""
write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage })
return
}
if (event.type === "session.next.step.failed") {
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return
}
if (event.type === "session.next.execution.settled") {
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return
state.wait = undefined
if (current.interrupted) {
current.resolve()
return
}
if (event.data.outcome === "failure") {
if (current.failureRendered) {
current.resolve()
return
}
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed"))
return
}
current.resolve()
}
}
const receive = (event: RunV2Event) => {
if (state.buffered) {
state.buffered.push(event)
return
}
apply(event)
}
const connect = async () => {
while (!controller.signal.aborted && !input.footer.isClosed) {
const error = await (async () => {
const connection = new AbortController()
const abortConnection = () => connection.abort()
controller.signal.addEventListener("abort", abortConnection, { once: true })
const response = await input.sdk.v2.event.subscribe({
signal: connection.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
try {
const first = await stream.next()
if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected")
const buffered: RunV2Event[] = []
let booting = true
const consume = (async () => {
while (!connection.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected")
if (booting) buffered.push(next.value)
else receive(next.value)
}
})()
void consume.catch(() => {})
await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial })
state.initial = false
booting = false
for (const event of buffered.splice(0)) apply(event)
state.connected = true
readyResolve()
await consume
} finally {
controller.signal.removeEventListener("abort", abortConnection)
connection.abort()
void stream.return?.(undefined).catch(() => {})
}
})().catch((error) => error)
state.connected = false
if (controller.signal.aborted || input.footer.isClosed) return
input.trace?.write("recv.reconnect", { error: formatUnknownError(error) })
write([], { phase: "running", status: "reconnecting" })
await wait(250, controller.signal)
}
}
const connection = connect()
try {
await ready
} catch (error) {
offFooterClose()
throw error
} finally {
controller.signal.removeEventListener("abort", abortReady)
}
return {
async runPromptTurn(next) {
if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts")
if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts")
if (state.wait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
if (next.agent) {
await input.sdk.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: next.agent },
{ throwOnError: true, signal: next.signal },
)
}
const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await input.sdk.v2.session.switchModel(
{ sessionID: input.sessionID, model: selected },
{ throwOnError: true, signal: next.signal },
)
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
const promptFiles = next.prompt.parts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: promptFileSource(part),
},
]
: [],
)
const attachments = [
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
...promptFiles,
]
const agents = next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
)
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
let resolve!: () => void
let reject!: (error: unknown) => void
const done = new Promise<void>((done, fail) => {
resolve = done
reject = fail
})
const active: Wait = {
messageID,
promoted: false,
interrupted: false,
failureRendered: false,
resolve,
reject,
onVisibleOutput: next.onVisibleOutput,
}
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await input.sdk.v2.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [
next.prompt.text,
...prepared.flatMap((file) => (file.text ? [file.text] : [])),
].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
},
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
)
await done
} catch (error) {
if (state.wait === active) state.wait = undefined
if (next.signal?.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", interrupt)
}
},
async interruptActiveTurn() {
if (state.wait) state.wait.interrupted = true
await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
},
selectSubagent(sessionID) {
subagents.select(sessionID)
},
async replayOnResize(next) {
if (!input.replay || state.closed || input.footer.isClosed) return false
const buffered: RunV2Event[] = []
state.buffered = buffered
try {
await input.footer.idle()
await next.reset()
state.messageIDs.clear()
state.text.clear()
state.projectedText.clear()
state.reasoning.clear()
state.projectedReasoning.clear()
state.tools.clear()
state.finishedTools.clear()
state.errors.clear()
await hydrate({ render: true, reuseVisibleWait: false })
} finally {
state.buffered = undefined
}
for (const event of buffered) apply(event)
for (const row of next.localRows()) {
if (row.commit.messageID && state.messageIDs.has(row.commit.messageID)) continue
input.footer.append(row.commit)
}
return true
},
async close() {
state.closed = true
offFooterClose()
controller.abort()
void connection.catch(() => {})
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,876 +0,0 @@
import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import {
bootstrapSessionData,
createSessionData,
formatError,
reduceSessionData,
type SessionData,
} from "./session-data"
import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
export const SUBAGENT_BOOTSTRAP_LIMIT = 200
export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
const SUBAGENT_COMMIT_LIMIT = 80
const SUBAGENT_CALL_LIMIT = 32
const SUBAGENT_ROLE_LIMIT = 32
const SUBAGENT_ERROR_LIMIT = 16
const SUBAGENT_ECHO_LIMIT = 8
type SessionMessage = {
parts: Part[]
}
type BootstrapChildMessage = SessionMessage & {
info: Message
}
type Frame = {
key: string
commit: StreamCommit
}
type DetailState = {
sessionID: string
data: SessionData
frames: Frame[]
}
export type SubagentData = {
tabs: Map<string, FooterSubagentTab>
details: Map<string, DetailState>
}
export type BootstrapSubagentInput = {
data: SubagentData
messages: SessionMessage[]
children: Array<{ id: string; title?: string }>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}
function createDetail(sessionID: string): DetailState {
return {
sessionID,
data: createSessionData({
includeUserText: true,
}),
frames: [],
}
}
function ensureDetail(data: SubagentData, sessionID: string) {
const current = data.details.get(sessionID)
if (current) {
return current
}
const next = createDetail(sessionID)
data.details.set(sessionID, next)
return next
}
export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
if (!a || !b) {
return false
}
return (
a.sessionID === b.sessionID &&
a.partID === b.partID &&
a.callID === b.callID &&
a.label === b.label &&
a.description === b.description &&
a.status === b.status &&
a.background === b.background &&
a.title === b.title &&
a.toolCalls === b.toolCalls &&
a.lastUpdatedAt === b.lastUpdatedAt
)
}
function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
return (
left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
)
}
function queueSnapshot(data: SessionData) {
return {
permissions: data.permissions.slice(),
questions: data.questions.slice(),
}
}
function queueChanged(data: SessionData, before: ReturnType<typeof queueSnapshot>) {
return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions)
}
function sameCommit(left: StreamCommit, right: StreamCommit) {
return (
left.kind === right.kind &&
left.text === right.text &&
left.phase === right.phase &&
left.source === right.source &&
left.messageID === right.messageID &&
left.partID === right.partID &&
left.tool === right.tool &&
left.interrupted === right.interrupted &&
left.toolState === right.toolState &&
left.toolError === right.toolError
)
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined
}
const next = value.trim()
return next || undefined
}
function num(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
return undefined
}
function inputLabel(input: Record<string, unknown>): string | undefined {
const description = text(input.description)
if (description) {
return description
}
const command = text(input.command)
if (command) {
return command
}
const filePath = text(input.filePath) ?? text(input.filepath)
if (filePath) {
return filePath
}
const pattern = text(input.pattern)
if (pattern) {
return pattern
}
const query = text(input.query)
if (query) {
return query
}
const url = text(input.url)
if (url) {
return url
}
const path = text(input.path)
if (path) {
return path
}
const prompt = text(input.prompt)
if (prompt) {
return prompt
}
return undefined
}
function stateTitle(part: ToolPart) {
return text("title" in part.state ? part.state.title : undefined)
}
function callKey(messageID: string | undefined, callID: string | undefined): string | undefined {
if (!messageID || !callID) {
return undefined
}
return `${messageID}:${callID}`
}
function compactToolState(part: ToolPart): ToolPart["state"] {
if (part.state.status === "pending") {
return {
status: "pending",
input: part.state.input,
raw: part.state.raw,
}
}
if (part.state.status === "running") {
return {
status: "running",
input: part.state.input,
time: part.state.time,
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
...(part.state.title ? { title: part.state.title } : {}),
}
}
if (part.state.status === "completed") {
return {
status: "completed",
input: part.state.input,
output: part.state.output,
title: part.state.title,
metadata: part.state.metadata,
time: part.state.time,
}
}
return {
status: "error",
input: part.state.input,
error: part.state.error,
time: part.state.time,
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
}
}
function recent<T>(input: Iterable<T>, limit: number) {
const list = [...input]
return list.slice(Math.max(0, list.length - limit))
}
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
const out = new Map<K, V>()
for (const [key, value] of source) {
if (!keep.has(key)) {
continue
}
out.set(key, value)
}
return out
}
function compactToolPart(part: ToolPart): ToolPart {
return {
id: part.id,
type: "tool",
sessionID: part.sessionID,
messageID: part.messageID,
callID: part.callID,
tool: part.tool,
state: compactToolState(part),
...(part.metadata ? { metadata: part.metadata } : {}),
}
}
function compactCommit(commit: StreamCommit): StreamCommit {
if (!commit.part) {
return commit
}
return {
...commit,
part: compactToolPart(commit.part),
}
}
function stateUpdatedAt(part: ToolPart) {
if (!("time" in part.state)) {
return Date.now()
}
const time = part.state.time
if (!("end" in time)) {
return time.start ?? Date.now()
}
return time.end ?? time.start ?? Date.now()
}
function metadata(part: ToolPart, key: string) {
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
}
function taskStatus(part: ToolPart): FooterSubagentTab["status"] {
if (part.state.status === "completed") {
return "completed"
}
if (part.state.status === "error") {
if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") {
return "cancelled"
}
return "error"
}
return "running"
}
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
return {
sessionID,
partID: part.id,
callID: part.callID,
label,
description,
status: taskStatus(part),
background: metadata(part, "background") === true,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
lastUpdatedAt: stateUpdatedAt(part),
}
}
function taskSessionID(part: ToolPart) {
return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
}
function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
if (part.tool !== "task") {
return false
}
const sessionID = taskSessionID(part)
if (!sessionID) {
return false
}
if (children && children.size > 0 && !children.has(sessionID)) {
return false
}
const next = taskTab(part, sessionID)
if (sameSubagentTab(data.tabs.get(sessionID), next)) {
ensureDetail(data, sessionID)
return false
}
data.tabs.set(sessionID, next)
ensureDetail(data, sessionID)
return true
}
function frameKey(commit: StreamCommit) {
if (commit.partID) {
return `${commit.kind}:${commit.partID}:${commit.phase}`
}
if (commit.messageID) {
return `${commit.kind}:${commit.messageID}:${commit.phase}`
}
return `${commit.kind}:${commit.phase}:${commit.text}`
}
function limitFrames(detail: DetailState) {
if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
return
}
detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
}
function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
if (current.phase !== "progress" || next.phase !== "progress") {
if (sameCommit(current, next)) {
return current
}
return next
}
const merged = {
...current,
...next,
text: current.text + next.text,
}
if (sameCommit(current, merged)) {
return current
}
return merged
}
function appendCommits(detail: DetailState, commits: StreamCommit[]) {
let changed = false
for (const commit of commits.map(compactCommit)) {
const key = frameKey(commit)
const index = detail.frames.findIndex((item) => item.key === key)
if (index === -1) {
detail.frames.push({
key,
commit,
})
changed = true
continue
}
const next = mergeLiveCommit(detail.frames[index].commit, commit)
if (sameCommit(detail.frames[index].commit, next)) {
continue
}
detail.frames[index] = {
key,
commit: next,
}
changed = true
}
if (changed) {
limitFrames(detail)
}
return changed
}
function ensureBlockerTab(
data: SubagentData,
sessionID: string,
title: string | undefined,
kind: "permission" | "question",
) {
const current = data.tabs.get(sessionID)
if (current) {
ensureDetail(data, sessionID)
if (current.status !== "running") {
return false
}
const next = {
...current,
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running" as const,
title: current.title ?? title,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
data.tabs.set(sessionID, {
sessionID,
partID: `bootstrap:${sessionID}`,
callID: `bootstrap:${sessionID}`,
label: text(title) ?? Locale.titlecase(kind),
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running",
lastUpdatedAt: Date.now(),
})
ensureDetail(data, sessionID)
return true
}
function isAbortedAssistantMessage(info: Message) {
return info.role === "assistant" && info.error?.name === "MessageAbortedError"
}
function cancelSubagentTab(data: SubagentData, sessionID: string) {
const current = data.tabs.get(sessionID)
if (!current || current.status !== "running") {
return false
}
const next = {
...current,
status: "cancelled" as const,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
function compactCallMap(detail: DetailState) {
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
for (const request of detail.data.permissions) {
const key = callKey(request.tool?.messageID, request.tool?.callID)
if (key) {
keep.add(key)
}
}
for (const item of detail.frames) {
const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
if (key) {
keep.add(key)
}
}
return copyMap(detail.data.call, keep)
}
function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
return copyMap(data.echo, keys)
}
function compactIDs(detail: DetailState) {
return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
}
function compactDetail(detail: DetailState) {
const next = createSessionData({
includeUserText: true,
})
const activePartIDs = new Set(detail.data.part.keys())
const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
const messageIDs = new Set([
...[...activePartIDs]
.map((partID) => detail.data.msg.get(partID))
.filter((item): item is string => typeof item === "string"),
...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
])
next.announced = detail.data.announced
next.permissions = detail.data.permissions
next.questions = detail.data.questions
next.ids = compactIDs(detail)
next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
next.call = compactCallMap(detail)
next.role = copyMap(detail.data.role, messageIDs)
next.msg = copyMap(detail.data.msg, activePartIDs)
next.part = copyMap(detail.data.part, activePartIDs)
next.text = copyMap(detail.data.text, activePartIDs)
next.sent = copyMap(detail.data.sent, activePartIDs)
next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
next.echo = compactEchoMap(detail.data, messageIDs)
detail.data = next
}
function applyChildEvent(input: {
detail: DetailState
event: Event
thinking: boolean
limits: Record<string, number>
}) {
const before = queueSnapshot(input.detail.data)
const out = reduceSessionData({
data: input.detail.data,
event: input.event,
sessionID: input.detail.sessionID,
thinking: input.thinking,
limits: input.limits,
})
const changed = appendCommits(input.detail, out.commits)
compactDetail(input.detail)
return changed || queueChanged(input.detail.data, before)
}
function bootstrapChildEvent(input: {
detail: DetailState
event: Event
thinking: boolean
limits: Record<string, number>
}) {
const out = reduceSessionData({
data: input.detail.data,
event: input.event,
sessionID: input.detail.sessionID,
thinking: input.thinking,
limits: input.limits,
})
return appendCommits(input.detail, out.commits)
}
function bootstrapChildMessages(input: {
detail: DetailState
messages: BootstrapChildMessage[]
thinking: boolean
limits: Record<string, number>
}) {
let changed = false
for (const message of input.messages) {
changed =
bootstrapChildEvent({
detail: input.detail,
event: {
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: input.detail.sessionID,
info: message.info,
},
},
thinking: input.thinking,
limits: input.limits,
}) || changed
for (const part of message.parts) {
changed =
bootstrapChildEvent({
detail: input.detail,
event: {
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: input.detail.sessionID,
part,
time: 0,
},
},
thinking: input.thinking,
limits: input.limits,
}) || changed
}
}
compactDetail(input.detail)
return changed
}
function knownSession(data: SubagentData, sessionID: string) {
return data.tabs.has(sessionID)
}
export function listSubagentPermissions(data: SubagentData) {
return [...data.details.values()].flatMap((detail) => detail.data.permissions)
}
export function listSubagentQuestions(data: SubagentData) {
return [...data.details.values()].flatMap((detail) => detail.data.questions)
}
export function createSubagentData(): SubagentData {
return {
tabs: new Map(),
details: new Map(),
}
}
function snapshotDetail(detail: DetailState) {
return {
sessionID: detail.sessionID,
commits: detail.frames.map((item) => item.commit),
}
}
export function listSubagentTabs(data: SubagentData) {
return [...data.tabs.values()].sort((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) {
return active
}
return b.lastUpdatedAt - a.lastUpdatedAt
})
}
function snapshotQueues(data: SubagentData) {
return {
permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
}
}
function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState {
return {
tabs: listSubagentTabs(data),
details,
...snapshotQueues(data),
}
}
export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
return snapshotState(
data,
Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])),
)
}
export function snapshotSelectedSubagentData(
data: SubagentData,
selectedSessionID: string | undefined,
): FooterSubagentState {
const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {})
}
export function bootstrapSubagentData(input: BootstrapSubagentInput) {
const child = new Map(input.children.map((item) => [item.id, item]))
const children = new Set(child.keys())
let changed = false
for (const message of input.messages) {
for (const part of message.parts) {
if (part.type !== "tool") {
continue
}
changed = syncTaskTab(input.data, part, children) || changed
}
}
for (const item of input.permissions) {
if (!children.has(item.sessionID)) {
continue
}
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
}
for (const item of input.questions) {
if (!children.has(item.sessionID)) {
continue
}
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
}
for (const sessionID of input.data.tabs.keys()) {
const detail = ensureDetail(input.data, sessionID)
const before = queueSnapshot(detail.data)
bootstrapSessionData({
data: detail.data,
messages: [],
permissions: input.permissions
.filter((item) => item.sessionID === sessionID)
.sort((a, b) => a.id.localeCompare(b.id)),
questions: input.questions
.filter((item) => item.sessionID === sessionID)
.sort((a, b) => a.id.localeCompare(b.id)),
})
compactDetail(detail)
changed = queueChanged(detail.data, before) || changed
}
return changed
}
export function bootstrapSubagentCalls(input: {
data: SubagentData
sessionID: string
messages: BootstrapChildMessage[]
thinking: boolean
limits: Record<string, number>
}) {
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
return false
}
const detail = ensureDetail(input.data, input.sessionID)
const before = queueSnapshot(detail.data)
const beforeCallCount = detail.data.call.size
bootstrapSessionData({
data: detail.data,
messages: input.messages,
permissions: detail.data.permissions,
questions: detail.data.questions,
})
const changed = bootstrapChildMessages({
detail,
messages: input.messages,
thinking: input.thinking,
limits: input.limits,
})
return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
}
export function reduceSubagentData(input: {
data: SubagentData
event: Event
sessionID: string
thinking: boolean
limits: Record<string, number>
}) {
const event = input.event
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID === input.sessionID) {
if (part.type !== "tool") {
return false
}
return syncTaskTab(input.data, part)
}
}
const sessionID =
event.type === "message.updated" ||
event.type === "message.part.delta" ||
event.type === "permission.asked" ||
event.type === "permission.replied" ||
event.type === "question.asked" ||
event.type === "question.replied" ||
event.type === "question.rejected" ||
event.type === "session.error" ||
event.type === "session.status"
? event.properties.sessionID
: event.type === "message.part.updated"
? event.properties.part.sessionID
: undefined
if (!sessionID || !knownSession(input.data, sessionID)) {
return false
}
const detail = ensureDetail(input.data, sessionID)
const cancelled =
event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info)
? cancelSubagentTab(input.data, sessionID)
: false
if (event.type === "session.status") {
if (event.properties.status.type !== "retry") {
return cancelled
}
return (
appendCommits(detail, [
{
kind: "error",
text: event.properties.status.message,
phase: "start",
source: "system",
messageID: `retry:${event.properties.status.attempt}`,
},
]) || cancelled
)
}
if (event.type === "session.error" && event.properties.error) {
return (
appendCommits(detail, [
{
kind: "error",
text: formatError(event.properties.error),
phase: "start",
source: "system",
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
},
]) || cancelled
)
}
return (
applyChildEvent({
detail,
event,
thinking: input.thinking,
limits: input.limits,
}) || cancelled
)
}

View file

@ -1,6 +1,4 @@
import * as Locale from "@/util/locale"
import type { SessionMessages } from "./session.shared"
import type { RunProvider, StreamCommit } from "./types"
import type { StreamCommit } from "./types"
export function turnSummaryCommit(input: {
agent: string
@ -21,27 +19,3 @@ export function turnSummaryCommit(input: {
messageID: input.messageID,
}
}
export function messageTurnSummaryCommit(
message: SessionMessages[number],
providers?: RunProvider[],
): StreamCommit | undefined {
const info = message.info
if (info.role !== "assistant") {
return
}
const completed = info.time.completed
if (typeof completed !== "number" || completed <= info.time.created) {
return
}
const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
return turnSummaryCommit({
agent: Locale.titlecase(info.agent),
model: model ?? info.modelID,
duration: Locale.duration(completed - info.time.created),
messageID: info.id,
})
}

View file

@ -1,4 +1,4 @@
// Shared type vocabulary for the direct interactive mode (`opencode --mini`).
// Shared type vocabulary for the direct interactive mode (`opencode mini`).
//
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
// session transcript, and a mutable footer for prompt input, status, and
@ -26,9 +26,63 @@ type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
export type RunCommand = NonNullable<Awaited<ReturnType<OpencodeClient["command"]["list"]>>["data"]>[number]
export type RunCommand = {
name: string
description?: string
source?: string
template?: string
hints?: unknown[]
agent?: string
model?: {
[key: string]: unknown
}
subtask?: boolean
}
export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["data"]>["all"][number]
export type RunProviderModel = {
id: string
providerID: string
api?: {
[key: string]: unknown
}
name?: string
capabilities?: {
[key: string]: unknown
}
cost?: {
input: number
output?: number
cache?: {
read: number
write: number
}
}
limit?: {
context: number
input?: number
output?: number
}
status?: string
options?: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
release_date?: string
variants?: Record<string, unknown>
}
export type RunProvider = {
id: string
name: string
source?: string
env?: string[]
options?: {
[key: string]: unknown
}
models: Record<string, RunProviderModel>
}
export type RunPrompt = {
messageID?: string
@ -48,11 +102,16 @@ export type FooterQueuedPrompt = {
prompt: RunPrompt
}
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
export type RunAgent = {
name: string
description?: string
mode: "subagent" | "primary" | "all"
hidden: boolean
}
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
export type RunResource = RunResourceMap[string]
export type RunReference = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
>["data"][number]
export type RunInput = {
sdk: OpencodeClient
@ -224,7 +283,7 @@ export type FooterEvent =
| {
type: "catalog"
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
commands?: RunCommand[]
}
| {

View file

@ -10,6 +10,8 @@ import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record"
@ -136,69 +138,69 @@ function state(value: unknown): ModelState {
}
}
function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* FSUtil.Service
const layer = Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* FSUtil.Service
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
})
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
})
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
return (yield* read()).variant?.[variantKey(model)]
})
return (yield* read()).variant?.[variantKey(model)]
})
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
if (!variant) {
delete next[key]
}
if (!variant) {
delete next[key]
}
yield* file
.writeJson(MODEL_FILE, {
...current,
variant: next,
})
.pipe(Effect.orElseSucceed(() => undefined))
})
yield* file
.writeJson(MODEL_FILE, {
...current,
variant: next,
})
.pipe(Effect.orElseSucceed(() => undefined))
})
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
).pipe(Layer.provide(fs)),
)
}
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
),
)
const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
/** @internal Exported for testing. */
export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime {
const runtime = makeRuntime(Service, createLayer(fs))
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),

View file

@ -89,73 +89,8 @@ export const TuiThreadCommand = cmd({
type: "boolean",
hidden: true,
default: false,
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
if (args.mini) {
const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) =>
process.argv.some((arg) => arg === option || arg.startsWith(option + "=")),
)
if (network) {
UI.error(`${network} cannot be used with --mini`)
process.exitCode = 1
return
}
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
demo: args.demo,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
["--demo", args.demo !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const unguard = win32InstallCtrlCGuard()
try {
const { TuiConfig } = await import("@/config/tui")

View file

@ -19,6 +19,7 @@ import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/attach"
import { MiniCommand } from "./cli/cmd/mini"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
@ -80,6 +81,7 @@ const cli = yargs(args)
.completion("completion", "generate shell completion script")
.command(AcpCommand)
.command(McpCommand)
.command(MiniCommand)
.command(TuiThreadCommand)
.command(AttachCommand)
.command(RunCommand)

View file

@ -34,6 +34,8 @@ type OpenApiSchema = {
additionalProperties?: OpenApiSchema | boolean
allOf?: OpenApiSchema[]
anyOf?: OpenApiSchema[]
contentMediaType?: string
contentSchema?: OpenApiSchema
description?: string
enum?: Array<string | boolean>
items?: OpenApiSchema
@ -97,6 +99,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
}
normalizeComponentNames(spec)
collapseDuplicateComponents(spec)
normalizeV2EventSchemas(spec)
applyLegacySchemaOverrides(spec)
normalizeComponentDescriptions(spec)
addLegacyErrorSchemas(spec)
@ -229,6 +232,16 @@ function collapseDuplicateComponents(spec: OpenApiSpec) {
}
}
function normalizeV2EventSchemas(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas?.V2Event1?.anyOf || schemas.V2Event?.type !== "string") return
schemas.V2EventStream = schemas.V2Event
rewriteRefs(spec, "V2Event", "V2EventStream")
schemas.V2Event = schemas.V2Event1
delete schemas.V2Event1
rewriteRefs(spec, "V2Event1", "V2Event")
}
function normalizeComponentNames(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return

View file

@ -66,6 +66,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { lazy } from "@/util/lazy"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
@ -297,8 +298,11 @@ export function createRoutes(
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(PtyEnvironment.layer),
// PluginRuntime.providerNode shares this build so plugin tools (subagent,
// shell jobs) capture the same SessionV2/Job instances the handlers use.
// Without it the plugin runtime cell stays empty and subagents cannot spawn.
Layer.provide(
AppNodeBuilderV1.build(SessionV2.node, [
AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, PluginRuntime.providerNode]), [
[LocationServiceMap.node, locationServiceMapV2],
[SessionExecution.node, SessionExecutionLocal.node],
]),

View file

@ -1,4 +1,5 @@
import yargs from "yargs"
import { MiniCommand } from "./cli/cmd/mini"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { hideBin } from "yargs/helpers"
@ -27,5 +28,6 @@ const cli = yargs(hideBin(process.argv))
if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
})
.command(MiniCommand)
.command(TuiThreadCommand)
.parse()

View file

@ -41,6 +41,34 @@ Options:
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = `
"opencode mini
start the minimal interactive interface
Commands:
opencode mini [project] start the minimal interactive interface [default]
opencode mini attach <url> attach to a running opencode server with the minimal interface
Positionals:
project path to start opencode in [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--prompt prompt to use [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--no-replay disable session history replay on resume and after resize [boolean]
--replay-limit cap visible replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
"opencode attach <url>
@ -50,21 +78,17 @@ Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
--mini start the minimal interactive interface [boolean] [default: false]
--no-replay disable mini session history replay on resume and after resize [boolean]
--replay-limit cap visible mini replay to the newest N messages [number]"
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
@ -85,7 +109,6 @@ Options:
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
@ -403,6 +426,31 @@ Options:
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = `
"opencode mini attach <url>
attach to a running opencode server with the minimal interface
Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory on the remote server [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--no-replay disable session history replay on resume and after resize [boolean]
--replay-limit cap visible replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
"opencode mcp list

View file

@ -13,16 +13,27 @@
// version (changes per release), so we'd snapshot a moving target.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { fileURLToPath } from "node:url"
import { cliIt } from "../../lib/cli-process"
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
const PACKAGE_ROOT_PATTERN = new RegExp(
fileURLToPath(new URL("../../..", import.meta.url))
.replace(/[/\\]$/, "")
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"g",
)
// Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific
// rules:
//
// 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to `<HOME>`.
// `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows.
//
// 2. yargs wraps the `[string] [default: "..."]` clause based on the
// 2. Some command defaults use the package cwd when the harness spawns the
// CLI. Collapse that path too so snapshots do not depend on checkout path.
//
// 3. yargs wraps the `[string] [default: "..."]` clause based on the
// pre-normalized default's character length, so different random home
// path widths produce different leading-whitespace counts (or even
// line-wraps onto a fresh line on Windows). `\s+` matches both forms.
@ -33,6 +44,7 @@ function normalize(text: string): string {
// (the harness now uses FileSystem.makeTempDirectoryScoped under the
// hood). A `[a-z0-9]+` regex would leave uppercase chars trailing.
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), "<HOME>"],
[PACKAGE_ROOT_PATTERN, "<HOME>"],
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
],
})
@ -45,6 +57,7 @@ function normalize(text: string): string {
const TOP_LEVEL = [
"acp",
"mcp",
"mini",
"attach",
"run",
"debug",
@ -69,6 +82,7 @@ const TOP_LEVEL = [
// distinct argv shape, not every leaf. Add new entries when a subcommand
// gains user-visible flags that we want to lock in.
const SUBCOMMANDS = [
["mini", "attach"],
["mcp", "list"],
["mcp", "add"],
["mcp", "auth"],
@ -101,7 +115,8 @@ describe("opencode CLI help-text snapshots", () => {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith("\n")).toBe(true)
expect(topLevel.stderr).toContain("--mini")
expect(topLevel.stderr).toContain("opencode mini")
expect(topLevel.stderr).not.toContain("--mini")
expect(topLevel.stderr).not.toContain("--thinking")
expect(topLevel.stderr).not.toContain("--variant")
expect(topLevel.stderr).not.toContain("--demo")

View file

@ -0,0 +1,132 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared"
afterEach(() => {
mock.restore()
})
describe("run catalog shared", () => {
test("loads visible project references from the current reference catalog", async () => {
const client = new OpencodeClient()
const list = spyOn(client.v2.reference, "list").mockImplementation(
() =>
Promise.resolve({
data: {
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [
{
name: "effect",
path: "/repos/effect",
description: "Effect v4 sources",
source: { type: "local", path: "/repos/effect" },
},
{
name: "secret",
path: "/repos/secret",
hidden: true,
source: { type: "local", path: "/repos/secret" },
},
],
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}) as never,
)
const references = await loadRunReferences(client, "/tmp")
expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }])
})
test("merges current providers and models into the footer catalog shape", () => {
const providers = runProviders(
[
{
id: "openai",
name: "OpenAI",
api: { type: "native", settings: {} },
request: { settings: {}, headers: {}, body: {} },
},
],
[
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
api: { id: "openai", type: "native", settings: {} },
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
settings: {},
headers: {},
body: {},
},
variants: [
{
id: "high",
settings: {},
headers: {},
body: {},
},
],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
},
],
)
expect(providers).toEqual([
{
id: "openai",
name: "OpenAI",
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
capabilities: expect.objectContaining({ tools: true }),
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
variants: {
high: {},
},
},
},
},
])
})
})

View file

@ -187,7 +187,7 @@ async function renderFooter(
directory="/tmp"
findFiles={async () => []}
agents={() => []}
resources={() => []}
references={() => []}
commands={() => input.commands ?? []}
providers={() => input.providers}
currentModel={() => input.currentModel}
@ -934,7 +934,7 @@ test("direct footer shows editable prompts and additional queued work while runn
directory="/tmp"
findFiles={async () => []}
agents={() => []}
resources={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => ({

View file

@ -2,11 +2,12 @@
// These exercise the real CLI binary against a TestLLMServer running in the
// same process. See `test/lib/cli-process.ts` for the harness — each test uses
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
// an isolated test provider config under the fixture's temp home.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { reply } from "../../lib/llm-server"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
describe("opencode run (non-interactive subprocess)", () => {
// Happy path: prompt completes, output reaches stdout, process exits 0.
@ -28,7 +29,7 @@ describe("opencode run (non-interactive subprocess)", () => {
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().text(" before tool ").tool("bash", {
reply().text(" before tool ").tool("shell", {
command: "printf tool-output",
description: "Print deterministic output",
}),
@ -89,7 +90,7 @@ describe("opencode run (non-interactive subprocess)", () => {
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().text("partial response").tool("bash", {
reply().text("partial response").tool("shell", {
command: "printf tool",
description: "Print deterministic output",
}),
@ -168,7 +169,7 @@ describe("opencode run (non-interactive subprocess)", () => {
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().reason("reasoning").text("before").tool("bash", {
reply().reason("reasoning").text("before").tool("shell", {
command: "printf tool",
description: "Print deterministic output",
}),
@ -198,7 +199,7 @@ describe("opencode run (non-interactive subprocess)", () => {
expect(events.find((event) => event.type === "tool_use")?.part).toEqual(
expect.objectContaining({
type: "tool",
tool: "bash",
tool: "shell",
state: expect.objectContaining({ status: "completed" }),
}),
)
@ -217,7 +218,7 @@ describe("opencode run (non-interactive subprocess)", () => {
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().text("partial json").tool("bash", {
reply().text("partial json").tool("shell", {
command: "printf tool",
description: "Print deterministic output",
}),
@ -227,16 +228,9 @@ describe("opencode run (non-interactive subprocess)", () => {
const events = opencode.parseJsonEvents(result.stdout)
expect(result.exitCode).toBe(0)
expect(events.map((event) => event.type)).toEqual([
"step_start",
"text",
"tool_use",
"step_finish",
"step_start",
"step_finish",
])
expect(events.map((event) => event.type)).toEqual(["step_start", "text", "tool_use", "step_finish"])
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish" }))
}),
60_000,
)
@ -245,29 +239,29 @@ describe("opencode run (non-interactive subprocess)", () => {
"rejects requested permissions by default and allows them with the dangerous flag",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" })
yield* llm.tool("shell", { command: "rm -f denied-file", description: "Remove a test file" })
yield* llm.text("continued after rejection")
const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } })
const denied = yield* opencode.run("request permission", { permission: { shell: "ask" } })
opencode.expectExit(denied, 0)
expect(denied.stderr).toContain("permission requested: bash")
expect(denied.stderr).toContain("permission requested: shell")
expect(denied.stdout).toBe("")
yield* llm.reset
yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" })
yield* llm.tool("shell", { command: "rm -f allowed-file", description: "Remove a test file" })
yield* llm.text("continued after approval")
const allowed = yield* opencode.run("request permission", {
permission: { bash: "ask" },
permission: { shell: "ask" },
extraArgs: ["--dangerously-skip-permissions"],
})
opencode.expectExit(allowed, 0)
expect(allowed.stderr).not.toContain("permission requested: bash")
expect(allowed.stderr).not.toContain("permission requested: shell")
expect(allowed.stdout).toContain("continued after approval")
yield* llm.reset
yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" })
yield* llm.tool("shell", { command: "touch explicitly-denied", description: "Create a denied marker" })
yield* llm.text("continued after explicit denial")
const explicitlyDenied = yield* opencode.run("request denied permission", {
permission: { bash: "deny" },
permission: { shell: "deny" },
extraArgs: ["--dangerously-skip-permissions"],
})
opencode.expectExit(explicitlyDenied, 0)
@ -277,6 +271,135 @@ describe("opencode run (non-interactive subprocess)", () => {
60_000,
)
cliIt.concurrent(
"rejects unattended questions without hanging",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.tool("question", {
questions: [
{
question: "Continue?",
header: "Continue",
options: [{ label: "Yes", description: "Continue execution" }],
},
],
})
const result = yield* opencode.run("ask a question")
opencode.expectExit(result, 0)
expect(result.stdout).toBe("")
}),
60_000,
)
cliIt.concurrent(
"continues a current session with projected history",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const env = { OPENCODE_DB: `${home}/run-continue.sqlite` }
yield* llm.text("first response")
const first = yield* opencode.run("first prompt", { env })
opencode.expectExit(first, 0)
yield* llm.text("second response")
const second = yield* opencode.run("second prompt", { env, extraArgs: ["--continue"] })
opencode.expectExit(second, 0)
expect(second.stdout).toBe("second response\n")
expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt")
}),
60_000,
)
cliIt.concurrent(
"forks the latest current session for --continue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const env = { OPENCODE_DB: `${home}/run-fork-continue.sqlite` }
yield* llm.text("first response")
const first = yield* opencode.run("first prompt", { env, format: "json" })
opencode.expectExit(first, 0)
const firstSessionID = opencode.parseJsonEvents(first.stdout)[0]?.sessionID
expect(typeof firstSessionID).toBe("string")
yield* llm.text("forked response")
const second = yield* opencode.run("second prompt", {
env,
format: "json",
extraArgs: ["--continue", "--fork"],
})
opencode.expectExit(second, 0)
const secondSessionID = String(opencode.parseJsonEvents(second.stdout)[0]?.sessionID)
expect(secondSessionID).not.toBe(String(firstSessionID))
expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt")
}),
60_000,
)
cliIt.concurrent(
"forks a current session selected by --session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const env = { OPENCODE_DB: `${home}/run-fork-session.sqlite` }
yield* llm.text("first response")
const first = yield* opencode.run("first prompt", { env, format: "json" })
opencode.expectExit(first, 0)
const firstSessionID = opencode.parseJsonEvents(first.stdout)[0]?.sessionID
expect(typeof firstSessionID).toBe("string")
yield* llm.text("forked response")
const second = yield* opencode.run("second prompt", {
env,
format: "json",
extraArgs: ["--session", String(firstSessionID), "--fork"],
})
opencode.expectExit(second, 0)
const secondSessionID = String(opencode.parseJsonEvents(second.stdout)[0]?.sessionID)
expect(secondSessionID).not.toBe(String(firstSessionID))
expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt")
}),
60_000,
)
cliIt.concurrent(
"applies a variant to the configured default model",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("variant response")
const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], {
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
})
opencode.expectExit(result, 0)
expect(result.stdout).toBe("variant response\n")
}),
60_000,
)
cliIt.live(
"preserves local image files as media attachments",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const source = `${home}/image.png`
yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64")))
yield* llm.text("attachment received")
const config = testProviderConfig(llm.url)
config.provider.test.models["test-model"].attachment = true
const result = yield* opencode.run("read the attachment", {
extraArgs: [`--file=${source}`, "--"],
config,
})
opencode.expectExit(result, 0)
const input = JSON.stringify(yield* llm.inputs)
expect(input).toContain("image/png")
expect(input).not.toContain("<file name=\\\"image.png\\\">")
}),
60_000,
)
cliIt.live(
"attach mode sends client-local file contents without a shared path",
({ home, llm, opencode }) =>
@ -328,4 +451,19 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
30_000,
)
cliIt.live(
"SIGINT before admission prevents provider execution",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.hang
const run = yield* opencode.startRun("do not start")
run.interrupt()
const result = yield* run.result
expect(result.exitCode).not.toBe(0)
expect(yield* llm.inputs).toHaveLength(0)
}),
30_000,
)
})

View file

@ -1,58 +1,71 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { Resolved } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
}
function provider(id: string, name: string) {
return {
id,
name,
api: { type: "native" as const, settings: {} },
request: { headers: {}, body: {} },
}
}
function model(id: string, providerID: string, context: number, variants: string[] = []) {
return {
id,
providerID,
api: {
id: providerID,
url: `https://${providerID}.test`,
npm: `@ai-sdk/${providerID}`,
type: "native" as const,
settings: {},
},
name: id,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
tools: true,
input: ["text"],
output: ["text"],
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
request: {
headers: {},
body: {},
},
variants: variants.map((variant) => ({
id: variant,
headers: {},
body: {},
})),
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
limit: {
context,
output: 8192,
},
status: "active" as const,
options: {},
headers: {},
release_date: "2026-01-01",
variants,
enabled: true,
}
}
@ -160,119 +173,101 @@ describe("run runtime boot", () => {
await expect(resolveDiffStyle()).resolves.toBe("auto")
})
test("prefers configured providers for model selector data", async () => {
test("loads v2 providers and models for model selector data", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
const providers = [provider("openai", "OpenAI")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
"gpt-5": {
id: "gpt-5",
providerID: "openai",
name: "gpt-5",
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
variants: {
high: {},
minimal: {},
},
},
},
},
],
default: {},
connected: [],
}
const configured = {
providers: [data.all[0]!],
default: {},
}
const list = spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(sdk.config, "providers").mockImplementation(() =>
Promise.resolve({
data: configured,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: configured.providers,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
},
})
expect(list).not.toHaveBeenCalled()
expect(providerList).toHaveBeenCalledWith(
{
location: {
directory: "/workspace",
},
},
{ throwOnError: true },
)
})
test("falls back to provider list when configured providers are unavailable", async () => {
test("loads context limits across v2 providers", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: data.all,
providers: [
expect.objectContaining({
id: "openai",
name: "OpenAI",
models: expect.objectContaining({
"gpt-5": expect.objectContaining({
variants: {
high: {},
minimal: {},
},
}),
}),
}),
expect.objectContaining({
id: "anthropic",
name: "Anthropic",
models: expect.objectContaining({
sonnet: expect.objectContaining({
variants: {},
}),
}),
}),
],
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,

View file

@ -3,44 +3,18 @@ import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
const provider: RunProvider = {
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "openai",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name: "Little Frank",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
tools: true,
input: ["text"],
output: ["text"],
},
cost: {
input: 0,
@ -55,9 +29,7 @@ const provider: RunProvider = {
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
variants: {},
},
},
}
@ -141,44 +113,129 @@ describe("run interactive runtime", () => {
const providers = defer<void>()
const sdk = new OpencodeClient()
spyOn(sdk.config, "providers").mockImplementation(async () => {
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused"))
const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused"))
spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
providersStarted.resolve()
await providers.promise
return ok({ providers: [provider], default: {} })
return ok({
location: {
directory: "/tmp",
},
data: [
{
id: "openai",
name: "OpenAI",
api: {
type: "native",
settings: {},
},
request: {
headers: {},
body: {},
},
},
],
}) as never
})
spyOn(sdk.session, "messages").mockImplementation(() =>
ok([
{
info: {
spyOn(sdk.v2.model, "list").mockImplementation(() =>
ok({
location: {
directory: "/tmp",
},
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
api: {
id: "openai",
type: "native",
settings: {},
},
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: [],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
},
],
}) as never,
)
spyOn(sdk.v2.session, "messages").mockImplementation(() =>
ok({
data: [
{
id: "msg-user-1",
sessionID: "ses-1",
role: "user",
type: "user",
text: "hello",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
variant: undefined,
},
],
cursor: {},
}),
)
spyOn(sdk.v2.session, "get").mockImplementation(() =>
ok({
data: {
id: "ses-1",
projectID: "pro-1",
title: "Session",
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
parts: [
{
id: "part-user-1",
sessionID: "ses-1",
messageID: "msg-user-1",
type: "text",
text: "hello",
},
],
} satisfies SessionMessage,
]),
time: {
created: 1,
updated: 1,
},
location: {
directory: "/tmp",
},
model: {
providerID: "openai",
id: "gpt-5",
},
},
}),
)
spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
spyOn(sdk.command, "list").mockImplementation(() => ok([]))
spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveMode(
{
@ -215,6 +272,7 @@ describe("run interactive runtime", () => {
}, 0)
return {
runPromptTurn: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
close: async () => {},
@ -234,5 +292,8 @@ describe("run interactive runtime", () => {
await task
expect(transportProviders).toEqual([[provider]])
expect(legacyProviders).not.toHaveBeenCalled()
expect(legacyAgents).not.toHaveBeenCalled()
expect(legacyCommands).not.toHaveBeenCalled()
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
@ -547,28 +547,6 @@ describe("run session data", () => {
])
})
test("flushInterrupted emits one interrupted final per live part", () => {
const data = reduce(
createSessionData(),
text({
id: "txt-1",
messageID: "msg-1",
text: "unfinished",
}),
).data
const first: StreamCommit[] = []
flushInterrupted(data, first)
expect(first).toEqual([
expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
])
const next: StreamCommit[] = []
flushInterrupted(data, next)
expect(next).toEqual([])
})
test("surfaces session errors as error commits", () => {
const out = reduce(createSessionData(), {
type: "session.error",

View file

@ -1,691 +0,0 @@
import { describe, expect, test } from "bun:test"
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
function userMessage(id: string, text: string): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text,
},
],
}
}
function assistantInfo(
id: string,
input: {
parentID?: string
modelID?: string
providerID?: string
time?: { created: number; completed?: number }
} = {},
) {
return {
id,
sessionID: "session-1",
role: "assistant" as const,
time: input.time ?? { created: 2 },
parentID: input.parentID ?? "msg-user-1",
modelID: input.modelID ?? "gpt-5",
providerID: input.providerID ?? "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
}
function assistantMessage(
id: string,
text: string,
input: {
parentID?: string
modelID?: string
providerID?: string
time?: { created: number; completed?: number }
} = {},
): SessionMessages[number] {
const time = input.time ?? {
created: 200,
completed: 3000,
}
return {
info: assistantInfo(id, {
...input,
time,
}),
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text,
time: {
start: time.created,
end: time.completed,
},
},
],
}
}
const provider = (name: string): RunProvider => ({
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "openai",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
})
function runningToolMessage(id: string): SessionMessages[number] {
return {
info: assistantInfo(id),
parts: [
{
id: `${id}-tool`,
sessionID: "session-1",
messageID: id,
type: "tool",
callID: `${id}-call`,
tool: "bash",
state: {
status: "running",
input: {
command: "pwd",
},
time: {
start: 2,
},
},
},
],
}
}
function shellUserMessage(id: string): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text: "The following tool was executed by the user",
synthetic: true,
},
],
}
}
function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
return {
info: assistantInfo(id, {
parentID,
time: {
created: 200,
completed: 3000,
},
}),
parts: [
{
id: `${id}-tool`,
sessionID: "session-1",
messageID: id,
type: "tool",
callID: `${id}-call`,
tool: "bash",
state: {
status: "completed",
input: {
command: "ls",
},
output: "account.ts\n",
title: "",
metadata: {
output: "account.ts\n",
},
time: {
start: 200,
end: 3000,
},
},
},
],
}
}
describe("run session replay", () => {
test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
assistantMessage("msg-1", "What city or ZIP code should I check?"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "user",
text: "Hello, whats the weather today?",
phase: "start",
source: "system",
messageID: "msg-user-1",
}),
expect.objectContaining({
kind: "assistant",
text: "What city or ZIP code should I check?",
phase: "progress",
source: "assistant",
messageID: "msg-1",
}),
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
summary: {
agent: "Build",
model: "gpt-5",
duration: "2.8s",
},
}),
])
expect(out.patch).toEqual(
expect.objectContaining({
phase: "idle",
status: "",
}),
)
})
test("uses provider model names for replayed turn summaries when available", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
assistantMessage("msg-1", "What city or ZIP code should I check?"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
providers: [provider("Little Frank")],
})
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
duration: "2.8s",
},
}),
)
})
test("replays one turn summary for the final assistant in a multi-step turn", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Plan and then answer"),
assistantMessage("msg-step-1", "Working", {
parentID: "msg-user-1",
time: { created: 200, completed: 900 },
}),
assistantMessage("msg-step-2", "Done", {
parentID: "msg-user-1",
time: { created: 1000, completed: 3000 },
}),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.0s",
messageID: "msg-step-2",
}),
])
})
test("keeps the footer in a running state for resumed active tools", () => {
const out = replaySession({
messages: [runningToolMessage("msg-1")],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.patch).toEqual(
expect.objectContaining({
phase: "running",
status: "running bash",
}),
)
})
test("does not replay turn summaries for shell-mode commands", () => {
const out = replaySession({
messages: [
shellUserMessage("msg-shell-user-1"),
shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits.some((commit) => commit.summary)).toBe(false)
expect(out.commits).toContainEqual(
expect.objectContaining({
kind: "tool",
text: "account.ts\n",
tool: "bash",
toolState: "completed",
}),
)
})
test("merges failed local rows ahead of later persisted prompts", () => {
const persisted = {
kind: "user",
text: "successful",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
const failed = {
kind: "user",
text: "failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "network unavailable",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]),
).toEqual([failed, error, persisted])
})
test("retains local errors but not duplicate local prompts once a prompt persists", () => {
const persisted = {
kind: "user",
text: "failed after persistence",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "connection closed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "failed after persistence")],
[persisted],
[{ commit: persisted }, { commit: error }],
),
).toEqual([persisted, error])
})
test("keeps a local turn failure below assistant output already visible for that turn", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const answer = {
kind: "assistant",
text: "partial answer",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const second = {
kind: "user",
text: "retry",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
[first, answer, second],
[
{
commit: error,
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
},
],
),
).toEqual([first, answer, error, second])
})
test("keeps a local failure above assistant output received after the failure", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "request failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const late = {
kind: "assistant",
text: "late answer",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
} as const
expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
first,
error,
late,
])
})
test("inserts a local failure between persisted output chunks spanning that failure", () => {
const first = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const complete = {
kind: "assistant",
text: "before after",
phase: "progress",
source: "assistant",
messageID: "msg-assistant-1",
partID: "part-1",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "start")],
[first, complete],
[
{
commit: error,
after: {
kind: "assistant",
text: "before ",
phase: "progress",
messageID: "msg-assistant-1",
partID: "part-1",
visible: "before ",
},
},
],
),
).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
})
test("places an unpersisted failed prompt before live output from that turn", () => {
const prompt = {
kind: "user",
text: "start",
phase: "start",
source: "system",
messageID: "msg-1",
} as const
const answer = {
kind: "assistant",
text: "partial answer",
phase: "progress",
source: "assistant",
messageID: "msg-2",
} as const
const error = {
kind: "error",
text: "stream failed",
phase: "start",
source: "system",
messageID: "msg-1",
} as const
expect(
replayLocalRows(
[],
[answer],
[
{ commit: prompt },
{
commit: error,
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
},
],
),
).toEqual([prompt, answer, error])
})
test("anchors a failure after the visible start of a tool that later completes", () => {
const prompt = {
kind: "user",
text: "run ls",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const running = {
kind: "tool",
text: "running bash",
phase: "start",
source: "tool",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "running",
} as const
const completed = {
kind: "tool",
text: "file.txt",
phase: "final",
source: "tool",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "completed",
} as const
const error = {
kind: "error",
text: "connection lost",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "run ls")],
[prompt, running, completed],
[
{
commit: error,
after: {
kind: "tool",
text: "running bash",
phase: "start",
messageID: "msg-assistant-1",
partID: "part-tool-1",
toolState: "running",
},
},
],
),
).toEqual([prompt, running, error, completed])
})
test("retains an unpersisted local diagnostic before later persisted prompts", () => {
const first = {
kind: "user",
text: "before",
phase: "start",
source: "system",
messageID: "msg-user-1",
} as const
const error = {
kind: "error",
text: "failed to start new session",
phase: "start",
source: "system",
messageID: "msg-user-2",
} as const
const second = {
kind: "user",
text: "after",
phase: "start",
source: "system",
messageID: "msg-user-3",
} as const
expect(
replayLocalRows(
[userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")],
[first, second],
[{ commit: error }],
),
).toEqual([first, error, second])
})
})

View file

@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import {
createSession,
resolveCurrentSession,
sessionHistory,
sessionVariant,
type RunSession,
@ -18,6 +20,10 @@ const model = {
modelID: "gpt-5",
}
afterEach(() => {
mock.restore()
})
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
return {
info: {
@ -244,4 +250,74 @@ describe("run session shared", () => {
expect(sessionVariant(session, model)).toBe("minimal")
})
test("restores current prompt history from stored text and file references", async () => {
const client = new OpencodeClient()
spyOn(client.v2.session, "messages").mockImplementation(() =>
Promise.resolve({
data: {
data: [
{
id: "msg_prompt",
type: "user",
text: "Review @note.ts",
files: [
{
uri: "file:///tmp/note.ts",
mime: "text/plain",
name: "note.ts",
source: { start: 7, end: 15, text: "@note.ts" },
},
],
agents: [],
time: { created: 1 },
},
],
cursor: {},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(client.v2.session, "get").mockImplementation(() =>
Promise.resolve({
data: {
data: {
id: "ses_1",
title: "Session",
version: "dev",
projectID: "proj_1",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
const out = await resolveCurrentSession(client, "ses_1")
expect(out.turns[0]?.prompt).toEqual({
text: "Review @note.ts",
parts: [
{
type: "file",
url: "file:///tmp/note.ts",
mime: "text/plain",
filename: "note.ts",
source: {
type: "file",
path: "note.ts",
text: { start: 7, end: 15, value: "@note.ts" },
},
},
],
})
})
})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,547 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
} from "@/cli/cmd/run/subagent-data"
type SessionMessage = Parameters<typeof bootstrapSubagentData>[0]["messages"][number]
type ChildMessage = Parameters<typeof bootstrapSubagentCalls>[0]["messages"][number]
function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
return commits.flatMap((item) => {
const body = entryBody(item)
if (body.type === "none") {
return []
}
if (body.type === "structured") {
if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
return [body.snapshot.title]
}
if (body.snapshot.kind === "diff") {
return body.snapshot.items.map((item) => item.title)
}
if (body.snapshot.kind === "todo") {
return ["# Todos"]
}
return ["# Questions"]
}
return [body.content]
})
}
function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
return reduceSubagentData({
data,
event: event as Event,
sessionID: "parent-1",
thinking: true,
limits: {},
})
}
function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
if (status === "running") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "running",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1 },
},
},
],
}
}
if (status === "interrupted") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "error",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
error: "Tool execution aborted",
metadata: {
sessionId: sessionID,
toolcalls: 4,
interrupted: true,
},
time: { start: 1, end: 2 },
},
},
],
}
}
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "completed",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
output: "",
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1, end: 2 },
},
},
],
}
}
function question(id: string, sessionID: string) {
return {
id,
sessionID,
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "Fast", description: "Quick pass" }],
multiple: false,
},
],
}
}
function childMessage(input: {
messageID: string
sessionID: string
role: "user" | "assistant"
parts: ChildMessage["parts"]
}) {
if (input.role === "user") {
return {
info: {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: {
created: 1,
},
agent: "test",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: input.parts,
} satisfies ChildMessage
}
return {
info: {
id: input.messageID,
sessionID: input.sessionID,
role: "assistant",
time: {
created: 2,
completed: 3,
},
parentID: "msg-user-1",
providerID: "openai",
modelID: "gpt-5",
mode: "default",
agent: "explore",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "stop",
},
parts: input.parts,
} satisfies ChildMessage
}
describe("run subagent data", () => {
test("bootstraps tabs and child blockers from parent task parts", () => {
const data = createSubagentData()
expect(
bootstrapSubagentData({
data,
messages: [taskMessage("child-1")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [
{
id: "perm-1",
sessionID: "child-1",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
{
id: "perm-2",
sessionID: "other",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
],
questions: [question("question-1", "child-1"), question("question-2", "other")],
}),
).toBe(true)
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Scan reducer paths",
title: "Reducer touchpoints",
status: "completed",
toolCalls: 4,
}),
])
expect(snapshot.details).toEqual({
"child-1": {
sessionID: "child-1",
commits: [],
},
})
expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"])
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
})
test("marks interrupted task tabs as cancelled during bootstrap", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "interrupted")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
status: "cancelled",
}),
])
})
test("captures child activity and blocker metadata in the footer detail state", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-user-1",
messageID: "msg-user-1",
sessionID: "child-1",
type: "text",
text: "Inspect footer tabs",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-user-1",
role: "user",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
role: "assistant",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "reason-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "reasoning",
text: "planning next steps",
time: { start: 1 },
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "tool",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
time: { start: 1 },
},
},
},
})
reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "child-1",
permission: "bash",
patterns: ["git status --short"],
metadata: {},
always: [],
tool: {
messageID: "msg-assistant-1",
callID: "call-1",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "text",
text: "hello",
},
},
})
reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "child-1",
messageID: "msg-assistant-1",
partID: "txt-1",
field: "text",
delta: " world",
},
})
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
" Inspect footer tabs",
"_Thinking:_ planning next steps",
"$ git status --short",
"hello world",
])
expect(snapshot.permissions).toEqual([
expect.objectContaining({
id: "perm-1",
metadata: {
input: {
command: "git status --short",
},
},
}),
])
expect(snapshot.questions).toEqual([])
})
test("replays bootstrapped child session messages into inspector commits", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "completed")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
expect(
bootstrapSubagentCalls({
data,
sessionID: "child-1",
messages: [
childMessage({
messageID: "msg-user-1",
sessionID: "child-1",
role: "user",
parts: [
{
id: "txt-user-1",
messageID: "msg-user-1",
sessionID: "child-1",
type: "text",
text: "Inspect footer tabs",
time: { start: 1, end: 1 },
},
],
}),
childMessage({
messageID: "msg-assistant-1",
sessionID: "child-1",
role: "assistant",
parts: [
{
id: "reason-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "reasoning",
text: "planning next steps",
time: { start: 2, end: 2 },
},
{
id: "txt-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "text",
text: "hello world",
time: { start: 2, end: 3 },
},
],
}),
],
thinking: true,
limits: {},
}),
).toBe(true)
expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([
" Inspect footer tabs",
"_Thinking:_ planning next steps",
"hello world",
])
})
test("marks a running tab cancelled when the child session aborts", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
sessionID: "child-1",
role: "assistant",
time: {
created: 1,
completed: 2,
},
error: {
name: "MessageAbortedError",
data: {
message: "Aborted",
},
},
parentID: "msg-user-1",
providerID: "openai",
modelID: "gpt-5",
mode: "default",
agent: "explore",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "error",
},
},
})
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
status: "cancelled",
}),
])
})
})

View file

@ -1,9 +1,8 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Effect, Layer } from "effect"
import { Global } from "@opencode-ai/core/global"
import {
createVariantRuntime,
@ -99,7 +98,7 @@ function userMessage(
}
}
const it = testEffect(Layer.mergeAll(LayerNode.compile(FSUtil.node), NodeFileSystem.layer))
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
@ -124,7 +123,7 @@ function remappedFs(root: string) {
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node)))
}
describe("run variant shared", () => {
@ -160,9 +159,8 @@ describe("run variant shared", () => {
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* FSUtil.Service
const root = yield* filesys.makeTempDirectoryScoped()
const root = yield* fs.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* fs.writeJson(file, {
@ -172,7 +170,7 @@ describe("run variant shared", () => {
},
})
const svc = createVariantRuntime(remappedFs(root))
const svc = createVariantRuntime([[FSUtil.node, remappedFs(root)]])
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
@ -197,14 +195,13 @@ describe("run variant shared", () => {
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* FSUtil.Service
const root = yield* filesys.makeTempDirectoryScoped()
const root = yield* fs.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* filesys.writeFileString(file, "{")
yield* fs.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root))
const svc = createVariantRuntime([[FSUtil.node, remappedFs(root)]])
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")

View file

@ -4,6 +4,7 @@ import fs from "fs/promises"
import path from "path"
import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture"
import { MiniLocalCommand } from "../../../src/cli/cmd/mini"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process"
@ -45,12 +46,12 @@ describe("tui thread", () => {
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
})
test("parses supported --no-replay forms", async () => {
test("parses supported mini --no-replay forms", async () => {
for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) {
const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} })
.command({ ...MiniLocalCommand, handler: () => {} })
.exitProcess(false)
.parse(["--mini", option, "--replay-limit", "10"])
.parse([option, "--replay-limit", "10"])
expect(args.replay === false || args.noReplay === true).toBe(true)
expect(args.replayLimit).toBe(10)
@ -66,30 +67,48 @@ describe("tui thread", () => {
expect(args.mdns).toBe(false)
})
cliIt.live("rejects mini-only options without --mini", ({ opencode }) =>
cliIt.live("rejects removed top-level mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["--replay-limit", "10"])
const result = yield* opencode.spawn(["--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("--replay-limit requires --mini")
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("routes attached sessions to mini mode", ({ opencode }) =>
cliIt.live("rejects removed run mini flag", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["run", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("routes local sessions through mini", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["mini"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("routes attached sessions through mini attach", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed attach mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("--mini requires a TTY stdout")
}),
)
cliIt.live("rejects network options in mini mode", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["--mini", "--port", "4096"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("--port cannot be used with --mini")
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
})

View file

@ -5,8 +5,7 @@
// argv parsing → server boot → SDK call → event consumption → exit code (like
// the original /event race or #27371's invalid-model hang).
//
// Configuration flows through opencode's built-in test affordances:
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
// Configuration flows through an isolated global opencode.json under the temp home:
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
// - OPENCODE_PURE : skip external plugin discovery + install
@ -59,15 +58,16 @@ function forkStderrDrain(stream: ReadableStream<Uint8Array>, into: string[]) {
)
}
function isolatedEnv(home: string, configJson: string): Record<string, string> {
function isolatedEnv(home: string): Record<string, string> {
return {
OPENCODE_TEST_HOME: home,
PWD: home,
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local/share"),
XDG_STATE_HOME: path.join(home, ".local/state"),
XDG_CACHE_HOME: path.join(home, ".cache"),
OPENCODE_CONFIG_CONTENT: configJson,
OPENCODE_CONFIG_DIR: path.join(home, ".opencode-config"),
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_DISABLE_AUTOUPDATE: "1",
@ -89,7 +89,11 @@ export type RunHandle = {
readonly result: Effect.Effect<RunResult>
}
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
export type SpawnOpts = {
readonly timeoutMs?: number
readonly env?: Record<string, string>
readonly config?: ReturnType<typeof testProviderConfig> & Record<string, unknown>
}
// Typed equivalent of constructing argv for `opencode run`. New flags should
// land here so tests stay grep-able and refactor-safe.
@ -201,10 +205,15 @@ export function withCliFixture<A, E>(
.pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore),
)
const configJson = JSON.stringify(testProviderConfig(llm.url))
const env = isolatedEnv(home, configJson)
const env = isolatedEnv(home)
const writeConfig = (config?: SpawnOpts["config"]) =>
fs
.writeWithDirs(path.join(env.OPENCODE_CONFIG_DIR, "opencode.json"), JSON.stringify(config ?? testProviderConfig(llm.url)))
.pipe(Effect.orDie)
yield* writeConfig()
const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) {
yield* writeConfig(opts?.config)
const start = Date.now()
const timeoutMs = opts?.timeoutMs ?? 30_000
// stdin: "ignore" so the child doesn't see a piped stdin and block
@ -212,7 +221,7 @@ export function withCliFixture<A, E>(
// consumed as the prompt). The old Process.run wrapper defaulted to
// ignore; ChildProcess.make defaults to pipe, so we set it explicitly.
const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], {
cwd: home,
cwd: opencodeRoot,
env: { ...env, ...opts?.env },
extendEnv: true,
stdin: "ignore",
@ -250,6 +259,7 @@ export function withCliFixture<A, E>(
const runArgs = (message: string, opts?: RunOpts) => {
const argv: string[] = ["run"]
if (!opts?.extraArgs?.includes("--attach")) argv.push("--dir", home)
if (opts?.printLogs) argv.push("--print-logs")
argv.push("--model", opts?.model ?? testModelID)
if (opts?.agent) argv.push("--agent", opts.agent)
@ -264,13 +274,8 @@ export function withCliFixture<A, E>(
if (!opts?.permission) return opts
return {
...opts,
env: {
...opts.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify({
...testProviderConfig(llm.url),
permission: opts.permission,
}),
},
env: opts.env,
config: { ...testProviderConfig(llm.url), permission: opts.permission },
}
}
@ -281,10 +286,11 @@ export function withCliFixture<A, E>(
const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) {
const start = Date.now()
const options = runOpts(opts)
yield* writeConfig(options?.config)
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
cwd: home,
cwd: opencodeRoot,
env: { ...process.env, ...env, ...options?.env },
stdin: "ignore",
stdout: "pipe",
@ -325,7 +331,7 @@ export function withCliFixture<A, E>(
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
cwd: home,
cwd: opencodeRoot,
env: { ...process.env, ...env, ...opts?.env },
stdout: "pipe",
stderr: "pipe",
@ -396,7 +402,7 @@ export function withCliFixture<A, E>(
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
cwd: opts?.cwd ?? home,
cwd: opencodeRoot,
env: { ...process.env, ...env, ...opts?.env },
stdin: "pipe",
stdout: "pipe",

View file

@ -30,7 +30,7 @@ export function testProviderConfig(llmUrl: string) {
options: {},
},
},
options: { apiKey: "test-key", baseURL: llmUrl },
options: { apiKey: "test-key", baseURL: llmUrl, body: { apiKey: "test-key" } },
},
},
}

View file

@ -665,6 +665,8 @@ const scenarios: Scenario[] = [
http.protected.get("/api/location", "v2.location.get").json(200, object),
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
// The default model may be undefined in the exercise environment, so only the location envelope is asserted.
http.protected.get("/api/model/default", "v2.model.default").json(200, object),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected.get("/api/integration", "v2.integration.list").json(200, locationData(array)),
http.protected

View file

@ -249,8 +249,14 @@ export type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]
export type Endpoint6_0Output = EffectValue<ReturnType<RawClient["server.model"]["model.list"]>>
export type ModelListOperation<E = never> = (input?: Endpoint6_0Input) => Effect.Effect<Endpoint6_0Output, E>
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
export type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
export type Endpoint6_1Output = EffectValue<ReturnType<RawClient["server.model"]["model.default"]>>
export type ModelDefaultOperation<E = never> = (input?: Endpoint6_1Input) => Effect.Effect<Endpoint6_1Output, E>
export interface ModelApi<E = never> {
readonly list: ModelListOperation<E>
readonly default: ModelDefaultOperation<E>
}
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]

View file

@ -21,6 +21,21 @@ export const ModelGroup = HttpApiGroup.make("server.model")
}),
),
)
.add(
HttpApiEndpoint.get("model.default", "/api/model/default", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Model.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.default",
summary: "Get default model",
description: "Retrieve the model used when a session has no explicit model selection.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "models",

View file

@ -119,6 +119,16 @@ export const PromptAdmitted = Event.define({
})
export type PromptAdmitted = typeof PromptAdmitted.Type
export const ExecutionSettled = Event.define({
type: "session.next.execution.settled",
schema: {
...Base,
outcome: Schema.Literals(["success", "failure", "interrupted"]),
error: UnknownError.pipe(optional),
},
})
export type ExecutionSettled = typeof ExecutionSettled.Type
export const ContextUpdated = Event.define({
type: "session.next.context.updated",
...options,
@ -524,6 +534,7 @@ export const Definitions = Event.inventory(
Forked,
Prompted,
PromptAdmitted,
ExecutionSettled,
ContextUpdated,
Synthetic,
Skill.Activated,

View file

@ -301,6 +301,8 @@ import type {
V2LocationGetResponses,
V2McpListErrors,
V2McpListResponses,
V2ModelDefaultErrors,
V2ModelDefaultResponses,
V2ModelListErrors,
V2ModelListResponses,
V2PermissionRequestListErrors,
@ -6120,6 +6122,28 @@ export class Model extends HeyApiClient {
...params,
})
}
/**
* Get default model
*
* Retrieve the model used when a session has no explicit model selection.
*/
public default<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ModelDefaultResponses, V2ModelDefaultErrors, ThrowOnError>({
url: "/api/model/default",
...options,
...params,
})
}
}
export class Generate extends HeyApiClient {

View file

@ -24,6 +24,7 @@ export type Event =
| EventSessionNextForked
| EventSessionNextPrompted
| EventSessionNextPromptAdmitted
| EventSessionNextExecutionSettled
| EventSessionNextContextUpdated
| EventSessionNextSynthetic
| EventSessionNextSkillActivated
@ -923,6 +924,16 @@ export type GlobalEvent = {
delivery: "steer" | "queue"
}
}
| {
id: string
type: "session.next.execution.settled"
properties: {
timestamp: number
sessionID: string
outcome: "success" | "failure" | "interrupted"
error?: SessionErrorUnknown
}
}
| {
id: string
type: "session.next.context.updated"
@ -3010,6 +3021,7 @@ export type V2Event =
| SessionNextForked
| SessionNextPrompted
| SessionNextPromptAdmitted
| SessionNextExecutionSettled
| SessionNextContextUpdated
| SessionNextSynthetic
| SessionNextSkillActivated
@ -5597,6 +5609,26 @@ export type MessagePartRemoved = {
}
}
export type SessionNextExecutionSettled = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.execution.settled"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
outcome: "success" | "failure" | "interrupted"
error?: SessionErrorUnknown
}
}
export type SessionNextTextDelta = {
id: string
metadata?: {
@ -6802,6 +6834,17 @@ export type EventSessionNextPromptAdmitted = {
}
}
export type EventSessionNextExecutionSettled = {
id: string
type: "session.next.execution.settled"
properties: {
timestamp: number
sessionID: string
outcome: "success" | "failure" | "interrupted"
error?: SessionErrorUnknown
}
}
export type EventSessionNextContextUpdated = {
id: string
type: "session.next.context.updated"
@ -12875,6 +12918,47 @@ export type V2ModelListResponses = {
export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses]
export type V2ModelDefaultData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/model/default"
}
export type V2ModelDefaultErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableError
}
export type V2ModelDefaultError = V2ModelDefaultErrors[keyof V2ModelDefaultErrors]
export type V2ModelDefaultResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: ModelV2Info
}
}
export type V2ModelDefaultResponse = V2ModelDefaultResponses[keyof V2ModelDefaultResponses]
export type V2GenerateTextData = {
body: {
prompt: string

View file

@ -6,12 +6,20 @@ import { response } from "../location"
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"model.list",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
return yield* response(catalog.model.available())
}),
)
return handlers
.handle(
"model.list",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
return yield* response(catalog.model.available())
}),
)
.handle(
"model.default",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
return yield* response(catalog.model.default())
}),
)
}),
)

View file

@ -1,5 +1,19 @@
# V2 Schema Changelog
## 2026-07-02: Add Default Model Endpoint
- Add `GET /api/model/default` (`v2.model.default`) returning the Location's resolved default model, or `undefined` when no model is available.
- Expose the existing core `Catalog.model.default()` resolution (configured default first, then availability heuristics) over HTTP with regenerated Promise, Effect, and legacy JavaScript client surfaces.
Change:
- Clients that need to pin a full model reference before prompt admission (for example `run --variant` without `--model` on a session with no model) previously had no current API for the default model and fell back to the legacy `/config` read or the first entry of `v2.model.list`, which can diverge from the runner's own default resolution.
Compatibility:
- Purely additive HTTP surface; no durable-event, projection, or database change.
- `v2.model.list` ordering and semantics are unchanged.
## 2026-07-01: Synthetic Message Metadata And Model-Visible Leak Fix
- Add optional `metadata: Record<string, unknown>` to the durable `session.next.synthetic.1` event data so synthetic messages can carry a durable ledger (e.g. lazy-instruction dedup paths).