run: add -i interactive minimal mode

This commit is contained in:
Simon Klee 2026-04-24 14:07:31 +02:00
commit 6da2e6fe50
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
51 changed files with 18055 additions and 330 deletions

View file

@ -0,0 +1,348 @@
import { describe, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
}
function toolPart(tool: string, state: ToolPart["state"], id = `${tool}-1`, messageID = `msg-${tool}`): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
state: ToolPart["state"]
phase?: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
id?: string
messageID?: string
}) {
return commit({
kind: "tool",
text: input.text ?? "",
phase: input.phase ?? "final",
source: "tool",
tool: input.tool,
toolState: input.toolState ?? "completed",
part: toolPart(input.tool, input.state, input.id, input.messageID),
})
}
function structured(next: StreamCommit) {
const body = entryBody(next)
expect(body.type).toBe("structured")
if (body.type !== "structured") {
throw new Error("expected structured body")
}
return body.snapshot
}
describe("run entry body", () => {
test("renders assistant, reasoning, and user entries in their display formats", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "# Title\n\nHello **world**",
phase: "progress",
source: "assistant",
partID: "part-1",
}),
),
).toEqual({
type: "markdown",
content: "# Title\n\nHello **world**",
})
const reasoning = entryBody(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
partID: "reason-1",
}),
)
expect(reasoning).toEqual({
type: "code",
filetype: "markdown",
content: "_Thinking:_ plan next steps",
})
expect(
entryCanStream(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
}),
reasoning,
),
).toBe(true)
expect(
entryBody(
commit({
kind: "user",
text: "Inspect footer tabs",
phase: "start",
source: "system",
}),
),
).toEqual({
type: "text",
content: " Inspect footer tabs",
})
})
for (const item of [
{
name: "keeps completed write tool finals structured",
commit: toolCommit({
tool: "write",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\n",
},
output: "",
title: "",
metadata: {},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "code",
title: "# Wrote src/a.ts",
content: "const x = 1\n",
file: "src/a.ts",
},
},
{
name: "keeps completed edit tool finals structured",
commit: toolCommit({
tool: "edit",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
},
output: "",
title: "",
metadata: {
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Edited src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
},
],
},
},
{
name: "keeps completed apply_patch tool finals structured",
commit: toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Patched src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
deletions: 0,
},
],
},
},
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
test(item.name, () => {
expect(structured(item.commit)).toEqual(item.snapshot)
})
}
test("keeps running task tool state out of scrollback", () => {
expect(
entryBody(
toolCommit({
tool: "task",
phase: "start",
toolState: "running",
text: "running inspect reducer",
state: {
status: "running",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
time: { start: 1 },
},
}),
),
).toEqual({
type: "none",
})
})
test("promotes task results to markdown and falls back to structured task summaries", () => {
expect(
entryBody(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"# Findings\n\n- Footer stays live",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "markdown",
content: "# Findings\n\n- Footer stays live",
})
expect(
structured(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
kind: "task",
title: "# Explore Task",
rows: ["Inspect reducer"],
tail: "",
})
})
test("streams tool progress text and treats completed progress as done", () => {
const body = entryBody(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
partID: "tool-2",
}),
)
expect(body).toEqual({
type: "text",
content: "partial output",
})
expect(
entryCanStream(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
}),
body,
),
).toBe(true)
expect(
entryDone(
commit({
kind: "tool",
text: "output",
phase: "progress",
source: "tool",
tool: "bash",
toolState: "completed",
}),
),
).toBe(true)
})
test("renders interrupted assistant finals as text", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
interrupted: true,
partID: "part-1",
}),
),
).toEqual({
type: "text",
content: "assistant interrupted",
})
})
})

View file

@ -0,0 +1,48 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
text: "I",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
const app = await testRender(() => (
<box width={80} height={4}>
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
</box>
), {
width: 80,
height: 4,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I")
setCommit({
kind: "tool",
text: "I need to inspect the codebase",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I need to inspect the codebase")
} finally {
app.renderer.destroy()
}
})

View file

@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import {
createPermissionBodyState,
permissionAlwaysLines,
permissionCancel,
permissionEscape,
permissionInfo,
permissionReject,
permissionRun,
} from "@/cli/cmd/run/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: [],
metadata: {},
always: [],
...input,
}
}
describe("run permission shared", () => {
test("replies immediately for allow once", () => {
const out = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "once")
expect(out.reply).toEqual({
requestID: "perm-1",
reply: "once",
})
})
test("requires confirmation for allow always", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "always")
expect(next.state.stage).toBe("always")
expect(next.state.selected).toBe("confirm")
expect(next.reply).toBeUndefined()
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
requestID: "perm-1",
reply: "always",
})
expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("builds trimmed reject replies and stage transitions", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "reject")
expect(next.state.stage).toBe("reject")
const out = permissionReject({ ...next.state, message: " use rg " }, "perm-1")
expect(out).toEqual({
requestID: "perm-1",
reply: "reject",
message: "use rg",
})
expect(permissionCancel(next.state)).toMatchObject({
stage: "permission",
selected: "reject",
})
expect(permissionEscape(createPermissionBodyState("perm-1"))).toMatchObject({
stage: "reject",
selected: "reject",
})
expect(permissionEscape({ ...next.state, stage: "always", selected: "confirm" })).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("maps supported permission types into display info", () => {
expect(
permissionInfo(
req({
permission: "bash",
metadata: {
input: {
command: "git status --short",
},
},
}),
),
).toMatchObject({
title: "Shell command",
lines: ["$ git status --short"],
})
expect(
permissionInfo(
req({
permission: "task",
metadata: {
description: "investigate stream",
subagent_type: "general",
},
}),
),
).toMatchObject({
title: "General Task",
lines: ["◉ investigate stream"],
})
expect(
permissionInfo(
req({
permission: "external_directory",
patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
}),
),
).toMatchObject({
title: "Access external directory /tmp/work",
lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
})
expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({
title: "Continue after repeated failures",
})
expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
title: "Call tool custom_tool",
lines: ["Tool: custom_tool"],
})
})
test("formats always-allow copy for wildcard and explicit patterns", () => {
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
"This will allow bash until OpenCode is restarted.",
])
expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
"This will allow the following patterns until OpenCode is restarted.",
"- src/**/*.ts",
"- src/**/*.tsx",
])
})
})

