mini-interactive.drive.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import { Effect } from "effect"
  2. import { defineScript, Llm } from "opencode-drive"
  3. import { mkdir } from "node:fs/promises"
  4. import path from "node:path"
  5. export default defineScript({
  6. launch: "manual",
  7. config: { autoupdate: false },
  8. run: ({ artifacts, llm, server }) =>
  9. Effect.gen(function* () {
  10. yield* Effect.promise(() => configureServicePort(artifacts))
  11. yield* server.launch()
  12. const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
  13. const root = path.resolve(import.meta.dir, "../../../..")
  14. const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
  15. const session = `mini-stage2-${process.pid}`
  16. const snapshots = path.join(artifacts, "mini-stage2")
  17. const explicitDirectory = path.join(artifacts, "explicit-model")
  18. yield* Effect.promise(() =>
  19. Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))),
  20. )
  21. /** @param {string} directory @param {string | undefined} model */
  22. const mini = (directory, model) => [
  23. "env",
  24. `PWD=${directory}`,
  25. `OPENCODE_PASSWORD=${registration.password}`,
  26. `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
  27. `OPENCODE_TEST_HOME=${artifacts}`,
  28. `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
  29. `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
  30. `XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
  31. `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
  32. "OPENCODE_DISABLE_AUTOUPDATE=1",
  33. "OPENCODE_DIRECT_TRACE=1",
  34. process.execPath,
  35. "--conditions=browser",
  36. `--preload=${preload}`,
  37. path.join(root, "packages/cli/src/index.ts"),
  38. "mini",
  39. "--server",
  40. registration.url,
  41. ...(model ? ["--model", model] : []),
  42. ]
  43. yield* llm.queue(
  44. Llm.toolCall({
  45. index: 0,
  46. id: "mini-shell",
  47. name: "shell",
  48. input: { command: "printf 'drive-mini-tool-output\\n'" },
  49. }),
  50. Llm.finish("tool-calls"),
  51. )
  52. yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
  53. const journey = Effect.gen(function* () {
  54. yield* Effect.uninterruptible(
  55. Effect.promise(() =>
  56. tmux([
  57. "new-session",
  58. "-d",
  59. "-s",
  60. session,
  61. "-x",
  62. "140",
  63. "-y",
  64. "30",
  65. "--",
  66. ...mini(path.join(artifacts, "files"), undefined),
  67. ]),
  68. ),
  69. )
  70. yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
  71. const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
  72. yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
  73. if (first.includes("drive mini response complete"))
  74. throw new Error("response rendered before prompt submission")
  75. yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
  76. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
  77. yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
  78. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
  79. yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
  80. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  81. yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
  82. yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
  83. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
  84. yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
  85. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
  86. yield* Effect.sleep(100)
  87. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  88. const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
  89. if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
  90. yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
  91. yield* Effect.sleep(500)
  92. const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
  93. yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
  94. yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
  95. yield* Effect.promise(() =>
  96. waitForFile(
  97. resizeOutput,
  98. (value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
  99. ),
  100. )
  101. yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
  102. const resized = yield* Effect.promise(() => captureVisiblePane(session))
  103. if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
  104. yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
  105. yield* llm.queue(
  106. Llm.toolCall({
  107. index: 0,
  108. id: "mini-question",
  109. name: "question",
  110. input: {
  111. questions: [
  112. {
  113. header: "Drive form",
  114. question: "Choose the Mini Form answer",
  115. options: [{ label: "Accepted", description: "Continue the run" }],
  116. multiple: false,
  117. },
  118. ],
  119. },
  120. }),
  121. Llm.finish("tool-calls"),
  122. )
  123. yield* llm.queue(Llm.text("drive mini form complete"))
  124. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
  125. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  126. yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
  127. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  128. yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
  129. yield* llm.queue(
  130. Llm.toolCall({
  131. index: 0,
  132. id: "mini-slow-shell",
  133. name: "shell",
  134. input: { command: "sleep 10" },
  135. }),
  136. Llm.finish("tool-calls"),
  137. )
  138. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
  139. yield* Effect.sleep(100)
  140. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  141. yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
  142. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
  143. const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
  144. yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
  145. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
  146. const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
  147. yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
  148. yield* Effect.promise(async () => {
  149. if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
  150. })
  151. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
  152. yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
  153. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
  154. yield* Effect.promise(() => waitForDeadPane(session))
  155. const status = yield* Effect.promise(() => paneDeadStatus(session))
  156. if (status !== 0) throw new Error(`Mini exited with status ${status}`)
  157. const exited = yield* Effect.promise(() => capturePane(session))
  158. if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
  159. throw new Error("Mini exit splash was not rendered before teardown")
  160. yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
  161. yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
  162. yield* Effect.promise(() =>
  163. tmux(["respawn-pane", "-k", "-t", session, "--", ...mini(explicitDirectory, "simulation/gpt-sim-model")]),
  164. )
  165. const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
  166. yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
  167. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
  168. yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
  169. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
  170. yield* Effect.promise(() => waitForDeadPane(session))
  171. if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
  172. throw new Error("Explicit-model Mini did not exit cleanly")
  173. yield* Effect.promise(async () => {
  174. for (const failure of [
  175. {
  176. args: ["--model", "simulation/definitely-missing"],
  177. capture: "08-unavailable-model.txt",
  178. expected: "Model unavailable: simulation/definitely-missing",
  179. },
  180. {
  181. args: ["--agent", "definitely-missing"],
  182. capture: "09-unavailable-agent.txt",
  183. expected: 'Agent not found: "definitely-missing"',
  184. },
  185. ]) {
  186. const child = Bun.spawn(
  187. [
  188. process.execPath,
  189. path.join(root, "packages/cli/src/index.ts"),
  190. "run",
  191. "--server",
  192. registration.url,
  193. ...failure.args,
  194. "optimistic selection check",
  195. ],
  196. {
  197. cwd: path.join(root, "packages/cli"),
  198. env: {
  199. ...process.env,
  200. PWD: path.join(artifacts, "files"),
  201. OPENCODE_PASSWORD: registration.password,
  202. OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
  203. OPENCODE_DISABLE_AUTOUPDATE: "1",
  204. },
  205. stdin: "ignore",
  206. stdout: "pipe",
  207. stderr: "pipe",
  208. },
  209. )
  210. const [exitCode, stdout, stderr] = await Promise.all([
  211. child.exited,
  212. new Response(child.stdout).text(),
  213. new Response(child.stderr).text(),
  214. ])
  215. await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
  216. if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
  217. if (!stderr.includes(failure.expected))
  218. throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
  219. }
  220. })
  221. })
  222. yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
  223. }),
  224. })
  225. /** @param {string[]} args */
  226. async function tmux(args, allowFailure = false) {
  227. const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
  228. let timedOut = false
  229. const timeout = setTimeout(() => {
  230. timedOut = true
  231. child.kill("SIGKILL")
  232. }, 5_000)
  233. const [status, stdout, stderr] = await Promise.all([
  234. child.exited,
  235. new Response(child.stdout).text(),
  236. new Response(child.stderr).text(),
  237. ])
  238. clearTimeout(timeout)
  239. if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
  240. if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
  241. return stdout
  242. }
  243. /** @param {string} session */
  244. function capturePane(session) {
  245. return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
  246. }
  247. /** @param {string} session */
  248. function captureVisiblePane(session) {
  249. return tmux(["capture-pane", "-p", "-t", session])
  250. }
  251. /** @param {string} session @param {string} text @param {number} [timeout] */
  252. async function waitForVisiblePane(session, text, timeout = 5_000) {
  253. const deadline = Date.now() + timeout
  254. let last = ""
  255. while (Date.now() < deadline) {
  256. last = await captureVisiblePane(session)
  257. if (last.includes(text)) return last
  258. if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
  259. await Bun.sleep(50)
  260. }
  261. throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
  262. }
  263. /** @param {string} session */
  264. async function paneAlive(session) {
  265. return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
  266. }
  267. /** @param {string} session */
  268. async function paneDeadStatus(session) {
  269. return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
  270. }
  271. /**
  272. * @param {string} session
  273. * @param {string} text
  274. * @param {number} [timeout]
  275. * @param {(() => Promise<void>) | undefined} [trigger]
  276. */
  277. async function waitForPane(session, text, timeout = 5_000, trigger) {
  278. const deadline = Date.now() + timeout
  279. let last = ""
  280. while (Date.now() < deadline) {
  281. await trigger?.()
  282. last = await capturePane(session)
  283. if (last.includes(text)) return last
  284. if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
  285. await Bun.sleep(50)
  286. }
  287. throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
  288. }
  289. /** @param {string} session */
  290. async function waitForDeadPane(session) {
  291. for (let attempt = 0; attempt < 100; attempt++) {
  292. if (!(await paneAlive(session))) return
  293. await Bun.sleep(50)
  294. }
  295. throw new Error("Mini did not tear down after the exit sequence")
  296. }
  297. /**
  298. * @param {string} file
  299. * @param {(value: string) => boolean} accept
  300. */
  301. async function waitForFile(file, accept) {
  302. let value = ""
  303. for (let attempt = 0; attempt < 100; attempt++) {
  304. value = await Bun.file(file)
  305. .text()
  306. .catch(() => "")
  307. if (accept(value)) return value
  308. await Bun.sleep(50)
  309. }
  310. throw new Error("resize did not replay committed transcript output")
  311. }
  312. /** @param {string} artifacts */
  313. async function configureServicePort(artifacts) {
  314. const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
  315. const port = probe.port
  316. await probe.stop(true)
  317. if (!port) throw new Error("Failed to allocate a Drive service port")
  318. const file = path.join(artifacts, "files/.opencode/service-local.json")
  319. await mkdir(path.dirname(file), { recursive: true })
  320. await Bun.write(file, JSON.stringify({ port }))
  321. }
  322. /** @param {string} artifacts */
  323. async function serviceRegistration(artifacts) {
  324. const directory = path.join(artifacts, "home/.local/state/opencode")
  325. for (let attempt = 0; attempt < 200; attempt++) {
  326. for (const name of ["service-local.json", "service.json"]) {
  327. const value = await Bun.file(path.join(directory, name))
  328. .json()
  329. .catch(() => undefined)
  330. if (isRegistration(value)) return value
  331. }
  332. await Bun.sleep(50)
  333. }
  334. throw new Error("Drive service registration was not written")
  335. }
  336. /** @param {unknown} value */
  337. function isRegistration(value) {
  338. return (
  339. typeof value === "object" &&
  340. value !== null &&
  341. "url" in value &&
  342. typeof value.url === "string" &&
  343. "password" in value &&
  344. typeof value.password === "string"
  345. )
  346. }