mini-interactive.drive.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  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. yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
  18. yield* llm.queue(
  19. Llm.toolCall({
  20. index: 0,
  21. id: "mini-shell",
  22. name: "shell",
  23. input: { command: "printf 'drive-mini-tool-output\\n'" },
  24. }),
  25. Llm.finish("tool-calls"),
  26. )
  27. yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
  28. const journey = Effect.gen(function* () {
  29. yield* Effect.uninterruptible(
  30. Effect.promise(() =>
  31. tmux([
  32. "new-session",
  33. "-d",
  34. "-s",
  35. session,
  36. "-x",
  37. "140",
  38. "-y",
  39. "30",
  40. "--",
  41. "env",
  42. `PWD=${path.join(artifacts, "files")}`,
  43. `OPENCODE_PASSWORD=${registration.password}`,
  44. `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
  45. `OPENCODE_TEST_HOME=${artifacts}`,
  46. `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
  47. `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
  48. `XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
  49. `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
  50. "OPENCODE_DISABLE_AUTOUPDATE=1",
  51. "OPENCODE_DIRECT_TRACE=1",
  52. process.execPath,
  53. "--conditions=browser",
  54. `--preload=${preload}`,
  55. path.join(root, "packages/cli/src/index.ts"),
  56. "mini",
  57. "--server",
  58. registration.url,
  59. "--model",
  60. "simulation/gpt-sim-model",
  61. ]),
  62. ),
  63. )
  64. yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
  65. const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
  66. yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
  67. if (first.includes("drive mini response complete"))
  68. throw new Error("response rendered before prompt submission")
  69. yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
  70. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
  71. yield* Effect.sleep(100)
  72. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  73. const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
  74. if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
  75. yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
  76. yield* Effect.sleep(500)
  77. const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
  78. yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
  79. yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
  80. yield* Effect.promise(() =>
  81. waitForFile(
  82. resizeOutput,
  83. (value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
  84. ),
  85. )
  86. yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
  87. const resized = yield* Effect.promise(() => captureVisiblePane(session))
  88. if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
  89. yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
  90. yield* llm.queue(
  91. Llm.toolCall({
  92. index: 0,
  93. id: "mini-question",
  94. name: "question",
  95. input: {
  96. questions: [
  97. {
  98. header: "Drive form",
  99. question: "Choose the Mini Form answer",
  100. options: [{ label: "Accepted", description: "Continue the run" }],
  101. multiple: false,
  102. },
  103. ],
  104. },
  105. }),
  106. Llm.finish("tool-calls"),
  107. )
  108. yield* llm.queue(Llm.text("drive mini form complete"))
  109. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
  110. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  111. yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
  112. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  113. yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
  114. yield* llm.queue(
  115. Llm.toolCall({
  116. index: 0,
  117. id: "mini-slow-shell",
  118. name: "shell",
  119. input: { command: "sleep 10" },
  120. }),
  121. Llm.finish("tool-calls"),
  122. )
  123. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
  124. yield* Effect.sleep(100)
  125. yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
  126. yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
  127. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
  128. const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
  129. yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
  130. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
  131. const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
  132. yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
  133. yield* Effect.promise(async () => {
  134. if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
  135. })
  136. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
  137. yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
  138. yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
  139. yield* Effect.promise(() => waitForDeadPane(session))
  140. const status = yield* Effect.promise(() => paneDeadStatus(session))
  141. if (status !== 0) throw new Error(`Mini exited with status ${status}`)
  142. const exited = yield* Effect.promise(() => capturePane(session))
  143. if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
  144. throw new Error("Mini exit splash was not rendered before teardown")
  145. yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
  146. })
  147. yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
  148. }),
  149. })
  150. /** @param {string[]} args */
  151. async function tmux(args, allowFailure = false) {
  152. const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
  153. let timedOut = false
  154. const timeout = setTimeout(() => {
  155. timedOut = true
  156. child.kill("SIGKILL")
  157. }, 5_000)
  158. const [status, stdout, stderr] = await Promise.all([
  159. child.exited,
  160. new Response(child.stdout).text(),
  161. new Response(child.stderr).text(),
  162. ])
  163. clearTimeout(timeout)
  164. if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
  165. if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
  166. return stdout
  167. }
  168. /** @param {string} session */
  169. function capturePane(session) {
  170. return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
  171. }
  172. /** @param {string} session */
  173. function captureVisiblePane(session) {
  174. return tmux(["capture-pane", "-p", "-t", session])
  175. }
  176. /** @param {string} session */
  177. async function paneAlive(session) {
  178. return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
  179. }
  180. /** @param {string} session */
  181. async function paneDeadStatus(session) {
  182. return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
  183. }
  184. /**
  185. * @param {string} session
  186. * @param {string} text
  187. * @param {number} [timeout]
  188. * @param {(() => Promise<void>) | undefined} [trigger]
  189. */
  190. async function waitForPane(session, text, timeout = 5_000, trigger) {
  191. const deadline = Date.now() + timeout
  192. let last = ""
  193. while (Date.now() < deadline) {
  194. await trigger?.()
  195. last = await capturePane(session)
  196. if (last.includes(text)) return last
  197. if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
  198. await Bun.sleep(50)
  199. }
  200. throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
  201. }
  202. /** @param {string} session */
  203. async function waitForDeadPane(session) {
  204. for (let attempt = 0; attempt < 100; attempt++) {
  205. if (!(await paneAlive(session))) return
  206. await Bun.sleep(50)
  207. }
  208. throw new Error("Mini did not tear down after the exit sequence")
  209. }
  210. /**
  211. * @param {string} file
  212. * @param {(value: string) => boolean} accept
  213. */
  214. async function waitForFile(file, accept) {
  215. let value = ""
  216. for (let attempt = 0; attempt < 100; attempt++) {
  217. value = await Bun.file(file)
  218. .text()
  219. .catch(() => "")
  220. if (accept(value)) return value
  221. await Bun.sleep(50)
  222. }
  223. throw new Error("resize did not replay committed transcript output")
  224. }
  225. /** @param {string} artifacts */
  226. async function configureServicePort(artifacts) {
  227. const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
  228. const port = probe.port
  229. await probe.stop(true)
  230. if (!port) throw new Error("Failed to allocate a Drive service port")
  231. const file = path.join(artifacts, "files/.opencode/service-local.json")
  232. await mkdir(path.dirname(file), { recursive: true })
  233. await Bun.write(file, JSON.stringify({ port }))
  234. }
  235. /** @param {string} artifacts */
  236. async function serviceRegistration(artifacts) {
  237. const directory = path.join(artifacts, "home/.local/state/opencode")
  238. for (let attempt = 0; attempt < 200; attempt++) {
  239. for (const name of ["service-local.json", "service.json"]) {
  240. const value = await Bun.file(path.join(directory, name))
  241. .json()
  242. .catch(() => undefined)
  243. if (isRegistration(value)) return value
  244. }
  245. await Bun.sleep(50)
  246. }
  247. throw new Error("Drive service registration was not written")
  248. }
  249. /** @param {unknown} value */
  250. function isRegistration(value) {
  251. return (
  252. typeof value === "object" &&
  253. value !== null &&
  254. "url" in value &&
  255. typeof value.url === "string" &&
  256. "password" in value &&
  257. typeof value.password === "string"
  258. )
  259. }