opencode-drive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +90 -0
  2. package/bin/opencode-drive +2 -0
  3. package/package.json +45 -0
  4. package/src/cli/commands.ts +175 -0
  5. package/src/cli/describe.ts +14 -0
  6. package/src/cli/driver-runner.ts +39 -0
  7. package/src/cli/driver.ts +28 -0
  8. package/src/cli/index.ts +158 -0
  9. package/src/cli/instance.ts +218 -0
  10. package/src/cli/parse.ts +24 -0
  11. package/src/cli/registry.ts +120 -0
  12. package/src/cli/send.ts +48 -0
  13. package/src/cli/start.ts +56 -0
  14. package/src/cli/types.ts +46 -0
  15. package/src/client/backend.ts +184 -0
  16. package/src/client/client.ts +252 -0
  17. package/src/client/index.ts +17 -0
  18. package/src/client/protocol.ts +186 -0
  19. package/src/experimental/campaign-api.ts +34 -0
  20. package/src/experimental/campaign.ts +144 -0
  21. package/src/experimental/cli-campaign.ts +179 -0
  22. package/src/experimental/drive.ts +37 -0
  23. package/src/experimental/driver.ts +41 -0
  24. package/src/experimental/flow-driver.ts +189 -0
  25. package/src/experimental/flows/generate.ts +278 -0
  26. package/src/experimental/flows/index.ts +6 -0
  27. package/src/experimental/flows/properties.ts +51 -0
  28. package/src/experimental/flows/types.ts +47 -0
  29. package/src/experimental/flows/weights.ts +198 -0
  30. package/src/experimental/generators/config.ts +240 -0
  31. package/src/experimental/generators/filesystem.ts +95 -0
  32. package/src/experimental/generators/generate.ts +32 -0
  33. package/src/experimental/generators/index.ts +10 -0
  34. package/src/experimental/generators/initial-state.ts +37 -0
  35. package/src/experimental/generators/random.ts +35 -0
  36. package/src/experimental/hello-driver.ts +42 -0
  37. package/src/experimental/index.ts +3 -0
  38. package/src/experimental/model/derive.ts +119 -0
  39. package/src/experimental/model/index.ts +2 -0
  40. package/src/experimental/model/model.ts +42 -0
  41. package/src/experimental/opencode-simulation.ts +1 -0
  42. package/src/experimental/reproduce-stale-running-visible.ts +60 -0
  43. package/src/experimental/reproduce-stale-running.ts +26 -0
  44. package/src/experimental/stale-running-driver.ts +74 -0
  45. package/src/index.ts +1 -0
