opencode-drive 0.1.2 → 0.1.3

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.
@@ -1,16 +1,31 @@
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"
1
+ import {
2
+ connectBackendSimulation,
3
+ connectSimulation,
4
+ type OpenedExchange,
5
+ } from "../client/index.js"
6
+ import {
7
+ flowProperties,
8
+ type FlowPropertyContext,
9
+ type FlowResult,
10
+ type FlowScenario,
11
+ type TurnOutcome,
12
+ } from "./flows/index.js"
3
13
 
4
14
  const scenarioPath = process.argv[2]
5
15
  const resultPath = process.argv[3]
6
- if (scenarioPath === undefined) throw new Error("usage: flow-driver SCENARIO [RESULT]")
16
+ if (scenarioPath === undefined)
17
+ throw new Error("usage: flow-driver SCENARIO [RESULT]")
7
18
 
8
19
  const scenario: FlowScenario = await Bun.file(scenarioPath).json()
9
20
  const stepDelay = Number(process.env.OPENCODE_PROBE_STEP_DELAY ?? "0")
10
21
  const chunkDelay = Number(process.env.OPENCODE_PROBE_CHUNK_DELAY ?? "30")
11
22
  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") })
23
+ const ui = await connectSimulation({
24
+ url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
25
+ })
26
+ const backend = await connectBackendSimulation({
27
+ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
28
+ })
14
29
  const responses = scenario.turns.flatMap((turn) => turn.responses)
15
30
  let assistantExchanges = 0
16
31
  let responseCursor = 0
