mini.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import { describe, expect, test } from "bun:test"
  2. import { ClientError, OpenCode } from "@opencode-ai/client/promise"
  3. import { OPENCODE_VERSION } from "../src/version"
  4. import path from "node:path"
  5. import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
  6. import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
  7. import { parseSessionTargetModel } from "../src/session-target"
  8. async function cli(args: string[]) {
  9. const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
  10. cwd: path.join(import.meta.dir, ".."),
  11. stdout: "pipe",
  12. stderr: "pipe",
  13. })
  14. const [stdout, stderr, exitCode] = await Promise.all([
  15. new Response(child.stdout).text(),
  16. new Response(child.stderr).text(),
  17. child.exited,
  18. ])
  19. return { stdout, stderr, exitCode }
  20. }
  21. describe("mini command", () => {
  22. test("uses piped stdin as the initial prompt", () => {
  23. expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
  24. expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
  25. })
  26. test("constructs a fresh authenticated client for a replacement endpoint", async () => {
  27. const authorization: Array<string | null> = []
  28. const initial = Bun.serve({
  29. port: 0,
  30. fetch() {
  31. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  32. },
  33. })
  34. const replacement = Bun.serve({
  35. port: 0,
  36. fetch(request) {
  37. authorization.push(request.headers.get("authorization"))
  38. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  39. },
  40. })
  41. const controller = new AbortController()
  42. let signal: AbortSignal | undefined
  43. try {
  44. const connection = createMiniConnection({
  45. endpoint: { url: initial.url.toString() },
  46. reconnect: async (next) => {
  47. signal = next
  48. return {
  49. url: replacement.url.toString(),
  50. auth: { type: "basic", username: "replacement", password: "secret" },
  51. }
  52. },
  53. })
  54. const client = await connection.reconnect?.(controller.signal)
  55. if (!client) throw new Error("Expected a replacement client")
  56. await client.health.get()
  57. expect(client).not.toBe(connection.sdk)
  58. expect(signal).toBe(controller.signal)
  59. expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`])
  60. expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined()
  61. } finally {
  62. initial.stop(true)
  63. replacement.stop(true)
  64. }
  65. })
  66. test("re-resolves a managed target when the endpoint moves before transport construction", async () => {
  67. const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" })
  68. const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" })
  69. const controller = new AbortController()
  70. const seen: (typeof initial)[] = []
  71. let reconnects = 0
  72. const result = await resolveMiniTarget({
  73. sdk: initial,
  74. reconnect: async (signal) => {
  75. expect(signal).toBe(controller.signal)
  76. reconnects++
  77. if (reconnects === 1) throw new Error("service still moving")
  78. return replacement
  79. },
  80. signal: controller.signal,
  81. resolve: async (sdk) => {
  82. seen.push(sdk)
  83. if (sdk === initial) throw new ClientError("Transport")
  84. return "ses-replacement"
  85. },
  86. })
  87. expect(seen).toEqual([initial, replacement])
  88. expect(reconnects).toBe(2)
  89. expect(result).toEqual({ sdk: replacement, value: "ses-replacement" })
  90. })
  91. test("merges non-interactive argument and stdin input", () => {
  92. expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
  93. expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
  94. })
  95. test("parses model variants from the model reference", () => {
  96. expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
  97. JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
  98. )
  99. expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
  100. providerID: "openrouter",
  101. id: "openai/gpt-5",
  102. variant: "high",
  103. })
  104. })
  105. test("is registered in the preview CLI", async () => {
  106. const result = await cli(["--help"])
  107. expect(result.exitCode).toBe(0)
  108. expect(result.stdout).toContain("mini Start the minimal interactive interface")
  109. expect(result.stdout).toContain("run Run OpenCode with a message")
  110. })
  111. test("exposes run without legacy interactive, attach, or command modes", async () => {
  112. const result = await cli(["run", "--help"])
  113. expect(result.exitCode).toBe(0)
  114. expect(result.stdout).toContain("--server string")
  115. expect(result.stdout).not.toContain("--interactive")
  116. expect(result.stdout).not.toContain("--variant")
  117. expect(result.stdout).not.toContain("--attach")
  118. expect(result.stdout).not.toContain("--command")
  119. })
  120. test("keeps option-like prompt text after the argument separator", async () => {
  121. const result = await cli(["run", "--server", "http://127.0.0.1:1", "--", "--foo"])
  122. expect(result.exitCode).toBe(1)
  123. expect(result.stderr).not.toContain("You must provide a message")
  124. })
  125. test("passes explicit selections to session creation without catalog preflight", async () => {
  126. const requests: string[] = []
  127. let session: unknown
  128. const server = Bun.serve({
  129. port: 0,
  130. async fetch(request) {
  131. const url = new URL(request.url)
  132. requests.push(url.pathname)
  133. if (url.pathname === "/api/health")
  134. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  135. if (url.pathname === "/api/location")
  136. return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
  137. if (url.pathname === "/api/session") {
  138. session = await request.json()
  139. return new Response("boom", { status: 500 })
  140. }
  141. return new Response(undefined, { status: 404 })
  142. },
  143. })
  144. try {
  145. const result = await cli([
  146. "run",
  147. "--server",
  148. server.url.toString(),
  149. "--model",
  150. "definitely/missing",
  151. "--agent",
  152. "definitely-missing",
  153. "hi",
  154. ])
  155. expect(result.exitCode).toBe(1)
  156. expect(result.stderr).toContain("UnexpectedStatus")
  157. expect(session).toMatchObject({
  158. agent: "definitely-missing",
  159. model: { providerID: "definitely", id: "missing" },
  160. })
  161. expect(requests).not.toContain("/api/model")
  162. expect(requests).not.toContain("/api/agent")
  163. expect(requests).not.toContain("/api/location/wait")
  164. } finally {
  165. server.stop(true)
  166. }
  167. })
  168. test("reports pre-admission errors as JSON", async () => {
  169. const server = Bun.serve({
  170. port: 0,
  171. fetch(request) {
  172. if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
  173. return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
  174. },
  175. })
  176. try {
  177. const result = await cli(["run", "--format", "json", "--server", server.url.toString(), "hi"])
  178. expect(result.exitCode).toBe(1)
  179. expect(JSON.parse(result.stdout)).toMatchObject({
  180. type: "error",
  181. sessionID: "",
  182. error: { type: "unknown", message: "UnexpectedStatus" },
  183. })
  184. } finally {
  185. server.stop(true)
  186. }
  187. })
  188. test("uses the shared V2 server option instead of an attach command", async () => {
  189. const result = await cli(["mini", "--help"])
  190. expect(result.exitCode).toBe(0)
  191. expect(result.stdout).toContain("--server string")
  192. expect(result.stdout).toContain("--prompt string")
  193. expect(result.stdout).toContain("--replay")
  194. expect(result.stdout).toContain("disable with --no-replay")
  195. expect(result.stdout).toContain("--replay-limit integer")
  196. expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)")
  197. expect(result.stdout).not.toContain("SUBCOMMANDS")
  198. })
  199. test("routes local and explicit-server invocations into mini", async () => {
  200. for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) {
  201. const result = await cli(args)
  202. expect(result.exitCode).toBe(1)
  203. expect(result.stderr).toContain("opencode mini requires a TTY stdout")
  204. }
  205. })
  206. })