1
0

runtime.test.ts 19 KB

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