runtime.boot.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
  2. import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
  3. import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
  4. import { formatBindings } from "@/cli/cmd/run/keymap.shared"
  5. import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } 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 resolveFooterKeybinds()
  110. expect(result.leader).toBe("ctrl+g")
  111. expect(result.leaderTimeout).toBe(2000)
  112. expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
  113. expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
  114. expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
  115. expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
  116. expect(formatBindings(result.historyNext, result.leader)).toBe("j")
  117. expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
  118. expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
  119. expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
  120. })
  121. test("falls back to default keybinds when config load fails", async () => {
  122. spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
  123. const result = await resolveFooterKeybinds()
  124. expect(result.leader).toBe("ctrl+x")
  125. expect(result.leaderTimeout).toBe(2000)
  126. expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
  127. expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t")
  128. expect(formatBindings(result.interrupt, result.leader)).toBe("esc")
  129. expect(formatBindings(result.historyPrevious, result.leader)).toBe("up")
  130. expect(formatBindings(result.historyNext, result.leader)).toBe("down")
  131. expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c")
  132. expect(formatBindings(result.inputSubmit, result.leader)).toBe("return")
  133. expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j")
  134. })
  135. test("reads diff style and falls back to auto", async () => {
  136. spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
  137. await expect(resolveDiffStyle()).resolves.toBe("stacked")
  138. mock.restore()
  139. spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
  140. await expect(resolveDiffStyle()).resolves.toBe("auto")
  141. })
  142. test("prefers configured providers for model selector data", async () => {
  143. const sdk = new OpencodeClient()
  144. const data: {
  145. all: Provider[]
  146. default: Record<string, string>
  147. connected: string[]
  148. } = {
  149. all: [
  150. {
  151. id: "openai",
  152. name: "OpenAI",
  153. source: "api",
  154. env: [],
  155. options: {},
  156. models: {
  157. "gpt-5": model("gpt-5", "openai", 128000, {
  158. high: {},
  159. minimal: {},
  160. }),
  161. },
  162. },
  163. {
  164. id: "anthropic",
  165. name: "Anthropic",
  166. source: "api",
  167. env: [],
  168. options: {},
  169. models: {
  170. sonnet: model("sonnet", "anthropic", 200000),
  171. },
  172. },
  173. ],
  174. default: {},
  175. connected: [],
  176. }
  177. const configured = {
  178. providers: [data.all[0]!],
  179. default: {},
  180. }
  181. const list = spyOn(sdk.provider, "list").mockImplementation(() =>
  182. Promise.resolve({
  183. data,
  184. error: undefined,
  185. request: new Request("https://opencode.test"),
  186. response: new Response(),
  187. }),
  188. )
  189. spyOn(sdk.config, "providers").mockImplementation(() =>
  190. Promise.resolve({
  191. data: configured,
  192. error: undefined,
  193. request: new Request("https://opencode.test"),
  194. response: new Response(),
  195. }),
  196. )
  197. await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
  198. providers: configured.providers,
  199. variants: ["high", "minimal"],
  200. limits: {
  201. "openai/gpt-5": 128000,
  202. },
  203. })
  204. expect(list).not.toHaveBeenCalled()
  205. })
  206. test("falls back to provider list when configured providers are unavailable", async () => {
  207. const sdk = new OpencodeClient()
  208. const data: {
  209. all: Provider[]
  210. default: Record<string, string>
  211. connected: string[]
  212. } = {
  213. all: [
  214. {
  215. id: "openai",
  216. name: "OpenAI",
  217. source: "api",
  218. env: [],
  219. options: {},
  220. models: {
  221. "gpt-5": model("gpt-5", "openai", 128000, {
  222. high: {},
  223. minimal: {},
  224. }),
  225. },
  226. },
  227. {
  228. id: "anthropic",
  229. name: "Anthropic",
  230. source: "api",
  231. env: [],
  232. options: {},
  233. models: {
  234. sonnet: model("sonnet", "anthropic", 200000),
  235. },
  236. },
  237. ],
  238. default: {},
  239. connected: [],
  240. }
  241. spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
  242. spyOn(sdk.provider, "list").mockImplementation(() =>
  243. Promise.resolve({
  244. data,
  245. error: undefined,
  246. request: new Request("https://opencode.test"),
  247. response: new Response(),
  248. }),
  249. )
  250. await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
  251. providers: data.all,
  252. variants: ["high", "minimal"],
  253. limits: {
  254. "openai/gpt-5": 128000,
  255. "anthropic/sonnet": 200000,
  256. },
  257. })
  258. })
  259. })