| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
- import { OpenCode } from "@opencode-ai/client/promise"
- import { runInteractiveDeferredMode } from "../../src/mini/runtime"
- import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
- import type { FooterEvent, MiniHost } from "../../src/mini/types"
- import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
- import { createFooterApiFixture } from "./fixture/footer-api"
- function defer<T>() {
- let resolve!: (value: T | PromiseLike<T>) => void
- const promise = new Promise<T>((done) => {
- resolve = done
- })
- return { promise, resolve }
- }
- function ok<T>(data: T) {
- return Promise.resolve(data)
- }
- function host(): MiniHost {
- return {
- terminal: { stdin: process.stdin },
- platform: "linux",
- stdout: { write() {} },
- files: { readText: async () => "" },
- editor: { open: async () => undefined },
- paths: { home: "/home/test" },
- signals: {
- sigint: { subscribe: () => () => {} },
- sigusr2: { subscribe: () => () => {} },
- },
- startup: { showTiming: false, now: () => 0 },
- diagnostics: {},
- preferences: {
- resolveVariant: async () => undefined,
- saveVariant: async () => {},
- },
- }
- }
- function footer(events: FooterEvent[] = []) {
- return createFooterApiFixture({ events }).api
- }
- afterEach(() => {
- mock.restore()
- })
- describe("run interactive runtime", () => {
- test("resolves the default model reactively without blocking catalog startup", async () => {
- const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
- const events: FooterEvent[] = []
- const ui = createFooterApiFixture({ events })
- const api = ui.api
- const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
- const catalogLoaded = defer<void>()
- const defaultModelReloaded = defer<void>()
- const modelShown = defer<void>()
- const turnStarted = defer<void>()
- const model = catalogModel({
- id: "resolved",
- providerID: "test",
- name: "Resolved Model",
- variants: ["low", "high"],
- })
- let lifecycle!: LifecycleInput
- let turnAgent: string | undefined
- let turnModel: { providerID: string; modelID: string } | undefined
- let refreshCatalog: (() => Promise<unknown>) | undefined
- stubCatalogLists(sdk, {
- providers: [catalogProvider("test", "Test Provider")],
- models: [model],
- })
- let defaultModelCalls = 0
- const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
- defaultModelCalls++
- if (defaultModelCalls === 2) defaultModelReloaded.resolve()
- return selected.promise
- })
- const emit = api.event.bind(api)
- api.event = (event) => {
- emit(event)
- if (event.type === "model") modelShown.resolve()
- }
- const task = runInteractiveDeferredMode(
- {
- host: host(),
- sdk,
- directory: "/tmp",
- target: async () => ({
- sessionID: "ses_root",
- location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
- agent: "build",
- model: undefined,
- variant: undefined,
- resume: false,
- }),
- agent: "build",
- model: undefined,
- variant: undefined,
- files: [],
- },
- {
- createRuntimeLifecycle: async (input) => {
- lifecycle = input
- return {
- footer: api,
- onResize: () => () => {},
- refreshTheme: () => {},
- setTitle: () => {},
- resetForReplay: () => Promise.resolve(),
- close: () => Promise.resolve(),
- }
- },
- streamTransport: Promise.resolve({
- createSessionTransport: async (input) => {
- refreshCatalog = () => Promise.resolve(input.onCatalogRefresh?.())
- await refreshCatalog()
- catalogLoaded.resolve()
- return {
- runPromptTurn: async (input) => {
- turnAgent = input.agent
- turnModel = input.model
- turnStarted.resolve()
- api.close()
- },
- admitPromptTurn: async () => {},
- waitForIdle: async () => {},
- interruptActiveTurn: async () => {},
- selectSubagent: () => {},
- replayOnResize: async () => false,
- close: async () => {},
- }
- },
- formatUnknownError: (error: unknown) => String(error),
- }),
- },
- )
- await catalogLoaded.promise
- expect(events.some((event) => event.type === "model")).toBe(false)
- await refreshCatalog?.()
- expect(defaultModel).toHaveBeenCalledTimes(1)
- selected.resolve({
- location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
- data: model,
- })
- await defaultModelReloaded.promise
- await modelShown.promise
- expect(events).toContainEqual({
- type: "model",
- model: "Resolved Model · Test Provider",
- selection: { providerID: "test", modelID: "resolved" },
- })
- expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
- lifecycle.onAgentSelect?.("review")
- await ui.promptReady
- expect(ui.submit("hello")).toBe(true)
- await turnStarted.promise
- expect(turnAgent).toBe("review")
- expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
- await task
- })
- test("routes form responses to their owners with global location and local settlement", async () => {
- const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
- const api = footer()
- const streamStarted = defer<void>()
- let lifecycle!: LifecycleInput
- const settled: Array<{ sessionID: string; formID: string }> = []
- stubCatalogLists(sdk)
- const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
- const task = runInteractiveDeferredMode(
- {
- host: host(),
- sdk,
- directory: "/tmp",
- target: async () => ({
- sessionID: "ses_root",
- location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
- agent: "build",
- model: { providerID: "test", modelID: "model" },
- variant: undefined,
- resume: false,
- }),
- agent: "build",
- model: { providerID: "test", modelID: "model" },
- variant: undefined,
- files: [],
- thinking: false,
- },
- {
- createRuntimeLifecycle: async (input) => {
- lifecycle = input
- return {
- footer: api,
- onResize: () => () => {},
- refreshTheme: () => {},
- setTitle: () => {},
- resetForReplay: () => Promise.resolve(),
- close: () => Promise.resolve(),
- }
- },
- streamTransport: Promise.resolve({
- createSessionTransport: async () => {
- streamStarted.resolve()
- return {
- runPromptTurn: async () => {},
- admitPromptTurn: async () => {},
- waitForIdle: async () => {},
- interruptActiveTurn: async () => {},
- selectSubagent: () => {},
- settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
- replayOnResize: async () => false,
- close: async () => {},
- }
- },
- formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
- }),
- },
- )
- await streamStarted.promise
- await lifecycle.onFormReply({
- sessionID: "global",
- formID: "frm_global",
- answer: { value: "yes" },
- location: { directory: "/remote work", workspaceID: "wrk_1" },
- })
- expect(reply).toHaveBeenCalledWith(
- {
- sessionID: "global",
- formID: "frm_global",
- answer: { value: "yes" },
- location: { directory: "/remote work", workspaceID: "wrk_1" },
- },
- {
- headers: {
- "x-opencode-directory": "%2Fremote%20work",
- "x-opencode-workspace": "wrk_1",
- },
- },
- )
- expect(settled).toEqual([{ sessionID: "global", formID: "frm_global" }])
- reply.mockImplementationOnce(() => Promise.reject({ _tag: "FormInvalidAnswerError", message: "Invalid answer" }))
- await expect(
- lifecycle.onFormReply({ sessionID: "ses_child", formID: "frm_invalid", answer: { value: 3 } }),
- ).rejects.toEqual({ _tag: "FormInvalidAnswerError", message: "Invalid answer" })
- expect(settled.some((item) => item.formID === "frm_invalid")).toBe(false)
- api.close()
- await task
- })
- 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
- stubCatalogLists(sdk)
- const task = runInteractiveDeferredMode(
- {
- host: host(),
- sdk,
- directory: "/tmp",
- target: async () => {
- resolved++
- api.close()
- return {
- sessionID: "ses-deferred",
- sessionTitle: "Deferred",
- location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
- agent: "build",
- model: { providerID: "openai", modelID: "gpt-5" },
- variant: undefined,
- resume: false,
- }
- },
- agent: "build",
- model: { providerID: "openai", modelID: "gpt-5" },
- variant: undefined,
- files: [],
- thinking: false,
- },
- {
- createRuntimeLifecycle: async () => {
- lifecycleStarted.resolve()
- return {
- footer: api,
- onResize: () => () => {},
- refreshTheme: () => {},
- setTitle: () => {},
- 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 titles: Array<string | undefined> = []
- 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,
- )
- stubCatalogLists(sdk, {
- providers: [catalogProvider("openai", "OpenAI")],
- models: [
- catalogModel({
- id: "gpt-5",
- providerID: "openai",
- name: "Little Frank",
- variants: ["high"],
- }),
- ],
- })
- const task = runInteractiveDeferredMode(
- {
- host: host(),
- sdk,
- directory: "/tmp",
- target: async () => ({
- sessionID: "ses-resume",
- sessionTitle: "Resume",
- location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
- agent: "review",
- model: { providerID: "openai", modelID: "gpt-5" },
- variant: "high",
- resume: true,
- }),
- agent: "build",
- model: undefined,
- variant: undefined,
- files: [],
- thinking: false,
- },
- {
- createRuntimeLifecycle: async () => {
- lifecycleStarted.resolve()
- return {
- footer: api,
- onResize: () => () => {},
- refreshTheme: () => {},
- setTitle: (title) => titles.push(title),
- 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: "agent", agent: "review" })
- expect(titles).toEqual(["Resume"])
- expect(events).toContainEqual({
- type: "model",
- model: "Little Frank · OpenAI · high",
- selection: { providerID: "openai", modelID: "gpt-5" },
- })
- })
- test("aborts deferred resume history on close and uses the cached exit title", async () => {
- const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
- const painted = defer<void>()
- const readsStarted = defer<void>()
- const api = footer()
- api.idle = () => painted.promise
- let reads = 0
- let aborted = 0
- let closedTitle: string | undefined
- const pending = (signal: AbortSignal | undefined) =>
- new Promise<never>((_resolve, reject) => {
- reads++
- if (reads === 2) readsStarted.resolve()
- signal?.addEventListener(
- "abort",
- () => {
- aborted++
- reject(new Error("resume history aborted"))
- },
- { once: true },
- )
- })
- const messages = spyOn(sdk.message, "list").mockImplementation(
- (_request, options) => pending(options?.signal) as never,
- )
- const session = spyOn(sdk.session, "get").mockImplementation(
- (_request, options) => pending(options?.signal) as never,
- )
- stubCatalogLists(sdk)
- const task = runInteractiveDeferredMode(
- {
- host: host(),
- sdk,
- directory: "/tmp",
- target: async () => ({
- sessionID: "ses-resume-abort",
- sessionTitle: "Cached title",
- location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
- agent: "build",
- model: undefined,
- variant: undefined,
- resume: true,
- }),
- agent: "build",
- model: undefined,
- variant: undefined,
- files: [],
- thinking: false,
- },
- {
- createRuntimeLifecycle: async () => ({
- footer: api,
- onResize: () => () => {},
- refreshTheme: () => {},
- setTitle: () => {},
- resetForReplay: () => Promise.resolve(),
- close: async (input) => {
- closedTitle = input.sessionTitle
- },
- }),
- },
- )
- painted.resolve()
- await readsStarted.promise
- api.close()
- await task
- expect(aborted).toBe(2)
- expect(messages).toHaveBeenCalledWith(
- { sessionID: "ses-resume-abort", limit: 200, order: "desc" },
- { signal: expect.any(AbortSignal) },
- )
- expect(session).toHaveBeenCalledTimes(1)
- expect(session).toHaveBeenCalledWith({ sessionID: "ses-resume-abort" }, { signal: expect.any(AbortSignal) })
- expect(closedTitle).toBe("Cached title")
- })
- test("adopts the deferred target location for catalogs, files, and runtime placement", async () => {
- const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
- const lifecycleStarted = defer<void>()
- const painted = defer<void>()
- const api = footer()
- api.idle = () => painted.promise
- let targets = 0
- let getDirectory: (() => string) | undefined
- let findFiles: ((query: string) => Promise<string[]>) | undefined
- let transportLocation: unknown
- const catalogs = stubCatalogLists(sdk, {
- location: { directory: "/session", workspaceID: "work-1" },
- })
- const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
- location: {
- directory: "/session",
- workspaceID: "work-1",
- project: { id: "pro-1", directory: "/session", canonical: "/session" },
- },
- data: [{ path: "src/index.ts", type: "file" }],
- } as never)
- const task = runInteractiveDeferredMode(
- {
- host: host(),
- sdk,
- directory: "/launch",
- target: async () => {
- targets++
- return {
- sessionID: "ses-target",
- location: {
- directory: "/session",
- workspaceID: "work-1",
- project: { id: "location-project", directory: "/session", canonical: "/session" },
- },
- agent: "review",
- model: { providerID: "openai", modelID: "gpt-5" },
- variant: "high",
- resume: false,
- }
- },
- agent: undefined,
- model: undefined,
- variant: undefined,
- files: [],
- },
- {
- createRuntimeLifecycle: async (input) => {
- getDirectory = input.getDirectory
- findFiles = input.findFiles
- lifecycleStarted.resolve()
- return {
- footer: api,
- onResize: () => () => {},
- refreshTheme: () => {},
- setTitle: () => {},
- resetForReplay: () => Promise.resolve(),
- close: () => Promise.resolve(),
- }
- },
- streamTransport: Promise.resolve({
- createSessionTransport: async (input) => {
- transportLocation = input.location
- await findFiles?.("index")
- setTimeout(() => input.footer.close(), 0)
- return {
- runPromptTurn: async () => {},
- admitPromptTurn: async () => {},
- waitForIdle: async () => {},
- interruptActiveTurn: async () => {},
- selectSubagent: () => {},
- replayOnResize: async () => false,
- close: async () => {},
- }
- },
- formatUnknownError: (error: unknown) => String(error),
- }),
- },
- )
- await lifecycleStarted.promise
- expect(targets).toBe(0)
- expect(getDirectory?.()).toBe("/launch")
- painted.resolve()
- await task
- const query = { location: { directory: "/session", workspace: "work-1" } }
- expect(getDirectory?.()).toBe("/session")
- expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
- expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
- expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
- expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
- expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
- expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
- expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
- expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
- })
- })
|