View file

@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isExitCommand,
movePromptHistory,
printableBinding,
promptCycle,
promptInfo,
promptKeys,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { FooterKeybinds, RunPrompt } from "@/cli/cmd/run/types"
const keybinds: FooterKeybinds = {
leader: "ctrl+x",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputSubmit: "return",
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
}
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
}
describe("run prompt shared", () => {
test("filters blank prompts and dedupes consecutive history", () => {
const out = createPromptHistory([prompt(" "), prompt("one"), prompt("one"), prompt("two"), prompt("one")])
expect(out.items.map((item) => item.text)).toEqual(["one", "two", "one"])
expect(out.index).toBeNull()
expect(out.draft).toBe("")
})
test("push ignores blanks and dedupes only the latest item", () => {
const base = createPromptHistory([prompt("one")])
expect(pushPromptHistory(base, prompt(" ")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("one")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("two")).items.map((item) => item.text)).toEqual(["one", "two"])
})
test("moves through history only at input boundaries and restores draft", () => {
const base = createPromptHistory([prompt("one"), prompt("two")])
expect(movePromptHistory(base, -1, "draft", 1)).toEqual({
state: base,
apply: false,
})
const up = movePromptHistory(base, -1, "draft", 0)
expect(up.apply).toBe(true)
expect(up.text).toBe("two")
expect(up.cursor).toBe(0)
expect(up.state.index).toBe(1)
expect(up.state.draft).toBe("draft")
const older = movePromptHistory(up.state, -1, "two", 0)
expect(older.apply).toBe(true)
expect(older.text).toBe("one")
expect(older.cursor).toBe(0)
expect(older.state.index).toBe(0)
const newer = movePromptHistory(older.state, 1, "one", 3)
expect(newer.apply).toBe(true)
expect(newer.text).toBe("two")
expect(newer.cursor).toBe(3)
expect(newer.state.index).toBe(1)
const draft = movePromptHistory(newer.state, 1, "two", 3)
expect(draft.apply).toBe(true)
expect(draft.text).toBe("draft")
expect(draft.cursor).toBe(5)
expect(draft.state.index).toBeNull()
})
test("handles direct and leader-based variant cycling", () => {
const keys = promptKeys(keybinds)
expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: true,
clear: false,
cycle: false,
consume: true,
})
expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: true,
cycle: true,
consume: true,
})
expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: false,
cycle: true,
consume: true,
})
})
test("prints bindings with leader substitution and esc normalization", () => {
expect(printableBinding("<leader>t", "ctrl+x")).toBe("ctrl+x t")
expect(printableBinding("escape", "ctrl+x")).toBe("esc")
expect(printableBinding("", "ctrl+x")).toBe("")
})
test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true)
expect(isExitCommand("/quit now")).toBe(false)
})
})

View file

