mini: move frontend into tui package (#37754)
This commit is contained in:
parent
3f5ad8441f
commit
c50554d907
80 changed files with 2365 additions and 1488 deletions
251
packages/cli/test/drive/mini-interactive.drive.mjs
Normal file
251
packages/cli/test/drive/mini-interactive.drive.mjs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { defineScript } from "opencode-drive"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
setup({ config }) {
|
||||
config.autoupdate = false
|
||||
},
|
||||
async run({ artifacts, llm, server, signal }) {
|
||||
await configureServicePort(artifacts)
|
||||
await server.launch()
|
||||
|
||||
const registration = await serviceRegistration(artifacts)
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const session = `mini-stage2-${process.pid}`
|
||||
const snapshots = path.join(artifacts, "mini-stage2")
|
||||
await mkdir(snapshots, { recursive: true })
|
||||
|
||||
llm.queue(
|
||||
llm.toolCall({
|
||||
index: 0,
|
||||
id: "mini-shell",
|
||||
name: "shell",
|
||||
input: { command: "printf 'drive-mini-tool-output\\n'" },
|
||||
}),
|
||||
llm.finish("tool-calls"),
|
||||
)
|
||||
llm.queue(llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
|
||||
|
||||
const abort = () => {
|
||||
void tmux(["kill-session", "-t", session], true).catch(() => {})
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
try {
|
||||
await tmux([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
session,
|
||||
"-x",
|
||||
"140",
|
||||
"-y",
|
||||
"30",
|
||||
"--",
|
||||
"env",
|
||||
`PWD=${path.join(artifacts, "files")}`,
|
||||
`OPENCODE_PASSWORD=${registration.password}`,
|
||||
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
||||
`OPENCODE_TEST_HOME=${artifacts}`,
|
||||
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
||||
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
||||
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
||||
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DIRECT_TRACE=1",
|
||||
process.execPath,
|
||||
"--conditions=browser",
|
||||
"--preload=@opentui/solid/preload",
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"mini",
|
||||
"--server",
|
||||
registration.url,
|
||||
"--model",
|
||||
"simulation/gpt-sim-model",
|
||||
])
|
||||
await tmux(["set-option", "-t", session, "remain-on-exit", "on"])
|
||||
|
||||
const first = await waitForPane(session, "OpenCode")
|
||||
await Bun.write(path.join(snapshots, "01-first-paint.txt"), first)
|
||||
if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission")
|
||||
|
||||
await waitForPane(session, "Simulated Model", 15_000)
|
||||
await tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])
|
||||
await Bun.sleep(100)
|
||||
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||
const completed = await waitForPane(session, "drive mini response complete", 20_000)
|
||||
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
|
||||
await Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)
|
||||
|
||||
await Bun.sleep(500)
|
||||
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
|
||||
await tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`])
|
||||
await tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"])
|
||||
await waitForFile(
|
||||
resizeOutput,
|
||||
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
|
||||
)
|
||||
await tmux(["pipe-pane", "-t", session])
|
||||
const resized = await captureVisiblePane(session)
|
||||
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
|
||||
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
|
||||
|
||||
llm.queue(
|
||||
llm.toolCall({
|
||||
index: 0,
|
||||
id: "mini-slow-shell",
|
||||
name: "shell",
|
||||
input: { command: "sleep 10" },
|
||||
}),
|
||||
llm.finish("tool-calls"),
|
||||
)
|
||||
await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
|
||||
await Bun.sleep(100)
|
||||
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||
await waitForPane(session, "$ sleep 10")
|
||||
await tmux(["send-keys", "-t", session, "Escape"])
|
||||
const armed = await waitForPane(session, "again to interrupt")
|
||||
await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
|
||||
await tmux(["send-keys", "-t", session, "Escape"])
|
||||
const interrupted = await waitForPane(session, "Step interrupted", 10_000)
|
||||
await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
|
||||
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
|
||||
|
||||
await tmux(["send-keys", "-t", session, "C-c"])
|
||||
await waitForPane(session, "Press ctrl+c again to exit")
|
||||
await tmux(["send-keys", "-t", session, "C-c"])
|
||||
await waitForDeadPane(session)
|
||||
const status = await paneDeadStatus(session)
|
||||
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
|
||||
const exited = await capturePane(session)
|
||||
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
||||
throw new Error("Mini exit splash was not rendered before teardown")
|
||||
await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
await tmux(["kill-session", "-t", session], true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** @param {string[]} args */
|
||||
async function tmux(args, allowFailure = false) {
|
||||
const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill("SIGKILL")
|
||||
}, 5_000)
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
clearTimeout(timeout)
|
||||
if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
|
||||
if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
function capturePane(session) {
|
||||
return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
function captureVisiblePane(session) {
|
||||
return tmux(["capture-pane", "-p", "-t", session])
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function paneAlive(session) {
|
||||
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function paneDeadStatus(session) {
|
||||
return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} session
|
||||
* @param {string} text
|
||||
* @param {number} [timeout]
|
||||
* @param {(() => Promise<void>) | undefined} [trigger]
|
||||
*/
|
||||
async function waitForPane(session, text, timeout = 5_000, trigger) {
|
||||
const deadline = Date.now() + timeout
|
||||
let last = ""
|
||||
while (Date.now() < deadline) {
|
||||
await trigger?.()
|
||||
last = await capturePane(session)
|
||||
if (last.includes(text)) return last
|
||||
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function waitForDeadPane(session) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (!(await paneAlive(session))) return
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Mini did not tear down after the exit sequence")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} file
|
||||
* @param {(value: string) => boolean} accept
|
||||
*/
|
||||
async function waitForFile(file, accept) {
|
||||
let value = ""
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
value = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (accept(value)) return value
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("resize did not replay committed transcript output")
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function configureServicePort(artifacts) {
|
||||
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const port = probe.port
|
||||
await probe.stop(true)
|
||||
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await Bun.write(file, JSON.stringify({ port }))
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function serviceRegistration(artifacts) {
|
||||
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
for (const name of ["service-local.json", "service.json"]) {
|
||||
const value = await Bun.file(path.join(directory, name))
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (isRegistration(value)) return value
|
||||
}
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Drive service registration was not written")
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function isRegistration(value) {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"url" in value &&
|
||||
typeof value.url === "string" &&
|
||||
"password" in value &&
|
||||
typeof value.password === "string"
|
||||
)
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RunFooterView } from "../src/mini/footer.view"
|
||||
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
|
||||
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
|
||||
|
||||
test("down opens subagents from an empty prompt", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "gpt-5",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
const [view] = createSignal<FooterView>({ type: "prompt" })
|
||||
const [subagents] = createSignal<FooterSubagentState>({
|
||||
tabs: [
|
||||
{
|
||||
sessionID: "subagent-1",
|
||||
partID: "part-1",
|
||||
callID: "call-1",
|
||||
label: "Explore",
|
||||
description: "Inspect the keymap",
|
||||
status: "running",
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
})
|
||||
const config = resolve(
|
||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
function Harness() {
|
||||
return (
|
||||
<Keymap.Provider config={config}>
|
||||
<RunFooterView
|
||||
directory="/tmp"
|
||||
findFiles={async () => []}
|
||||
agents={() => []}
|
||||
references={() => []}
|
||||
commands={() => []}
|
||||
providers={() => undefined}
|
||||
currentModel={() => undefined}
|
||||
variants={() => []}
|
||||
currentVariant={() => undefined}
|
||||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={config}
|
||||
agent="opencode"
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
onQuestionReply={() => {}}
|
||||
onQuestionReject={() => {}}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onEditorOpen={async () => undefined}
|
||||
onInputClear={() => {}}
|
||||
onExit={() => {}}
|
||||
onModelSelect={() => {}}
|
||||
onVariantSelect={() => {}}
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
app.mockInput.pressArrow("down")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Select subagent")
|
||||
} finally {
|
||||
app.renderer.currentFocusedRenderable?.blur()
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
@ -2,28 +2,51 @@ import { describe, expect, test } from "bun:test"
|
|||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
const root = path.resolve(import.meta.dir, "../../..")
|
||||
|
||||
describe("CLI frontend import boundaries", () => {
|
||||
test("exposes only the run entrypoints from the run package export", async () => {
|
||||
const entrypoint = await import("@opencode-ai/cli/run")
|
||||
const mini = await import("@opencode-ai/cli/mini")
|
||||
test("exposes only the intentional package entrypoints", async () => {
|
||||
const run = await import("@opencode-ai/cli/run")
|
||||
const mini = await import("@opencode-ai/tui/mini")
|
||||
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
|
||||
|
||||
expect(Object.keys(entrypoint).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["mergeInteractiveInput", "runMini", "validateMiniTerminal"])
|
||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps run and Mini handlers on separate leaf graphs", async () => {
|
||||
const run = await bundleInputs("src/commands/handlers/run.ts")
|
||||
expect(run).toContain("src/run/run.ts")
|
||||
expect(run).not.toContain("src/mini/mini.ts")
|
||||
expect(run).not.toContain("src/mini/runtime.ts")
|
||||
test("keeps run and Mini on separate evaluation graphs", async () => {
|
||||
const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts")
|
||||
expect(run).toContain("packages/cli/src/run/run.ts")
|
||||
expect(run).toContain("packages/tui/src/mini/tool.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/runtime.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/footer.ts")
|
||||
expect(run).not.toContain("packages/tui/src/mini/scrollback.surface.ts")
|
||||
expect(run).not.toContain("packages/tui/src/runtime.tsx")
|
||||
|
||||
const mini = await bundleInputs("src/commands/handlers/mini.ts")
|
||||
expect(mini).toContain("src/mini/mini.ts")
|
||||
expect(mini).not.toContain("src/run/run.ts")
|
||||
expect(mini).not.toContain("src/run/noninteractive.ts")
|
||||
expect(mini).not.toContain("src/run/ui.ts")
|
||||
const mini = await bundleInputs("packages/cli/src/commands/handlers/mini.ts")
|
||||
expect(mini).toContain("packages/cli/src/mini.ts")
|
||||
expect(mini).toContain("packages/tui/src/mini/index.ts")
|
||||
expect(mini).toContain("packages/tui/src/mini/runtime.ts")
|
||||
expect(mini).not.toContain("packages/cli/src/run/run.ts")
|
||||
expect(mini).not.toContain("packages/cli/src/run/noninteractive.ts")
|
||||
expect(mini).not.toContain("packages/cli/src/run/ui.ts")
|
||||
expect(mini).not.toContain("packages/tui/src/runtime.tsx")
|
||||
})
|
||||
|
||||
test("keeps TUI Mini independent from Core, Server, and CLI", async () => {
|
||||
const glob = new Bun.Glob("**/*.{ts,tsx}")
|
||||
const imports: string[] = []
|
||||
for await (const file of glob.scan({ cwd: path.join(root, "packages/tui/src/mini") })) {
|
||||
const source = await Bun.file(path.join(root, "packages/tui/src/mini", file)).text()
|
||||
if (/["']@opencode-ai\/(?:core|server|cli)(?:\/[^"']*)?["']/.test(source)) imports.push(file)
|
||||
}
|
||||
expect(imports).toEqual([])
|
||||
|
||||
const graph = await bundleInputs("packages/tui/src/mini/index.ts")
|
||||
expect(graph.filter((file) => file.startsWith("packages/core/"))).toEqual([])
|
||||
expect(graph.filter((file) => file.startsWith("packages/cli/") || file.startsWith("packages/server/"))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -38,7 +61,8 @@ async function bundleInputs(entrypoint: string) {
|
|||
entrypoint,
|
||||
"--target=bun",
|
||||
"--format=esm",
|
||||
"--packages=external",
|
||||
"--packages=bundle",
|
||||
"--external=@opentui/core-*",
|
||||
`--metafile=${metafile}`,
|
||||
`--outdir=${path.join(temporary, "out")}`,
|
||||
],
|
||||
|
|
|
|||
193
packages/cli/test/mini-host.test.ts
Normal file
193
packages/cli/test/mini-host.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Readable } from "node:stream"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import {
|
||||
createMiniHost,
|
||||
INTERACTIVE_INPUT_ERROR,
|
||||
resolveInteractiveStdin,
|
||||
type InteractiveStdin,
|
||||
usingInteractiveStdin,
|
||||
} from "../src/mini-host"
|
||||
|
||||
const temporary: string[] = []
|
||||
const model = { providerID: "openai", modelID: "gpt-5" }
|
||||
|
||||
function stream(isTTY: boolean) {
|
||||
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
|
||||
}
|
||||
|
||||
async function root() {
|
||||
const directory = await mkdtemp(path.join(import.meta.dir, ".mini-host-"))
|
||||
temporary.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
function host(terminal: InteractiveStdin, directory: string) {
|
||||
return createMiniHost({
|
||||
terminal,
|
||||
directory,
|
||||
paths: { home: directory, state: directory, log: directory },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe("Mini CLI host", () => {
|
||||
test("reuses tty stdin without taking ownership", () => {
|
||||
const stdin = stream(true)
|
||||
const seen: string[] = []
|
||||
const terminal = resolveInteractiveStdin(
|
||||
stdin,
|
||||
(target) => {
|
||||
seen.push(target)
|
||||
return stream(true)
|
||||
},
|
||||
"linux",
|
||||
)
|
||||
|
||||
expect(terminal.stdin).toBe(stdin)
|
||||
terminal.cleanup()
|
||||
expect(stdin.destroyed).toBe(false)
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
test("opens and cleans the controlling terminal exactly once for piped stdin", () => {
|
||||
const tty = stream(true)
|
||||
const seen: string[] = []
|
||||
let destroys = 0
|
||||
const destroy = tty.destroy.bind(tty)
|
||||
tty.destroy = ((...args: Parameters<typeof tty.destroy>) => {
|
||||
destroys++
|
||||
return destroy(...args)
|
||||
}) as typeof tty.destroy
|
||||
const terminal = resolveInteractiveStdin(
|
||||
stream(false),
|
||||
(target) => {
|
||||
seen.push(target)
|
||||
return tty
|
||||
},
|
||||
"linux",
|
||||
)
|
||||
|
||||
terminal.cleanup()
|
||||
terminal.cleanup()
|
||||
expect(seen).toEqual(["/dev/tty"])
|
||||
expect(destroys).toBe(1)
|
||||
})
|
||||
|
||||
test("uses the platform terminal and reports acquisition failures", () => {
|
||||
const seen: string[] = []
|
||||
resolveInteractiveStdin(
|
||||
stream(false),
|
||||
(target) => {
|
||||
seen.push(target)
|
||||
return stream(true)
|
||||
},
|
||||
"win32",
|
||||
).cleanup()
|
||||
expect(seen).toEqual(["CONIN$"])
|
||||
expect(() =>
|
||||
resolveInteractiveStdin(
|
||||
stream(false),
|
||||
() => {
|
||||
throw new Error("open failed")
|
||||
},
|
||||
"linux",
|
||||
),
|
||||
).toThrow(INTERACTIVE_INPUT_ERROR)
|
||||
})
|
||||
|
||||
test("cleans the controlling terminal when hosted frontend startup fails", async () => {
|
||||
let cleaned = 0
|
||||
const order: string[] = []
|
||||
const terminal = {
|
||||
stdin: stream(true),
|
||||
cleanup() {
|
||||
order.push("cleanup")
|
||||
cleaned++
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
usingInteractiveStdin(
|
||||
async () => {
|
||||
order.push("run")
|
||||
throw new Error("frontend failed")
|
||||
},
|
||||
() => {
|
||||
order.push("terminal")
|
||||
return terminal
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("frontend failed")
|
||||
expect(cleaned).toBe(1)
|
||||
expect(order).toEqual(["terminal", "run", "cleanup"])
|
||||
})
|
||||
|
||||
test("subscribes and unsubscribes process signals through host capabilities", async () => {
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, await root())
|
||||
const sigint = process.listenerCount("SIGINT")
|
||||
const sigusr2 = process.listenerCount("SIGUSR2")
|
||||
const offInt = input.signals.sigint.subscribe(() => {})
|
||||
const offTheme = input.signals.sigusr2.subscribe(() => {})
|
||||
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigint + 1)
|
||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2 + 1)
|
||||
offInt()
|
||||
offInt()
|
||||
offTheme()
|
||||
offTheme()
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigint)
|
||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
||||
})
|
||||
|
||||
test("passes paths, platform, timing, and diagnostic context", async () => {
|
||||
const directory = await root()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||
|
||||
expect(input.paths).toEqual({ home: directory, state: directory, log: directory })
|
||||
expect(input.platform).toBe(process.platform)
|
||||
expect(typeof input.files.readText).toBe("function")
|
||||
const file = path.join(directory, "attachment.txt")
|
||||
await Bun.write(file, "attachment contents")
|
||||
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
|
||||
expect(typeof input.startup.showTiming).toBe("boolean")
|
||||
expect(typeof input.startup.now()).toBe("number")
|
||||
expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory })
|
||||
})
|
||||
|
||||
test("merges, clears, and repairs persisted model variants", async () => {
|
||||
const directory = await root()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||
const file = path.join(directory, "model.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||
variant: { "openai/gpt-4.1": "low", invalid: 42 },
|
||||
}),
|
||||
)
|
||||
|
||||
await input.preferences.saveVariant(model, "high")
|
||||
expect(await input.preferences.resolveVariant(model)).toBe("high")
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
|
||||
})
|
||||
|
||||
await input.preferences.saveVariant(model, undefined)
|
||||
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||
variant: { "openai/gpt-4.1": "low" },
|
||||
})
|
||||
|
||||
await Bun.write(file, "{")
|
||||
await input.preferences.saveVariant(model, "high")
|
||||
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "node:path"
|
||||
import { mergeInput as mergeInteractiveInput } from "../src/mini/mini"
|
||||
import { toolInlineInfo, toolOutputText, toolView } from "../src/mini/tool"
|
||||
import { mergeInput as mergeInteractiveInput } from "../src/mini"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
|
|
@ -20,41 +19,6 @@ async function cli(args: string[]) {
|
|||
}
|
||||
|
||||
describe("mini command", () => {
|
||||
test("renders the renamed shell tool with the shell rule", () => {
|
||||
const part = {
|
||||
id: "part-shell",
|
||||
sessionID: "session-shell",
|
||||
messageID: "message-shell",
|
||||
callID: "call-shell",
|
||||
tool: "shell",
|
||||
state: {
|
||||
status: "pending" as const,
|
||||
input: { command: "pwd" },
|
||||
},
|
||||
} as const
|
||||
|
||||
expect(toolView(part.tool)).toEqual({ output: true, final: false })
|
||||
expect(toolInlineInfo(part)).toMatchObject({ icon: "$", title: "pwd", mode: "block" })
|
||||
})
|
||||
|
||||
test("uses non-empty V2 shell output without the model-facing status", () => {
|
||||
expect(
|
||||
toolOutputText("shell", [
|
||||
{ type: "text", text: "mini-output\n" },
|
||||
{ type: "text", text: "Command exited with code 0." },
|
||||
]),
|
||||
).toBe("mini-output\n")
|
||||
})
|
||||
|
||||
test("keeps empty V2 shell output empty", () => {
|
||||
expect(
|
||||
toolOutputText("shell", [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Command exited with code 0." },
|
||||
]),
|
||||
).toBe("")
|
||||
})
|
||||
|
||||
test("uses piped stdin as the initial prompt", () => {
|
||||
expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
|
||||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue