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,240 @@
1
+ import { createRng, type Rng } from "./random.js"
2
+
3
+ /**
4
+ * Profile-based OpenCode config generation.
5
+ *
6
+ * Unlike `Schema.toArbitrary`, these generators produce configs a person could
7
+ * plausibly have written, while still covering most schema branches:
8
+ *
9
+ * - minimal: the smallest useful configs
10
+ * - typical: realistic daily-driver configs
11
+ * - maximal: every major section populated
12
+ * - edge: unusual-but-valid shapes (disabled agents, deny-all permissions, ...)
13
+ */
14
+ export type ConfigProfile = "minimal" | "typical" | "maximal" | "edge"
15
+
16
+ export const configProfiles: ReadonlyArray<ConfigProfile> = ["minimal", "typical", "maximal", "edge"]
17
+
18
+ export type ConfigJson = Record<string, unknown>
19
+
20
+ const schemaUrl = "https://opencode.ai/config.json"
21
+
22
+ const models = [
23
+ "opencode/gpt-5.5",
24
+ "opencode/big-pickle",
25
+ "opencode/claude-sonnet-4-5",
26
+ "anthropic/claude-sonnet-4-5",
27
+ "openai/gpt-5-codex",
28
+ ]
29
+
30
+ const agentNames = ["reviewer", "researcher", "planner", "tester", "docs"]
31
+
32
+ const agentDescriptions: Record<string, string> = {
33
+ reviewer: "Reviews changes for bugs, regressions, and risky patterns",
34
+ researcher: "Explores the codebase and summarizes findings with references",
35
+ planner: "Breaks work into small verifiable steps before editing",
36
+ tester: "Writes and runs focused tests around changed behavior",
37
+ docs: "Writes and updates documentation to match the code",
38
+ }
39
+
40
+ const permissionPool: ReadonlyArray<ConfigJson> = [
41
+ { action: "bash", resource: "git *", effect: "allow" },
42
+ { action: "bash", resource: "*", effect: "ask" },
43
+ { action: "edit", resource: "src/**", effect: "allow" },
44
+ { action: "edit", resource: "*", effect: "ask" },
45
+ { action: "read", resource: "*.env", effect: "deny" },
46
+ { action: "webfetch", resource: "*", effect: "allow" },
47
+ { action: "external_directory", resource: "*", effect: "deny" },
48
+ { action: "question", resource: "*", effect: "deny" },
49
+ ]
50
+
51
+ const commandPool: Record<string, string> = {
52
+ review: "Review the current diff for bugs and risky changes.",
53
+ explain: "Explain what the selected code does and why it exists.",
54
+ triage: "Summarize this issue and propose concrete next steps.",
55
+ ship: "Prepare a release summary from recent commits.",
56
+ }
57
+
58
+ const permissions = (rng: Rng, minimum: number, maximum: number): ConfigJson[] =>
59
+ rng.subset(permissionPool, minimum, maximum).map((rule) => ({ ...rule }))
60
+
61
+ const agent = (rng: Rng, name: string): ConfigJson => ({
62
+ description: agentDescriptions[name] ?? `Helps with ${name}`,
63
+ ...(rng.boolean(0.7) ? { model: rng.pick(models) } : {}),
64
+ ...(rng.boolean(0.4) ? { mode: rng.pick(["subagent", "primary", "all"]) } : {}),
65
+ ...(rng.boolean(0.3) ? { system: `You are the ${name} agent. Stay focused and concise.` } : {}),
66
+ ...(rng.boolean(0.3) ? { steps: rng.int(4, 24) } : {}),
67
+ ...(rng.boolean(0.25) ? { color: rng.pick(["primary", "accent", "#4f46e5", "#0ea5e9"]) } : {}),
68
+ ...(rng.boolean(0.3) ? { permissions: permissions(rng, 1, 3) } : {}),
69
+ })
70
+
71
+ const agents = (rng: Rng, names: ReadonlyArray<string>): ConfigJson =>
72
+ Object.fromEntries(names.map((name) => [name, agent(rng, name)]))
73
+
74
+ const commands = (rng: Rng, count: number): ConfigJson =>
75
+ Object.fromEntries(
76
+ rng.sample(Object.keys(commandPool), count).map((name) => [
77
+ name,
78
+ {
79
+ template: commandPool[name]!,
80
+ ...(rng.boolean(0.4) ? { agent: rng.pick(agentNames) } : {}),
81
+ ...(rng.boolean(0.3) ? { model: rng.pick(models) } : {}),
82
+ ...(rng.boolean(0.2) ? { subtask: rng.boolean() } : {}),
83
+ },
84
+ ]),
85
+ )
86
+
87
+ const references = (rng: Rng): ConfigJson => ({
88
+ docs: "./docs",
89
+ ...(rng.boolean(0.6) ? { design: { path: "./design", description: "Product and architecture notes" } } : {}),
90
+ ...(rng.boolean(0.4)
91
+ ? {
92
+ opencode: {
93
+ repository: "https://github.com/anomalyco/opencode",
94
+ branch: "dev",
95
+ description: "OpenCode source checkout",
96
+ },
97
+ }
98
+ : {}),
99
+ })
100
+
101
+ const mcp = (rng: Rng): ConfigJson => ({
102
+ ...(rng.boolean(0.4) ? { timeout: { startup: 10_000, request: 30_000 } } : {}),
103
+ servers: {
104
+ filesystem: {
105
+ type: "local",
106
+ command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."],
107
+ ...(rng.boolean(0.3) ? { environment: { MCP_FS_READONLY: "1" } } : {}),
108
+ },
109
+ ...(rng.boolean(0.5)
110
+ ? {
111
+ linear: {
112
+ type: "remote",
113
+ url: "https://mcp.linear.app/sse",
114
+ ...(rng.boolean(0.5) ? { headers: { Authorization: "Bearer ${LINEAR_TOKEN}" } } : {}),
115
+ },
116
+ }
117
+ : {}),
118
+ },
119
+ })
120
+
121
+ const providers = (rng: Rng): ConfigJson => ({
122
+ local: {
123
+ name: "Local OpenAI-compatible",
124
+ api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "http://localhost:11434/v1" },
125
+ ...(rng.boolean(0.3) ? { env: ["LOCAL_API_KEY"] } : {}),
126
+ models: {
127
+ "llama-3.3-70b": {
128
+ name: "Llama 3.3 70B",
129
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
130
+ limit: { context: 131072, output: 8192 },
131
+ ...(rng.boolean(0.3) ? { cost: { input: 0, output: 0 } } : {}),
132
+ },
133
+ },
134
+ },
135
+ })
136
+
137
+ const formatter = (rng: Rng): ConfigJson => ({
138
+ prettier: { command: ["prettier", "--write", "$FILE"], extensions: [".ts", ".tsx", ".md"] },
139
+ ...(rng.boolean(0.3) ? { gofmt: { disabled: true } } : {}),
140
+ })
141
+
142
+ const lsp = (): ConfigJson => ({
143
+ typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
144
+ eslint: { disabled: true },
145
+ })
146
+
147
+ const minimal = (rng: Rng): ConfigJson => ({
148
+ $schema: schemaUrl,
149
+ model: rng.pick(models),
150
+ ...(rng.boolean(0.3) ? { share: "manual" } : {}),
151
+ })
152
+
153
+ const typical = (rng: Rng): ConfigJson => {
154
+ const names = rng.sample(agentNames, rng.int(1, 2))
155
+ return {
156
+ $schema: schemaUrl,
157
+ model: rng.pick(models),
158
+ ...(rng.boolean(0.5) ? { default_agent: names[0] } : {}),
159
+ ...(rng.boolean(0.5) ? { autoupdate: rng.pick<boolean | string>([true, false, "notify"]) } : {}),
160
+ share: rng.pick(["manual", "auto"]),
161
+ permissions: permissions(rng, 2, 4),
162
+ agents: agents(rng, names),
163
+ ...(rng.boolean(0.6) ? { skills: ["./skills"] } : {}),
164
+ ...(rng.boolean(0.6) ? { instructions: ["AGENTS.md"] } : {}),
165
+ ...(rng.boolean(0.5) ? { commands: commands(rng, rng.int(1, 2)) } : {}),
166
+ }
167
+ }
168
+
169
+ const maximal = (rng: Rng): ConfigJson => {
170
+ const names = rng.sample(agentNames, rng.int(3, 4))
171
+ return {
172
+ $schema: schemaUrl,
173
+ model: rng.pick(models),
174
+ default_agent: names[0],
175
+ autoupdate: rng.pick<boolean | string>([true, "notify"]),
176
+ share: rng.pick(["manual", "auto"]),
177
+ username: rng.pick(["james", "kit", "dax", "adam"]),
178
+ snapshots: rng.boolean(0.8),
179
+ permissions: permissions(rng, 3, 6),
180
+ agents: agents(rng, names),
181
+ watcher: { ignore: ["node_modules/**", "dist/**", ...(rng.boolean(0.5) ? [".git/**"] : [])] },
182
+ formatter: formatter(rng),
183
+ lsp: lsp(),
184
+ attachments: { image: { auto_resize: true, max_width: 1568, max_height: 1568 } },
185
+ tool_output: { max_lines: rng.int(200, 2000), max_bytes: rng.int(16, 256) * 1024 },
186
+ mcp: mcp(rng),
187
+ compaction: {
188
+ auto: true,
189
+ ...(rng.boolean(0.5) ? { prune: true } : {}),
190
+ keep: { tokens: rng.int(4, 32) * 1024 },
191
+ buffer: rng.int(2, 16) * 1024,
192
+ },
193
+ skills: ["./skills", ...(rng.boolean(0.3) ? ["https://github.com/anomalyco/skills"] : [])],
194
+ commands: commands(rng, rng.int(2, 4)),
195
+ instructions: ["AGENTS.md", ...(rng.boolean(0.5) ? ["docs/style.md"] : [])],
196
+ references: references(rng),
197
+ plugins: ["opencode-notify", { package: "opencode-datadog", options: { site: "datadoghq.com" } }],
198
+ providers: providers(rng),
199
+ }
200
+ }
201
+
202
+ const edge = (rng: Rng): ConfigJson => {
203
+ const disabledAgent = rng.pick(agentNames)
204
+ return {
205
+ $schema: schemaUrl,
206
+ ...(rng.boolean(0.5) ? {} : { model: rng.pick(models) }),
207
+ autoupdate: "notify",
208
+ share: "disabled",
209
+ snapshots: false,
210
+ lsp: false,
211
+ formatter: false,
212
+ permissions: [
213
+ { action: "*", resource: "*", effect: "deny" },
214
+ ...(rng.boolean(0.5) ? [{ action: "read", resource: "*", effect: "allow" }] : []),
215
+ ],
216
+ agents: {
217
+ [disabledAgent]: { ...agent(rng, disabledAgent), disabled: true, hidden: true },
218
+ },
219
+ skills: [],
220
+ instructions: [],
221
+ commands: {},
222
+ mcp: {},
223
+ tool_output: { max_lines: 5, max_bytes: 1024 },
224
+ compaction: { auto: false, prune: true, keep: { tokens: 0 }, buffer: 0 },
225
+ }
226
+ }
227
+
228
+ export interface GenerateConfigOptions {
229
+ readonly seed: number
230
+ readonly profile?: ConfigProfile
231
+ }
232
+
233
+ export const generateConfigJson = (options: GenerateConfigOptions): ConfigJson => {
234
+ const rng = createRng(options.seed)
235
+ const profile = options.profile ?? rng.pick(configProfiles)
236
+ if (profile === "minimal") return minimal(rng)
237
+ if (profile === "typical") return typical(rng)
238
+ if (profile === "maximal") return maximal(rng)
239
+ return edge(rng)
240
+ }
@@ -0,0 +1,95 @@
1
+ import { createRng, type Rng } from "./random.js"
2
+ import type { ConfigJson } from "./config.js"
3
+
4
+ /**
5
+ * Virtual filesystem generation.
6
+ *
7
+ * Configs reference files (skills, instructions, references); a coherent
8
+ * initial state must actually contain those files, otherwise the derived
9
+ * model and the real OpenCode behavior diverge for boring reasons.
10
+ */
11
+ export interface VirtualFile {
12
+ readonly path: string
13
+ readonly content: string
14
+ }
15
+
16
+ export interface VirtualFileTree {
17
+ readonly files: ReadonlyArray<VirtualFile>
18
+ }
19
+
20
+ const skillPool: Record<string, string> = {
21
+ "code-review": "Review changes for bugs, regressions, and risky patterns.",
22
+ "diagnosing-bugs": "Build a tight pass/fail signal before guessing at causes.",
23
+ "release-notes": "Summarize shipped changes for users.",
24
+ "ast-grep": "Search code structurally with ast-grep patterns.",
25
+ }
26
+
27
+ const instructionPool = [
28
+ "Answer concisely. Lead with the conclusion.",
29
+ "Prefer small verifiable steps. Run the tests you touch.",
30
+ "Never commit secrets. Ask before external side effects.",
31
+ ]
32
+
33
+ const title = (name: string): string =>
34
+ name
35
+ .split("-")
36
+ .map((part) => (part.length === 0 ? part : part[0]!.toUpperCase() + part.slice(1)))
37
+ .join(" ")
38
+
39
+ export const normalizePath = (value: string): string => value.replace(/^\.\//, "").replace(/\/+$/, "")
40
+
41
+ const isLocalPath = (value: string): boolean => !value.includes("://")
42
+
43
+ const skillFile = (root: string, name: string, description: string): VirtualFile => ({
44
+ path: `${root}/${name}/SKILL.md`,
45
+ content: ["---", `name: ${name}`, `description: ${description}`, "---", "", `# ${title(name)}`, "", description, ""].join(
46
+ "\n",
47
+ ),
48
+ })
49
+
50
+ const stringArray = (value: unknown): string[] =>
51
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
52
+
53
+ const referencePath = (entry: unknown): string | undefined => {
54
+ if (typeof entry === "string") return entry
55
+ if (typeof entry === "object" && entry !== null && "path" in entry) {
56
+ const path = (entry as { readonly path: unknown }).path
57
+ if (typeof path === "string") return path
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ const referenceFiles = (config: ConfigJson, rng: Rng): VirtualFile[] => {
63
+ const references = config.references
64
+ if (typeof references !== "object" || references === null || Array.isArray(references)) return []
65
+ return Object.entries(references).flatMap(([name, entry]) => {
66
+ const path = referencePath(entry)
67
+ if (path === undefined || !isLocalPath(path)) return []
68
+ return [
69
+ {
70
+ path: `${normalizePath(path)}/README.md`,
71
+ content: `# ${title(name)}\n\n${rng.pick(instructionPool)}\n`,
72
+ },
73
+ ]
74
+ })
75
+ }
76
+
77
+ export const generateFilesForConfig = (config: ConfigJson, seed: number): VirtualFileTree => {
78
+ const rng = createRng(seed)
79
+ const files: VirtualFile[] = []
80
+
81
+ for (const source of stringArray(config.skills).filter(isLocalPath)) {
82
+ const root = normalizePath(source)
83
+ for (const name of rng.sample(Object.keys(skillPool), rng.int(1, 3))) {
84
+ files.push(skillFile(root, name, skillPool[name]!))
85
+ }
86
+ }
87
+
88
+ for (const path of stringArray(config.instructions).filter(isLocalPath)) {
89
+ files.push({ path: normalizePath(path), content: `${rng.pick(instructionPool)}\n` })
90
+ }
91
+
92
+ files.push(...referenceFiles(config, rng))
93
+
94
+ return { files }
95
+ }
@@ -0,0 +1,32 @@
1
+ import { Effect, Schema } from "effect"
2
+ import { configProfiles, generateConfigJson, type ConfigJson } from "./config.js"
3
+
4
+ // Imported through a variable so `tsgo` does not typecheck all of OpenCode
5
+ // core; the runtime import still tracks the latest checkout.
6
+ const opencodeConfigModule = "../../opencode-latest/packages/core/src/config.ts"
7
+
8
+ export interface GenerateConfigsOptions {
9
+ readonly count?: number
10
+ readonly seed?: number
11
+ }
12
+
13
+ /**
14
+ * Generates realistic OpenCode config.json objects and validates every one of
15
+ * them against the latest checkout's `Config.Info` schema, so the generator
16
+ * fails loudly whenever upstream config contracts change.
17
+ */
18
+ export const generateConfigs = (
19
+ options?: GenerateConfigsOptions,
20
+ ): Effect.Effect<ReadonlyArray<ConfigJson>> =>
21
+ Effect.promise(async () => {
22
+ const { Config } = await import(opencodeConfigModule)
23
+ const decode = Schema.decodeUnknownSync(Config.Info)
24
+ const count = options?.count ?? 8
25
+ const seed = options?.seed ?? 1
26
+ return Array.from({ length: count }, (_, index) => {
27
+ const profile = configProfiles[index % configProfiles.length]!
28
+ const config = generateConfigJson({ seed: seed + index, profile })
29
+ decode(config)
30
+ return config
31
+ })
32
+ })
@@ -0,0 +1,10 @@
1
+ export { configProfiles, generateConfigJson } from "./config.js"
2
+ export type { ConfigJson, ConfigProfile, GenerateConfigOptions } from "./config.js"
3
+ export { generateFilesForConfig, normalizePath } from "./filesystem.js"
4
+ export type { VirtualFile, VirtualFileTree } from "./filesystem.js"
5
+ export { generateConfigs } from "./generate.js"
6
+ export type { GenerateConfigsOptions } from "./generate.js"
7
+ export { generateInitialState, generateInitialStates } from "./initial-state.js"
8
+ export type { InitialState, InitialStateOptions } from "./initial-state.js"
9
+ export { createRng } from "./random.js"
10
+ export type { Rng } from "./random.js"
@@ -0,0 +1,37 @@
1
+ import { configProfiles, generateConfigJson, type ConfigJson, type ConfigProfile } from "./config.js"
2
+ import { generateFilesForConfig, type VirtualFileTree } from "./filesystem.js"
3
+ import { createRng } from "./random.js"
4
+
5
+ /**
6
+ * One complete initial state for model-based testing:
7
+ * the config OpenCode reads, the files it can see, and the environment.
8
+ *
9
+ * The config generator stays pure (config objects only); this module is the
10
+ * coordination point that keeps config and filesystem coherent.
11
+ */
12
+ export interface InitialState {
13
+ readonly config: ConfigJson
14
+ readonly files: VirtualFileTree
15
+ readonly env: Readonly<Record<string, string>>
16
+ }
17
+
18
+ export interface InitialStateOptions {
19
+ readonly seed?: number
20
+ readonly profile?: ConfigProfile
21
+ }
22
+
23
+ export const generateInitialState = (options?: InitialStateOptions): InitialState => {
24
+ const seed = options?.seed ?? 1
25
+ const rng = createRng(seed * 7919 + 17)
26
+ const config = generateConfigJson({ seed, ...(options?.profile === undefined ? {} : { profile: options.profile }) })
27
+ const files = generateFilesForConfig(config, seed * 31 + 7)
28
+ const env: Record<string, string> = rng.boolean(0.2) ? { OPENCODE_DISABLE_AUTOUPDATE: "1" } : {}
29
+ return { config, files, env }
30
+ }
31
+
32
+ export const generateInitialStates = (count: number, options?: { readonly seed?: number }): InitialState[] => {
33
+ const seed = options?.seed ?? 1
34
+ return Array.from({ length: count }, (_, index) =>
35
+ generateInitialState({ seed: seed + index, profile: configProfiles[index % configProfiles.length]! }),
36
+ )
37
+ }
@@ -0,0 +1,35 @@
1
+ export interface Rng {
2
+ readonly next: () => number
3
+ readonly int: (minimum: number, maximum: number) => number
4
+ readonly boolean: (probability?: number) => boolean
5
+ readonly pick: <T>(items: ReadonlyArray<T>) => T
6
+ readonly sample: <T>(items: ReadonlyArray<T>, count: number) => T[]
7
+ readonly subset: <T>(items: ReadonlyArray<T>, minimum?: number, maximum?: number) => T[]
8
+ }
9
+
10
+ /** Deterministic mulberry32-style RNG so generated fixtures are reproducible per seed. */
11
+ export const createRng = (seed: number): Rng => {
12
+ let state = seed >>> 0 || 1
13
+ const next = () => {
14
+ state = (state + 0x6d2b79f5) >>> 0
15
+ let t = state
16
+ t = Math.imul(t ^ (t >>> 15), t | 1)
17
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
18
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
19
+ }
20
+ const int = (minimum: number, maximum: number) => minimum + Math.floor(next() * (maximum - minimum + 1))
21
+ const boolean = (probability = 0.5) => next() < probability
22
+ const pick = <T>(items: ReadonlyArray<T>): T => {
23
+ if (items.length === 0) throw new Error("Cannot pick from an empty list")
24
+ return items[int(0, items.length - 1)]!
25
+ }
26
+ const sample = <T>(items: ReadonlyArray<T>, count: number): T[] => {
27
+ const copy = [...items]
28
+ const out: T[] = []
29
+ while (out.length < count && copy.length > 0) out.push(copy.splice(int(0, copy.length - 1), 1)[0]!)
30
+ return out
31
+ }
32
+ const subset = <T>(items: ReadonlyArray<T>, minimum = 0, maximum = items.length): T[] =>
33
+ sample(items, int(Math.min(minimum, items.length), Math.min(maximum, items.length)))
34
+ return { next, int, boolean, pick, sample, subset }
35
+ }
@@ -0,0 +1,42 @@
1
+ import { connectBackendSimulation, connectSimulation, type OpenedExchange } from "../client/index.js"
2
+
3
+ const ui = await connectSimulation({ url: requiredEnv("OPENCODE_SIMULATION_UI_WS") })
4
+ const backend = await connectBackendSimulation({ url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS") })
5
+ let completed!: () => void
6
+ const responseCompleted = new Promise<void>((resolve) => {
7
+ completed = resolve
8
+ })
9
+
10
+ await backend.attach(async (request: OpenedExchange) => {
11
+ await backend.chunk(request.id, [{ type: "textDelta", text: "hello" }])
12
+ await backend.finish(request.id, "stop")
13
+ completed()
14
+ })
15
+
16
+ try {
17
+ await waitFor(async () => (await ui.state()).focused.editor)
18
+ await ui.typeText("Say hello")
19
+ await ui.pressEnter()
20
+ await responseCompleted
21
+ await waitFor(async () => (await backend.pendingExchanges()).exchanges.length === 0)
22
+ await ui.state()
23
+ await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
24
+ } finally {
25
+ ui.close()
26
+ backend.close()
27
+ }
28
+
29
+ function requiredEnv(name: string) {
30
+ const value = process.env[name]
31
+ if (value === undefined) throw new Error(`${name} is required`)
32
+ return value
33
+ }
34
+
35
+ async function waitFor(check: () => Promise<boolean>) {
36
+ const deadline = Date.now() + 30_000
37
+ while (Date.now() < deadline) {
38
+ if (await check()) return
39
+ await Bun.sleep(50)
40
+ }
41
+ throw new Error("timed out waiting for UI state")
42
+ }
@@ -0,0 +1,3 @@
1
+ export * from "../client/index.js"
2
+ export * from "./campaign-api.js"
3
+ export * from "./drive.js"
@@ -0,0 +1,119 @@
1
+ import type { InitialState } from "../generators/initial-state.js"
2
+ import { normalizePath } from "../generators/filesystem.js"
3
+ import type { ModelAgent, ModelPermission, ModelSkill, ProbeModel } from "./model.js"
4
+
5
+ /**
6
+ * Derives the expected model from an initial state.
7
+ *
8
+ * Mirrors how OpenCode interprets the config and filesystem, but only for the
9
+ * facts the model asserts. Skill discovery intentionally follows core's
10
+ * `{*.md,**\/SKILL.md}` + frontmatter-name rules.
11
+ */
12
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
13
+ typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
14
+
15
+ const asString = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined)
16
+
17
+ const stringArray = (value: unknown): string[] =>
18
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
19
+
20
+ const frontmatter = (content: string): Record<string, string> => {
21
+ const match = content.match(/^---\n([\s\S]*?)\n---/)
22
+ if (!match) return {}
23
+ const out: Record<string, string> = {}
24
+ for (const line of match[1]!.split("\n")) {
25
+ const colon = line.indexOf(":")
26
+ if (colon === -1) continue
27
+ out[line.slice(0, colon).trim()] = line.slice(colon + 1).trim()
28
+ }
29
+ return out
30
+ }
31
+
32
+ const deriveAgents = (config: Record<string, unknown>): Map<string, ModelAgent> => {
33
+ const out = new Map<string, ModelAgent>()
34
+ for (const [name, value] of Object.entries(asRecord(config.agents) ?? {})) {
35
+ const entry = asRecord(value) ?? {}
36
+ const mode = entry.mode
37
+ out.set(name, {
38
+ name,
39
+ disabled: entry.disabled === true,
40
+ hidden: entry.hidden === true,
41
+ ...(mode === "subagent" || mode === "primary" || mode === "all" ? { mode } : {}),
42
+ ...(asString(entry.model) === undefined ? {} : { model: asString(entry.model)! }),
43
+ })
44
+ }
45
+ return out
46
+ }
47
+
48
+ const derivePermissions = (config: Record<string, unknown>): ModelPermission[] => {
49
+ if (!Array.isArray(config.permissions)) return []
50
+ return config.permissions.flatMap((value) => {
51
+ const rule = asRecord(value)
52
+ const action = asString(rule?.action)
53
+ const resource = asString(rule?.resource)
54
+ const effect = rule?.effect
55
+ if (action === undefined || resource === undefined) return []
56
+ if (effect !== "allow" && effect !== "deny" && effect !== "ask") return []
57
+ return [{ action, resource, effect }]
58
+ })
59
+ }
60
+
61
+ const deriveSkills = (state: InitialState): ModelSkill[] => {
62
+ const sources = stringArray(state.config.skills)
63
+ .filter((source) => !source.includes("://"))
64
+ .map(normalizePath)
65
+ const skills: ModelSkill[] = []
66
+ for (const source of sources) {
67
+ const prefix = `${source}/`
68
+ for (const file of state.files.files) {
69
+ if (!file.path.startsWith(prefix)) continue
70
+ const rest = file.path.slice(prefix.length)
71
+ const isRootMarkdown = !rest.includes("/") && rest.endsWith(".md")
72
+ const isNestedSkill = rest.endsWith("/SKILL.md")
73
+ if (!isRootMarkdown && !isNestedSkill) continue
74
+ const name = frontmatter(file.content).name ?? (isRootMarkdown ? rest.slice(0, -3) : undefined)
75
+ if (name !== undefined) skills.push({ name, path: file.path })
76
+ }
77
+ }
78
+ return skills
79
+ }
80
+
81
+ const deriveReferences = (config: Record<string, unknown>): Map<string, string> => {
82
+ const out = new Map<string, string>()
83
+ for (const [name, value] of Object.entries(asRecord(config.references) ?? {})) {
84
+ const target =
85
+ asString(value) ?? asString(asRecord(value)?.path) ?? asString(asRecord(value)?.repository)
86
+ if (target !== undefined) out.set(name, target)
87
+ }
88
+ return out
89
+ }
90
+
91
+ const derivePlugins = (config: Record<string, unknown>): Set<string> => {
92
+ const out = new Set<string>()
93
+ if (!Array.isArray(config.plugins)) return out
94
+ for (const entry of config.plugins) {
95
+ const name = asString(entry) ?? asString(asRecord(entry)?.package)
96
+ if (name !== undefined) out.add(name)
97
+ }
98
+ return out
99
+ }
100
+
101
+ export const deriveModel = (state: InitialState): ProbeModel => {
102
+ const config = state.config
103
+ const share = config.share
104
+ const autoupdate = config.autoupdate
105
+ return {
106
+ ...(asString(config.model) === undefined ? {} : { configuredModel: asString(config.model)! }),
107
+ ...(asString(config.default_agent) === undefined ? {} : { defaultAgent: asString(config.default_agent)! }),
108
+ ...(share === "manual" || share === "auto" || share === "disabled" ? { share } : {}),
109
+ ...(typeof autoupdate === "boolean" || autoupdate === "notify" ? { autoupdate } : {}),
110
+ agents: deriveAgents(config),
111
+ commands: new Set(Object.keys(asRecord(config.commands) ?? {})),
112
+ skills: deriveSkills(state),
113
+ references: deriveReferences(config),
114
+ permissions: derivePermissions(config),
115
+ mcpServers: new Set(Object.keys(asRecord(asRecord(config.mcp)?.servers) ?? {})),
116
+ plugins: derivePlugins(config),
117
+ providers: new Set(Object.keys(asRecord(config.providers) ?? {})),
118
+ }
119
+ }
@@ -0,0 +1,2 @@
1
+ export { deriveModel } from "./derive.js"
2
+ export type { ModelAgent, ModelPermission, ModelSkill, ProbeModel } from "./model.js"
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The simplified expected-state model for model-based testing.
3
+ *
4
+ * This is not a copy of OpenCode internals. It captures only the facts we
5
+ * intend to assert against a running OpenCode: which agents/skills/commands
6
+ * exist, which model is configured, what the permission posture is, etc.
7
+ *
8
+ * Initial states seed this model; test commands transition it.
9
+ */
10
+ export interface ModelAgent {
11
+ readonly name: string
12
+ readonly disabled: boolean
13
+ readonly hidden: boolean
14
+ readonly mode?: "subagent" | "primary" | "all"
15
+ readonly model?: string
16
+ }
17
+
18
+ export interface ModelPermission {
19
+ readonly action: string
20
+ readonly resource: string
21
+ readonly effect: "allow" | "deny" | "ask"
22
+ }
23
+
24
+ export interface ModelSkill {
25
+ readonly name: string
26
+ readonly path: string
27
+ }
28
+
29
+ export interface ProbeModel {
30
+ readonly configuredModel?: string
31
+ readonly defaultAgent?: string
32
+ readonly share?: "manual" | "auto" | "disabled"
33
+ readonly autoupdate?: boolean | "notify"
34
+ readonly agents: ReadonlyMap<string, ModelAgent>
35
+ readonly commands: ReadonlySet<string>
36
+ readonly skills: ReadonlyArray<ModelSkill>
37
+ readonly references: ReadonlyMap<string, string>
38
+ readonly permissions: ReadonlyArray<ModelPermission>
39
+ readonly mcpServers: ReadonlySet<string>
40
+ readonly plugins: ReadonlySet<string>
41
+ readonly providers: ReadonlySet<string>
42
+ }
@@ -0,0 +1 @@
1
+ export { SimulationProtocol } from "../../../opencode-latest/packages/simulation/src/protocol/index"