@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import {
createQuestionBodyState,
questionConfirm,
questionReject,
questionSave,
questionSelect,
questionSetSelected,
questionStoreCustom,
questionSubmit,
questionSync,
} from "@/cli/cmd/run/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
...input,
}
}
describe("run question shared", () => {
test("replies immediately for a single-select question", () => {
const out = questionSelect(createQuestionBodyState("question-1"), req())
expect(out.reply).toEqual({
requestID: "question-1",
answers: [["chunked"]],
})
})
test("advances multi-question flows and submits from confirm", () => {
const ask = req({
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
{
question: "Output?",
header: "Output",
options: [
{ label: "yes", description: "Show tool output" },
{ label: "no", description: "Hide tool output" },
],
multiple: false,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.tab).toBe(1)
state = questionSetSelected(state, 1)
state = questionSelect(state, ask).state
expect(questionConfirm(ask, state)).toBe(true)
expect(questionSubmit(ask, state)).toEqual({
requestID: "question-1",
answers: [["chunked"], ["no"]],
})
})
test("toggles answers for multiple-choice questions", () => {
const ask = req({
questions: [
{
question: "Tags?",
header: "Tags",
options: [{ label: "bug", description: "Bug fix" }],
multiple: true,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.answers).toEqual([["bug"]])
state = questionSelect(state, ask).state
expect(state.answers).toEqual([[]])
})
test("stores and submits custom answers", () => {
let state = questionSetSelected(createQuestionBodyState("question-1"), 1)
let next = questionSelect(state, req())
expect(next.state.editing).toBe(true)
state = questionStoreCustom(next.state, 0, " custom mode ")
next = questionSave(state, req())
expect(next.reply).toEqual({
requestID: "question-1",
answers: [["custom mode"]],
})
})
test("resets state when the request id changes and builds reject payloads", () => {
const state = questionSetSelected(createQuestionBodyState("question-1"), 1)
expect(questionSync(state, "question-1")).toBe(state)
expect(questionSync(state, "question-2")).toEqual(createQuestionBodyState("question-2"))
expect(questionReject(req())).toEqual({
requestID: "question-1",
})
})
})

View file

@ -0,0 +1,165 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import {
resolveDiffStyle,
resolveFooterKeybinds,
resolveModelInfo,
} from "@/cli/cmd/run/runtime.boot"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
return {
id,
providerID,
api: {
id: providerID,
url: `https://${providerID}.test`,
npm: `@ai-sdk/${providerID}`,
},
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,
},
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,
}
}
describe("run runtime boot", () => {
afterEach(() => {
mock.restore()
})
test("merges footer keybind config and injects leader cycle once", async () => {
spyOn(TuiConfig, "get").mockResolvedValue({
keybinds: {
leader: " ctrl+g ",
variant_cycle: " ctrl+t, <leader>t , alt+t ",
session_interrupt: " ctrl+c ",
history_previous: " k ",
history_next: " j ",
input_submit: " ctrl+s ",
input_newline: " alt+return ",
},
})
await expect(resolveFooterKeybinds()).resolves.toEqual({
leader: "ctrl+g",
variantCycle: "ctrl+t,<leader>t,alt+t",
interrupt: "ctrl+c",
historyPrevious: "k",
historyNext: "j",
inputSubmit: "ctrl+s",
inputNewline: "alt+return",
})
})
test("falls back to default keybinds when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveFooterKeybinds()).resolves.toEqual({
leader: "ctrl+x",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputSubmit: "return",
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
})
})
test("collects model variants and context limits", 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.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
"anthropic/sonnet": 200000,
},
})
})
test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue({ diff_style: "stacked" })
await expect(resolveDiffStyle()).resolves.toBe("stacked")
mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
})
})

View file

@ -0,0 +1,248 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
submit(text: string) {
const next = { text, parts: [] as RunPrompt["parts"] }
for (const fn of [...prompts]) {
fn(next)
}
},
}
}
describe("run runtime queue", () => {
test("ignores empty prompts", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit(" ")
ui.api.close()
await task
expect(calls).toBe(0)
})
test("treats /exit as a close command", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit("/exit")
await task
expect(calls).toBe(0)
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
expect(ui.commits).toEqual([
{
kind: "user",
text: " hello ",
phase: "start",
source: "system",
},
])
})
test("runs queued prompts in order", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await task
expect(seen).toEqual(["one", "two"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await Promise.resolve()
ui.submit("two")
await task
expect(seen).toEqual(["one", "two"])
})
test("close aborts the active run and drops pending queued work", async () => {
const ui = footer()
const seen: string[] = []
let hit = false
const task = runPromptQueue({
footer: ui.api,
run: async (input, signal) => {
seen.push(input.text)
await new Promise<void>((resolve) => {
if (signal.aborted) {
hit = true
resolve()
return
}
signal.addEventListener(
"abort",
() => {
hit = true
resolve()
},
{ once: true },
)
})
},
})
ui.submit("one")
await Promise.resolve()
ui.submit("two")
ui.api.close()
await task
expect(hit).toBe(true)
expect(seen).toEqual(["one"])
})
test("propagates run errors", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
throw new Error("boom")
},
})
ui.submit("one")
await expect(task).rejects.toThrow("boom")
})
})

View file

