runtime.boot.test.ts 8.4 KB

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