mini-interactive.drive.mjs 16 KB

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