mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 02:36:57 -04:00
a3e2cc0dcd
Fixes #36121
575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
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 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],
|
|
})
|
|
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
|
|
|
|
const task = runInteractiveDeferredMode(
|
|
{
|
|
host: host(),
|
|
sdk,
|
|
directory: "/tmp",
|
|
target: async () => ({
|
|
sessionID: "ses_root",
|
|
location: { directory: "/tmp", project: { id: "pro-1", directory: "/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
|
|
api.close()
|
|
},
|
|
queuePromptTurn: 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" } },
|
|
data: model,
|
|
})
|
|
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
|
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
|
|
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")
|
|
ui.submit("hello")
|
|
while (!turnModel) await Bun.sleep(0)
|
|
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" } },
|
|
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 () => {},
|
|
queuePromptTurn: 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" } },
|
|
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" } },
|
|
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" } },
|
|
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" },
|
|
},
|
|
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" },
|
|
},
|
|
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 () => {},
|
|
queuePromptTurn: 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 })
|
|
})
|
|
})
|