import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" 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", name: "OpenAI", models: { "gpt-5": { id: "gpt-5", providerID: "openai", name: "Little Frank", capabilities: { tools: true, input: ["text"], output: ["text"], }, cost: { input: 0, output: 0, cache: { read: 0, write: 0, }, }, limit: { context: 128000, output: 8192, }, status: "active", variants: {}, }, }, } const transportProviders: RunProvider[][] = [] function defer() { let resolve!: (value: T | PromiseLike) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } function ok(data: T) { return Promise.resolve(data) } function footer(events: FooterEvent[] = []): FooterApi { let closed = false const closes = new Set<() => void>() const notify = () => { for (const fn of closes) fn() } return { get isClosed() { return closed }, onPrompt: () => () => {}, onQueuedRemove: () => () => {}, onClose(fn) { if (closed) { fn() return () => {} } closes.add(fn) return () => { closes.delete(fn) } }, event(value) { events.push(value) }, append() {}, idle() { return Promise.resolve() }, close() { if (closed) { return } closed = true notify() }, destroy() { if (closed) { return } closed = true notify() }, } } afterEach(() => { mock.restore() transportProviders.length = 0 }) 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() const painted = defer() 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, }, { 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() const painted = defer() 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, }, { 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() const providers = defer() const lifecycleModels: unknown[] = [] const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) spyOn(sdk.provider, "list").mockImplementation(async () => { providersStarted.resolve() await providers.promise return ok({ location: { directory: "/tmp", }, data: [ { id: "openai", name: "OpenAI", api: { type: "native", settings: {}, }, request: { headers: {}, body: {}, }, }, ], }) as never }) spyOn(sdk.model, "list").mockImplementation( () => ok({ location: { directory: "/tmp", }, data: [ { id: "gpt-5", providerID: "openai", name: "Little Frank", api: { id: "openai", type: "native", settings: {}, }, capabilities: { tools: true, input: ["text"], output: ["text"], }, request: { headers: {}, body: {}, }, variants: [], time: { released: 1, }, cost: [ { input: 0, output: 0, cache: { read: 0, write: 0, }, }, ], status: "active", enabled: true, limit: { context: 128000, output: 8192, }, }, ], }) as never, ) spyOn(sdk.message, "list").mockImplementation(() => ok({ data: [ { id: "msg-user-1", type: "user", text: "hello", time: { created: 1, }, }, ], cursor: {}, }), ) spyOn(sdk.session, "get").mockImplementation( () => ok({ id: "ses-1", projectID: "pro-1", title: "Session", 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", }, }) 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( { sdk, directory: "/tmp", sessionID: "ses-1", sessionTitle: "Session", resume: true, replay: true, replayLimit: 100, agent: "build", model: undefined, variant: undefined, files: [], thinking: true, }, { createRuntimeLifecycle: async (input) => { lifecycleModels.push(input.model) return { footer: footer(), onResize: () => () => {}, refreshTheme: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } }, streamTransport: Promise.resolve({ createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => { transportProviders.push(input.providers?.() ?? []) setTimeout(() => { input.footer.close() }, 0) return { runPromptTurn: async () => {}, interruptActiveTurn: async () => {}, selectSubagent: () => {}, replayOnResize: async () => false, close: async () => {}, } }, formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), }), }, ) await providersStarted.promise expect(transportProviders).toEqual([]) providers.resolve() await task expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(transportProviders).toEqual([[provider]]) }) test("defers catalog-selected model resolution until after first paint", async () => { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const defaultStarted = defer() const releaseDefault = defer() const lifecycleStarted = defer() const painted = defer() const modelShown = defer() let defaultRequested = false const events: FooterEvent[] = [] const api = footer(events) api.idle = () => painted.promise const event = api.event api.event = (value) => { event(value) if (value.type !== "model") return modelShown.resolve() api.close() } spyOn(sdk.model, "default").mockImplementation(async () => { defaultRequested = true defaultStarted.resolve() await releaseDefault.promise return ok({ location: { directory: "/tmp" }, data: { id: "gpt-5", providerID: "openai" }, }) 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( { sdk, directory: "/tmp", sessionID: "ses-fresh", resume: false, agent: "build", model: undefined, variant: undefined, files: [], thinking: false, }, { createRuntimeLifecycle: async (input) => { expect(input.model).toBeUndefined() lifecycleStarted.resolve() return { footer: api, onResize: () => () => {}, refreshTheme: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } }, streamTransport: Promise.resolve({ createSessionTransport: async () => ({ runPromptTurn: async () => {}, interruptActiveTurn: async () => {}, selectSubagent: () => {}, replayOnResize: async () => false, close: async () => {}, }), formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), }), }, ) await lifecycleStarted.promise expect(defaultRequested).toBe(false) painted.resolve() await defaultStarted.promise releaseDefault.resolve() await modelShown.promise await task expect(events.find((event) => event.type === "model")).toEqual({ type: "model", model: "gpt-5 · openai", selection: { providerID: "openai", modelID: "gpt-5" }, }) }) test("does not start deferred work after the footer closes", async () => { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const lifecycleStarted = defer() const painted = defer() const api = footer() api.idle = () => painted.promise const defaultModel = spyOn(sdk.model, "default") const task = runInteractiveMode( { sdk, directory: "/tmp", sessionID: "ses-closed", resume: false, agent: "build", model: undefined, variant: undefined, files: [], thinking: false, }, { createRuntimeLifecycle: async () => { lifecycleStarted.resolve() return { footer: api, onResize: () => () => {}, refreshTheme: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } }, }, ) await lifecycleStarted.promise api.close() painted.resolve() await task 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, }, { 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 = OpenCode.make({ baseUrl: "https://opencode.test" }) const refreshGate = defer() let providerCalls = 0 let modelCalls = 0 let agentCalls = 0 let referenceCalls = 0 const events: FooterEvent[] = [] const api = footer(events) spyOn(sdk.provider, "list").mockImplementation(async () => { providerCalls++ if (providerCalls === 2) { await refreshGate.promise throw new Error("provider refresh failed") } return ok({ location: { directory: "/tmp" }, data: [ { id: "openai", name: providerCalls >= 3 ? "OpenAI refreshed" : "OpenAI", api: { type: "native", settings: {} }, request: { headers: {}, body: {} }, }, ], }) as never }) spyOn(sdk.model, "list").mockImplementation(() => { modelCalls++ return 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: 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", enabled: true, limit: { context: modelCalls >= 3 ? 256000 : 128000, output: 8192 }, }, ], }) as never }) spyOn(sdk.agent, "list").mockImplementation(async () => { agentCalls++ if (agentCalls === 2) throw new Error("agent refresh failed") return ok({ location: { directory: "/tmp" }, data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }], }) as never }) spyOn(sdk.reference, "list").mockImplementation(() => { referenceCalls++ return ok({ location: { directory: "/tmp" }, data: [ { name: "effect", path: "/effect", description: referenceCalls >= 3 ? "Refreshed reference" : "Reference" }, ], }) 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 = {} let retainedProviders: RunProvider[] = [] let retainedLimits: Record = {} let retainedCatalog: FooterEvent | undefined let selectedDefault: unknown let selectDefault: (() => unknown) | undefined let selectVariant: ((variant: string | undefined) => unknown) | undefined let defaultRefreshVariants: FooterEvent | undefined await runInteractiveMode( { sdk, directory: "/tmp", sessionID: "ses-1", sessionTitle: "Session", resume: false, agent: "build", model: { providerID: "openai", modelID: "gpt-5" }, variant: "low", files: [], thinking: false, }, { createRuntimeLifecycle: async (input) => { selectDefault = () => input.onVariantSelect?.(undefined) selectVariant = (variant) => input.onVariantSelect?.(variant) return { footer: api, onResize: () => () => {}, refreshTheme: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } }, streamTransport: Promise.resolve({ createSessionTransport: async (input) => { while ( !events.some( (event) => event.type === "variants" && event.variants.includes("low") && event.current === "low", ) ) await Bun.sleep(0) selectedDefault = await Promise.resolve(selectDefault?.()) input.onCatalogRefresh?.() input.onCatalogRefresh?.() input.onCatalogRefresh?.() while (providerCalls < 2) await Bun.sleep(0) refreshGate.resolve() await new Promise((resolve) => setTimeout(resolve, 0)) retainedProviders = input.providers?.() ?? [] retainedLimits = input.limits() retainedCatalog = events.filter((event) => event.type === "catalog").at(-1) input.onCatalogRefresh?.() input.onCatalogRefresh?.() while (providerCalls < 3 || modelCalls < 3 || agentCalls < 3) await Bun.sleep(0) await new Promise((resolve) => setTimeout(resolve, 0)) defaultRefreshVariants = events.filter((event) => event.type === "variants").at(-1) await Promise.resolve(selectVariant?.("high")) input.onCatalogRefresh?.() while (providerCalls < 4 || modelCalls < 4) await Bun.sleep(0) await new Promise((resolve) => setTimeout(resolve, 0)) finalProviders = input.providers?.() ?? [] finalLimits = input.limits() setTimeout(() => input.footer.close(), 0) return { runPromptTurn: async () => {}, interruptActiveTurn: async () => {}, selectSubagent: () => {}, replayOnResize: async () => false, close: async () => {}, } }, formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), }), }, ) expect(providerCalls).toBe(4) expect(modelCalls).toBe(4) expect(retainedProviders[0]?.name).toBe("OpenAI") expect(retainedProviders[0]?.models["gpt-5"]?.variants).toEqual({ low: {} }) expect(retainedLimits["openai/gpt-5"]).toBe(128000) expect(retainedCatalog).toMatchObject({ agents: [{ name: "build", description: "Agent" }], references: [{ name: "effect", description: "Reference" }], }) expect(selectedDefault).toMatchObject({ variant: undefined }) expect(defaultRefreshVariants).toMatchObject({ variants: ["high"], current: undefined }) expect(finalProviders[0]?.name).toBe("OpenAI refreshed") expect(finalProviders[0]?.models["gpt-5"]?.variants).toEqual({}) expect(finalLimits["openai/gpt-5"]).toBe(256000) expect(events.filter((event) => event.type === "variants").at(-1)).toMatchObject({ variants: [], current: undefined, }) expect(events.filter((event) => event.type === "catalog").at(-1)).toMatchObject({ agents: [{ name: "build", description: "Refreshed agent" }], references: [{ name: "effect", description: "Refreshed reference" }], commands: [{ name: "check", description: "Check" }], }) }) })