@@ -0,0 +1,144 @@
1
+ import path from "node:path"
2
+ import { generateFlow, type FlowResult } from "./flows/index.js"
3
+ import { parseWeightsFromArgs, parseEnabledTools } from "./flows/weights.js"
4
+
5
+ const config = (seed: number) => {
6
+ const provider = seed % 2 === 0 ? "openai" : "simulation"
7
+ return {
8
+ model: `${provider}/sim-model`,
9
+ permissions: [{ action: "*", resource: "*", effect: seed % 3 === 0 ? "ask" : "allow" }],
10
+ skills: [".opencode/skills"],
11
+ providers: {
12
+ [provider]: {
13
+ name: "Simulation",
14
+ request: { body: { apiKey: "sim-key" } },
15
+ models: {
16
+ "sim-model": {
17
+ name: "Simulated Model",
18
+ api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.openai.com/v1" },
19
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
20
+ limit: { context: 128000, output: 16000 },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ }
26
+ }
27
+
28
+ const options = parseArgs(process.argv.slice(2))
29
+ const weights = parseWeightsFromArgs(process.argv.slice(2))
30
+ const enabledTools = parseEnabledTools(process.argv.slice(2)) ?? undefined
31
+ const root = "/tmp/opencode-drive-campaign"
32
+ await Bun.$`rm -rf ${root}`.quiet()
33
+ await Bun.$`mkdir -p ${root}`.quiet()
34
+ const results: FlowResult[] = []
35
+ const coverage = {
36
+ responseKinds: { text: 0, chunked: 0, reasoning: 0, markdown: 0, raw: 0, tool: 0 },
37
+ toolNames: {} as Record<string, number>,
38
+ interactions: { normal: 0, "double-submit": 0, steer: 0, interrupt: 0, "provider-drop": 0 },
39
+ streamChunkTypes: new Set<string>(),
40
+ }
41
+
42
+ for (let index = 0; index < options.count; index++) {
43
+ const seed = options.seed + index
44
+ const directory = path.join(root, `flow-${String(index + 1).padStart(2, "0")}-${seed}`)
45
+ const state = path.join(directory, "state", "files")
46
+ await Bun.$`mkdir -p ${path.join(state, ".config/opencode")} ${path.join(state, "src")}`.quiet()
47
+ await Bun.$`mkdir -p ${path.join(state, ".opencode/skills/simulation-demo")}`.quiet()
48
+ const scenario = generateFlow(seed, { turns: options.turns, weights, enabledTools })
49
+ coverage.responseKinds.text += scenario.coverage.responseKinds.text
50
+ coverage.responseKinds.chunked += scenario.coverage.responseKinds.chunked
51
+ coverage.responseKinds.reasoning += scenario.coverage.responseKinds.reasoning
52
+ coverage.responseKinds.markdown += scenario.coverage.responseKinds.markdown
53
+ coverage.responseKinds.raw += scenario.coverage.responseKinds.raw
54
+ coverage.responseKinds.tool += scenario.coverage.responseKinds.tool
55
+ for (const [name, count] of Object.entries(scenario.coverage.toolNames)) {
56
+ coverage.toolNames[name] = (coverage.toolNames[name] ?? 0) + count
57
+ }
58
+ coverage.interactions.normal += scenario.coverage.interactions.normal
59
+ coverage.interactions["double-submit"] += scenario.coverage.interactions["double-submit"]
60
+ coverage.interactions.steer += scenario.coverage.interactions.steer
61
+ coverage.interactions.interrupt += scenario.coverage.interactions.interrupt
62
+ coverage.interactions["provider-drop"] += scenario.coverage.interactions["provider-drop"]
63
+ for (const type of scenario.coverage.streamChunkTypes) coverage.streamChunkTypes.add(type)
64
+ await Promise.all([
65
+ Bun.write(path.join(directory, "scenario.json"), `${JSON.stringify(scenario, undefined, 2)}\n`),
66
+ Bun.write(path.join(state, "opencode.json"), `${JSON.stringify(config(seed), undefined, 2)}\n`),
67
+ Bun.write(path.join(state, ".config/opencode/opencode.json"), `${JSON.stringify(config(seed), undefined, 2)}\n`),
68
+ Bun.write(path.join(state, ".opencode/skills/simulation-demo/SKILL.md"), "---\nname: simulation-demo\ndescription: Simulation fixture skill\n---\nUse this skill to exercise the built-in skill loader.\n"),
69
+ Bun.write(path.join(state, "src/example.ts"), "export function greet(name: string) {\n return `hello ${name}`\n}\n"),
70
+ Bun.write(path.join(state, "src/server.ts"), "export function createServer() {\n return { close() {} }\n}\n"),
71
+ Bun.write(path.join(state, "src/cache.ts"), "export const invalidate = (key: string) => key\n"),
72
+ Bun.write(path.join(state, "src/config.ts"), "export const loadConfig = () => ({})\n"),
73
+ ])
74
+ const driverLog = path.join(directory, "driver.log")
75
+ const simulationLog = path.join(directory, "simulation.log")
76
+ console.log(`[${index + 1}/${options.count}] seed=${seed} turns=${scenario.turns.length} exchanges=${scenario.coverage.providerExchanges}`)
77
+ const visible = options.renderer === "visible"
78
+ const process = Bun.spawn([
79
+ path.resolve("bin/opencode-sim"),
80
+ "--state", path.join(directory, "state"),
81
+ "--anchor", path.join(directory, "anchor"),
82
+ "--renderer", options.renderer,
83
+ "--driver", `bun ${path.resolve("src/experimental/flow-driver.ts")} ${path.join(directory, "scenario.json")} ${path.join(directory, "result.json")}`,
84
+ "--",
85
+ "bun", "run", "--conditions=browser", "--preload=@opentui/solid/preload",
86
+ "/root/projects/opencode-latest/packages/cli/src/index.ts", "--standalone",
87
+ ], {
88
+ cwd: path.resolve("."),
89
+ env: {
90
+ ...processEnv(),
91
+ OPENCODE_SIMULATION_DRIVER_LOG: driverLog,
92
+ OPENCODE_SIMULATION_LOG: simulationLog,
93
+ OPENCODE_PROBE_STEP_DELAY: String(options.stepDelay),
94
+ OPENCODE_PROBE_CHUNK_DELAY: String(options.chunkDelay),
95
+ },
96
+ stdin: visible ? "inherit" : "ignore",
97
+ stdout: visible ? "inherit" : "pipe",
98
+ stderr: visible ? "inherit" : "pipe",
99
+ })
100
+ const [status, stdout, stderr] = await Promise.all([
101
+ process.exited,
102
+ process.stdout instanceof ReadableStream ? new Response(process.stdout).text() : "",
103
+ process.stderr instanceof ReadableStream ? new Response(process.stderr).text() : "",
104
+ ])
105
+ if (status !== 0) {
106
+ throw new Error(`flow ${index + 1} failed (seed ${seed})\n${stdout}\n${stderr}\n${await Bun.file(driverLog).text()}`)
107
+ }
108
+ const result: FlowResult = await Bun.file(path.join(directory, "result.json")).json()
109
+ results.push(result)
110
+ console.log(` passed in ${result.durationMs}ms; trace=${result.traceRecords} title=${result.titleExchanges}`)
111
+ }
112
+
113
+ const summary = {
114
+ count: results.length,
115
+ seed: options.seed,
116
+ turns: results.reduce((total, result) => total + result.turns, 0),
117
+ assistantExchanges: results.reduce((total, result) => total + result.assistantExchanges, 0),
118
+ subagentExchanges: results.reduce((total, result) => total + result.subagentExchanges, 0),
119
+ titleExchanges: results.reduce((total, result) => total + result.titleExchanges, 0),
120
+ traceRecords: results.reduce((total, result) => total + result.traceRecords, 0),
121
+ durationMs: results.reduce((total, result) => total + result.durationMs, 0),
122
+ coverage: { ...coverage, streamChunkTypes: [...coverage.streamChunkTypes] },
123
+ }
124
+ await Bun.write(path.join(root, "summary.json"), `${JSON.stringify(summary, undefined, 2)}\n`)
125
+ console.log(`Campaign passed: ${JSON.stringify(summary)}`)
126
+
127
+ function parseArgs(args: string[]) {
128
+ const value = (name: string, fallback: string) => {
129
+ const index = args.indexOf(name)
130
+ return index === -1 ? fallback : (args[index + 1] ?? fallback)
131
+ }
132
+ return {
133
+ count: Number(value("--count", "10")),
134
+ seed: Number(value("--seed", String(Date.now() % 1_000_000))),
135
+ turns: Number(value("--turns", "7")),
136
+ renderer: value("--renderer", "headless") === "visible" ? "visible" as const : "headless" as const,
137
+ stepDelay: Number(value("--step-delay", "0")),
138
+ chunkDelay: Number(value("--chunk-delay", "30")),
139
+ }
140
+ }
141
+
142
+ function processEnv(): Record<string, string> {
143
+ return Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined))
144
+ }
@@ -0,0 +1,179 @@
1
+ import { mkdir } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { basename, join, resolve } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import type { DefinedCampaign } from "./campaign-api.js"
6
+ import { connectDrive } from "./drive.js"
7
+ import { launchInstance } from "../cli/instance.js"
8
+ import type { StartOptions } from "../cli/types.js"
9
+
10
+ interface CaseResult {
11
+ readonly index: number
12
+ readonly seed: number
13
+ readonly status: "passed" | "failed"
14
+ readonly durationMs: number
15
+ readonly artifacts: string
16
+ readonly error?: string
17
+ readonly result?: unknown
18
+ }
19
+
20
+ export async function runCampaign(options: StartOptions) {
21
+ const file = resolve(options.campaign!)
22
+ const campaign = await loadCampaign(file)
23
+ const root = resolve(process.env.DRIVE_CAMPAIGN_ROOT ?? join(tmpdir(), `opencode-drive-campaign-${Date.now()}`))
24
+ const indexes = options.caseIndex === undefined
25
+ ? Array.from({ length: options.count ?? campaign.count ?? 1 }, (_, index) => index)
26
+ : [options.caseIndex]
27
+ if (indexes.length === 0) throw new Error("campaign count must be greater than zero")
28
+ await mkdir(root, { recursive: true })
29
+ const results: CaseResult[] = []
30
+ const cursor = { value: 0 }
31
+ const controller = new AbortController()
32
+ const active = new Set<Awaited<ReturnType<typeof launchInstance>>>()
33
+ const interrupt = () => {
34
+ controller.abort()
35
+ void Promise.all([...active].map((instance) => instance.stop(true)))
36
+ }
37
+ process.once("SIGINT", interrupt)
38
+ process.once("SIGTERM", interrupt)
39
+ try {
40
+ const workers = Array.from(
41
+ { length: Math.min(options.visible ? 1 : options.concurrency, indexes.length) },
42
+ async () => {
43
+ while (!controller.signal.aborted && cursor.value < indexes.length) {
44
+ const index = indexes[cursor.value++]!
45
+ const result = await runCase(campaign, file, root, index, options, controller.signal, active)
46
+ results.push(result)
47
+ console.log(`[${results.length}/${indexes.length}] case=${index} seed=${result.seed} ${result.status}`)
48
+ }
49
+ },
50
+ )
51
+ await Promise.all(workers)
52
+ } finally {
53
+ process.off("SIGINT", interrupt)
54
+ process.off("SIGTERM", interrupt)
55
+ await Promise.all([...active].map((instance) => instance.stop(true)))
56
+ }
57
+ if (controller.signal.aborted) throw new Error("campaign interrupted")
58
+ results.sort((a, b) => a.index - b.index)
59
+ const summary = {
60
+ campaign: file,
61
+ seed: options.seed,
62
+ count: results.length,
63
+ passed: results.filter((result) => result.status === "passed").length,
64
+ failed: results.filter((result) => result.status === "failed").length,
65
+ durationMs: results.reduce((total, result) => total + result.durationMs, 0),
66
+ results,
67
+ }
68
+ await Bun.write(resolve(root, "summary.json"), `${JSON.stringify(summary, undefined, 2)}\n`)
69
+ if (summary.failed > 0) throw new Error(`${summary.failed} campaign case${summary.failed === 1 ? "" : "s"} failed; see ${root}`)
70
+ console.log(JSON.stringify(summary, undefined, 2))
71
+ }
72
+
73
+ async function runCase(
74
+ campaign: DefinedCampaign,
75
+ campaignFile: string,
76
+ root: string,
77
+ index: number,
78
+ options: StartOptions,
79
+ signal: AbortSignal,
80
+ active: Set<Awaited<ReturnType<typeof launchInstance>>>,
81
+ ): Promise<CaseResult> {
82
+ const seed = options.seed + index
83
+ const artifacts = resolve(root, `case-${String(index).padStart(6, "0")}-${seed}`)
84
+ const started = Date.now()
85
+ await mkdir(artifacts, { recursive: true })
86
+ const generated = { index, seed, artifacts }
87
+ try {
88
+ const flow = await campaign.generate(generated)
89
+ await Bun.write(resolve(artifacts, "flow.json"), `${JSON.stringify(flow, undefined, 2)}\n`)
90
+ const prepared = await campaign.prepare?.({ ...generated, flow })
91
+ const instance = await launchInstance({
92
+ name: `campaign-${process.pid}-${index}`,
93
+ command: options.command.length > 0 ? options.command : prepared?.command,
94
+ dev: options.dev,
95
+ state: prepared?.state ?? options.state,
96
+ visible: options.visible,
97
+ env: prepared?.env,
98
+ })
99
+ active.add(instance)
100
+ try {
101
+ await instance.waitForDrive("both")
102
+ const session = await connectDrive(instance.manifest.endpoints)
103
+ try {
104
+ const result = await campaign.run({
105
+ ...generated,
106
+ flow,
107
+ name: instance.manifest.name,
108
+ ui: session.ui,
109
+ backend: session.backend,
110
+ signal,
111
+ })
112
+ const value: CaseResult = {
113
+ index,
114
+ seed,
115
+ status: "passed",
116
+ durationMs: Date.now() - started,
117
+ artifacts,
118
+ result,
119
+ }
120
+ await Bun.write(resolve(artifacts, "result.json"), `${JSON.stringify(value, undefined, 2)}\n`)
121
+ return value
122
+ } finally {
123
+ session.close()
124
+ }
125
+ } finally {
126
+ active.delete(instance)
127
+ await instance.stop(true)
128
+ }
129
+ } catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error)
131
+ const result: CaseResult = {
132
+ index,
133
+ seed,
134
+ status: "failed",
135
+ durationMs: Date.now() - started,
136
+ artifacts,
137
+ error: message,
138
+ }
139
+ await Bun.write(resolve(artifacts, "failure.json"), `${JSON.stringify({
140
+ ...result,
141
+ replay: replayCommand(campaignFile, index, options),
142
+ }, undefined, 2)}\n`)
143
+ return result
144
+ }
145
+ }
146
+
147
+ function replayCommand(campaignFile: string, index: number, options: StartOptions) {
148
+ const args = [
149
+ "opencode-drive",
150
+ "start",
151
+ "--campaign",
152
+ campaignFile,
153
+ "--seed",
154
+ String(options.seed),
155
+ "--case",
156
+ String(index),
157
+ "--visible",
158
+ ...(options.state ? ["--state", options.state] : []),
159
+ ...(options.anchor ? ["--anchor", options.anchor] : []),
160
+ ...(options.command.length > 0 ? ["--", ...options.command] : []),
161
+ ]
162
+ return args.map((arg) => JSON.stringify(arg)).join(" ")
163
+ }
164
+
165
+ async function loadCampaign(file: string): Promise<DefinedCampaign> {
166
+ const module: { readonly default?: unknown } = await import(`${pathToFileURL(file).href}?drive=${Date.now()}`)
167
+ const value = module.default
168
+ if (!isCampaign(value)) {
169
+ throw new Error(`${basename(file)} must default-export defineCampaign(...)`)
170
+ }
171
+ return value
172
+ }
173
+
174
+ function isCampaign(value: unknown): value is DefinedCampaign {
175
+ if (typeof value !== "object" || value === null) return false
176
+ if (!("kind" in value) || value.kind !== "opencode-drive/campaign") return false
177
+ if (!("generate" in value) || typeof value.generate !== "function") return false
178
+ return "run" in value && typeof value.run === "function"
179
+ }
@@ -0,0 +1,37 @@
1
+ import { connectBackendSimulation, connectSimulation } from "../client/index.js"
2
+ import type { BackendSimulationClient, SimulationClient } from "../client/index.js"
3
+
4
+ export interface DriveEndpoints {
5
+ readonly ui: string
6
+ readonly backend: string
7
+ }
8
+
9
+ export interface DriveContext {
10
+ readonly name: string
11
+ readonly ui: SimulationClient
12
+ readonly backend: BackendSimulationClient
13
+ readonly artifacts: string
14
+ readonly signal: AbortSignal
15
+ }
16
+
17
+ export type Driver = (context: DriveContext) => void | Promise<void>
18
+
19
+ export function defineDriver(driver: Driver) {
20
+ return driver
21
+ }
22
+
23
+ export async function connectDrive(endpoints: DriveEndpoints, timeout = 30_000) {
24
+ const ui = await connectSimulation({ url: endpoints.ui, timeout })
25
+ const backend = await connectBackendSimulation({ url: endpoints.backend, timeout }).catch((error) => {
26
+ ui.close()
27
+ throw error
28
+ })
29
+ return {
30
+ ui,
31
+ backend,
32
+ close() {
33
+ ui.close()
34
+ backend.close()
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,41 @@
1
+ import { connectBackendSimulation, connectSimulation, type OpenedExchange } from "../client/index.js"
2
+
3
+ const uiUrl = process.env.OPENCODE_SIMULATION_UI_WS
4
+ const backendUrl = process.env.OPENCODE_SIMULATION_BACKEND_WS
5
+
6
+ const ui = await connectSimulation(uiUrl ? { url: uiUrl } : undefined)
7
+ const backend = await connectBackendSimulation(backendUrl ? { url: backendUrl } : undefined)
8
+
9
+ console.log("ui:", ui.url)
10
+ console.log("backend:", backend.url)
11
+
12
+ await backend.attach(async (request: OpenedExchange) => {
13
+ console.log("llm.request:", JSON.stringify({ id: request.id, model: (request.body as { model?: string }).model }))
14
+ await backend.chunk(request.id, [{ type: "textDelta", text: "Hello from opencode-probe." }])
15
+ await backend.finish(request.id, "stop")
16
+ })
17
+
18
+ for (let attempt = 0; attempt < 60; attempt++) {
19
+ const state = await ui.state()
20
+ if (state.focused.editor) break
21
+ await new Promise((resolve) => setTimeout(resolve, 250))
22
+ if (attempt === 59) throw new Error("prompt editor did not become ready")
23
+ }
24
+
25
+ await ui.typeText(process.argv.slice(2).join(" ") || "Hello from opencode-probe")
26
+ await ui.pressEnter()
27
+
28
+ for (let attempt = 0; attempt < 30; attempt++) {
29
+ await new Promise((resolve) => setTimeout(resolve, 500))
30
+ if ((await backend.pendingExchanges()).exchanges.length === 0) {
31
+ console.log("response complete")
32
+ ui.close()
33
+ backend.close()
34
+ process.exit(0)
35
+ }
36
+ }
37
+
38
+ console.log(await ui.state())
39
+ ui.close()
40
+ backend.close()
41
+ throw new Error("assistant reply did not render")
@@ -0,0 +1,189 @@
1
+ import { connectBackendSimulation, connectSimulation, type OpenedExchange } from "../client/index.js"
2
+ import { flowProperties, type FlowPropertyContext, type FlowResult, type FlowScenario, type TurnOutcome } from "./flows/index.js"
3
+
4
+ const scenarioPath = process.argv[2]
5
+ const resultPath = process.argv[3]
6
+ if (scenarioPath === undefined) throw new Error("usage: flow-driver SCENARIO [RESULT]")
7
+
8
+ const scenario: FlowScenario = await Bun.file(scenarioPath).json()
9
+ const stepDelay = Number(process.env.OPENCODE_PROBE_STEP_DELAY ?? "0")
10
+ const chunkDelay = Number(process.env.OPENCODE_PROBE_CHUNK_DELAY ?? "30")
11
+ const started = Date.now()
12
+ const ui = await connectSimulation({ url: requiredEnv("OPENCODE_SIMULATION_UI_WS") })
13
+ const backend = await connectBackendSimulation({ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS") })
14
+ const responses = scenario.turns.flatMap((turn) => turn.responses)
15
+ let assistantExchanges = 0
16
+ let responseCursor = 0
17
+ let subagentExchanges = 0
18
+ let titleExchanges = 0
19
+ let failure: Error | undefined
20
+ const active = new Set<string>()
21
+ const interrupted = new Set<string>()
22
+ let chunksSent = 0
23
+
24
+ await backend.attach(async (request: OpenedExchange) => {
25
+ try {
26
+ if (isTitleRequest(request)) {
27
+ titleExchanges++
28
+ await backend.chunk(request.id, [{ type: "textDelta", text: scenario.name }])
29
+ await backend.finish(request.id, "stop")
30
+ return
31
+ }
32
+ if (isSubagentRequest(request)) {
33
+ subagentExchanges++
34
+ await backend.chunk(request.id, [{ type: "textDelta", text: "The nested simulation fixture is consistent." }])
35
+ await backend.finish(request.id, "stop")
36
+ return
37
+ }
38
+ const response = responses[responseCursor++]
39
+ assistantExchanges++
40
+ if (response === undefined) throw new Error(`unexpected assistant exchange ${assistantExchanges}`)
41
+ active.add(request.id)
42
+ for (const chunk of response.chunks) {
43
+ await backend.chunk(request.id, chunk)
44
+ chunksSent++
45
+ if (chunkDelay > 0) await Bun.sleep(chunkDelay)
46
+ }
47
+ if (chunkDelay > 0) await Bun.sleep(Math.max(chunkDelay * 3, 100))
48
+ if (response.terminal === "disconnect") await backend.disconnect(request.id)
49
+ else if (response.terminal !== "invalid-provider-event") await backend.finish(request.id, response.finish)
50
+ } catch (error) {
51
+ if (!interrupted.has(request.id)) failure = error instanceof Error ? error : new Error(String(error))
52
+ } finally {
53
+ active.delete(request.id)
54
+ }
55
+ })
56
+
57
+ try {
58
+ await waitFor("prompt editor", async () => (await ui.state()).focused.editor)
59
+ let expectedExchanges = 0
60
+ for (const turn of scenario.turns) {
61
+ if (failure !== undefined) throw failure
62
+ await waitFor("prompt editor before submit", async () => (await ui.state()).focused.editor)
63
+ expectedExchanges += turn.responses.length
64
+ const beforeChunks = chunksSent
65
+ await ui.typeText(turn.prompt)
66
+ await ui.pressEnter()
67
+ await runProperties("afterSubmit", { turn, ui, backend, waitFor })
68
+ if (turn.interaction === "double-submit") await ui.pressEnter()
69
+ let outcome: TurnOutcome = "completed"
70
+ const hasTools = turn.responses.some((response) => (response.toolNames?.length ?? 0) > 0)
71
+ const settleTools = turn.interaction === "interrupt" || !hasTools
72
+ ? undefined
73
+ : settleBlockingUi(expectedExchanges)
74
+ if (turn.interaction === "steer") {
75
+ await waitFor("active stream", async () => active.size > 0 && chunksSent > beforeChunks)
76
+ await ui.typeText(turn.steerPrompt ?? "Also inspect the active boundary.")
77
+ await ui.pressEnter()
78
+ }
79
+ if (turn.interaction === "interrupt") {
80
+ await waitFor("interruptible stream", async () => active.size > 0 && chunksSent > beforeChunks)
81
+ for (const id of active) interrupted.add(id)
82
+ await ui.pressKey("escape")
83
+ await ui.pressKey("escape")
84
+ await waitFor("interrupted provider drain", async () => active.size === 0 && (await backend.pendingExchanges()).exchanges.length === 0)
85
+ responseCursor = expectedExchanges
86
+ outcome = "interrupted"
87
+ } else if (turn.interaction === "provider-drop") {
88
+ await waitFor("dropped provider exchange", async () => responseCursor >= expectedExchanges)
89
+ await waitFor("dropped provider drain", async () => active.size === 0 && (await backend.pendingExchanges()).exchanges.length === 0)
90
+ outcome = "provider-error"
91
+ } else {
92
+ await settleTools
93
+ await waitFor(turn.marker, async () => {
94
+ if (failure !== undefined) throw failure
95
+ await ui.state()
96
+ return responseCursor >= expectedExchanges
97
+ })
98
+ }
99
+ await runProperties("afterTerminal", { turn, ui, backend, outcome, waitFor })
100
+ if (stepDelay > 0) await Bun.sleep(stepDelay)
101
+ }
102
+ await waitFor("provider idle", async () => (await backend.pendingExchanges()).exchanges.length === 0)
103
+ const trace = await ui.traceExport()
104
+ const result: FlowResult = {
105
+ seed: scenario.seed,
106
+ name: scenario.name,
107
+ turns: scenario.turns.length,
108
+ assistantExchanges,
109
+ subagentExchanges,
110
+ titleExchanges,
111
+ traceRecords: trace.records.length,
112
+ durationMs: Date.now() - started,
113
+ finalState: await ui.state(),
114
+ }
115
+ if (resultPath !== undefined) await Bun.write(resultPath, `${JSON.stringify(result, undefined, 2)}\n`)
116
+ console.log(JSON.stringify({ ...result, finalState: undefined }))
117
+ } catch (error) {
118
+ if (resultPath !== undefined) {
119
+ await Bun.write(`${resultPath}.failure.json`, `${JSON.stringify({
120
+ error: error instanceof Error ? error.message : String(error),
121
+ assistantExchanges,
122
+ subagentExchanges,
123
+ titleExchanges,
124
+ pendingExchanges: (await backend.pendingExchanges()).exchanges,
125
+ state: await ui.state(),
126
+ }, undefined, 2)}\n`)
127
+ }
128
+ throw error
129
+ } finally {
130
+ ui.close()
131
+ backend.close()
132
+ }
133
+
134
+ function requiredEnv(name: string) {
135
+ const value = process.env[name]
136
+ if (value === undefined) throw new Error(`${name} is required`)
137
+ return value
138
+ }
139
+
140
+ function isTitleRequest(request: OpenedExchange) {
141
+ if (typeof request.body !== "object" || request.body === null || !("messages" in request.body)) return false
142
+ const messages = request.body.messages
143
+ if (!Array.isArray(messages)) return false
144
+ const first = messages[0]
145
+ if (typeof first !== "object" || first === null || !("content" in first)) return false
146
+ return typeof first.content === "string" && first.content.includes("You are a title generator")
147
+ }
148
+
149
+ function isSubagentRequest(request: OpenedExchange) {
150
+ if (typeof request.body !== "object" || request.body === null || !("messages" in request.body)) return false
151
+ const messages = request.body.messages
152
+ if (!Array.isArray(messages)) return false
153
+ return messages.some((message) => {
154
+ if (typeof message !== "object" || message === null || !("content" in message)) return false
155
+ return typeof message.content === "string" && message.content.includes("Inspect the simulation fixture.")
156
+ })
157
+ }
158
+
159
+ async function waitFor(label: string, check: () => Promise<boolean>, timeout = 30_000) {
160
+ const deadline = Date.now() + timeout
161
+ while (Date.now() < deadline) {
162
+ if (await check()) return
163
+ await Bun.sleep(50)
164
+ }
165
+ throw new Error(`timed out waiting for ${label}`)
166
+ }
167
+
168
+ async function runProperties(stage: "afterSubmit" | "afterTerminal", context: FlowPropertyContext) {
169
+ for (const property of flowProperties) {
170
+ const check = property[stage]
171
+ if (check === undefined) continue
172
+ try {
173
+ await check(context)
174
+ } catch (error) {
175
+ throw new Error(`property ${property.name} failed for ${context.turn.marker}: ${error instanceof Error ? error.message : String(error)}`, {
176
+ cause: error,
177
+ })
178
+ }
179
+ }
180
+ }
181
+
182
+ async function settleBlockingUi(expectedExchanges: number) {
183
+ const deadline = Date.now() + 30_000
184
+ while (Date.now() < deadline && responseCursor < expectedExchanges) {
185
+ await ui.pressEnter()
186
+ await Bun.sleep(25)
187
+ }
188
+ if (responseCursor < expectedExchanges) throw new Error("timed out settling blocking tool UI")
189
+ }