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,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,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,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"
|