mini migrate to v2 (#35526)

This commit is contained in:
Simon Klee
2026-07-06 11:03:30 +02:00
committed by GitHub
parent fbcbf3c6ef
commit b2cf7a2953
93 changed files with 2246 additions and 2335 deletions
@@ -41,34 +41,6 @@ Options:
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = `
"opencode mini
start the minimal interactive interface
Commands:
opencode mini [project] start the minimal interactive interface [default]
opencode mini attach <url> attach to a running opencode server with the minimal interface
Positionals:
project path to start opencode in [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--prompt prompt to use [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--no-replay disable session history replay on resume and after resize [boolean]
--replay-limit cap visible replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
"opencode attach <url>
@@ -100,33 +72,25 @@ Positionals:
message message to send [array] [default: []]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
[string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided)
[number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal)
[string]
--thinking show thinking blocks [boolean]
-i, --interactive run in direct interactive split-footer mode [boolean] [default: false]
--auto auto-approve permissions that are not explicitly denied (dangerous!)
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--server connect to a running opencode server [string]
--dir directory to run in, or a path on the remote server [string]
--variant model variant [string]
--thinking show thinking blocks [boolean]
--auto auto-approve permissions that are not explicitly denied (dangerous!)
[boolean] [default: false]"
`;
@@ -426,31 +390,6 @@ Options:
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = `
"opencode mini attach <url>
attach to a running opencode server with the minimal interface
Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory on the remote server [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--no-replay disable session history replay on resume and after resize [boolean]
--replay-limit cap visible replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
"opencode mcp list
@@ -57,7 +57,6 @@ function normalize(text: string): string {
const TOP_LEVEL = [
"acp",
"mcp",
"mini",
"attach",
"run",
"debug",
@@ -82,7 +81,6 @@ const TOP_LEVEL = [
// distinct argv shape, not every leaf. Add new entries when a subcommand
// gains user-visible flags that we want to lock in.
const SUBCOMMANDS = [
["mini", "attach"],
["mcp", "list"],
["mcp", "add"],
["mcp", "auth"],
@@ -115,7 +113,7 @@ describe("opencode CLI help-text snapshots", () => {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith("\n")).toBe(true)
expect(topLevel.stderr).toContain("opencode mini")
expect(topLevel.stderr).not.toContain("opencode mini")
expect(topLevel.stderr).not.toContain("--mini")
expect(topLevel.stderr).not.toContain("--thinking")
expect(topLevel.stderr).not.toContain("--variant")
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared"
import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders, waitForDefaultModel } from "@opencode-ai/cli/mini/catalog.shared"
afterEach(() => {
mock.restore()
@@ -8,20 +8,12 @@ afterEach(() => {
describe("run catalog shared", () => {
test("resolves the catalog-selected model for the footer", async () => {
const client = new OpencodeClient()
const selected = spyOn(client.v2.model, "default").mockImplementation(
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = spyOn(client.model, "default").mockImplementation(
() =>
Promise.resolve({
data: {
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: {
id: "gpt-5",
providerID: "openai",
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: { id: "gpt-5", providerID: "openai" },
}) as never,
)
@@ -29,17 +21,16 @@ describe("run catalog shared", () => {
providerID: "openai",
modelID: "gpt-5",
})
expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
})
test("loads visible project references from the current reference catalog", async () => {
const client = new OpencodeClient()
const list = spyOn(client.v2.reference, "list").mockImplementation(
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const list = spyOn(client.reference, "list").mockImplementation(
() =>
Promise.resolve({
data: {
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [
{
name: "effect",
path: "/repos/effect",
@@ -52,17 +43,13 @@ describe("run catalog shared", () => {
hidden: true,
source: { type: "local", path: "/repos/secret" },
},
],
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
],
}) as never,
)
const references = await loadRunReferences(client, "/tmp")
expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }])
})
@@ -1,7 +1,7 @@
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"
import { entryBody, entryCanStream, entryDone } from "@opencode-ai/cli/mini/entry.body"
import type { StreamCommit, ToolSnapshot } from "@opencode-ai/cli/mini/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@opencode-ai/cli/mini/footer.menu"
function mount(count: number, limit = FOOTER_MENU_ROWS) {
let dispose!: () => void
@@ -15,10 +15,10 @@ import {
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command"
import { RunFooterView } from "@/cli/cmd/run/footer.view"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
} from "@opencode-ai/cli/mini/footer.command"
import { RunFooterView } from "@opencode-ai/cli/mini/footer.view"
import { RunEntryContent } from "@opencode-ai/cli/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
import type {
FooterState,
FooterSubagentState,
@@ -30,10 +30,10 @@ import type {
RunProvider,
RunTuiConfig,
StreamCommit,
} from "@/cli/cmd/run/types"
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
import { selectedCommand } from "@/cli/cmd/run/footer.prompt"
import { RejectField } from "@/cli/cmd/run/footer.permission"
} from "@opencode-ai/cli/mini/types"
import { RunQuestionBody } from "@opencode-ai/cli/mini/footer.question"
import { selectedCommand } from "@opencode-ai/cli/mini/footer.prompt"
import { RejectField } from "@opencode-ai/cli/mini/footer.permission"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const tuiConfig = createTuiResolvedConfig()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { footerWidthPolicy } from "@/cli/cmd/run/footer.width"
import { footerWidthPolicy } from "@opencode-ai/cli/mini/footer.width"
describe("run footer width", () => {
test("preserves shared dialog and statusline breakpoints", () => {
@@ -1,16 +1,12 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
import { runNonInteractivePrompt } from "@/cli/cmd/run/noninteractive"
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
function form(id: string, sessionID: string): FormInfo {
@@ -43,8 +39,8 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
const sdk = new OpencodeClient()
const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }]
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
let wake: (() => void) | undefined
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {
@@ -58,22 +54,20 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
yield value
}
})()
spyOn(sdk.v2.event, "subscribe").mockImplementation(
() => Promise.resolve({ stream }) as ReturnType<typeof sdk.v2.event.subscribe>,
)
spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never)
spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never)
spyOn(sdk.v2.session.form, "list").mockImplementation(
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.form, "list").mockImplementation(
(request) =>
ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never,
ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.v2.session.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.v2.session, "prompt").mockImplementation((request) => {
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
values.push(...input.turn(messageID))
wake?.()
wake = undefined
return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 } }) as never
return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
})
await runNonInteractivePrompt({
client: sdk,
@@ -102,9 +96,9 @@ describe("runNonInteractivePrompt", () => {
// which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
})
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
})
test("attach mode cancels only session-owned forms", async () => {
@@ -113,9 +107,9 @@ describe("runNonInteractivePrompt", () => {
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.v2.session.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
})
})
@@ -8,7 +8,7 @@ import {
permissionInfo,
permissionReject,
permissionRun,
} from "@/cli/cmd/run/permission.shared"
} from "@opencode-ai/cli/mini/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
import type { RunPromptPart } from "@/cli/cmd/run/types"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@opencode-ai/cli/mini/prompt.editor"
import type { RunPromptPart } from "@opencode-ai/cli/mini/types"
describe("run prompt editor helpers", () => {
test("strips the local /editor command from the initial editor text", () => {
@@ -5,8 +5,8 @@ import {
isNewCommand,
movePromptHistory,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types"
} from "@opencode-ai/cli/mini/prompt.shared"
import type { RunPrompt } from "@opencode-ai/cli/mini/types"
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
@@ -10,7 +10,7 @@ import {
questionStoreCustom,
questionSubmit,
questionSync,
} from "@/cli/cmd/run/question.shared"
} from "@opencode-ai/cli/mini/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {
@@ -363,11 +363,11 @@ describe("opencode run (non-interactive subprocess)", () => {
)
cliIt.concurrent(
"applies a variant to the configured default model",
"applies a variant to the selected model",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("variant response")
const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], {
const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
})
@@ -1,17 +1,11 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
function provider(id: string, name: string) {
@@ -108,23 +102,21 @@ describe("run runtime boot", () => {
})
test("reads footer keybinds from resolved keybind config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(
config({
leader: "ctrl+g",
bindings: {
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
}),
)
const input = config({
leader: "ctrl+g",
bindings: {
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
})
const result = await resolveRunTuiConfig()
const result = await resolveRunTuiConfig(input)
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
expect(result.leader_timeout).toBe(2000)
@@ -139,9 +131,7 @@ describe("run runtime boot", () => {
})
test("falls back to default tui keymap config when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
const result = await resolveRunTuiConfig()
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
expect(result.leader_timeout).toBe(2000)
@@ -157,31 +147,23 @@ describe("run runtime boot", () => {
})
test("preserves disabled leader from resolved tui config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
const result = await resolveRunTuiConfig()
const result = await resolveRunTuiConfig(config({ leader: "none" }))
expect(result.keybinds.get("leader")).toEqual([])
})
test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
await expect(resolveDiffStyle()).resolves.toBe("stacked")
await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked")
mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto")
})
test("loads v2 providers and models for model selector data", async () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const providers = [provider("openai", "OpenAI")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [
@@ -230,19 +212,15 @@ describe("run runtime boot", () => {
directory: "/workspace",
},
},
{ throwOnError: true },
)
})
test("loads context limits across v2 providers", async () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [
@@ -1,6 +1,6 @@
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"
import { runPromptQueue } from "@opencode-ai/cli/mini/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@opencode-ai/cli/mini/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@opencode-ai/cli/mini/runtime.stdin"
function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
+283 -108
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
import type { FooterApi, FooterEvent, RunProvider } from "@/cli/cmd/run/types"
import { OpenCode } from "@opencode-ai/client/promise"
import { runInteractiveDeferredMode, runInteractiveMode } from "@opencode-ai/cli/mini/runtime"
import type { FooterApi, FooterEvent, RunProvider } from "@opencode-ai/cli/mini/types"
const provider: RunProvider = {
id: "openai",
@@ -45,12 +45,7 @@ function defer<T>() {
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
function footer(events: FooterEvent[] = []): FooterApi {
@@ -110,16 +105,173 @@ afterEach(() => {
})
describe("run interactive runtime", () => {
test("resolves the deferred session only after first paint", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const api = footer()
let resolved = 0
api.idle = () => painted.promise
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveDeferredMode(
{
sdk,
directory: "/tmp",
resolveAgent: async () => "build",
session: async () => {
resolved++
api.close()
return { id: "ses-deferred", title: "Deferred" }
},
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => {
lifecycleStarted.resolve()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
await lifecycleStarted.promise
expect(resolved).toBe(0)
painted.resolve()
await task
expect(resolved).toBe(1)
})
test("restores deferred session history and model after first paint", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const events: FooterEvent[] = []
const api = footer(events)
api.idle = () => painted.promise
const event = api.event
api.event = (value) => {
event(value)
if (value.type === "model") api.close()
}
spyOn(sdk.session, "get").mockImplementation(
() =>
ok({
id: "ses-resume",
projectID: "pro-1",
title: "Resume",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: "/tmp" },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
}) as never,
)
spyOn(sdk.message, "list").mockImplementation(
() =>
ok({
data: [{ id: "msg-user", type: "user", text: "previous prompt", time: { created: 1 } }],
cursor: {},
}) as never,
)
spyOn(sdk.provider, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
}) as never,
)
spyOn(sdk.model, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} },
variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 128000, output: 8192 },
},
],
}) as never,
)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveDeferredMode(
{
sdk,
directory: "/tmp",
resolveAgent: async () => "build",
session: async () => ({ id: "ses-resume", title: "Resume", resume: true }),
agent: "build",
model: undefined,
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => {
lifecycleStarted.resolve()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
await lifecycleStarted.promise
expect(sdk.session.get).not.toHaveBeenCalled()
painted.resolve()
await task
expect(events).toContainEqual({
type: "history",
history: [{ text: "previous prompt", parts: [] }],
})
expect(events).toContainEqual({
type: "model",
model: "Little Frank · OpenAI · high",
selection: { providerID: "openai", modelID: "gpt-5" },
})
})
test("waits for provider metadata before eager replay transport bootstrap", async () => {
const providersStarted = defer<void>()
const providers = defer<void>()
const lifecycleModels: unknown[] = []
const sdk = new OpencodeClient()
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused"))
const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused"))
spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(sdk.provider, "list").mockImplementation(async () => {
providersStarted.resolve()
await providers.promise
return ok({
@@ -142,55 +294,56 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.model, "list").mockImplementation(() =>
ok({
location: {
directory: "/tmp",
},
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
api: {
id: "openai",
type: "native",
settings: {},
},
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: [],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
spyOn(sdk.model, "list").mockImplementation(
() =>
ok({
location: {
directory: "/tmp",
},
],
}) as never,
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
api: {
id: "openai",
type: "native",
settings: {},
},
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: [],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
},
],
}) as never,
)
spyOn(sdk.v2.session, "messages").mockImplementation(() =>
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: [
{
@@ -205,9 +358,9 @@ describe("run interactive runtime", () => {
cursor: {},
}),
)
spyOn(sdk.v2.session, "get").mockImplementation(() =>
ok({
data: {
spyOn(sdk.session, "get").mockImplementation(
() =>
ok({
id: "ses-1",
projectID: "pro-1",
title: "Session",
@@ -232,13 +385,12 @@ describe("run interactive runtime", () => {
providerID: "openai",
id: "gpt-5",
},
},
}),
}) as never,
)
spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveMode(
{
@@ -296,13 +448,10 @@ describe("run interactive runtime", () => {
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
expect(transportProviders).toEqual([[provider]])
expect(legacyProviders).not.toHaveBeenCalled()
expect(legacyAgents).not.toHaveBeenCalled()
expect(legacyCommands).not.toHaveBeenCalled()
})
test("defers catalog-selected model resolution until after first paint", async () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const defaultStarted = defer<void>()
const releaseDefault = defer<void>()
const lifecycleStarted = defer<void>()
@@ -320,7 +469,7 @@ describe("run interactive runtime", () => {
api.close()
}
spyOn(sdk.v2.model, "default").mockImplementation(async () => {
spyOn(sdk.model, "default").mockImplementation(async () => {
defaultRequested = true
defaultStarted.resolve()
await releaseDefault.promise
@@ -329,24 +478,12 @@ describe("run interactive runtime", () => {
data: { id: "gpt-5", providerID: "openai" },
}) as never
})
spyOn(sdk.v2.provider, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.model, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.agent, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.reference, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.command, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.skill, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveMode(
{
@@ -402,12 +539,12 @@ describe("run interactive runtime", () => {
})
test("does not start deferred work after the footer closes", async () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const api = footer()
api.idle = () => painted.promise
const defaultModel = spyOn(sdk.v2.model, "default")
const defaultModel = spyOn(sdk.model, "default")
const task = runInteractiveMode(
{
@@ -444,8 +581,50 @@ describe("run interactive runtime", () => {
expect(defaultModel).not.toHaveBeenCalled()
})
test("searches files through the V2 file API", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const api = footer()
const find = spyOn(sdk.file, "find").mockImplementation(
() =>
ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [{ path: "src/index.ts", type: "file" }],
}) as never,
)
await runInteractiveMode(
{
sdk,
directory: "/tmp",
sessionID: "ses-files",
resume: false,
agent: "build",
model: undefined,
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async (input) => {
await expect(input.findFiles("index")).resolves.toEqual(["src/index.ts"])
api.close()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
expect(find).toHaveBeenCalledWith({ query: "index", type: "file", location: { directory: "/tmp" } })
})
test("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const refreshGate = defer<void>()
let providerCalls = 0
let modelCalls = 0
@@ -453,7 +632,7 @@ describe("run interactive runtime", () => {
let referenceCalls = 0
const events: FooterEvent[] = []
const api = footer(events)
spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
spyOn(sdk.provider, "list").mockImplementation(async () => {
providerCalls++
if (providerCalls === 2) {
await refreshGate.promise
@@ -471,7 +650,7 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.model, "list").mockImplementation(() => {
spyOn(sdk.model, "list").mockImplementation(() => {
modelCalls++
return ok({
location: { directory: "/tmp" },
@@ -484,9 +663,7 @@ describe("run interactive runtime", () => {
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} },
variants:
modelCalls >= 4
? []
: [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
@@ -496,7 +673,7 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.agent, "list").mockImplementation(async () => {
spyOn(sdk.agent, "list").mockImplementation(async () => {
agentCalls++
if (agentCalls === 2) throw new Error("agent refresh failed")
return ok({
@@ -504,7 +681,7 @@ describe("run interactive runtime", () => {
data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }],
}) as never
})
spyOn(sdk.v2.reference, "list").mockImplementation(() => {
spyOn(sdk.reference, "list").mockImplementation(() => {
referenceCalls++
return ok({
location: { directory: "/tmp" },
@@ -513,12 +690,10 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.command, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
)
spyOn(sdk.v2.skill, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
spyOn(sdk.command, "list").mockImplementation(
() => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
let finalProviders: RunProvider[] = []
let finalLimits: Record<string, number> = {}
let retainedProviders: RunProvider[] = []
@@ -2,9 +2,9 @@ import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { RGBA, SyntaxStyle } from "@opentui/core"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
import { RunScrollbackStream } from "@opencode-ai/cli/mini/scrollback.surface"
import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
import type { StreamCommit } from "@opencode-ai/cli/mini/types"
type ClaimedCommit = {
snapshot: {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types"
import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data"
import type { StreamCommit } from "@opencode-ai/cli/mini/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { OpenCode } from "@opencode-ai/client/promise"
import {
createSession,
resolveCurrentSession,
@@ -7,7 +7,7 @@ import {
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/session.shared"
} from "@opencode-ai/cli/mini/session.shared"
type Message = SessionMessages[number]
type Part = Message["parts"][number]
@@ -252,11 +252,10 @@ describe("run session shared", () => {
})
test("restores current prompt history from stored text and file references", async () => {
const client = new OpencodeClient()
spyOn(client.v2.session, "messages").mockImplementation(() =>
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.message, "list").mockImplementation(() =>
Promise.resolve({
data: {
data: [
data: [
{
id: "msg_prompt",
type: "user",
@@ -272,32 +271,20 @@ describe("run session shared", () => {
agents: [],
time: { created: 1 },
},
],
cursor: {},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
],
cursor: {},
}),
)
spyOn(client.v2.session, "get").mockImplementation(() =>
spyOn(client.session, "get").mockImplementation(() =>
Promise.resolve({
data: {
data: {
id: "ses_1",
title: "Session",
version: "dev",
projectID: "proj_1",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
id: "ses_1",
title: "Session",
projectID: "proj_1",
location: { directory: "/tmp" },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
}),
)
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "node:url"
import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
import { tmpdir } from "../../fixture/fixture"
type RunV2Event = V2Event
type RunV2Event = EventSubscribeOutput
function feed() {
const values: RunV2Event[] = []
@@ -41,16 +41,11 @@ function feed() {
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
function connected(id = "evt_connected") {
return { id, created: 0, type: "server.connected", data: {} } satisfies RunV2Event
return { id, type: "server.connected", data: {} } satisfies RunV2Event
}
function durable(sessionID: string, seq = 0, version = 1) {
@@ -85,9 +80,7 @@ function footer() {
return { api, commits, events }
}
type SessionMessages = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["session"]["messages"]>>["data"]
>["data"][number][]
type SessionMessages = MessageListOutput["data"]
function sdk(input: {
streams: ReturnType<typeof feed>[]
@@ -95,15 +88,10 @@ function sdk(input: {
messages?: Record<string, SessionMessages>
sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }>
}) {
const client = new OpencodeClient()
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
let subscription = 0
spyOn(client.v2.event, "subscribe").mockImplementation(
() =>
Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType<
typeof client.v2.event.subscribe
>,
)
spyOn(client.v2.session, "messages").mockImplementation((request) =>
spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream)
spyOn(client.message, "list").mockImplementation((request) =>
ok({
data: input.messages?.[request.sessionID] ?? [
{
@@ -118,14 +106,14 @@ function sdk(input: {
cursor: {},
}),
)
spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }))
spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] }))
spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
spyOn(client.permission, "list").mockImplementation(() => ok([]))
spyOn(client.question, "list").mockImplementation(() => ok([]))
spyOn(client.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
// The generated methods have conditional return types for throwOnError; the
// minimal shapes below are enough for family discovery and model fallback.
spyOn(client.v2.session, "list").mockImplementation((request) => {
spyOn(client.session, "list").mockImplementation((request) => {
const parentID = request?.parentID
return ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@@ -139,7 +127,7 @@ function sdk(input: {
) ?? [],
}) as never
})
spyOn(client.v2.model, "default").mockImplementation(
spyOn(client.model, "default").mockImplementation(
() =>
ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@@ -170,9 +158,7 @@ describe("V2 mini transport", () => {
expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"])
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@@ -185,7 +171,7 @@ describe("V2 mini transport", () => {
delivery: "steer" as const,
timeCreated: 2,
},
})
}) as never
})
const turn = transport.runPromptTurn({
@@ -250,10 +236,8 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
spyOn(client.session, "prompt").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@@ -282,7 +266,7 @@ describe("V2 mini transport", () => {
delivery: "steer" as const,
timeCreated: 2,
},
})
}) as never
})
await transport.runPromptTurn({
@@ -344,10 +328,10 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((input) => {
spyOn(client.session, "prompt").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@@ -441,10 +425,10 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((input) => {
spyOn(client.session, "prompt").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@@ -561,7 +545,7 @@ describe("V2 mini transport", () => {
},
})
let projected = false
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: projected
? [
@@ -589,7 +573,7 @@ describe("V2 mini transport", () => {
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@@ -632,7 +616,7 @@ describe("V2 mini transport", () => {
return active
},
})
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: projected
? [
@@ -661,7 +645,7 @@ describe("V2 mini transport", () => {
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@@ -701,7 +685,7 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
@@ -745,7 +729,7 @@ describe("V2 mini transport", () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
@@ -842,7 +826,7 @@ describe("V2 mini transport", () => {
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@@ -850,7 +834,7 @@ describe("V2 mini transport", () => {
data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 },
})
})
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({
agent: undefined,
@@ -886,21 +870,19 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
// The generated method has conditional return types for throwOnError; the test only needs the nested model field.
// @ts-expect-error minimal session shape is enough for this lookup
spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } }))
spyOn(client.v2.model, "default").mockImplementation(
spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never)
spyOn(client.model, "default").mockImplementation(
() =>
ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: { id: "gpt-5", providerID: "openai" },
}) as never,
)
const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
const switched = spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@@ -938,7 +920,7 @@ describe("V2 mini transport", () => {
expect(switched).toHaveBeenCalledWith(
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
expect.objectContaining({ throwOnError: true }),
{ signal: undefined },
)
await transport.close()
})
@@ -958,7 +940,7 @@ describe("V2 mini transport", () => {
let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@@ -966,7 +948,7 @@ describe("V2 mini transport", () => {
data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 },
})
})
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const controller = new AbortController()
const turn = transport.runPromptTurn({
agent: undefined,
@@ -1014,8 +996,8 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
spyOn(client.v2.session, "shell").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
spyOn(client.session, "shell").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@@ -1094,7 +1076,7 @@ describe("V2 mini transport", () => {
})
let started = false
let aborted = false
spyOn(client.v2.session, "shell").mockImplementation(
spyOn(client.session, "shell").mockImplementation(
(_input, options) =>
new Promise((_, reject) => {
started = true
@@ -1104,7 +1086,7 @@ describe("V2 mini transport", () => {
})
}) as never,
)
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({
agent: undefined,
@@ -1135,9 +1117,9 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
let complete!: () => void
spyOn(client.v2.session, "shell").mockImplementation((input) => {
spyOn(client.session, "shell").mockImplementation((input) => {
request = input
return new Promise<void>((resolve) => {
complete = resolve
@@ -1414,8 +1396,8 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["command"]>[0] | undefined
spyOn(client.v2.session, "command").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["command"]>[0] | undefined
spyOn(client.session, "command").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@@ -1436,14 +1418,12 @@ describe("V2 mini transport", () => {
})
})
return ok({
data: {
admittedSeq: 1,
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
prompt: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
},
admittedSeq: 1,
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
prompt: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
})
})
@@ -1471,8 +1451,8 @@ describe("V2 mini transport", () => {
delivery: "steer",
})
// Selection rides the command payload; no separate client-side switch.
expect(client.v2.session.switchAgent).not.toHaveBeenCalled()
expect(client.v2.session.switchModel).not.toHaveBeenCalled()
expect(client.session.switchAgent).not.toHaveBeenCalled()
expect(client.session.switchModel).not.toHaveBeenCalled()
await transport.close()
})
@@ -1488,10 +1468,10 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["skill"]>[0] | undefined
const command = spyOn(client.v2.session, "command")
const prompt = spyOn(client.v2.session, "prompt")
spyOn(client.v2.session, "skill").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["skill"]>[0] | undefined
const command = spyOn(client.session, "command")
const prompt = spyOn(client.session, "prompt")
spyOn(client.session, "skill").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@@ -1551,7 +1531,7 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
let sent = false
spyOn(client.v2.session, "skill").mockImplementation(() => {
spyOn(client.session, "skill").mockImplementation(() => {
sent = true
return ok(undefined) as never
})
@@ -1721,20 +1701,18 @@ describe("V2 mini transport", () => {
],
},
})
spyOn(client.v2.session, "get").mockImplementation(() =>
spyOn(client.session, "get").mockImplementation(() =>
ok({
data: {
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
},
}),
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never,
)
const ui = footer()
const transport = await createSessionTransport({
@@ -1857,7 +1835,7 @@ describe("V2 mini transport", () => {
const hydration = new Promise<void>((resolve) => {
releaseHydration = resolve
})
spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID === "ses_child") {
childHydrating = true
await hydration
@@ -1927,7 +1905,7 @@ describe("V2 mini transport", () => {
const retry = new Promise<void>((resolve) => {
releaseRetry = resolve
})
spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
childRequests++
if (childRequests === 1) {
@@ -2006,7 +1984,7 @@ describe("V2 mini transport", () => {
const hydration = new Promise<void>((resolve) => {
releaseHydration = resolve
})
spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
childHydrating = true
await hydration
@@ -2119,21 +2097,19 @@ describe("V2 mini transport", () => {
const gate = new Promise<void>((resolve) => {
resolveGet = resolve
})
spyOn(client.v2.session, "get").mockImplementation(async () => {
spyOn(client.session, "get").mockImplementation(async () => {
await gate
return ok({
data: {
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
},
})
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never
})
const ui = footer()
const transport = await createSessionTransport({
@@ -2178,21 +2154,19 @@ describe("V2 mini transport", () => {
const gate = new Promise<void>((resolve) => {
resolveGet = resolve
})
spyOn(client.v2.session, "get").mockImplementation(async () => {
spyOn(client.session, "get").mockImplementation(async () => {
await gate
return ok({
data: {
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
},
})
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never
})
const ui = footer()
const transport = await createSessionTransport({
@@ -2286,10 +2260,7 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
expect(client.v2.session.list).toHaveBeenCalledWith(
{ parentID: "ses_1", limit: 100, order: "desc" },
{ throwOnError: true },
)
expect(client.session.list).toHaveBeenCalledWith({ parentID: "ses_1", limit: 100, order: "desc" })
expect(states.at(-1)?.tabs).toMatchObject([
{
sessionID: "ses_child_old",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
import { writeSessionOutput } from "@opencode-ai/cli/mini/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
function footer() {
const events: FooterEvent[] = []
+1 -1
View File
@@ -1,6 +1,6 @@
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"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@opencode-ai/cli/mini/theme"
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
@@ -10,9 +10,9 @@ import {
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
} from "@opencode-ai/cli/mini/variant.shared"
import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared"
import type { RunProvider } from "@opencode-ai/cli/mini/types"
import { testEffect } from "../../lib/effect"
const model = {
+1 -32
View File
@@ -4,7 +4,6 @@ import fs from "fs/promises"
import path from "path"
import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture"
import { MiniLocalCommand } from "../../../src/cli/cmd/mini"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process"
@@ -38,7 +37,7 @@ describe("tui thread", () => {
await check(".")
})
test("resolves a relative mini project from PWD when cwd differs", async () => {
test("resolves a relative project from PWD when cwd differs", async () => {
await using pwd = await tmpdir({ git: true })
await using cwd = await tmpdir({ git: true })
@@ -46,18 +45,6 @@ describe("tui thread", () => {
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
})
test("parses supported mini --no-replay forms", async () => {
for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) {
const args = await yargs([])
.command({ ...MiniLocalCommand, handler: () => {} })
.exitProcess(false)
.parse([option, "--replay-limit", "10"])
expect(args.replay === false || args.noReplay === true).toBe(true)
expect(args.replayLimit).toBe(10)
}
})
test("preserves boolean negation for existing options", async () => {
const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} })
@@ -85,24 +72,6 @@ describe("tui thread", () => {
}),
)
cliIt.live("routes local sessions through mini", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["mini"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("routes attached sessions through mini attach", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed attach mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])