1
0

runtime.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
  2. import { OpenCode } from "@opencode-ai/client/promise"
  3. import { runInteractiveDeferredMode } from "../../src/mini/runtime"
  4. import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
  5. import type { FooterEvent, MiniHost } from "../../src/mini/types"
  6. import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
  7. import { createFooterApiFixture } from "./fixture/footer-api"
  8. import { createTuiResolvedConfig } from "../fixture/tui-runtime"
  9. function defer<T>() {
  10. let resolve!: (value: T | PromiseLike<T>) => void
  11. const promise = new Promise<T>((done) => {
  12. resolve = done
  13. })
  14. return { promise, resolve }
  15. }
  16. function ok<T>(data: T) {
  17. return Promise.resolve(data)
  18. }
  19. function host(): MiniHost {
  20. return {
  21. terminal: { stdin: process.stdin },
  22. platform: "linux",
  23. stdout: { write() {} },
  24. files: { readText: async () => "" },
  25. editor: { open: async () => undefined },
  26. paths: { home: "/home/test" },
  27. signals: {
  28. sigint: { subscribe: () => () => {} },
  29. sigusr2: { subscribe: () => () => {} },
  30. },
  31. startup: { showTiming: false, now: () => 0 },
  32. diagnostics: {},
  33. preferences: {
  34. resolveVariant: async () => undefined,
  35. saveVariant: async () => {},
  36. },
  37. }
  38. }
  39. function footer(events: FooterEvent[] = []) {
  40. return createFooterApiFixture({ events }).api
  41. }
  42. afterEach(() => {
  43. mock.restore()
  44. })
  45. describe("run interactive runtime", () => {
  46. test("resolves the default model reactively without blocking catalog startup", async () => {
  47. const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
  48. const events: FooterEvent[] = []
  49. const ui = createFooterApiFixture({ events })
  50. const api = ui.api
  51. const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
  52. const catalogLoaded = defer<void>()
  53. const defaultModelReloaded = defer<void>()
  54. const modelShown = defer<void>()
  55. const turnStarted = defer<void>()
  56. const model = catalogModel({
  57. id: "resolved",
  58. providerID: "test",
  59. name: "Resolved Model",
  60. variants: ["low", "high"],
  61. })
  62. let lifecycle!: LifecycleInput
  63. let turnAgent: string | undefined
  64. let turnModel: { providerID: string; modelID: string } | undefined
  65. let refreshCatalog: (() => Promise<unknown>) | undefined
  66. stubCatalogLists(sdk, {
  67. providers: [catalogProvider("test", "Test Provider")],
  68. models: [model],
  69. })
  70. let defaultModelCalls = 0
  71. const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
  72. defaultModelCalls++
  73. if (defaultModelCalls === 2) defaultModelReloaded.resolve()
  74. return selected.promise
  75. })
  76. const emit = api.event.bind(api)
  77. api.event = (event) => {
  78. emit(event)
  79. if (event.type === "model") modelShown.resolve()
  80. }
  81. const task = runInteractiveDeferredMode(
  82. {
  83. host: host(),
  84. sdk,
  85. directory: "/tmp",
  86. target: async () => ({
  87. sessionID: "ses_root",
  88. location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
  89. agent: "build",
  90. model: undefined,
  91. variant: undefined,
  92. resume: false,
  93. }),
  94. agent: "build",
  95. model: undefined,
  96. variant: undefined,
  97. files: [],
  98. },
  99. {
  100. createRuntimeLifecycle: async (input) => {
  101. lifecycle = input
  102. return {
  103. footer: api,
  104. onResize: () => () => {},
  105. refreshTheme: () => {},
  106. setTitle: () => {},
  107. resetForReplay: () => Promise.resolve(),
  108. close: () => Promise.resolve(),
  109. }
  110. },
  111. streamTransport: Promise.resolve({
  112. createSessionTransport: async (input) => {
  113. refreshCatalog = () => Promise.resolve(input.onCatalogRefresh?.())
  114. await refreshCatalog()
  115. catalogLoaded.resolve()
  116. return {
  117. runPromptTurn: async (input) => {
  118. turnAgent = input.agent
  119. turnModel = input.model
  120. turnStarted.resolve()
  121. api.close()
  122. },
  123. admitPromptTurn: async () => {},
  124. waitForIdle: async () => {},
  125. interruptActiveTurn: async () => {},
  126. selectSubagent: () => {},
  127. replayOnResize: async () => false,
  128. close: async () => {},
  129. }
  130. },
  131. formatUnknownError: (error: unknown) => String(error),
  132. }),
  133. },
  134. )
  135. await catalogLoaded.promise
  136. expect(events.some((event) => event.type === "model")).toBe(false)
  137. await refreshCatalog?.()
  138. expect(defaultModel).toHaveBeenCalledTimes(1)
  139. selected.resolve({
  140. location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
  141. data: model,
  142. })
  143. await defaultModelReloaded.promise
  144. await modelShown.promise
  145. expect(events).toContainEqual({
  146. type: "model",
  147. model: "Resolved Model · Test Provider",
  148. selection: { providerID: "test", modelID: "resolved" },
  149. })
  150. expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
  151. lifecycle.onAgentSelect?.("review")
  152. await ui.promptReady
  153. expect(ui.submit("hello")).toBe(true)
  154. await turnStarted.promise
  155. expect(turnAgent).toBe("review")
  156. expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
  157. await task
  158. })
  159. test("routes form responses to their owners with global location and local settlement", async () => {
  160. const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
  161. const api = footer()
  162. const streamStarted = defer<void>()
  163. let lifecycle!: LifecycleInput
  164. const settled: Array<{ sessionID: string; formID: string }> = []
  165. stubCatalogLists(sdk)
  166. const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
  167. const task = runInteractiveDeferredMode(
  168. {
  169. host: host(),
  170. sdk,
  171. directory: "/tmp",
  172. target: async () => ({
  173. sessionID: "ses_root",
  174. location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
  175. agent: "build",
  176. model: { providerID: "test", modelID: "model" },
  177. variant: undefined,
  178. resume: false,
  179. }),
  180. agent: "build",
  181. model: { providerID: "test", modelID: "model" },
  182. variant: undefined,
  183. files: [],
  184. thinking: false,
  185. },
  186. {
  187. createRuntimeLifecycle: async (input) => {
  188. lifecycle = input
  189. return {
  190. footer: api,
  191. onResize: () => () => {},
  192. refreshTheme: () => {},
  193. setTitle: () => {},
  194. resetForReplay: () => Promise.resolve(),
  195. close: () => Promise.resolve(),
  196. }
  197. },
  198. streamTransport: Promise.resolve({
  199. createSessionTransport: async () => {
  200. streamStarted.resolve()
  201. return {
  202. runPromptTurn: async () => {},
  203. admitPromptTurn: async () => {},
  204. waitForIdle: async () => {},
  205. interruptActiveTurn: async () => {},
  206. selectSubagent: () => {},
  207. settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
  208. replayOnResize: async () => false,
  209. close: async () => {},
  210. }
  211. },
  212. formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
  213. }),
  214. },
  215. )
  216. await streamStarted.promise
  217. await lifecycle.onFormReply({
  218. sessionID: "global",
  219. formID: "frm_global",
  220. answer: { value: "yes" },
  221. location: { directory: "/remote work", workspaceID: "wrk_1" },
  222. })
  223. expect(reply).toHaveBeenCalledWith(
  224. {
  225. sessionID: "global",
  226. formID: "frm_global",
  227. answer: { value: "yes" },
  228. location: { directory: "/remote work", workspaceID: "wrk_1" },
  229. },
  230. {
  231. headers: {
  232. "x-opencode-directory": "%2Fremote%20work",
  233. "x-opencode-workspace": "wrk_1",
  234. },
  235. },
  236. )
  237. expect(settled).toEqual([{ sessionID: "global", formID: "frm_global" }])
  238. reply.mockImplementationOnce(() => Promise.reject({ _tag: "FormInvalidAnswerError", message: "Invalid answer" }))
  239. await expect(
  240. lifecycle.onFormReply({ sessionID: "ses_child", formID: "frm_invalid", answer: { value: 3 } }),
  241. ).rejects.toEqual({ _tag: "FormInvalidAnswerError", message: "Invalid answer" })
  242. expect(settled.some((item) => item.formID === "frm_invalid")).toBe(false)
  243. api.close()
  244. await task
  245. })
  246. test("resolves the deferred session only after first paint", async () => {
  247. const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
  248. const lifecycleStarted = defer<void>()
  249. const painted = defer<void>()
  250. const api = footer()
  251. let resolved = 0
  252. api.idle = () => painted.promise
  253. stubCatalogLists(sdk)
  254. const task = runInteractiveDeferredMode(
  255. {
  256. host: host(),
  257. sdk,
  258. directory: "/tmp",
  259. target: async () => {
  260. resolved++
  261. api.close()
  262. return {
  263. sessionID: "ses-deferred",
  264. sessionTitle: "Deferred",
  265. location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
  266. agent: "build",
  267. model: { providerID: "openai", modelID: "gpt-5" },
  268. variant: undefined,
  269. resume: false,
  270. }
  271. },
  272. agent: "build",
  273. model: { providerID: "openai", modelID: "gpt-5" },
  274. variant: undefined,
  275. files: [],
  276. thinking: false,
  277. },
  278. {
  279. createRuntimeLifecycle: async () => {
  280. lifecycleStarted.resolve()
  281. return {
  282. footer: api,
  283. onResize: () => () => {},
  284. refreshTheme: () => {},
  285. setTitle: () => {},
  286. resetForReplay: () => Promise.resolve(),
  287. close: () => Promise.resolve(),
  288. }
  289. },
  290. },
  291. )
  292. await lifecycleStarted.promise
  293. expect(resolved).toBe(0)
  294. painted.resolve()
  295. await task
  296. expect(resolved).toBe(1)
  297. })
  298. test("restores deferred session history and model after first paint", async () => {
  299. const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
  300. const lifecycleStarted = defer<void>()
  301. const painted = defer<void>()
  302. const events: FooterEvent[] = []
  303. const titles: Array<string | undefined> = []
  304. const api = footer(events)
  305. api.idle = () => painted.promise
  306. const event = api.event
  307. api.event = (value) => {
  308. event(value)
  309. if (value.type === "model") api.close()
  310. }
  311. spyOn(sdk.session, "get").mockImplementation(
  312. () =>
  313. ok({
  314. id: "ses-resume",
  315. projectID: "pro-1",
  316. title: "Resume",
  317. cost: 0,
  318. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  319. time: { created: 1, updated: 1 },
  320. location: { directory: "/tmp" },
  321. model: { providerID: "openai", id: "gpt-5", variant: "high" },
  322. }) as never,
  323. )
  324. spyOn(sdk.message, "list").mockImplementation(
  325. () =>
  326. ok({
  327. data: [{ id: "msg-user", type: "user", text: "previous prompt", time: { created: 1 } }],
  328. cursor: {},
  329. }) as never,
  330. )
  331. stubCatalogLists(sdk, {
  332. providers: [catalogProvider("openai", "OpenAI")],
  333. models: [
  334. catalogModel({
  335. id: "gpt-5",
  336. providerID: "openai",
  337. name: "Little Frank",
  338. variants: ["high"],
  339. }),
  340. ],
  341. })
  342. const task = runInteractiveDeferredMode(
  343. {
  344. host: host(),
  345. sdk,
  346. directory: "/tmp",
  347. target: async () => ({
  348. sessionID: "ses-resume",
  349. sessionTitle: "Resume",
  350. location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
  351. agent: "review",
  352. model: { providerID: "openai", modelID: "gpt-5" },
  353. variant: "high",
  354. resume: true,
  355. }),
  356. agent: "build",
  357. model: undefined,
  358. variant: undefined,
  359. files: [],
  360. thinking: false,
  361. },
  362. {
  363. createRuntimeLifecycle: async () => {
  364. lifecycleStarted.resolve()
  365. return {
  366. footer: api,
  367. onResize: () => () => {},
  368. refreshTheme: () => {},
  369. setTitle: (title) => titles.push(title),
  370. resetForReplay: () => Promise.resolve(),
  371. close: () => Promise.resolve(),
  372. }
  373. },
  374. },
  375. )
  376. await lifecycleStarted.promise
  377. expect(sdk.session.get).not.toHaveBeenCalled()
  378. painted.resolve()
  379. await task
  380. expect(events).toContainEqual({
  381. type: "history",
  382. history: [{ text: "previous prompt", parts: [] }],
  383. })
  384. expect(events).toContainEqual({ type: "agent", agent: "review" })
  385. expect(titles).toEqual(["Resume"])
  386. expect(events).toContainEqual({
  387. type: "model",
  388. model: "Little Frank · OpenAI · high",
  389. selection: { providerID: "openai", modelID: "gpt-5" },
  390. })
  391. })
  392. test("aborts deferred resume history on close and uses the cached exit title", async () => {
  393. const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
  394. const painted = defer<void>()
  395. const readsStarted = defer<void>()
  396. const api = footer()
  397. api.idle = () => painted.promise
  398. let reads = 0
  399. let aborted = 0
  400. let closedTitle: string | undefined
  401. const pending = (signal: AbortSignal | undefined) =>
  402. new Promise<never>((_resolve, reject) => {
  403. reads++
  404. if (reads === 2) readsStarted.resolve()
  405. signal?.addEventListener(
  406. "abort",
  407. () => {
  408. aborted++
  409. reject(new Error("resume history aborted"))
  410. },
  411. { once: true },
  412. )
  413. })
  414. const messages = spyOn(sdk.message, "list").mockImplementation(
  415. (_request, options) => pending(options?.signal) as never,
  416. )
  417. const session = spyOn(sdk.session, "get").mockImplementation(
  418. (_request, options) => pending(options?.signal) as never,
  419. )
  420. stubCatalogLists(sdk)
  421. const task = runInteractiveDeferredMode(
  422. {
  423. host: host(),
  424. sdk,
  425. directory: "/tmp",
  426. target: async () => ({
  427. sessionID: "ses-resume-abort",
  428. sessionTitle: "Cached title",
  429. location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
  430. agent: "build",
  431. model: undefined,
  432. variant: undefined,
  433. resume: true,
  434. }),
  435. agent: "build",
  436. model: undefined,
  437. variant: undefined,
  438. files: [],
  439. thinking: false,
  440. },
  441. {
  442. createRuntimeLifecycle: async () => ({
  443. footer: api,
  444. onResize: () => () => {},
  445. refreshTheme: () => {},
  446. setTitle: () => {},
  447. resetForReplay: () => Promise.resolve(),
  448. close: async (input) => {
  449. closedTitle = input.sessionTitle
  450. },
  451. }),
  452. },
  453. )
  454. painted.resolve()
  455. await readsStarted.promise
  456. api.close()
  457. await task
  458. expect(aborted).toBe(2)
  459. expect(messages).toHaveBeenCalledWith(
  460. { sessionID: "ses-resume-abort", limit: 200, order: "desc" },
  461. { signal: expect.any(AbortSignal) },
  462. )
  463. expect(session).toHaveBeenCalledTimes(1)
  464. expect(session).toHaveBeenCalledWith({ sessionID: "ses-resume-abort" }, { signal: expect.any(AbortSignal) })
  465. expect(closedTitle).toBe("Cached title")
  466. })
  467. test("adopts deferred target placement and supplied TUI config", async () => {
  468. const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
  469. const lifecycleStarted = defer<void>()
  470. const painted = defer<void>()
  471. const api = footer()
  472. api.idle = () => painted.promise
  473. let targets = 0
  474. let getDirectory: (() => string) | undefined
  475. let findFiles: ((query: string) => Promise<string[]>) | undefined
  476. let transportLocation: unknown
  477. let runtimeConfig: LifecycleInput["tuiConfig"] | undefined
  478. const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } })
  479. const catalogs = stubCatalogLists(sdk, {
  480. location: { directory: "/session", workspaceID: "work-1" },
  481. })
  482. const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
  483. location: {
  484. directory: "/session",
  485. workspaceID: "work-1",
  486. project: { id: "pro-1", directory: "/session", canonical: "/session" },
  487. },
  488. data: [{ path: "src/index.ts", type: "file" }],
  489. } as never)
  490. const task = runInteractiveDeferredMode(
  491. {
  492. host: host(),
  493. sdk,
  494. directory: "/launch",
  495. target: async () => {
  496. targets++
  497. return {
  498. sessionID: "ses-target",
  499. location: {
  500. directory: "/session",
  501. workspaceID: "work-1",
  502. project: { id: "location-project", directory: "/session", canonical: "/session" },
  503. },
  504. agent: "review",
  505. model: { providerID: "openai", modelID: "gpt-5" },
  506. variant: "high",
  507. resume: false,
  508. }
  509. },
  510. agent: undefined,
  511. model: undefined,
  512. variant: undefined,
  513. files: [],
  514. tuiConfig,
  515. },
  516. {
  517. createRuntimeLifecycle: async (input) => {
  518. getDirectory = input.getDirectory
  519. findFiles = input.findFiles
  520. runtimeConfig = input.tuiConfig
  521. lifecycleStarted.resolve()
  522. return {
  523. footer: api,
  524. onResize: () => () => {},
  525. refreshTheme: () => {},
  526. setTitle: () => {},
  527. resetForReplay: () => Promise.resolve(),
  528. close: () => Promise.resolve(),
  529. }
  530. },
  531. streamTransport: Promise.resolve({
  532. createSessionTransport: async (input) => {
  533. transportLocation = input.location
  534. await findFiles?.("index")
  535. setTimeout(() => input.footer.close(), 0)
  536. return {
  537. runPromptTurn: async () => {},
  538. admitPromptTurn: async () => {},
  539. waitForIdle: async () => {},
  540. interruptActiveTurn: async () => {},
  541. selectSubagent: () => {},
  542. replayOnResize: async () => false,
  543. close: async () => {},
  544. }
  545. },
  546. formatUnknownError: (error: unknown) => String(error),
  547. }),
  548. },
  549. )
  550. await lifecycleStarted.promise
  551. expect(targets).toBe(0)
  552. expect(getDirectory?.()).toBe("/launch")
  553. painted.resolve()
  554. await task
  555. const query = { location: { directory: "/session", workspace: "work-1" } }
  556. expect(getDirectory?.()).toBe("/session")
  557. if (!runtimeConfig) throw new Error("runtime lifecycle did not receive TUI config")
  558. expect(await runtimeConfig).toBe(tuiConfig)
  559. expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
  560. expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
  561. expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
  562. expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
  563. expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
  564. expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
  565. expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
  566. expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
  567. })
  568. })