runtime.boot.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
  2. import { OpencodeClient, type Provider } from "@kirincode-ai/sdk/v2"
  3. import type { Resolved } from "@kirincode-ai/tui/config"
  4. import { TuiConfig } from "@/config/tui"
  5. import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
  6. import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
  7. function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
  8. return {
  9. id,
  10. providerID,
  11. api: {
  12. id: providerID,
  13. url: `https://${providerID}.test`,
  14. npm: `@ai-sdk/${providerID}`,
  15. },
  16. name: id,
  17. capabilities: {
  18. temperature: true,
  19. reasoning: true,
  20. attachment: true,
  21. toolcall: true,
  22. input: {
  23. text: true,
  24. audio: false,
  25. image: false,
  26. video: false,
  27. pdf: false,
  28. },
  29. output: {
  30. text: true,
  31. audio: false,
  32. image: false,
  33. video: false,
  34. pdf: false,
  35. },
  36. interleaved: false,
  37. },
  38. cost: {
  39. input: 0,
  40. output: 0,
  41. cache: {
  42. read: 0,
  43. write: 0,
  44. },
  45. },
  46. limit: {
  47. context,
  48. output: 8192,
  49. },
  50. status: "active" as const,
  51. options: {},
  52. headers: {},
  53. release_date: "2026-01-01",
  54. variants,
  55. }
  56. }
  57. function config(input?: {
  58. leader?: string
  59. leaderTimeout?: number
  60. diff_style?: "auto" | "stacked"
  61. bindings?: Partial<{
  62. commandList: string[]
  63. variantCycle: string[]
  64. interrupt: string[]
  65. historyPrevious: string[]
  66. historyNext: string[]
  67. inputClear: string[]
  68. inputSubmit: string[]
  69. inputNewline: string[]
  70. }>
  71. }): Resolved {
  72. const bind = input?.bindings
  73. return createTuiResolvedConfig({
  74. diff_style: input?.diff_style,
  75. leader_timeout: input?.leaderTimeout,
  76. keybinds: {
  77. ...(input?.leader && { leader: input.leader }),
  78. ...(bind?.commandList && { command_list: bind.commandList }),
  79. ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
  80. ...(bind?.interrupt && { session_interrupt: bind.interrupt }),
  81. ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
  82. ...(bind?.historyNext && { history_next: bind.historyNext }),
  83. ...(bind?.inputClear && { input_clear: bind.inputClear }),
  84. ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
  85. ...(bind?.inputNewline && { input_newline: bind.inputNewline }),
  86. },
  87. })
  88. }
  89. describe("run runtime boot", () => {
  90. afterEach(() => {
  91. mock.restore()
  92. })
  93. test("reads footer keybinds from resolved keybind config", async () => {
  94. spyOn(TuiConfig, "get").mockResolvedValue(
  95. config({
  96. leader: "ctrl+g",
  97. bindings: {
  98. commandList: ["ctrl+p"],
  99. variantCycle: ["ctrl+t", "alt+t"],
  100. interrupt: ["ctrl+c"],
  101. historyPrevious: ["k"],
  102. historyNext: ["j"],
  103. inputClear: ["ctrl+l"],
  104. inputSubmit: ["ctrl+s"],
  105. inputNewline: ["alt+return"],
  106. },
  107. }),
  108. )
  109. const result = await resolveRunTuiConfig()
  110. expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
  111. expect(result.leader_timeout).toBe(2000)
  112. expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
  113. expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
  114. expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
  115. expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
  116. expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
  117. expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
  118. expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
  119. expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
  120. })
  121. test("falls back to default tui keymap config when config load fails", async () => {
  122. spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
  123. const result = await resolveRunTuiConfig()
  124. expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
  125. expect(result.leader_timeout).toBe(2000)
  126. expect(result.diff_style).toBe("auto")
  127. expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
  128. expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
  129. expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
  130. expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
  131. expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
  132. expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
  133. expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
  134. expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
  135. })
  136. test("preserves disabled leader from resolved tui config", async () => {
  137. spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
  138. const result = await resolveRunTuiConfig()
  139. expect(result.keybinds.get("leader")).toEqual([])
  140. })
  141. test("reads diff style and falls back to auto", async () => {
  142. spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
  143. await expect(resolveDiffStyle()).resolves.toBe("stacked")
  144. mock.restore()
  145. spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
  146. await expect(resolveDiffStyle()).resolves.toBe("auto")
  147. })
  148. test("prefers configured providers for model selector data", async () => {
  149. const sdk = new OpencodeClient()
  150. const data: {
  151. all: Provider[]
  152. default: Record<string, string>
  153. connected: string[]
  154. } = {
  155. all: [
  156. {
  157. id: "openai",
  158. name: "OpenAI",
  159. source: "api",
  160. env: [],
  161. options: {},
  162. models: {
  163. "gpt-5": model("gpt-5", "openai", 128000, {
  164. high: {},
  165. minimal: {},
  166. }),
  167. },
  168. },
  169. {
  170. id: "anthropic",
  171. name: "Anthropic",
  172. source: "api",
  173. env: [],
  174. options: {},
  175. models: {
  176. sonnet: model("sonnet", "anthropic", 200000),
  177. },
  178. },
  179. ],
  180. default: {},
  181. connected: [],
  182. }
  183. const configured = {
  184. providers: [data.all[0]!],
  185. default: {},
  186. }
  187. const list = spyOn(sdk.provider, "list").mockImplementation(() =>
  188. Promise.resolve({
  189. data,
  190. error: undefined,
  191. request: new Request("https://opencode.test"),
  192. response: new Response(),
  193. }),
  194. )
  195. spyOn(sdk.config, "providers").mockImplementation(() =>
  196. Promise.resolve({
  197. data: configured,
  198. error: undefined,
  199. request: new Request("https://opencode.test"),
  200. response: new Response(),
  201. }),
  202. )
  203. await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
  204. providers: configured.providers,
  205. variants: ["high", "minimal"],
  206. limits: {
  207. "openai/gpt-5": 128000,
  208. },
  209. })
  210. expect(list).not.toHaveBeenCalled()
  211. })
  212. test("falls back to provider list when configured providers are unavailable", async () => {
  213. const sdk = new OpencodeClient()
  214. const data: {
  215. all: Provider[]
  216. default: Record<string, string>
  217. connected: string[]
  218. } = {
  219. all: [
  220. {
  221. id: "openai",
  222. name: "OpenAI",
  223. source: "api",
  224. env: [],
  225. options: {},
  226. models: {
  227. "gpt-5": model("gpt-5", "openai", 128000, {
  228. high: {},
  229. minimal: {},
  230. }),
  231. },
  232. },
  233. {
  234. id: "anthropic",
  235. name: "Anthropic",
  236. source: "api",
  237. env: [],
  238. options: {},
  239. models: {
  240. sonnet: model("sonnet", "anthropic", 200000),
  241. },
  242. },
  243. ],
  244. default: {},
  245. connected: [],
  246. }
  247. spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
  248. spyOn(sdk.provider, "list").mockImplementation(() =>
  249. Promise.resolve({
  250. data,
  251. error: undefined,
  252. request: new Request("https://opencode.test"),
  253. response: new Response(),
  254. }),
  255. )
  256. await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
  257. providers: data.all,
  258. variants: ["high", "minimal"],
  259. limits: {
  260. "openai/gpt-5": 128000,
  261. "anthropic/sonnet": 200000,
  262. },
  263. })
  264. })
  265. })