@ -0,0 +1,500 @@
import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
type ClaimedCommit = {
snapshot: {
height: number
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
destroy(): void
}
trailingNewline: boolean
}
type QueueRenderer = {
externalOutputQueue: {
claim(): ClaimedCommit[]
}
}
const decoder = new TextDecoder()
const active: TestRenderer[] = []
afterEach(() => {
for (const renderer of active.splice(0)) {
renderer.destroy()
}
})
function claim(renderer: TestRenderer) {
return (renderer as unknown as QueueRenderer).externalOutputQueue.claim()
}
function renderCommit(commit: ClaimedCommit) {
return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
}
function render(commits: ClaimedCommit[]) {
return commits.map(renderCommit).join("")
}
function renderRows(commit: ClaimedCommit, width = 80) {
const raw = decoder.decode(commit.snapshot.getRealCharBytes(true))
return Array.from({ length: commit.snapshot.height }, (_, index) =>
raw.slice(index * width, (index + 1) * width).trimEnd(),
)
}
function destroy(commits: ClaimedCommit[]) {
for (const commit of commits) {
commit.snapshot.destroy()
}
}
async function setup(input: {
width?: number
wrote?: boolean
} = {}) {
const out = await createTestRenderer({
width: input.width ?? 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
treeSitterClient.setMockResult({ highlights: [] })
return {
renderer: out.renderer,
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: input.wrote ?? false,
}),
}
}
function assistant(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
return {
kind: "assistant",
text,
phase,
source: "assistant",
messageID: "msg-1",
partID: "part-1",
}
}
function user(text: string): StreamCommit {
return {
kind: "user",
text,
phase: "start",
source: "system",
}
}
function toolPart(tool: string, state: Record<string, unknown>, id: string, messageID: string): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
phase: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
state?: Record<string, unknown>
id?: string
messageID?: string
}): StreamCommit {
const id = input.id ?? `${input.tool}-1`
const messageID = input.messageID ?? `msg-${input.tool}`
return {
kind: "tool",
text: input.text ?? "",
phase: input.phase,
source: "tool",
partID: id,
messageID,
tool: input.tool,
...(input.toolState ? { toolState: input.toolState } : {}),
...(input.state ? { part: toolPart(input.tool, input.state, id, messageID) } : {}),
}
}
test("finalizes markdown tables for streamed and coalesced input", async () => {
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
for (const chunks of [[text], [...text]]) {
const out = await setup()
try {
for (const chunk of chunks) {
await out.scrollback.append(assistant(chunk))
}
await out.scrollback.complete()
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output).toContain("Column 1")
expect(output).toContain("Row 2")
expect(output).toContain("Value 4")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
}
})
test("holds markdown code blocks until final commit and keeps newline ownership", async () => {
const out = await setup()
try {
await out.scrollback.append(
assistant('# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```'),
)
const progress = claim(out.renderer)
try {
expect(progress).toHaveLength(1)
expect(render(progress)).toContain("Markdown Sample")
expect(render(progress)).toContain("Item 2")
expect(render(progress)).not.toContain("console.log(message)")
} finally {
destroy(progress)
}
await out.scrollback.complete()
const final = claim(out.renderer)
try {
expect(final).toHaveLength(1)
expect(final[0]!.trailingNewline).toBe(false)
expect(render(final)).toContain('const message = "Hello, markdown"')
expect(render(final)).toContain("console.log(message)")
} finally {
destroy(final)
}
} finally {
out.scrollback.destroy()
}
})
test("renders todo and question summaries without boilerplate footer copy", async () => {
const cases = [
{
title: "# Todos",
include: [
"[✓] List files under `run/`",
"[•] Count functions in each `run/` file",
"[ ] Mark each tracking item complete",
],
exclude: ["Updating", "todos completed"],
start: toolCommit({
tool: "todowrite",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "todowrite",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
metadata: {},
time: { start: 1, end: 4 },
},
}),
},
{
title: "# Questions",
include: ["What should I work on in the codebase next?", "Bug fix"],
exclude: ["Asked", "questions completed"],
start: toolCommit({
tool: "question",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
questions: [
{
question: "What should I work on in the codebase next?",
header: "Next work",
options: [{ label: "bug", description: "Bug fix" }],
multiple: false,
},
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "question",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
questions: [
{
question: "What should I work on in the codebase next?",
header: "Next work",
options: [{ label: "bug", description: "Bug fix" }],
multiple: false,
},
],
},
metadata: {
answers: [["Bug fix"]],
},
time: { start: 1, end: 2100 },
},
}),
},
]
for (const item of cases) {
const out = await setup()
try {
await out.scrollback.append(item.start)
expect(claim(out.renderer)).toHaveLength(0)
await out.scrollback.append(item.final)
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
const rows = renderRows(commits[0]!)
const output = rows.join("\n")
expect(output).toContain(item.title)
for (const line of item.include) {
expect(output).toContain(line)
}
for (const line of item.exclude) {
expect(output).not.toContain(line)
}
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
}
})
test("inserts spacers for new visible groups", async () => {
const prior = await setup({ wrote: true })
try {
await prior.scrollback.append(user("use subagent to explore run.ts"))
const commits = claim(prior.renderer)
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).trim()).toBe(" use subagent to explore run.ts")
} finally {
destroy(commits)
}
} finally {
prior.scrollback.destroy()
}
const grouped = await setup()
try {
await grouped.scrollback.append(assistant("hello"))
await grouped.scrollback.complete()
destroy(claim(grouped.renderer))
await grouped.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
text: "running glob",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/run.ts",
},
time: { start: 1 },
},
}),
)
const commits = claim(grouped.renderer)
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
} finally {
destroy(commits)
}
} finally {
grouped.scrollback.destroy()
}
})
test("coalesces same-line tool progress into one snapshot", async () => {
const out = await setup()
try {
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "abc" }))
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "def" }))
await out.scrollback.append(toolCommit({ tool: "bash", phase: "final", text: "", toolState: "completed" }))
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
expect(render(commits)).toContain("abcdef")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders structured write finals once as code blocks", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "write",
phase: "start",
toolState: "running",
id: "tool-2",
messageID: "msg-2",
state: {
status: "running",
input: {
filePath: "src/a.ts",
content: "const x = 1\nconst y = 2\n",
},
time: { start: 1 },
},
}),
)
expect(claim(out.renderer)).toHaveLength(0)
await out.scrollback.append(
toolCommit({
tool: "write",
phase: "final",
toolState: "completed",
id: "tool-2",
messageID: "msg-2",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\nconst y = 2\n",
},
metadata: {},
time: { start: 1, end: 2 },
},
}),
)
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
const output = render(commits[0] ? [commits[0]] : [])
expect(output).toContain("# Wrote src/a.ts")
expect(output).toMatch(/1\s+const x = 1/)
expect(output).toMatch(/2\s+const y = 2/)
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders promoted task markdown without a leading blank row", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "task",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
description: "Explore run.ts",
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"Location: `/tmp/run.ts`",
"",
"Summary:",
"- Local interactive mode",
"- Attach mode",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
)
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output.startsWith("\n")).toBe(false)
expect(output).toContain("Summary:")
expect(output).toContain("Local interactive mode")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})

