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.
- package/README.md +90 -0
- package/bin/opencode-drive +2 -0
- package/package.json +45 -0
- package/src/cli/commands.ts +175 -0
- package/src/cli/describe.ts +14 -0
- package/src/cli/driver-runner.ts +39 -0
- package/src/cli/driver.ts +28 -0
- package/src/cli/index.ts +158 -0
- package/src/cli/instance.ts +218 -0
- package/src/cli/parse.ts +24 -0
- package/src/cli/registry.ts +120 -0
- package/src/cli/send.ts +48 -0
- package/src/cli/start.ts +56 -0
- package/src/cli/types.ts +46 -0
- package/src/client/backend.ts +184 -0
- package/src/client/client.ts +252 -0
- package/src/client/index.ts +17 -0
- package/src/client/protocol.ts +186 -0
- package/src/experimental/campaign-api.ts +34 -0
- package/src/experimental/campaign.ts +144 -0
- package/src/experimental/cli-campaign.ts +179 -0
- package/src/experimental/drive.ts +37 -0
- package/src/experimental/driver.ts +41 -0
- package/src/experimental/flow-driver.ts +189 -0
- package/src/experimental/flows/generate.ts +278 -0
- package/src/experimental/flows/index.ts +6 -0
- package/src/experimental/flows/properties.ts +51 -0
- package/src/experimental/flows/types.ts +47 -0
- package/src/experimental/flows/weights.ts +198 -0
- package/src/experimental/generators/config.ts +240 -0
- package/src/experimental/generators/filesystem.ts +95 -0
- package/src/experimental/generators/generate.ts +32 -0
- package/src/experimental/generators/index.ts +10 -0
- package/src/experimental/generators/initial-state.ts +37 -0
- package/src/experimental/generators/random.ts +35 -0
- package/src/experimental/hello-driver.ts +42 -0
- package/src/experimental/index.ts +3 -0
- package/src/experimental/model/derive.ts +119 -0
- package/src/experimental/model/index.ts +2 -0
- package/src/experimental/model/model.ts +42 -0
- package/src/experimental/opencode-simulation.ts +1 -0
- package/src/experimental/reproduce-stale-running-visible.ts +60 -0
- package/src/experimental/reproduce-stale-running.ts +26 -0
- package/src/experimental/stale-running-driver.ts +74 -0
- 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
|
+
}
|