run-process.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. // Subprocess integration tests for `opencode run` (non-interactive mode).
  2. // These exercise the real CLI binary against a TestLLMServer running in the
  3. // same process. See `test/lib/cli-process.ts` for the harness — each test uses
  4. // `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
  5. // `KIRINCODE_CONFIG_CONTENT` providing the test provider config inline.
  6. import { describe, expect } from "bun:test"
  7. import { Effect } from "effect"
  8. import { reply } from "../../lib/llm-server"
  9. import { cliIt } from "../../lib/cli-process"
  10. describe("kirincode run (non-interactive subprocess)", () => {
  11. // Happy path: prompt completes, output reaches stdout, process exits 0.
  12. // If this fails, all the others likely will too — debug here first.
  13. cliIt.concurrent(
  14. "exits 0 and writes the response to stdout on a successful prompt",
  15. ({ llm, kirincode }) =>
  16. Effect.gen(function* () {
  17. yield* llm.text("hello from the test llm")
  18. const result = yield* kirincode.run("say hi")
  19. opencode.expectExit(result, 0)
  20. expect(result.stdout).toBe("hello from the test llm\n")
  21. }),
  22. 60_000,
  23. )
  24. cliIt.concurrent(
  25. "prints each completed text part in order around a tool continuation",
  26. ({ llm, kirincode }) =>
  27. Effect.gen(function* () {
  28. yield* llm.push(
  29. reply().text(" before tool ").tool("bash", {
  30. command: "printf tool-output",
  31. description: "Print deterministic output",
  32. }),
  33. )
  34. yield* llm.text(" after tool ")
  35. const result = yield* kirincode.run("use a tool", {
  36. extraArgs: ["--dangerously-skip-permissions"],
  37. })
  38. opencode.expectExit(result, 0)
  39. expect(result.stdout).toBe("before tool\nafter tool\n")
  40. }),
  41. 60_000,
  42. )
  43. cliIt.concurrent(
  44. "prints reasoning before text only with --thinking",
  45. ({ llm, kirincode }) =>
  46. Effect.gen(function* () {
  47. yield* llm.reason(" considering ", { text: " answer " })
  48. const thinking = yield* kirincode.run("think", { extraArgs: ["--thinking"] })
  49. opencode.expectExit(thinking, 0)
  50. expect(thinking.stdout).toBe("Thinking: considering\nanswer\n")
  51. yield* llm.reason("hidden", { text: "visible" })
  52. const plain = yield* kirincode.run("think again")
  53. opencode.expectExit(plain, 0)
  54. expect(plain.stdout).toBe("visible\n")
  55. }),
  56. 60_000,
  57. )
  58. // Regression for #27371: an unknown model used to hang the process forever
  59. // waiting on a session.status === idle event that never arrived. The fix
  60. // makes the SDK call surface an error promptly so the process exits nonzero.
  61. // We assert nonzero exit AND wall-clock under the harness timeout — a hang
  62. // would expire the timeout and produce a different (signal-killed) failure.
  63. cliIt.concurrent(
  64. "exits nonzero promptly when the model is unknown (regression for #27371)",
  65. ({ kirincode }) =>
  66. Effect.gen(function* () {
  67. const result = yield* kirincode.run("say hi", {
  68. model: "test/nonexistent-model",
  69. timeoutMs: 15_000,
  70. })
  71. expect(result.exitCode).not.toBe(0)
  72. expect(result.durationMs).toBeLessThan(15_000)
  73. }),
  74. 30_000,
  75. )
  76. // The test provider's SSE error item is interpreted by the SDK as an unknown
  77. // finish, not a fatal provider/session error. Lock that distinction in so it
  78. // is not accidentally used as the failure compatibility oracle.
  79. cliIt.concurrent(
  80. "unknown stream finish preserves partial output and exits 0",
  81. ({ llm, kirincode }) =>
  82. Effect.gen(function* () {
  83. yield* llm.push(
  84. reply().text("partial response").tool("bash", {
  85. command: "printf tool",
  86. description: "Print deterministic output",
  87. }),
  88. )
  89. yield* llm.fail("upstream provider exploded mid-stream")
  90. const result = yield* kirincode.run("trigger midstream error", { timeoutMs: 30_000 })
  91. expect(result.exitCode).toBe(0)
  92. expect(result.stdout).toBe("partial response\n")
  93. expect(result.stderr).not.toContain("upstream provider exploded mid-stream")
  94. }),
  95. 60_000,
  96. )
  97. // --format json puts one JSON object per line on stdout for each emitted
  98. // event. Consumers (CI scripts, tooling) parse this stream. Asserts the
  99. // shape so a future event-emit change has to update this expectation.
  100. cliIt.concurrent(
  101. "--format json emits parseable line-delimited JSON to stdout",
  102. ({ llm, kirincode }) =>
  103. Effect.gen(function* () {
  104. yield* llm.text("structured output")
  105. const result = yield* kirincode.run("say hi", { format: "json" })
  106. opencode.expectExit(result, 0)
  107. const events = opencode.parseJsonEvents(result.stdout)
  108. expect(events.length).toBeGreaterThan(0)
  109. for (const evt of events) {
  110. expect(typeof evt.type).toBe("string")
  111. expect(typeof evt.sessionID).toBe("string")
  112. }
  113. expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"])
  114. expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([
  115. { type: "step_start", part: expect.objectContaining({ type: "step-start" }) },
  116. {
  117. type: "text",
  118. part: expect.objectContaining({ type: "text", text: "structured output" }),
  119. },
  120. { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) },
  121. ])
  122. expect(result.stdout.endsWith("\n")).toBe(true)
  123. expect(
  124. result.stdout
  125. .split("\n")
  126. .slice(0, -1)
  127. .every((line) => line.length > 0),
  128. ).toBe(true)
  129. }),
  130. 60_000,
  131. )
  132. cliIt.concurrent(
  133. "--format json emits a pure error record for a rejected prompt request",
  134. ({ kirincode }) =>
  135. Effect.gen(function* () {
  136. const result = yield* kirincode.run("use an unknown model", {
  137. model: "test/nonexistent-model",
  138. format: "json",
  139. })
  140. expect(result.exitCode).not.toBe(0)
  141. const events = opencode.parseJsonEvents(result.stdout)
  142. expect(events.map((event) => event.type)).toEqual(["error"])
  143. expect(events[0]).toEqual({
  144. type: "error",
  145. timestamp: expect.any(Number),
  146. sessionID: expect.any(String),
  147. error: expect.any(Object),
  148. })
  149. expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1)
  150. }),
  151. 30_000,
  152. )
  153. cliIt.concurrent(
  154. "--format json preserves reasoning, tool, and continuation ordering",
  155. ({ llm, kirincode }) =>
  156. Effect.gen(function* () {
  157. yield* llm.push(
  158. reply().reason("reasoning").text("before").tool("bash", {
  159. command: "printf tool",
  160. description: "Print deterministic output",
  161. }),
  162. )
  163. yield* llm.text("after")
  164. const result = yield* kirincode.run("exercise json records", {
  165. format: "json",
  166. extraArgs: ["--thinking", "--dangerously-skip-permissions"],
  167. })
  168. expect(result.exitCode).toBe(0)
  169. const events = opencode.parseJsonEvents(result.stdout)
  170. expect(events.map((event) => event.type)).toEqual([
  171. "step_start",
  172. "reasoning",
  173. "text",
  174. "tool_use",
  175. "step_finish",
  176. "step_start",
  177. "text",
  178. "step_finish",
  179. ])
  180. expect(events.find((event) => event.type === "reasoning")?.part).toEqual(
  181. expect.objectContaining({ type: "reasoning", text: "reasoning" }),
  182. )
  183. expect(events.find((event) => event.type === "tool_use")?.part).toEqual(
  184. expect.objectContaining({
  185. type: "tool",
  186. tool: "bash",
  187. state: expect.objectContaining({ status: "completed" }),
  188. }),
  189. )
  190. expect(
  191. result.stdout
  192. .split("\n")
  193. .slice(0, -1)
  194. .every((line) => line.startsWith("{")),
  195. ).toBe(true)
  196. }),
  197. 60_000,
  198. )
  199. cliIt.concurrent(
  200. "--format json records partial output for an unknown stream finish",
  201. ({ llm, kirincode }) =>
  202. Effect.gen(function* () {
  203. yield* llm.push(
  204. reply().text("partial json").tool("bash", {
  205. command: "printf tool",
  206. description: "Print deterministic output",
  207. }),
  208. )
  209. yield* llm.fail("provider failed")
  210. const result = yield* kirincode.run("fail after output", { format: "json" })
  211. const events = opencode.parseJsonEvents(result.stdout)
  212. expect(result.exitCode).toBe(0)
  213. expect(events.map((event) => event.type)).toEqual([
  214. "step_start",
  215. "text",
  216. "tool_use",
  217. "step_finish",
  218. "step_start",
  219. "step_finish",
  220. ])
  221. expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
  222. expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
  223. }),
  224. 60_000,
  225. )
  226. cliIt.concurrent(
  227. "rejects requested permissions by default and allows them with the dangerous flag",
  228. ({ home, llm, kirincode }) =>
  229. Effect.gen(function* () {
  230. yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" })
  231. yield* llm.text("continued after rejection")
  232. const denied = yield* kirincode.run("request permission", { permission: { bash: "ask" } })
  233. opencode.expectExit(denied, 0)
  234. expect(denied.stderr).toContain("permission requested: bash")
  235. expect(denied.stdout).toBe("")
  236. yield* llm.reset
  237. yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" })
  238. yield* llm.text("continued after approval")
  239. const allowed = yield* kirincode.run("request permission", {
  240. permission: { bash: "ask" },
  241. extraArgs: ["--dangerously-skip-permissions"],
  242. })
  243. opencode.expectExit(allowed, 0)
  244. expect(allowed.stderr).not.toContain("permission requested: bash")
  245. expect(allowed.stdout).toContain("continued after approval")
  246. yield* llm.reset
  247. yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" })
  248. yield* llm.text("continued after explicit denial")
  249. const explicitlyDenied = yield* kirincode.run("request denied permission", {
  250. permission: { bash: "deny" },
  251. extraArgs: ["--dangerously-skip-permissions"],
  252. })
  253. opencode.expectExit(explicitlyDenied, 0)
  254. expect(explicitlyDenied.stdout).toContain("continued after explicit denial")
  255. expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false)
  256. }),
  257. 60_000,
  258. )
  259. cliIt.live(
  260. "attach mode sends client-local file contents without a shared path",
  261. ({ home, llm, kirincode }) =>
  262. Effect.gen(function* () {
  263. const source = `${home}/client-only.txt`
  264. const sentinel = "client-only attachment sentinel"
  265. yield* Effect.promise(() => Bun.write(source, sentinel))
  266. yield* llm.text("attachment received")
  267. const server = yield* kirincode.serve()
  268. const result = yield* kirincode.run("read the attachment", {
  269. extraArgs: ["--attach", server.url, `--file=${source}`, "--"],
  270. })
  271. opencode.expectExit(result, 0)
  272. const input = JSON.stringify(yield* llm.inputs)
  273. expect(input).toContain(sentinel)
  274. expect(input).not.toContain(`file://${source}`)
  275. }),
  276. 60_000,
  277. )
  278. cliIt.concurrent(
  279. "attach mode rejects local directories before prompt admission",
  280. ({ home, kirincode }) =>
  281. Effect.gen(function* () {
  282. const result = yield* kirincode.run("read the directory", {
  283. extraArgs: ["--attach", "http://127.0.0.1:1", `--file=${home}`, "--"],
  284. })
  285. expect(result.exitCode).not.toBe(0)
  286. expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem")
  287. }),
  288. 30_000,
  289. )
  290. cliIt.live(
  291. "SIGINT interrupts an active non-interactive run without leaking the process",
  292. ({ llm, kirincode }) =>
  293. Effect.gen(function* () {
  294. yield* llm.hang
  295. const run = yield* kirincode.startRun("wait forever")
  296. yield* llm.wait(1)
  297. run.interrupt()
  298. const result = yield* run.result
  299. expect(result.exitCode).not.toBe(0)
  300. expect(result.durationMs).toBeLessThan(30_000)
  301. }),
  302. 30_000,
  303. )
  304. })