View file

@ -0,0 +1,383 @@
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 type { StreamCommit } from "@/cli/cmd/run/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking,
limits: {},
})
}
function assistant(id: string, extra: Record<string, unknown> = {}) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...extra,
},
},
}
}
function user(id: string) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "user",
},
},
}
}
function text(input: {
id: string
messageID: string
text: string
time?: Record<string, number>
}) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "text",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "reasoning",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function delta(messageID: string, partID: string, value: string) {
return {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID,
partID,
field: "text",
delta: value,
},
}
}
function tool(input: {
id: string
messageID: string
tool: string
state: Record<string, unknown>
callID?: string
}) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "tool",
tool: input.tool,
...(input.callID ? { callID: input.callID } : {}),
state: input.state,
},
},
}
}
describe("run session data", () => {
test("buffers delayed assistant text until the role is known", () => {
let data = createSessionData()
data = reduce(data, delta("msg-1", "txt-1", "hello")).data
data = reduce(data, assistant("msg-1")).data
const out = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "",
time: { end: 1 },
}),
)
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "hello",
partID: "txt-1",
}),
])
})
test("keeps leading whitespace buffered until real assistant content arrives", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
let out = reduce(data, delta("msg-1", "txt-1", " "))
expect(out.commits).toEqual([])
out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: " Found",
}),
])
})
test("drops delayed text once the message resolves to a user role", () => {
let data = createSessionData()
data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
const out = reduce(data, user("msg-user-1"))
expect(out.commits).toEqual([])
expect(out.data.ids.has("txt-user-1")).toBe(true)
})
test("suppresses reasoning commits when thinking is disabled", () => {
const out = reduce(
createSessionData(),
reasoning({
id: "reason-1",
messageID: "msg-1",
text: "hidden",
time: { end: 1 },
}),
false,
)
expect(out.commits).toEqual([])
expect(out.data.ids.has("reason-1")).toBe(true)
})
test("keeps permission precedence over queued questions", () => {
let data = createSessionData()
data = reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: ["/tmp/file.txt"],
metadata: {},
always: [],
},
}).data
const ask = reduce(data, {
type: "question.asked",
properties: {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
},
})
expect(ask.footer).toEqual({
patch: { status: "awaiting permission" },
view: {
type: "permission",
request: expect.objectContaining({ id: "perm-1" }),
},
})
expect(
reduce(ask.data, {
type: "permission.replied",
properties: {
sessionID: "session-1",
requestID: "perm-1",
reply: "reject",
},
}).footer,
).toEqual({
patch: { status: "awaiting answer" },
view: {
type: "question",
request: expect.objectContaining({ id: "question-1" }),
},
})
})
test("refreshes the active permission view when tool input arrives later", () => {
const data = reduce(createSessionData(), {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "bash",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
tool: {
messageID: "msg-1",
callID: "call-1",
},
},
}).data
const out = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
},
}),
)
expect(out.footer).toEqual({
view: {
type: "permission",
request: expect.objectContaining({
id: "perm-1",
metadata: expect.objectContaining({
input: {
command: "git status --short",
},
}),
}),
},
})
})
test("strips bash echo only from the first assistant flush", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
tool: "bash",
state: {
status: "completed",
input: {
command: "printf hi",
},
output: "echoed\n",
time: { start: 1, end: 2 },
},
}),
).data
const first = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "echoed\nanswer",
}),
)
expect(first.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "answer",
}),
])
expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "\nechoed\nagain",
}),
])
})
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",
properties: {
sessionID: "session-1",
error: {
name: "UnknownError",
data: {
message: "permission denied",
},
},
},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "error",
text: "permission denied",
}),
])
})
})

View file

@ -0,0 +1,247 @@
import { describe, expect, test } from "bun:test"
import {
createSession,
sessionHistory,
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/session.shared"
type Message = SessionMessages[number]
type Part = Message["parts"][number]
type TextPart = Extract<Part, { type: "text" }>
type AgentPart = Extract<Part, { type: "agent" }>
type FilePart = Extract<Part, { type: "file" }>
const model = {
providerID: "openai",
modelID: "gpt-5",
}
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
...model,
variant,
},
},
parts,
}
}
function assistantMessage(id: string, parts: Message["parts"]): Message {
return {
info: {
id,
sessionID: "session-1",
role: "assistant",
time: {
created: 1,
},
parentID: "msg-user-1",
modelID: "gpt-5",
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,
},
},
},
parts,
}
}
function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
text,
synthetic: input.synthetic,
}
}
function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
return {
id,
sessionID: "session-1",
messageID,
type: "agent",
name,
source,
}
}
function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
return {
id,
sessionID: "session-1",
messageID,
type: "file",
mime: input.mime ?? "text/plain",
filename: input.filename,
url,
source: input.source,
}
}
describe("run session shared", () => {
test("builds user prompt text from text, file, and agent parts", () => {
const msgs: SessionMessages = [
assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan"),
textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
agentPart("agent-user-1", "msg-user-1", "scan", {
start: 5,
end: 10,
value: "@scan",
}),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
]
const out = createSession(msgs)
expect(out.first).toBe(false)
expect(out.turns).toHaveLength(1)
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
expect(out.turns[0]?.prompt.parts).toEqual([
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
])
})
test("reuses existing mentions when file and agent parts have no source", () => {
const out = createSession([
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
agentPart("agent-user-1", "msg-user-1", "scan"),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
])
expect(out.turns[0]?.prompt).toEqual({
text: "look @scan @note.ts",
parts: [
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
],
})
})
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
const parts = [
{
type: "agent" as const,
name: "scan",
source: {
start: 0,
end: 5,
value: "@scan",
},
},
]
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
const out = sessionHistory(session)
expect(out.map((item) => item.text)).toEqual(["one", "two"])
expect(out[0]?.parts).toEqual(parts)
expect(out[0]?.parts).not.toBe(parts)
expect(out[0]?.parts[0]).not.toBe(parts[0])
})
test("returns the latest matching variant for the active model", () => {
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
{ prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
expect(sessionVariant(session, model)).toBeUndefined()
session.turns.push({
prompt: { text: "four", parts: [] },
provider: "openai",
model: "gpt-5",
variant: "minimal",
})
expect(sessionVariant(session, model)).toBe("minimal")
})
})

