runtime.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
  2. import { OpencodeClient } from "@kirincode-ai/sdk/v2"
  3. import { runInteractiveMode } from "@/cli/cmd/run/runtime"
  4. import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
  5. type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
  6. const provider: RunProvider = {
  7. id: "openai",
  8. name: "OpenAI",
  9. source: "api",
  10. env: [],
  11. options: {},
  12. models: {
  13. "gpt-5": {
  14. id: "gpt-5",
  15. providerID: "openai",
  16. api: {
  17. id: "openai",
  18. url: "https://openai.test",
  19. npm: "@ai-sdk/openai",
  20. },
  21. name: "Little Frank",
  22. capabilities: {
  23. temperature: true,
  24. reasoning: true,
  25. attachment: true,
  26. toolcall: true,
  27. input: {
  28. text: true,
  29. audio: false,
  30. image: false,
  31. video: false,
  32. pdf: false,
  33. },
  34. output: {
  35. text: true,
  36. audio: false,
  37. image: false,
  38. video: false,
  39. pdf: false,
  40. },
  41. interleaved: false,
  42. },
  43. cost: {
  44. input: 0,
  45. output: 0,
  46. cache: {
  47. read: 0,
  48. write: 0,
  49. },
  50. },
  51. limit: {
  52. context: 128000,
  53. output: 8192,
  54. },
  55. status: "active",
  56. options: {},
  57. headers: {},
  58. release_date: "2026-01-01",
  59. },
  60. },
  61. }
  62. const transportProviders: RunProvider[][] = []
  63. function defer<T>() {
  64. let resolve!: (value: T | PromiseLike<T>) => void
  65. const promise = new Promise<T>((done) => {
  66. resolve = done
  67. })
  68. return { promise, resolve }
  69. }
  70. function ok<T>(data: T) {
  71. return Promise.resolve({
  72. data,
  73. error: undefined,
  74. request: new Request("https://opencode.test"),
  75. response: new Response(),
  76. })
  77. }
  78. function footer(): FooterApi {
  79. let closed = false
  80. const closes = new Set<() => void>()
  81. const notify = () => {
  82. for (const fn of closes) fn()
  83. }
  84. return {
  85. get isClosed() {
  86. return closed
  87. },
  88. onPrompt: () => () => {},
  89. onQueuedRemove: () => () => {},
  90. onClose(fn) {
  91. if (closed) {
  92. fn()
  93. return () => {}
  94. }
  95. closes.add(fn)
  96. return () => {
  97. closes.delete(fn)
  98. }
  99. },
  100. event() {},
  101. append() {},
  102. idle() {
  103. return Promise.resolve()
  104. },
  105. close() {
  106. if (closed) {
  107. return
  108. }
  109. closed = true
  110. notify()
  111. },
  112. destroy() {
  113. if (closed) {
  114. return
  115. }
  116. closed = true
  117. notify()
  118. },
  119. }
  120. }
  121. afterEach(() => {
  122. mock.restore()
  123. transportProviders.length = 0
  124. })
  125. describe("run interactive runtime", () => {
  126. test("waits for provider metadata before eager replay transport bootstrap", async () => {
  127. const providersStarted = defer<void>()
  128. const providers = defer<void>()
  129. const sdk = new OpencodeClient()
  130. spyOn(sdk.config, "providers").mockImplementation(async () => {
  131. providersStarted.resolve()
  132. await providers.promise
  133. return ok({ providers: [provider], default: {} })
  134. })
  135. spyOn(sdk.session, "messages").mockImplementation(() =>
  136. ok([
  137. {
  138. info: {
  139. id: "msg-user-1",
  140. sessionID: "ses-1",
  141. role: "user",
  142. time: {
  143. created: 1,
  144. },
  145. agent: "build",
  146. model: {
  147. providerID: "openai",
  148. modelID: "gpt-5",
  149. variant: undefined,
  150. },
  151. },
  152. parts: [
  153. {
  154. id: "part-user-1",
  155. sessionID: "ses-1",
  156. messageID: "msg-user-1",
  157. type: "text",
  158. text: "hello",
  159. },
  160. ],
  161. } satisfies SessionMessage,
  162. ]),
  163. )
  164. spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
  165. spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
  166. spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
  167. spyOn(sdk.command, "list").mockImplementation(() => ok([]))
  168. const task = runInteractiveMode(
  169. {
  170. sdk,
  171. directory: "/tmp",
  172. sessionID: "ses-1",
  173. sessionTitle: "Session",
  174. resume: true,
  175. replay: true,
  176. replayLimit: 100,
  177. agent: "build",
  178. model: {
  179. providerID: "openai",
  180. modelID: "gpt-5",
  181. },
  182. variant: undefined,
  183. files: [],
  184. thinking: true,
  185. backgroundSubagents: false,
  186. },
  187. {
  188. createRuntimeLifecycle: async () => ({
  189. footer: footer(),
  190. onResize: () => () => {},
  191. refreshTheme: () => {},
  192. resetForReplay: () => Promise.resolve(),
  193. close: () => Promise.resolve(),
  194. }),
  195. streamTransport: Promise.resolve({
  196. createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
  197. transportProviders.push(input.providers?.() ?? [])
  198. setTimeout(() => {
  199. input.footer.close()
  200. }, 0)
  201. return {
  202. runPromptTurn: async () => {},
  203. selectSubagent: () => {},
  204. replayOnResize: async () => false,
  205. close: async () => {},
  206. }
  207. },
  208. formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
  209. }),
  210. },
  211. )
  212. await providersStarted.promise
  213. expect(transportProviders).toEqual([])
  214. providers.resolve()
  215. await task
  216. expect(transportProviders).toEqual([[provider]])
  217. })
  218. })