runtime.test.ts 19 KB

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