View file

@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
},
append: (next) => {
commits.push(next)
},
idle: () => Promise.resolve(),
close: () => {},
destroy: () => {},
}
return { api, events, commits }
}
describe("run stream bridge", () => {
test("defaults status patches to running phase", () => {
const out = footer()
writeSessionOutput(
{
footer: out.api,
},
{
commits: [],
footer: {
patch: {
status: "assistant responding",
},
},
},
)
expect(out.events).toEqual([
{
type: "stream.patch",
patch: {
phase: "running",
status: "assistant responding",
},
},
])
})
})

View file

@ -0,0 +1,712 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
type EventStream = Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>["stream"]
type SdkEvent = EventStream extends AsyncGenerator<infer T, unknown, unknown> ? T : never
type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
type SessionChild = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["children"]>>["data"]>[number]
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
afterEach(() => {
mock.restore()
})
function defer<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (error?: unknown) => void
const promise = new Promise<T>((next, fail) => {
resolve = next
reject = fail
})
return { promise, resolve, reject }
}
function busy(sessionID = "session-1") {
return {
type: "session.status",
properties: {
sessionID,
status: {
type: "busy",
},
},
} satisfies SdkEvent
}
function idle(sessionID = "session-1") {
return {
type: "session.status",
properties: {
sessionID,
status: {
type: "idle",
},
},
} satisfies SdkEvent
}
function assistant(id: string) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: assistantMessage({
sessionID: "session-1",
id,
parts: [],
}).info,
},
} satisfies SdkEvent
}
function feed() {
const list: SdkEvent[] = []
let done = false
let wake: (() => void) | undefined
const stream: EventStream = (async function* () {
while (!done || list.length > 0) {
if (list.length === 0) {
await new Promise<void>((resolve) => {
wake = resolve
})
continue
}
const next = list.shift()
if (!next) {
continue
}
yield next
}
})()
return {
stream,
push(value: SdkEvent) {
list.push(value)
wake?.()
wake = undefined
},
close() {
done = true
wake?.()
wake = undefined
},
}
}
function emptyStream(): EventStream {
return (async function* (): AsyncGenerator<SdkEvent> {})()
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
}
function sse(stream: EventStream) {
return Promise.resolve({ stream })
}
function assistantMessage(input: {
sessionID: string
id: string
parts: SessionMessage["parts"]
}): SessionMessage {
return {
info: {
id: input.id,
sessionID: input.sessionID,
role: "assistant",
time: {
created: 1,
},
parentID: "msg-user-1",
modelID: "gpt-5",
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,
},
},
},
parts: input.parts,
}
}
function runningTool(input: {
sessionID: string
messageID: string
id: string
callID: string
tool: string
body: Record<string, unknown>
metadata?: Record<string, unknown>
}): SessionToolPart {
return {
id: input.id,
sessionID: input.sessionID,
messageID: input.messageID,
type: "tool",
callID: input.callID,
tool: input.tool,
state: {
status: "running",
input: input.body,
...(input.metadata ? { metadata: input.metadata } : {}),
time: {
start: 1,
},
},
}
}
function textPart(id: string, messageID: string, text: string): TextPart {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
text,
}
}
function textUpdated(part: TextPart): SdkEvent {
return {
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 1,
},
}
}
function textDelta(messageID: string, partID: string, delta: string): SdkEvent {
return {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID,
partID,
field: "text",
delta,
},
}
}
function child(id: string): SessionChild {
return {
id,
slug: id,
projectID: "project-1",
directory: "/tmp",
title: id,
version: "1",
time: {
created: 1,
updated: 1,
},
}
}
function footer(fn?: (commit: StreamCommit) => void) {
const commits: StreamCommit[] = []
const events: FooterEvent[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onClose: () => () => {},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
fn?.(next)
},
idle() {
return Promise.resolve()
},
close() {
closed = true
},
destroy() {
closed = true
},
}
return { api, commits, events }
}
function sdk(input: {
stream?: EventStream
subscribe?: OpencodeClient["event"]["subscribe"]
promptAsync?: OpencodeClient["session"]["promptAsync"]
status?: OpencodeClient["session"]["status"]
messages?: OpencodeClient["session"]["messages"]
children?: OpencodeClient["session"]["children"]
permissions?: OpencodeClient["permission"]["list"]
questions?: OpencodeClient["question"]["list"]
} = {}) {
const client = new OpencodeClient()
const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream()))
const promptAsync: OpencodeClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined))
const status: OpencodeClient["session"]["status"] = input.status ?? (() => ok({}))
const messages: OpencodeClient["session"]["messages"] = input.messages ?? (() => ok([]))
const children: OpencodeClient["session"]["children"] = input.children ?? (() => ok([]))
const permissions: OpencodeClient["permission"]["list"] = input.permissions ?? (() => ok([]))
const questions: OpencodeClient["question"]["list"] = input.questions ?? (() => ok([]))
spyOn(client.event, "subscribe").mockImplementation(subscribe)
spyOn(client.session, "promptAsync").mockImplementation(promptAsync)
spyOn(client.session, "status").mockImplementation(status)
spyOn(client.session, "messages").mockImplementation(messages)
spyOn(client.session, "children").mockImplementation(children)
spyOn(client.permission, "list").mockImplementation(permissions)
spyOn(client.question, "list").mockImplementation(questions)
return client
}
describe("run stream transport", () => {
test("bootstraps child tabs and resumed blocker input", async () => {
const src = feed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) => {
if (sessionID === "session-1") {
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "task-1",
callID: "call-1",
tool: "task",
body: {
description: "Explore run folder",
subagent_type: "explore",
},
metadata: {
sessionId: "child-1",
},
}),
],
}),
])
}
return ok([
assistantMessage({
sessionID: "child-1",
id: "msg-child-1",
parts: [
runningTool({
sessionID: "child-1",
messageID: "msg-child-1",
id: "edit-1",
callID: "call-edit-1",
tool: "edit",
body: {
filePath: "src/run/subagent-data.ts",
diff: "@@ -1 +1 @@",
},
}),
],
}),
])
},
children: async () => ok([child("child-1")]),
permissions: async () => ok([
{
id: "perm-1",
sessionID: "child-1",
permission: "edit",
patterns: ["src/run/subagent-data.ts"],
metadata: {},
always: [],
tool: {
messageID: "msg-child-1",
callID: "call-edit-1",
},
},
]),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
expect(ui.events).toContainEqual({
type: "stream.subagent",
state: {
tabs: [
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Explore run folder",
status: "running",
}),
],
details: {},
permissions: [
expect.objectContaining({
id: "perm-1",
sessionID: "child-1",
metadata: {
input: {
filePath: "src/run/subagent-data.ts",
diff: "@@ -1 +1 @@",
},
},
}),
],
questions: [],
},
})
transport.selectSubagent("child-1")
expect(ui.events).toContainEqual({
type: "stream.subagent",
state: {
tabs: [
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
}),
],
details: {
"child-1": {
sessionID: "child-1",
commits: [],
},
},
permissions: [
expect.objectContaining({
id: "perm-1",
}),
],
questions: [],
},
})
expect(ui.events).toContainEqual({
type: "stream.view",
view: {
type: "permission",
request: expect.objectContaining({
id: "perm-1",
metadata: {
input: {
filePath: "src/run/subagent-data.ts",
diff: "@@ -1 +1 @@",
},
},
}),
},
})
} finally {
src.close()
await transport.close()
}
})
test("respects the includeFiles flag when building prompt payloads", async () => {
const src = feed()
const ui = footer()
const seen: unknown[] = []
const file: RunFilePart = {
type: "file",
url: "file:///tmp/a.ts",
filename: "a.ts",
mime: "text/plain",
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async (input) => {
seen.push(input)
queueMicrotask(() => {
src.push(busy())
src.push(idle())
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [file],
includeFiles: true,
})
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "again", parts: [] },
files: [file],
includeFiles: false,
})
expect(seen).toEqual([
expect.objectContaining({
parts: [file, { type: "text", text: "hello" }],
}),
expect.objectContaining({
parts: [{ type: "text", text: "again" }],
}),
])
} finally {
src.close()
await transport.close()
}
})
test("flushes interrupted output when the active turn aborts", async () => {
const src = feed()
const seen = defer()
const ui = footer((commit) => {
if (commit.kind === "assistant" && commit.phase === "progress") {
seen.resolve()
}
})
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-1"))
src.push(textUpdated(textPart("txt-1", "msg-1", "")))
src.push(textDelta("msg-1", "txt-1", "unfinished"))
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const task = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
await seen.promise
ctrl.abort()
await task
expect(ui.commits).toEqual([
{
kind: "assistant",
text: "unfinished",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "txt-1",
},
{
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
messageID: "msg-1",
partID: "txt-1",
interrupted: true,
},
])
} finally {
src.close()
await transport.close()
}
})
test("closes an active turn without rejecting it", async () => {
const src = feed()
const ui = footer()
const ready = defer()
let aborted = false
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async (_input, opt) => {
ready.resolve()
await new Promise<void>((resolve) => {
const onAbort = () => {
aborted = true
opt?.signal?.removeEventListener("abort", onAbort)
resolve()
}
opt?.signal?.addEventListener("abort", onAbort, { once: true })
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
const task = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
})
await ready.promise
await transport.close()
await task
expect(aborted).toBe(true)
} finally {
src.close()
await transport.close()
}
})
test("rejects the active turn when the event stream faults", async () => {
const ui = footer()
const ready = defer()
const transport = await createSessionTransport({
sdk: sdk({
subscribe: () => sse((async function* (): AsyncGenerator<SdkEvent> {
await ready.promise
yield busy()
throw new Error("boom")
})()),
promptAsync: async () => {
ready.resolve()
return ok(undefined)
},
status: async () => ok({ "session-1": { type: "busy" } }),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
await expect(
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
}),
).rejects.toThrow("boom")
} finally {
await transport.close()
}
})
test("rejects concurrent turns", async () => {
const src = feed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const task = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "one", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
await expect(
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "two", parts: [] },
files: [],
includeFiles: false,
}),
).rejects.toThrow("prompt already running")
ctrl.abort()
await task
} finally {
src.close()
await transport.close()
}
})
})