@@ -25,19 +40,27 @@ await backend.attach(async (request: OpenedExchange) => {
25
40
  try {
26
41
  if (isTitleRequest(request)) {
27
42
  titleExchanges++
28
- await backend.chunk(request.id, [{ type: "textDelta", text: scenario.name }])
43
+ await backend.chunk(request.id, [
44
+ { type: "textDelta", text: scenario.name },
45
+ ])
29
46
  await backend.finish(request.id, "stop")
30
47
  return
31
48
  }
32
49
  if (isSubagentRequest(request)) {
33
50
  subagentExchanges++
34
- await backend.chunk(request.id, [{ type: "textDelta", text: "The nested simulation fixture is consistent." }])
51
+ await backend.chunk(request.id, [
52
+ {
53
+ type: "textDelta",
54
+ text: "The nested simulation fixture is consistent.",
55
+ },
56
+ ])
35
57
  await backend.finish(request.id, "stop")
36
58
  return
37
59
  }
38
60
  const response = responses[responseCursor++]
39
61
  assistantExchanges++
40
- if (response === undefined) throw new Error(`unexpected assistant exchange ${assistantExchanges}`)
62
+ if (response === undefined)
63
+ throw new Error(`unexpected assistant exchange ${assistantExchanges}`)
41
64
  active.add(request.id)
42
65
  for (const chunk of response.chunks) {
43
66
  await backend.chunk(request.id, chunk)
@@ -46,9 +69,11 @@ await backend.attach(async (request: OpenedExchange) => {
46
69
  }
47
70
  if (chunkDelay > 0) await Bun.sleep(Math.max(chunkDelay * 3, 100))
48
71
  if (response.terminal === "disconnect") await backend.disconnect(request.id)
49
- else if (response.terminal !== "invalid-provider-event") await backend.finish(request.id, response.finish)
72
+ else if (response.terminal !== "invalid-provider-event")
73
+ await backend.finish(request.id, response.finish)
50
74
  } catch (error) {
51
- if (!interrupted.has(request.id)) failure = error instanceof Error ? error : new Error(String(error))
75
+ if (!interrupted.has(request.id))
76
+ failure = error instanceof Error ? error : new Error(String(error))
52
77
  } finally {
53
78
  active.delete(request.id)
54
79
  }
@@ -59,7 +84,10 @@ try {
59
84
  let expectedExchanges = 0
60
85
  for (const turn of scenario.turns) {
61
86
  if (failure !== undefined) throw failure
62
- await waitFor("prompt editor before submit", async () => (await ui.state()).focused.editor)
87
+ await waitFor(
88
+ "prompt editor before submit",
89
+ async () => (await ui.state()).focused.editor,
90
+ )
63
91
  expectedExchanges += turn.responses.length
64
92
  const beforeChunks = chunksSent
65
93
  await ui.typeText(turn.prompt)
@@ -67,26 +95,38 @@ try {
67
95
  await runProperties("afterSubmit", { turn, ui, backend, waitFor })
68
96
  if (turn.interaction === "double-submit") await ui.pressEnter()
69
97
  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)
98
+ const hasTools = turn.responses.some(
99
+ (response) => (response.toolNames?.length ?? 0) > 0,
100
+ )
101
+ const settleTools =
102
+ turn.interaction === "interrupt" || !hasTools
103
+ ? undefined
104
+ : settleBlockingUi(expectedExchanges)
74
105
  if (turn.interaction === "steer") {
75
- await waitFor("active stream", async () => active.size > 0 && chunksSent > beforeChunks)
106
+ await waitFor(
107
+ "active stream",
108
+ async () => active.size > 0 && chunksSent > beforeChunks,
109
+ )
76
110
  await ui.typeText(turn.steerPrompt ?? "Also inspect the active boundary.")
77
111
  await ui.pressEnter()
78
112
  }
79
113
  if (turn.interaction === "interrupt") {
80
- await waitFor("interruptible stream", async () => active.size > 0 && chunksSent > beforeChunks)
114
+ await waitFor(
115
+ "interruptible stream",
116
+ async () => active.size > 0 && chunksSent > beforeChunks,
117
+ )
81
118
  for (const id of active) interrupted.add(id)
82
119
  await ui.pressKey("escape")
83
120
  await ui.pressKey("escape")
84
- await waitFor("interrupted provider drain", async () => active.size === 0 && (await backend.pendingExchanges()).exchanges.length === 0)
121
+ await waitFor("interrupted provider drain", async () => active.size === 0)
85
122
  responseCursor = expectedExchanges
86
123
  outcome = "interrupted"
87
124
  } 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)
125
+ await waitFor(
126
+ "dropped provider exchange",
127
+ async () => responseCursor >= expectedExchanges,
128
+ )
129
+ await waitFor("dropped provider drain", async () => active.size === 0)
90
130
  outcome = "provider-error"
91
131
  } else {
92
132
  await settleTools
@@ -96,10 +136,16 @@ try {
96
136
  return responseCursor >= expectedExchanges
97
137
  })
98
138
  }
99
- await runProperties("afterTerminal", { turn, ui, backend, outcome, waitFor })
139
+ await runProperties("afterTerminal", {
140
+ turn,
141
+ ui,
142
+ backend,
143
+ outcome,
144
+ waitFor,
145
+ })
100
146
  if (stepDelay > 0) await Bun.sleep(stepDelay)
101
147
  }
102
- await waitFor("provider idle", async () => (await backend.pendingExchanges()).exchanges.length === 0)
148
+ await waitFor("provider idle", async () => active.size === 0)
103
149
  const result: FlowResult = {
104
150
  seed: scenario.seed,
105
151
  name: scenario.name,
@@ -110,18 +156,26 @@ try {
110
156
  durationMs: Date.now() - started,
111
157
  finalState: await ui.state(),
112
158
  }
113
- if (resultPath !== undefined) await Bun.write(resultPath, `${JSON.stringify(result, undefined, 2)}\n`)
159
+ if (resultPath !== undefined)
160
+ await Bun.write(resultPath, `${JSON.stringify(result, undefined, 2)}\n`)
114
161
  console.log(JSON.stringify({ ...result, finalState: undefined }))
115
162
  } catch (error) {
116
163
  if (resultPath !== undefined) {
117
- await Bun.write(`${resultPath}.failure.json`, `${JSON.stringify({
118
- error: error instanceof Error ? error.message : String(error),
119
- assistantExchanges,
120
- subagentExchanges,
121
- titleExchanges,
122
- pendingExchanges: (await backend.pendingExchanges()).exchanges,
123
- state: await ui.state(),
124
- }, undefined, 2)}\n`)
164
+ await Bun.write(
165
+ `${resultPath}.failure.json`,
166
+ `${JSON.stringify(
167
+ {
168
+ error: error instanceof Error ? error.message : String(error),
169
+ assistantExchanges,
170
+ subagentExchanges,
171
+ titleExchanges,
172
+ activeExchanges: [...active],
173
+ state: await ui.state(),
174
+ },
175
+ undefined,
176
+ 2,
177
+ )}\n`,
178
+ )
125
179
  }
126
180
  throw error
127
181
  } finally {
@@ -136,25 +190,51 @@ function requiredEnv(name: string) {
136
190
  }
137
191
 
138
192
  function isTitleRequest(request: OpenedExchange) {
139
- if (typeof request.body !== "object" || request.body === null || !("messages" in request.body)) return false
193
+ if (
194
+ typeof request.body !== "object" ||
195
+ request.body === null ||
196
+ !("messages" in request.body)
197
+ )
198
+ return false
140
199
  const messages = request.body.messages
141
200
  if (!Array.isArray(messages)) return false
142
201
  const first = messages[0]
143
- if (typeof first !== "object" || first === null || !("content" in first)) return false
144
- return typeof first.content === "string" && first.content.includes("You are a title generator")
202
+ if (typeof first !== "object" || first === null || !("content" in first))
203
+ return false
204
+ return (
205
+ typeof first.content === "string" &&
206
+ first.content.includes("You are a title generator")
207
+ )
145
208
  }
146
209
 
147
210
  function isSubagentRequest(request: OpenedExchange) {
148
- if (typeof request.body !== "object" || request.body === null || !("messages" in request.body)) return false
211
+ if (
212
+ typeof request.body !== "object" ||
213
+ request.body === null ||
214
+ !("messages" in request.body)
215
+ )
216
+ return false
149
217
  const messages = request.body.messages
150
218
  if (!Array.isArray(messages)) return false
151
219
  return messages.some((message) => {
152
- if (typeof message !== "object" || message === null || !("content" in message)) return false
153
- return typeof message.content === "string" && message.content.includes("Inspect the simulation fixture.")
220
+ if (
221
+ typeof message !== "object" ||
222
+ message === null ||
223
+ !("content" in message)
224
+ )
225
+ return false
226
+ return (
227
+ typeof message.content === "string" &&
228
+ message.content.includes("Inspect the simulation fixture.")
229
+ )
154
230
  })
155
231
  }
156
232
 
157
- async function waitFor(label: string, check: () => Promise<boolean>, timeout = 30_000) {
233
+ async function waitFor(
234
+ label: string,
235
+ check: () => Promise<boolean>,
236
+ timeout = 30_000,
237
+ ) {
158
238
  const deadline = Date.now() + timeout
159
239
  while (Date.now() < deadline) {
160
240
  if (await check()) return
@@ -163,16 +243,22 @@ async function waitFor(label: string, check: () => Promise<boolean>, timeout = 3
163
243
  throw new Error(`timed out waiting for ${label}`)
164
244
  }
165
245
 
166
- async function runProperties(stage: "afterSubmit" | "afterTerminal", context: FlowPropertyContext) {
246
+ async function runProperties(
247
+ stage: "afterSubmit" | "afterTerminal",
248
+ context: FlowPropertyContext,
249
+ ) {
167
250
  for (const property of flowProperties) {
168
251
  const check = property[stage]
169
252
  if (check === undefined) continue
170
253
  try {
171
254
  await check(context)
172
255
  } catch (error) {
173
- throw new Error(`property ${property.name} failed for ${context.turn.marker}: ${error instanceof Error ? error.message : String(error)}`, {
174
- cause: error,
175
- })
256
+ throw new Error(
257
+ `property ${property.name} failed for ${context.turn.marker}: ${error instanceof Error ? error.message : String(error)}`,
258
+ {
259
+ cause: error,
260
+ },
261
+ )
176
262
  }
177
263
  }
178
264
  }
@@ -183,5 +269,6 @@ async function settleBlockingUi(expectedExchanges: number) {
183
269
  await ui.pressEnter()
184
270
  await Bun.sleep(25)
185
271
  }
186
- if (responseCursor < expectedExchanges) throw new Error("timed out settling blocking tool UI")
272
+ if (responseCursor < expectedExchanges)
273
+ throw new Error("timed out settling blocking tool UI")
187
274
  }
@@ -1,4 +1,8 @@
1
- import type { BackendSimulationClient, SimulationClient, UiState } from "../../client/index.js"
1
+ import type {
2
+ BackendSimulationClient,
3
+ SimulationClient,
4
+ UiState,
5
+ } from "../../client/index.js"
2
6
  import type { FlowTurn } from "./types.js"
3
7
 
4
8
  export type TurnOutcome = "completed" | "interrupted" | "provider-error"
@@ -8,7 +12,10 @@ export interface FlowPropertyContext {
8
12
  readonly ui: SimulationClient
9
13
  readonly backend: BackendSimulationClient
10
14
  readonly outcome?: TurnOutcome
11
- readonly waitFor: (label: string, check: () => Promise<boolean>) => Promise<void>
15
+ readonly waitFor: (
16
+ label: string,
17
+ check: () => Promise<boolean>,
18
+ ) => Promise<void>
12
19
  }
13
20
 
14
21
  export interface FlowProperty {
@@ -23,17 +30,16 @@ export const flowProperties: ReadonlyArray<FlowProperty> = [
23
30
  defineProperty({
24
31
  name: "submitted-turn-shows-running",
25
32
  afterSubmit: (context) => {
26
- if (context.turn.responses.some((response) => response.terminal === "invalid-provider-event" || response.terminal === "disconnect")) return Promise.resolve()
27
- return context.waitFor("submitted turn to show running", async () => isRunning(await context.ui.state()))
28
- },
29
- }),
30
- defineProperty({
31
- name: "turn-reaches-terminal-outcome",
32
- afterTerminal: async (context) => {
33
- if (context.outcome === undefined) throw new Error("turn has no terminal outcome")
34
- await context.waitFor(
35
- "terminal turn to have no provider exchange",
36
- async () => (await context.backend.pendingExchanges()).exchanges.length === 0,
33
+ if (
34
+ context.turn.responses.some(
35
+ (response) =>
36
+ response.terminal === "invalid-provider-event" ||
37
+ response.terminal === "disconnect",
38
+ )
39
+ )
40
+ return Promise.resolve()
41
+ return context.waitFor("submitted turn to show running", async () =>
42
+ isRunning(await context.ui.state()),
37
43
  )
38
44
  },
39
45
  }),
@@ -1,7 +1,15 @@
1
- import { connectBackendSimulation, connectSimulation, type OpenedExchange } from "../client/index.js"
1
+ import {
2
+ connectBackendSimulation,
3
+ connectSimulation,
4
+ type OpenedExchange,
5
+ } from "../client/index.js"
2
6
 
3
- const ui = await connectSimulation({ url: requiredEnv("OPENCODE_SIMULATION_UI_WS") })
4
- const backend = await connectBackendSimulation({ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS") })
7
+ const ui = await connectSimulation({
8
+ url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
9
+ })
10
+ const backend = await connectBackendSimulation({
11
+ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
12
+ })
5
13
  let completed!: () => void
6
14
  const responseCompleted = new Promise<void>((resolve) => {
7
15
  completed = resolve
@@ -18,7 +26,6 @@ try {
18
26
  await ui.typeText("Say hello")
19
27
  await ui.pressEnter()
20
28
  await responseCompleted
21
- await waitFor(async () => (await backend.pendingExchanges()).exchanges.length === 0)
22
29
  await ui.state()
23
30
  await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
24
31
  } finally {
@@ -1,20 +1,35 @@
1
- import { connectBackendSimulation, connectSimulation, type OpenedExchange } from "../client/index.js"
1
+ import {
2
+ connectBackendSimulation,
3
+ connectSimulation,
4
+ type OpenedExchange,
5
+ } from "../client/index.js"
2
6
  import { isRunning } from "./flows/index.js"
3
7
 
4
- const ui = await connectSimulation({ url: requiredEnv("OPENCODE_SIMULATION_UI_WS") })
5
- const backend = await connectBackendSimulation({ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS") })
8
+ const ui = await connectSimulation({
9
+ url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
10
+ })
11
+ const backend = await connectBackendSimulation({
12
+ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
13
+ })
6
14
  const requestOpened = deferred()
7
15
  const releaseResponse = deferred()
8
16
  const responseFinished = deferred()
9
17
 
10
18
  await backend.attach(async (request: OpenedExchange) => {
11
19
  if (isTitleRequest(request)) {
12
- await backend.chunk(request.id, [{ type: "textDelta", text: "Stale running reproduction" }])
20
+ await backend.chunk(request.id, [
21
+ { type: "textDelta", text: "Stale running reproduction" },
22
+ ])
13
23
  await backend.finish(request.id, "stop")
14
24
  return
15
25
  }
16
26
  requestOpened.resolve()
17
- await backend.chunk(request.id, [{ type: "textDelta", text: "The provider turn is finishing while events are disconnected." }])
27
+ await backend.chunk(request.id, [
28
+ {
29
+ type: "textDelta",
30
+ text: "The provider turn is finishing while events are disconnected.",
31
+ },
32
+ ])
18
33
  await releaseResponse.promise
19
34
  await backend.finish(request.id, "stop")
20
35
  responseFinished.resolve()
@@ -22,19 +37,23 @@ await backend.attach(async (request: OpenedExchange) => {
22
37
 
23
38
  try {
24
39
  await waitFor("prompt editor", async () => (await ui.state()).focused.editor)
25
- await ui.typeText("Reproduce stale running status across an event-stream reconnect")
40
+ await ui.typeText(
41
+ "Reproduce stale running status across an event-stream reconnect",
42
+ )
26
43
  await ui.pressEnter()
27
44
  await requestOpened.promise
28
45
  await waitFor("running TUI", async () => isRunning(await ui.state()))
29
46
 
30
47
  releaseResponse.resolve()
31
48
  await responseFinished.promise
32
- await waitFor("provider drain", async () => (await backend.pendingExchanges()).exchanges.length === 0)
33
49
  await Bun.sleep(1_000)
34
50
 
35
51
  const state = await ui.state()
36
- if (!isRunning(state)) throw new Error("stale running status was not reproduced")
37
- console.log("REPRODUCED: backend provider work is idle while the TUI still displays running.")
52
+ if (!isRunning(state))
53
+ throw new Error("stale running status was not reproduced")
54
+ console.log(
55
+ "REPRODUCED: backend provider work is idle while the TUI still displays running.",
56
+ )
38
57
  await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
39
58
  } finally {
40
59
  ui.close()
@@ -56,15 +75,28 @@ function requiredEnv(name: string) {
56
75
  }
57
76
 
58
77
  function isTitleRequest(request: OpenedExchange) {
59
- if (typeof request.body !== "object" || request.body === null || !("messages" in request.body)) return false
78
+ if (
79
+ typeof request.body !== "object" ||
80
+ request.body === null ||
81
+ !("messages" in request.body)
82
+ )
83
+ return false
60
84
  const messages = request.body.messages
61
85
  if (!Array.isArray(messages)) return false
62
86
  const first = messages[0]
63
- if (typeof first !== "object" || first === null || !("content" in first)) return false
64
- return typeof first.content === "string" && first.content.includes("You are a title generator")
87
+ if (typeof first !== "object" || first === null || !("content" in first))
88
+ return false
89
+ return (
90
+ typeof first.content === "string" &&
91
+ first.content.includes("You are a title generator")
92
+ )
65
93
  }
66
94
 
67
- async function waitFor(label: string, check: () => Promise<boolean>, timeout = 30_000) {
95
+ async function waitFor(
96
+ label: string,
97
+ check: () => Promise<boolean>,
98
+ timeout = 30_000,
99
+ ) {
68
100
  const deadline = Date.now() + timeout
69
101
  while (Date.now() < deadline) {
70
102
  if (await check()) return
package/src/index.ts CHANGED
@@ -1 +1,3 @@
1
1
  export * from "./client/index.js"
2
+ export { defineScript } from "./cli/script.js"
3
+ export type { DriveScript, ScriptContext } from "./cli/script.js"
@@ -1,39 +0,0 @@
1
- import { resolve } from "node:path"
2
- import { pathToFileURL } from "node:url"
3
- import { connectDrive } from "../experimental/drive.js"
4
- import type { Driver } from "../experimental/drive.js"
5
-
6
- const driver = process.argv[2]
7
- if (!driver) throw new Error("driver file is required")
8
- const name = requiredArgument(3, "instance name")
9
- const ui = requiredArgument(4, "frontend WebSocket URL")
10
- const backend = requiredArgument(5, "backend WebSocket URL")
11
- const artifacts = requiredArgument(6, "artifacts directory")
12
-
13
- const module: { readonly default?: unknown } = await import(pathToFileURL(resolve(driver)).href)
14
- if (!isDriver(module.default)) throw new Error("driver must default-export defineDriver(...)")
15
- const controller = new AbortController()
16
- process.once("SIGINT", () => controller.abort())
17
- process.once("SIGTERM", () => controller.abort())
18
- const session = await connectDrive({ ui, backend })
19
- try {
20
- await module.default({
21
- name,
22
- ui: session.ui,
23
- backend: session.backend,
24
- artifacts,
25
- signal: controller.signal,
26
- })
27
- } finally {
28
- session.close()
29
- }
30
-
31
- function isDriver(value: unknown): value is Driver {
32
- return typeof value === "function"
33
- }
34
-
35
- function requiredArgument(index: number, name: string) {
36
- const value = process.argv[index]
37
- if (!value) throw new Error(`${name} is required`)
38
- return value
39
- }
package/src/cli/driver.ts DELETED
@@ -1,28 +0,0 @@
1
- import { mkdir } from "node:fs/promises"
2
- import { resolve } from "node:path"
3
- import type { InstanceManifest } from "./types.js"
4
-
5
- export async function runDriver(driver: string, manifest: InstanceManifest) {
6
- await mkdir(manifest.artifacts, { recursive: true })
7
- const child = Bun.spawn([
8
- process.execPath,
9
- resolve(import.meta.dir, "driver-runner.ts"),
10
- resolve(driver),
11
- manifest.name,
12
- manifest.endpoints.ui,
13
- manifest.endpoints.backend,
14
- manifest.artifacts,
15
- ], {
16
- cwd: process.cwd(),
17
- env: cleanEnv(process.env),
18
- stdin: "inherit",
19
- stdout: "inherit",
20
- stderr: "inherit",
21
- })
22
- const status = await child.exited
23
- if (status !== 0) throw new Error(`driver exited with status ${status}`)
24
- }
25
-
26
- function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
27
- return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined))
28
- }