chore: merge v2 into service channel config

This commit is contained in:
Dax Raad 2026-07-21 10:28:58 -04:00
commit e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.3",
"version": "1.18.4",
"type": "module",
"license": "MIT",
"bin": {

View file

@ -30,7 +30,8 @@ export default Runtime.handler(
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? global.config : process.cwd()))
yield* Effect.promise(() => write(configPath, input.name, server))
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
}),

View file

@ -60,8 +60,21 @@ Effect.logInfo("cli starting", {
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(Observability.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
],
]),
),
Effect.provide(
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
}),
),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),

View file

@ -1,8 +1,8 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { Flag } from "@opencode-ai/util/flag"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
@ -14,51 +14,17 @@ export type InteractiveStdin = {
}
type MiniHost = MiniFrontendInput["host"]
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function state(value: unknown): ModelState {
if (!isRecord(value)) return {}
const variant = isRecord(value.variant)
? Object.fromEntries(
Object.entries(value.variant).flatMap(([key, item]) =>
typeof item === "string" ? ([[key, item]] as const) : [],
),
)
: undefined
return { ...value, variant }
}
function variantKey(model: NonNullable<MiniFrontendInput["model"]>) {
return `${model.providerID}/${model.modelID}`
}
function preferences(statePath: string): MiniHost["preferences"] {
const file = path.join(statePath, "model.json")
const read = () =>
readFile(file, "utf8")
.then((value) => state(JSON.parse(value)))
.catch(() => state(undefined))
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
return {
async resolveVariant(model) {
if (!model) return
const variant = (await read()).variant?.[variantKey(model)]
return variant === "default" ? undefined : variant
return repository.resolveVariant(model)
},
async saveVariant(model, variant) {
if (!model) return
const current = await read()
const next = { ...current.variant }
if (variant) next[variantKey(model)] = variant
if (!variant) delete next[variantKey(model)]
await mkdir(path.dirname(file), { recursive: true })
.then(() => writeFile(file, JSON.stringify({ ...current, variant: next }, null, 2)))
.catch(() => {})
await repository.saveVariant(model, variant).catch(() => undefined)
},
}
}
@ -198,7 +164,7 @@ export function createMiniHost(input: {
sigusr2: signal("SIGUSR2"),
},
startup: {
showTiming: Flag.OPENCODE_SHOW_TTFD,
showTiming: ["1", "true"].includes(process.env.OPENCODE_SHOW_TTFD?.toLowerCase() ?? ""),
now: () => performance.now(),
},
diagnostics: {

View file

@ -5,6 +5,7 @@ import type {
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
@ -75,6 +76,9 @@ export async function runNonInteractivePrompt(input: Input) {
const messageID = SessionMessage.ID.create()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
let submitted = false
let promoted = false
let emittedError = false
@ -82,6 +86,8 @@ export async function runNonInteractivePrompt(input: Input) {
let formCancelled = false
let interrupted = false
let v1InvalidOutput = false
let prePromotionError: { message: string; [key: string]: unknown } | undefined
let finalizing = false
let admission: AbortController | undefined
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
@ -104,6 +110,17 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const writeReasoning = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("reasoning", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) return void process.stdout.write(line + EOL)
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
}
const flushStep = () => {
if (!pendingStep) return
const value = pendingStep
@ -181,6 +198,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.input.promoted") {
if (event.data.inputID === messageID) {
promoted = true
prePromotionError = undefined
continue
}
}
@ -191,7 +209,12 @@ export async function runNonInteractivePrompt(input: Input) {
) {
return
}
if (!promoted && event.type === "session.execution.failed") {
prePromotionError = event.data.error
continue
}
if (!promoted) continue
if (finalizing) continue
if (event.type === "session.step.started") {
const part = {
@ -219,12 +242,16 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.started") {
flushStep()
starts.set("text", { id: partID(event.id), timestamp: time })
starts.set(`text\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`text\u0000${key}`)
starts.delete(`text\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@ -233,18 +260,23 @@ export async function runNonInteractivePrompt(input: Input) {
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
renderedText.set(key, event.data.text)
writeText(part, time)
continue
}
if (event.type === "session.reasoning.started") {
flushStep()
starts.set("reasoning", { id: partID(event.id), timestamp: time })
starts.set(`reasoning\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`reasoning\u0000${key}`)
starts.delete(`reasoning\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@ -254,17 +286,8 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: event.data.state,
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()
renderedReasoning.set(key, event.data.text)
writeReasoning(part, time)
continue
}
@ -360,6 +383,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
continue
}
@ -405,6 +429,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
@ -480,6 +505,122 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const projectedMessages = async () => {
const messages: SessionMessageInfo[] = []
let cursor: string | undefined
while (true) {
const page = await input.client.message.list(
cursor
? { sessionID: input.sessionID, limit: 200, cursor }
: { sessionID: input.sessionID, limit: 200, order: "desc" },
)
for (const message of page.data) {
if (message.id === messageID) return { found: true, messages: messages.toReversed() }
messages.push(message)
}
cursor = page.cursor.next ?? undefined
if (!cursor) return { found: false, messages: [] }
}
}
const reconcile = async () => {
const projected = await projectedMessages()
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const ordinal = textOrdinal++
const key = contentKey(message.id, ordinal)
const rendered = renderedText.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
writeText(
{
id: projectedPartID(message.id, `text-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "text",
text,
time: { start: message.time.created, end: timestamp },
},
timestamp,
)
renderedText.set(key, item.text)
continue
}
if (item.type === "reasoning") {
const ordinal = reasoningOrdinal++
if (!input.thinking) continue
const key = contentKey(message.id, ordinal)
const rendered = renderedReasoning.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
const part = {
id: projectedPartID(message.id, `reasoning-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
writeReasoning(part, timestamp)
continue
}
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
id: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
tool: item.name,
state:
item.state.status === "completed"
? {
status: "completed",
input: item.state.input,
output: toolOutputText(item.name, item.state.content),
title: item.name,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
}
: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
renderedTools.add(key)
if (emit("tool_use", timestamp, { part })) continue
if (item.state.status === "completed") {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
}
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
}
}
return projected.found
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
@ -559,11 +700,27 @@ export async function runNonInteractivePrompt(input: Input) {
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
])
await completed
if (input.compatibility === "v1") {
await completed
return
}
const waiting = input.client.session.wait({ sessionID: input.sessionID })
await Promise.race([waiting, completed.then(() => waiting)])
finalizing = true
controller.abort()
const found = await reconcile()
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
emittedError = true
process.exitCode = 1
if (!emit("error", Date.now(), { error })) UI.error(error.message)
}
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
if (input.compatibility === "v1") await stream.return?.(undefined).catch(() => {})
else void stream.return?.(undefined).catch(() => {})
}
}
@ -595,6 +752,14 @@ function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function contentKey(messageID: string, ordinal: number) {
return `${messageID}\u0000${ordinal}`
}
function projectedPartID(messageID: string, part: string) {
return `prt_${messageID.replace(/^msg_/, "")}_${part}`
}
function fallbackTool(event: {
id: string
created: number

View file

@ -26,7 +26,14 @@ export type Options = {
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
],
]),
),
Effect.provide(NodeServices.layer),
)
})
@ -60,22 +67,57 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const server = yield* start({
hostname,
port: Option.fromNullishOr(port),
password,
instanceID,
service:
serviceOptions === undefined
? undefined
: {
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
}).pipe(
const server = yield* start(
{
client: process.env.OPENCODE_CLIENT ?? "cli",
hostname,
port,
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: process.env.OPENCODE_DB,
},
models: {
url: process.env.OPENCODE_MODELS_URL,
file: process.env.OPENCODE_MODELS_PATH,
fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
},
observability: {
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
},
config: {
directory: process.env.OPENCODE_CONFIG_DIR,
project: !truthy(
process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
),
file: process.env.OPENCODE_CONFIG,
content: process.env.OPENCODE_CONFIG_CONTENT,
},
windows: {
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
},
fs: {
filewatcher: !truthy(
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,
),
fff:
process.env.OPENCODE_DISABLE_FFF === undefined
? process.platform !== "win32"
: !truthy(process.env.OPENCODE_DISABLE_FFF),
},
},
serviceOptions === undefined
? undefined
: {
instanceID,
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
@ -168,6 +210,10 @@ function serviceURL(hostname: string, port: number) {
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
}
function truthy(value?: string) {
return value === "1" || value?.toLowerCase() === "true"
}
function addressInUse(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false
if ("code" in error && error.code === "EADDRINUSE") return true

View file

@ -1,5 +1,4 @@
import { Global } from "@opencode-ai/util/global"
import { Flag } from "@opencode-ai/util/flag"
import { AppProcess } from "@opencode-ai/util/process"
import {
InstallationChannel,
@ -118,19 +117,30 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Method, string[]> = {
const commands: Record<Exclude<Method, "bun">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "install", "--global", target],
bun: ["bun", "install", "--global", target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* run(commands[method], "5 minutes")
const result = yield* (method === "bun"
? Effect.scoped(
Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes")
}),
)
: run(commands[method], "5 minutes"))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const check = Effect.fn("cli.updater.check")(function* () {
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
if (
InstallationLocal ||
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
)
return yield* Effect.logInfo("update check skipped", {
reason: InstallationLocal ? "local-install" : "disabled",
version: InstallationVersion,

View file

@ -1,157 +1,162 @@
import { defineScript } from "opencode-drive"
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
setup({ config }) {
config.autoupdate = false
},
async run({ artifacts, llm, server, signal }) {
await configureServicePort(artifacts)
await server.launch()
config: { autoupdate: false },
run: ({ artifacts, llm, server }) =>
Effect.gen(function* () {
yield* Effect.promise(() => configureServicePort(artifacts))
yield* server.launch()
const registration = await serviceRegistration(artifacts)
const root = path.resolve(import.meta.dir, "../../../..")
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
await mkdir(snapshots, { recursive: true })
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
const root = path.resolve(import.meta.dir, "../../../..")
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
llm.queue(
llm.toolCall({
index: 0,
id: "mini-shell",
name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
llm.finish("tool-calls"),
)
llm.queue(llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
const abort = () => {
void tmux(["kill-session", "-t", session], true).catch(() => {})
}
signal.addEventListener("abort", abort, { once: true })
try {
await tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
"--preload=@opentui/solid/preload",
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
])
await tmux(["set-option", "-t", session, "remain-on-exit", "on"])
const first = await waitForPane(session, "OpenCode")
await Bun.write(path.join(snapshots, "01-first-paint.txt"), first)
if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission")
await waitForPane(session, "Simulated Model", 15_000)
await tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])
await Bun.sleep(100)
await tmux(["send-keys", "-H", "-t", session, "0d"])
const completed = await waitForPane(session, "drive mini response complete", 20_000)
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
await Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)
await Bun.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
await tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`])
await tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"])
await waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
)
await tmux(["pipe-pane", "-t", session])
const resized = await captureVisiblePane(session)
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
llm.queue(
llm.toolCall({
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-question",
name: "question",
input: {
questions: [
{
header: "Drive form",
question: "Choose the Mini Form answer",
options: [{ label: "Accepted", description: "Continue the run" }],
multiple: false,
},
],
},
}),
llm.finish("tool-calls"),
)
llm.queue(llm.text("drive mini form complete"))
await tmux(["send-keys", "-t", session, "-l", "exercise the form"])
await tmux(["send-keys", "-H", "-t", session, "0d"])
await waitForPane(session, "Choose the Mini Form answer", 20_000)
await tmux(["send-keys", "-H", "-t", session, "0d"])
await waitForPane(session, "drive mini form complete", 20_000)
llm.queue(
llm.toolCall({
index: 0,
id: "mini-slow-shell",
id: "mini-shell",
name: "shell",
input: { command: "sleep 10" },
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
llm.finish("tool-calls"),
Llm.finish("tool-calls"),
)
await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
await Bun.sleep(100)
await tmux(["send-keys", "-H", "-t", session, "0d"])
await waitForPane(session, "$ sleep 10")
await tmux(["send-keys", "-t", session, "Escape"])
const armed = await waitForPane(session, "again to interrupt")
await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
await tmux(["send-keys", "-t", session, "Escape"])
const interrupted = await waitForPane(session, "Step interrupted", 10_000)
await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
await tmux(["send-keys", "-t", session, "C-c"])
await waitForPane(session, "Press ctrl+c again to exit")
await tmux(["send-keys", "-t", session, "C-c"])
await waitForDeadPane(session)
const status = await paneDeadStatus(session)
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = await capturePane(session)
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
} finally {
signal.removeEventListener("abort", abort)
await tmux(["kill-session", "-t", session], true)
}
},
const journey = Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.promise(() =>
tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
]),
),
)
yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
if (first.includes("drive mini response complete"))
throw new Error("response rendered before prompt submission")
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
yield* Effect.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
yield* Effect.promise(() =>
waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
),
)
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
const resized = yield* Effect.promise(() => captureVisiblePane(session))
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-question",
name: "question",
input: {
questions: [
{
header: "Drive form",
question: "Choose the Mini Form answer",
options: [{ label: "Accepted", description: "Continue the run" }],
multiple: false,
},
],
},
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini form complete"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-slow-shell",
name: "shell",
input: { command: "sleep 10" },
}),
Llm.finish("tool-calls"),
)
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
yield* Effect.promise(async () => {
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
})
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
const status = yield* Effect.promise(() => paneDeadStatus(session))
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = yield* Effect.promise(() => capturePane(session))
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
}),
})
/** @param {string[]} args */

View file

@ -159,37 +159,19 @@ describe("Mini CLI host", () => {
expect(typeof input.startup.now()).toBe("number")
})
test("merges, clears, and repairs persisted model variants", async () => {
test("delegates model variant preferences", async () => {
const directory = await root()
const input = host({ stdin: stream(true), cleanup() {} }, directory)
const file = path.join(directory, "model.json")
await Bun.write(
file,
JSON.stringify({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", invalid: 42 },
}),
)
await input.preferences.saveVariant(model, "high")
expect(await input.preferences.resolveVariant(model)).toBe("high")
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
})
await input.preferences.saveVariant(model, undefined)
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
expect(await Bun.file(file).json()).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { "openai/gpt-4.1": "low" },
})
await Bun.write(file, JSON.stringify({ variant: { "openai/gpt-5": "default" } }))
await input.preferences.saveVariant(model, "default")
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
await Bun.write(file, "{")
await input.preferences.saveVariant(model, "high")
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
expect(await input.preferences.resolveVariant(model)).toBe("high")
})
})

View file

@ -1,5 +1,10 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import {
OpenCode,
type EventSubscribeOutput,
type SessionMessageAssistantTool,
type SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput
@ -162,10 +167,13 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
let wake: (() => void) | undefined
const wait = Promise.withResolvers<void>()
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {
const value = values.shift()
@ -175,6 +183,7 @@ async function run(input: {
})
continue
}
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
yield value
}
})()
@ -193,8 +202,19 @@ async function run(input: {
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
],
cursor: {},
}),
)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
promptID = messageID
values.push(...input.turn(messageID))
wake?.()
wake = undefined
@ -244,6 +264,63 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>()
let done = false
const task = capture({
format: "json",
turn: (messageID) => [prompted(messageID)],
wait: () => idle.promise,
messages: (messageID) => [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "projected answer" }],
finish: "stop",
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
}).then((output) => {
done = true
return output
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(done).toBe(false)
idle.resolve()
const output = await task
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([expect.objectContaining({ type: "text", part: expect.objectContaining({ text: "projected answer" }) })])
})
test("reports an observed execution failure before prompt promotion", async () => {
const output = await capture({
format: "json",
turn: () => [executionFailed("instructions unavailable")],
messages: () => [],
})
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({
type: "error",
error: { type: "provider.transport", message: "instructions unavailable" },
}),
])
})
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
const sdk = await run({
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
@ -307,6 +384,9 @@ describe("runNonInteractivePrompt", () => {
}),
])
expect(output.stderr).toBe("")
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
expect(sdk.session.wait).not.toHaveBeenCalled()
expect(sdk.message.list).not.toHaveBeenCalled()
})
test("V1 default output flushes step_start before an unrelated execution failure", async () => {