View file

@ -0,0 +1,328 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
} from "@/cli/cmd/run/subagent-data"
type SessionMessage = Parameters<typeof bootstrapSubagentData>[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" = "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 },
},
},
],
}
}
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,
},
],
}
}
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("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",
"# Shell\n$ 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("clears finished tabs on the next parent prompt", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [],
questions: [],
})
expect(clearFinishedSubagents(data)).toBe(true)
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({ sessionID: "child-2", status: "running" }),
])
})
})

View file

@ -0,0 +1,116 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
const palette = [
"#15161e",
"#f7768e",
"#9ece6a",
"#e0af68",
"#7aa2f7",
"#bb9af7",
"#7dcfff",
"#c0caf5",
] as const
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
defaultBackground: input.defaultBackground ?? "#1a1b26",
defaultForeground: input.defaultForeground ?? "#c0caf5",
cursorColor: input.cursorColor ?? "#ff9e64",
mouseForeground: input.mouseForeground ?? null,
mouseBackground: input.mouseBackground ?? null,
tekForeground: input.tekForeground ?? null,
tekBackground: input.tekBackground ?? null,
highlightBackground: input.highlightBackground ?? "#33467c",
highlightForeground: input.highlightForeground ?? "#c0caf5",
}
}
function renderer(input: {
themeMode?: "dark" | "light"
colors?: TerminalColors
fail?: boolean
} = {}) {
return {
themeMode: input.themeMode,
getPalette: async () => {
if (input.fail) {
throw new Error("boom")
}
return input.colors ?? terminalColors()
},
} as CliRenderer
}
function expectRgba(color: unknown) {
expect(color).toBeInstanceOf(RGBA)
if (!(color instanceof RGBA)) {
throw new Error("expected RGBA")
}
return color
}
function spread(color: RGBA) {
const [r, g, b] = color.toInts()
return Math.max(r, g, b) - Math.min(r, g, b)
}
test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
})
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
try {
expect(theme.block.syntax).toBeDefined()
expect(theme.block.subtleSyntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
expect(RGBA.getIntentTag(expectRgba(theme.splash.left))).toBeLessThan(256)
expect(RGBA.getIntentTag(expectRgba(theme.splash.right))).toBeLessThan(256)
expect(RGBA.getIntentTag(expectRgba(theme.splash.leftShadow))).toBeLessThan(256)
expect(RGBA.getIntentTag(expectRgba(theme.splash.rightShadow))).toBeLessThan(256)
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.surface)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
test("keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#0000ff",
defaultForeground: "#ffffff",
}),
"dark",
),
"dark",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(10)
expect(spread(theme.backgroundElement)).toBeLessThan(10)
})
test("keeps light surfaces close to neutral on warm backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
}),
"light",
),
"light",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(60)
expect(spread(theme.backgroundElement)).toBeLessThan(60)
})

