opencode-drive 0.1.1 → 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.
- package/README.md +90 -61
- package/package.json +2 -2
- package/src/cli/api.ts +5 -0
- package/src/cli/commands.ts +117 -134
- package/src/cli/control.ts +43 -0
- package/src/cli/describe.ts +4 -5
- package/src/cli/index.ts +85 -101
- package/src/cli/instance.ts +220 -98
- package/src/cli/mock-backend.ts +25 -0
- package/src/cli/registry.ts +41 -83
- package/src/cli/restart.ts +7 -0
- package/src/cli/script.ts +66 -0
- package/src/cli/send.ts +19 -39
- package/src/cli/start.ts +100 -33
- package/src/cli/stop.ts +4 -28
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +94 -37
- package/src/client/client.ts +15 -31
- package/src/client/index.ts +0 -3
- package/src/client/protocol.ts +133 -44
- package/src/client/protocol.types.ts +62 -0
- package/src/experimental/campaign.ts +2 -3
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -45
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/flows/types.ts +0 -1
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/reproduce-stale-running-visible.ts +1 -1
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
|
@@ -1,18 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
connectBackendSimulation,
|
|
3
|
+
connectSimulation,
|
|
4
|
+
type OpenedExchange,
|
|
5
|
+
} from "../client/index.js"
|
|
2
6
|
|
|
3
7
|
const uiUrl = process.env.OPENCODE_SIMULATION_UI_WS
|
|
4
8
|
const backendUrl = process.env.OPENCODE_SIMULATION_BACKEND_WS
|
|
5
9
|
|
|
6
10
|
const ui = await connectSimulation(uiUrl ? { url: uiUrl } : undefined)
|
|
7
|
-
const backend = await connectBackendSimulation(
|
|
11
|
+
const backend = await connectBackendSimulation(
|
|
12
|
+
backendUrl ? { url: backendUrl } : undefined,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
console.log("ui:", ui.url)
|
|
10
16
|
console.log("backend:", backend.url)
|
|
11
17
|
|
|
18
|
+
let completed!: () => void
|
|
19
|
+
const responseCompleted = new Promise<void>((resolve) => {
|
|
20
|
+
completed = resolve
|
|
21
|
+
})
|
|
22
|
+
|
|
12
23
|
await backend.attach(async (request: OpenedExchange) => {
|
|
13
|
-
console.log(
|
|
14
|
-
|
|
24
|
+
console.log(
|
|
25
|
+
"llm.request:",
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
id: request.id,
|
|
28
|
+
model: (request.body as { model?: string }).model,
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
await backend.chunk(request.id, [
|
|
32
|
+
{ type: "textDelta", text: "Hello from opencode-probe." },
|
|
33
|
+
])
|
|
15
34
|
await backend.finish(request.id, "stop")
|
|
35
|
+
completed()
|
|
16
36
|
})
|
|
17
37
|
|
|
18
38
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
@@ -22,20 +42,11 @@ for (let attempt = 0; attempt < 60; attempt++) {
|
|
|
22
42
|
if (attempt === 59) throw new Error("prompt editor did not become ready")
|
|
23
43
|
}
|
|
24
44
|
|
|
25
|
-
await ui.typeText(
|
|
45
|
+
await ui.typeText(
|
|
46
|
+
process.argv.slice(2).join(" ") || "Hello from opencode-probe",
|
|
47
|
+
)
|
|
26
48
|
await ui.pressEnter()
|
|
27
|
-
|
|
28
|
-
|
|
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())
|
|
49
|
+
await responseCompleted
|
|
50
|
+
console.log("response complete")
|
|
39
51
|
ui.close()
|
|
40
52
|
backend.close()
|
|
41
|
-
throw new Error("assistant reply did not render")
|
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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)
|
|
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({
|
|
13
|
-
|
|
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, [
|
|
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, [
|
|
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)
|
|
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")
|
|
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))
|
|
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(
|
|
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(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
89
|
-
|
|
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,11 +136,16 @@ try {
|
|
|
96
136
|
return responseCursor >= expectedExchanges
|
|
97
137
|
})
|
|
98
138
|
}
|
|
99
|
-
await runProperties("afterTerminal", {
|
|
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 () =>
|
|
103
|
-
const trace = await ui.traceExport()
|
|
148
|
+
await waitFor("provider idle", async () => active.size === 0)
|
|
104
149
|
const result: FlowResult = {
|
|
105
150
|
seed: scenario.seed,
|
|
106
151
|
name: scenario.name,
|
|
@@ -108,22 +153,29 @@ try {
|
|
|
108
153
|
assistantExchanges,
|
|
109
154
|
subagentExchanges,
|
|
110
155
|
titleExchanges,
|
|
111
|
-
traceRecords: trace.records.length,
|
|
112
156
|
durationMs: Date.now() - started,
|
|
113
157
|
finalState: await ui.state(),
|
|
114
158
|
}
|
|
115
|
-
if (resultPath !== undefined)
|
|
159
|
+
if (resultPath !== undefined)
|
|
160
|
+
await Bun.write(resultPath, `${JSON.stringify(result, undefined, 2)}\n`)
|
|
116
161
|
console.log(JSON.stringify({ ...result, finalState: undefined }))
|
|
117
162
|
} catch (error) {
|
|
118
163
|
if (resultPath !== undefined) {
|
|
119
|
-
await Bun.write(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
)
|
|
127
179
|
}
|
|
128
180
|
throw error
|
|
129
181
|
} finally {
|
|
@@ -138,25 +190,51 @@ function requiredEnv(name: string) {
|
|
|
138
190
|
}
|
|
139
191
|
|
|
140
192
|
function isTitleRequest(request: OpenedExchange) {
|
|
141
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
typeof request.body !== "object" ||
|
|
195
|
+
request.body === null ||
|
|
196
|
+
!("messages" in request.body)
|
|
197
|
+
)
|
|
198
|
+
return false
|
|
142
199
|
const messages = request.body.messages
|
|
143
200
|
if (!Array.isArray(messages)) return false
|
|
144
201
|
const first = messages[0]
|
|
145
|
-
if (typeof first !== "object" || first === null || !("content" in first))
|
|
146
|
-
|
|
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
|
+
)
|
|
147
208
|
}
|
|
148
209
|
|
|
149
210
|
function isSubagentRequest(request: OpenedExchange) {
|
|
150
|
-
if (
|
|
211
|
+
if (
|
|
212
|
+
typeof request.body !== "object" ||
|
|
213
|
+
request.body === null ||
|
|
214
|
+
!("messages" in request.body)
|
|
215
|
+
)
|
|
216
|
+
return false
|
|
151
217
|
const messages = request.body.messages
|
|
152
218
|
if (!Array.isArray(messages)) return false
|
|
153
219
|
return messages.some((message) => {
|
|
154
|
-
if (
|
|
155
|
-
|
|
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
|
+
)
|
|
156
230
|
})
|
|
157
231
|
}
|
|
158
232
|
|
|
159
|
-
async function waitFor(
|
|
233
|
+
async function waitFor(
|
|
234
|
+
label: string,
|
|
235
|
+
check: () => Promise<boolean>,
|
|
236
|
+
timeout = 30_000,
|
|
237
|
+
) {
|
|
160
238
|
const deadline = Date.now() + timeout
|
|
161
239
|
while (Date.now() < deadline) {
|
|
162
240
|
if (await check()) return
|
|
@@ -165,16 +243,22 @@ async function waitFor(label: string, check: () => Promise<boolean>, timeout = 3
|
|
|
165
243
|
throw new Error(`timed out waiting for ${label}`)
|
|
166
244
|
}
|
|
167
245
|
|
|
168
|
-
async function runProperties(
|
|
246
|
+
async function runProperties(
|
|
247
|
+
stage: "afterSubmit" | "afterTerminal",
|
|
248
|
+
context: FlowPropertyContext,
|
|
249
|
+
) {
|
|
169
250
|
for (const property of flowProperties) {
|
|
170
251
|
const check = property[stage]
|
|
171
252
|
if (check === undefined) continue
|
|
172
253
|
try {
|
|
173
254
|
await check(context)
|
|
174
255
|
} catch (error) {
|
|
175
|
-
throw new Error(
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
)
|
|
178
262
|
}
|
|
179
263
|
}
|
|
180
264
|
}
|
|
@@ -185,5 +269,6 @@ async function settleBlockingUi(expectedExchanges: number) {
|
|
|
185
269
|
await ui.pressEnter()
|
|
186
270
|
await Bun.sleep(25)
|
|
187
271
|
}
|
|
188
|
-
if (responseCursor < expectedExchanges)
|
|
272
|
+
if (responseCursor < expectedExchanges)
|
|
273
|
+
throw new Error("timed out settling blocking tool UI")
|
|
189
274
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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: (
|
|
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 (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
1
|
+
import {
|
|
2
|
+
connectBackendSimulation,
|
|
3
|
+
connectSimulation,
|
|
4
|
+
type OpenedExchange,
|
|
5
|
+
} from "../client/index.js"
|
|
2
6
|
|
|
3
|
-
const ui = await connectSimulation({
|
|
4
|
-
|
|
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 {
|
|
@@ -38,7 +38,7 @@ const child = Bun.spawn([
|
|
|
38
38
|
"--driver", `bun ${path.resolve("src/experimental/stale-running-driver.ts")}`,
|
|
39
39
|
"--",
|
|
40
40
|
"bun", "run", "--conditions=browser", "--preload=@opentui/solid/preload",
|
|
41
|
-
"/root/projects/opencode-latest/packages/cli/src/index.ts",
|
|
41
|
+
"/root/projects/opencode-latest/packages/cli/src/index.ts",
|
|
42
42
|
], {
|
|
43
43
|
cwd: path.resolve("."),
|
|
44
44
|
env: {
|
|
@@ -1,20 +1,35 @@
|
|
|
1
|
-
import {
|
|
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({
|
|
5
|
-
|
|
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, [
|
|
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, [
|
|
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(
|
|
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))
|
|
37
|
-
|
|
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 (
|
|
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))
|
|
64
|
-
|
|
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(
|
|
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
package/src/cli/driver-runner.ts
DELETED
|
@@ -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
|
-
}
|