View file

@ -0,0 +1,152 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "@opencode-ai/core/global"
import {
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import { testEffect } from "../../lib/effect"
const model = {
providerID: "openai",
modelID: "gpt-5",
}
function userMessage(id: string, input: { providerID: string; modelID: string; variant?: string }): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: input,
},
parts: [],
}
}
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
return root
}
if (file.startsWith(Global.Path.state + path.sep)) {
return path.join(root, path.relative(Global.Path.state, file))
}
return file
}
function remappedFs(root: string) {
return Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
readJson: (file) => fs.readJson(remap(root, file)),
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
}
describe("run variant shared", () => {
test("prefers cli then session then saved variants", () => {
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
})
test("cycles through variants and back to default", () => {
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
expect(cycleVariant("low", ["low", "high"])).toBe("high")
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
expect(cycleVariant(undefined, [])).toBeUndefined()
})
test("formats model labels", () => {
expect(formatModelLabel(model, undefined)).toBe("gpt-5 · openai")
expect(formatModelLabel(model, "high")).toBe("gpt-5 · openai · high")
})
test("picks the latest matching variant from raw session messages", () => {
const msgs: SessionMessages = [
userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }),
userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }),
userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }),
]
expect(pickVariant(model, msgs)).toBe("minimal")
})
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* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* fs.writeJson(file, {
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
"openai/gpt-5": "high",
},
})
yield* Effect.promise(() => svc.saveVariant(model, undefined))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined()
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
}),
)
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* filesys.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
variant: {
"openai/gpt-5": "high",
},
})
}),
)
})