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,278 @@
|
|
|
1
|
+
import { createRng } from "../generators/random.js"
|
|
2
|
+
import { defaultWeights, pickWeighted, type GenerationWeights } from "./weights.js"
|
|
3
|
+
import type { FlowResponse, FlowScenario, FlowTurn, InteractionKind, ResponseKind } from "./types.js"
|
|
4
|
+
|
|
5
|
+
const tasks = [
|
|
6
|
+
{
|
|
7
|
+
subject: "the request routing path",
|
|
8
|
+
target: "src/server.ts",
|
|
9
|
+
symbol: "createServer",
|
|
10
|
+
concern: "a request can be dispatched after shutdown begins",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
subject: "the cache invalidation behavior",
|
|
14
|
+
target: "src/cache.ts",
|
|
15
|
+
symbol: "invalidate",
|
|
16
|
+
concern: "stale entries survive a failed refresh",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
subject: "the configuration loading path",
|
|
20
|
+
target: "src/config.ts",
|
|
21
|
+
symbol: "loadConfig",
|
|
22
|
+
concern: "defaults are applied before validation",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
subject: "the greeting helper",
|
|
26
|
+
target: "src/example.ts",
|
|
27
|
+
symbol: "greet",
|
|
28
|
+
concern: "empty names produce awkward output",
|
|
29
|
+
},
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
const followUps = [
|
|
33
|
+
"Keep the change narrow and call out any assumptions.",
|
|
34
|
+
"Check the edge cases before proposing a change.",
|
|
35
|
+
"Prefer evidence from the repository over guesses.",
|
|
36
|
+
"Explain what should be tested when you are done.",
|
|
37
|
+
] as const
|
|
38
|
+
|
|
39
|
+
const split = (text: string, pieces: number) => {
|
|
40
|
+
const size = Math.ceil(text.length / pieces)
|
|
41
|
+
return Array.from({ length: pieces }, (_, index) => text.slice(index * size, (index + 1) * size)).filter(Boolean)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function generateFlow(seed: number, options?: { readonly turns?: number; readonly weights?: GenerationWeights; readonly enabledTools?: Set<keyof GenerationWeights["toolSelection"]> }): FlowScenario {
|
|
45
|
+
const rng = createRng(seed)
|
|
46
|
+
const weights = options?.weights ?? defaultWeights
|
|
47
|
+
const task = rng.pick(tasks)
|
|
48
|
+
const count = options?.turns ?? rng.int(6, 9)
|
|
49
|
+
|
|
50
|
+
if (options?.enabledTools && options.enabledTools.size > 0) {
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Use weights to select response kinds for each turn
|
|
54
|
+
const responseKindKeys: readonly ResponseKind[] = ["text", "chunked", "reasoning", "markdown", "raw", "tool"]
|
|
55
|
+
const responseKindWeights = responseKindKeys.map(k => weights.responseKinds[k])
|
|
56
|
+
const selected: ResponseKind[] = Array.from({ length: count }, () =>
|
|
57
|
+
pickWeighted(responseKindKeys, responseKindWeights, rng.next())
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const turns = selected.map((kind, index) => makeTurn(seed, index, kind, task, rng.pick(followUps), rng, weights, options?.enabledTools))
|
|
61
|
+
const responseKinds = { text: 0, chunked: 0, reasoning: 0, markdown: 0, raw: 0, tool: 0 }
|
|
62
|
+
const toolNames: Record<string, number> = {}
|
|
63
|
+
const interactions = { normal: 0, "double-submit": 0, steer: 0, interrupt: 0, "provider-drop": 0 }
|
|
64
|
+
const streamChunkTypes = new Set<string>()
|
|
65
|
+
for (const turn of turns) {
|
|
66
|
+
interactions[turn.interaction]++
|
|
67
|
+
for (const response of turn.responses) {
|
|
68
|
+
responseKinds[response.kind]++
|
|
69
|
+
for (const name of response.toolNames ?? []) toolNames[name] = (toolNames[name] ?? 0) + 1
|
|
70
|
+
for (const type of response.streamChunkTypes ?? []) streamChunkTypes.add(type)
|
|
71
|
+
for (const item of response.chunks.flat()) {
|
|
72
|
+
streamChunkTypes.add(item.type)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
seed,
|
|
79
|
+
name: `${task.symbol}-${seed.toString(36)}`,
|
|
80
|
+
turns,
|
|
81
|
+
coverage: {
|
|
82
|
+
responseKinds,
|
|
83
|
+
toolNames,
|
|
84
|
+
interactions,
|
|
85
|
+
streamChunkTypes: [...streamChunkTypes],
|
|
86
|
+
providerExchanges: turns.reduce((total, turn) => total + turn.responses.length, 0),
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeTurn(
|
|
92
|
+
seed: number,
|
|
93
|
+
index: number,
|
|
94
|
+
kind: ResponseKind,
|
|
95
|
+
task: (typeof tasks)[number],
|
|
96
|
+
followUp: string,
|
|
97
|
+
rng: ReturnType<typeof createRng>,
|
|
98
|
+
weights: GenerationWeights,
|
|
99
|
+
enabledTools?: Set<keyof GenerationWeights["toolSelection"]>,
|
|
100
|
+
): FlowTurn {
|
|
101
|
+
const marker = `[flow-${seed}-turn-${index + 1}-complete]`
|
|
102
|
+
|
|
103
|
+
// Use weights to select interaction kind
|
|
104
|
+
const interactionKeys: readonly InteractionKind[] = ["normal", "double-submit", "steer", "interrupt", "provider-drop"]
|
|
105
|
+
const interactionWeights = interactionKeys.map(k => weights.interactions[k])
|
|
106
|
+
const interaction: InteractionKind = kind === "raw"
|
|
107
|
+
? "provider-drop"
|
|
108
|
+
: kind === "tool"
|
|
109
|
+
? (seed % 2 === 0 ? "normal" : "interrupt")
|
|
110
|
+
: pickWeighted(interactionKeys, interactionWeights, rng.next())
|
|
111
|
+
|
|
112
|
+
const prompts = [
|
|
113
|
+
`Orient yourself in this project and identify where ${task.subject} lives. ${followUp}`,
|
|
114
|
+
`Inspect ${task.target} and trace ${task.symbol} through its callers. What behavior is observable?`,
|
|
115
|
+
`The suspected bug is that ${task.concern}. Find evidence for or against that hypothesis.`,
|
|
116
|
+
"Compare the smallest plausible fixes. Which one preserves existing behavior best?",
|
|
117
|
+
"Describe the implementation in enough detail that another engineer could apply it safely.",
|
|
118
|
+
"Now challenge that approach with one failure scenario and revise it if necessary.",
|
|
119
|
+
"Give me a focused test plan covering the happy path, boundary behavior, and regression risk.",
|
|
120
|
+
"Review the whole investigation for contradictions or unsupported claims.",
|
|
121
|
+
"Summarize the final recommendation and the next concrete action.",
|
|
122
|
+
]
|
|
123
|
+
return {
|
|
124
|
+
prompt: prompts[index % prompts.length]!,
|
|
125
|
+
marker,
|
|
126
|
+
interaction,
|
|
127
|
+
...(interaction === "steer" ? { steerPrompt: `While that is running, also check the concurrency boundary for turn ${index + 1}.` } : {}),
|
|
128
|
+
responses: [
|
|
129
|
+
...makeResponses(kind, marker, task, index, rng, weights, enabledTools),
|
|
130
|
+
...(interaction === "steer"
|
|
131
|
+
? [{ kind: "text" as const, chunks: [[{ type: "textDelta" as const, text: `I incorporated the in-flight steering prompt. ${marker}` }]], finish: "stop" as const }]
|
|
132
|
+
: []),
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function makeResponses(
|
|
138
|
+
kind: ResponseKind,
|
|
139
|
+
marker: string,
|
|
140
|
+
task: (typeof tasks)[number],
|
|
141
|
+
index: number,
|
|
142
|
+
rng: ReturnType<typeof createRng>,
|
|
143
|
+
weights: GenerationWeights,
|
|
144
|
+
enabledTools?: Set<keyof GenerationWeights["toolSelection"]>,
|
|
145
|
+
): ReadonlyArray<FlowResponse> {
|
|
146
|
+
const conclusion = `Turn ${index + 1}: the evidence keeps the investigation focused on \`${task.symbol}\`. ${marker}`
|
|
147
|
+
if (kind === "text") return [{ kind, chunks: [[{ type: "textDelta", text: conclusion }]], finish: "stop" }]
|
|
148
|
+
if (kind === "chunked") {
|
|
149
|
+
return [{ kind, chunks: split(`${conclusion} I will preserve the current API and verify the boundary explicitly.`, 4).map((text) => [{ type: "textDelta", text }] as const), finish: "stop" }]
|
|
150
|
+
}
|
|
151
|
+
if (kind === "reasoning") {
|
|
152
|
+
return [{
|
|
153
|
+
kind,
|
|
154
|
+
chunks: [
|
|
155
|
+
[{ type: "reasoningDelta", text: `I need to separate observed behavior from the hypothesis about ${task.concern}.` }],
|
|
156
|
+
[{ type: "textDelta", text: conclusion }],
|
|
157
|
+
],
|
|
158
|
+
finish: "stop",
|
|
159
|
+
}]
|
|
160
|
+
}
|
|
161
|
+
if (kind === "markdown") {
|
|
162
|
+
return [{
|
|
163
|
+
kind,
|
|
164
|
+
chunks: [[{
|
|
165
|
+
type: "textDelta",
|
|
166
|
+
text: `### Findings\n\n- Target: \`${task.target}\`\n- Symbol: \`${task.symbol}\`\n- Risk: ${task.concern}\n\n\`\`\`ts\n// Preserve the existing contract; test the boundary first.\n\`\`\`\n\n${marker}`,
|
|
167
|
+
}]],
|
|
168
|
+
finish: "stop",
|
|
169
|
+
}]
|
|
170
|
+
}
|
|
171
|
+
if (kind === "raw") {
|
|
172
|
+
// Use weights to decide whether to generate a failure at all
|
|
173
|
+
const failureTotal = weights.failures.disconnect +
|
|
174
|
+
weights.failures.invalidProviderEvent +
|
|
175
|
+
weights.failures.disconnectDuringToolInput +
|
|
176
|
+
weights.failures.disconnectAfterTools
|
|
177
|
+
const hasAnyFailure = failureTotal > 0 && rng.next() < (failureTotal / (failureTotal + 1))
|
|
178
|
+
|
|
179
|
+
if (!hasAnyFailure) {
|
|
180
|
+
// Normal raw response, no failures
|
|
181
|
+
return [{
|
|
182
|
+
kind,
|
|
183
|
+
chunks: [
|
|
184
|
+
[{ type: "raw", chunk: { id: `chatcmpl-${index}`, object: "chat.completion.chunk", created: 1, model: "sim-model", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] } }],
|
|
185
|
+
[{ type: "raw", chunk: { choices: [] } }],
|
|
186
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null, reasoning_content: "Checking the stream state. " }, finish_reason: null }] } }],
|
|
187
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null }, finish_reason: null }] } }],
|
|
188
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: conclusion }, finish_reason: null }] } }],
|
|
189
|
+
[{ type: "raw", chunk: { choices: [], usage: { prompt_tokens: 120, completion_tokens: 24, total_tokens: 144, prompt_tokens_details: { cached_tokens: 20 }, completion_tokens_details: { reasoning_tokens: 5 } } } }],
|
|
190
|
+
],
|
|
191
|
+
finish: (["stop", "length", "content-filter"] as const)[index % 3]!,
|
|
192
|
+
streamChunkTypes: [
|
|
193
|
+
"chat.metadata-role",
|
|
194
|
+
"chat.empty-choices",
|
|
195
|
+
"chat.reasoning-content",
|
|
196
|
+
"chat.content-null",
|
|
197
|
+
"chat.content-delta",
|
|
198
|
+
"chat.usage",
|
|
199
|
+
`chat.finish.${(["stop", "length", "content-filter"] as const)[index % 3]!}`,
|
|
200
|
+
],
|
|
201
|
+
}]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Pick which failure mode to use
|
|
205
|
+
const failureKeys: readonly (keyof typeof weights.failures)[] = ["disconnect", "invalidProviderEvent", "disconnectDuringToolInput", "disconnectAfterTools"]
|
|
206
|
+
const failureWeights = failureKeys.map(k => weights.failures[k])
|
|
207
|
+
const failureType = pickWeighted(failureKeys, failureWeights, rng.next())
|
|
208
|
+
|
|
209
|
+
const regularDisconnect = failureType === "disconnect"
|
|
210
|
+
const invalidEvent = failureType === "invalidProviderEvent"
|
|
211
|
+
|
|
212
|
+
return [{
|
|
213
|
+
kind,
|
|
214
|
+
chunks: [
|
|
215
|
+
[{ type: "raw", chunk: { id: `chatcmpl-${index}`, object: "chat.completion.chunk", created: 1, model: "sim-model", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] } }],
|
|
216
|
+
[{ type: "raw", chunk: { choices: [] } }],
|
|
217
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null, reasoning_content: "Checking the stream state. " }, finish_reason: null }] } }],
|
|
218
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null }, finish_reason: null }] } }],
|
|
219
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: conclusion }, finish_reason: null }] } }],
|
|
220
|
+
[{ type: "raw", chunk: { choices: [], usage: { prompt_tokens: 120, completion_tokens: 24, total_tokens: 144, prompt_tokens_details: { cached_tokens: 20 }, completion_tokens_details: { reasoning_tokens: 5 } } } }],
|
|
221
|
+
...(invalidEvent ? [[{ type: "raw" as const, chunk: { choices: "detached" } }]] : []),
|
|
222
|
+
],
|
|
223
|
+
finish: (["stop", "length", "content-filter"] as const)[index % 3]!,
|
|
224
|
+
...(regularDisconnect ? { terminal: "disconnect" as const } : invalidEvent ? { terminal: "invalid-provider-event" as const } : {}),
|
|
225
|
+
streamChunkTypes: [
|
|
226
|
+
"chat.metadata-role",
|
|
227
|
+
"chat.empty-choices",
|
|
228
|
+
"chat.reasoning-content",
|
|
229
|
+
"chat.content-null",
|
|
230
|
+
"chat.content-delta",
|
|
231
|
+
"chat.usage",
|
|
232
|
+
`chat.finish.${(["stop", "length", "content-filter"] as const)[index % 3]!}`,
|
|
233
|
+
...(invalidEvent ? ["chat.invalid-provider-event"] : []),
|
|
234
|
+
...(regularDisconnect ? ["chat.transport-disconnect"] : []),
|
|
235
|
+
],
|
|
236
|
+
}]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Tool response - keep it simple like the original, but filter tools
|
|
240
|
+
const baseTools = [
|
|
241
|
+
{ name: "glob" as const, input: { pattern: "src/**/*.ts" } },
|
|
242
|
+
{ name: "grep" as const, input: { pattern: task.symbol, path: "src", include: "*.ts" } },
|
|
243
|
+
{ name: "read" as const, input: { path: task.target, limit: 120 } },
|
|
244
|
+
{ name: "todowrite" as const, input: { todos: [{ content: "Reproduce detached loading state", status: "in_progress", priority: "high" }] } },
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
// Filter to only enabled tools
|
|
248
|
+
const availableTools = enabledTools
|
|
249
|
+
? baseTools.filter(t => enabledTools.has(t.name))
|
|
250
|
+
: baseTools
|
|
251
|
+
|
|
252
|
+
if (availableTools.length === 0) {
|
|
253
|
+
// No tools enabled, return text response instead
|
|
254
|
+
return [{ kind: "text", chunks: [[{ type: "textDelta", text: conclusion }]], finish: "stop" }]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const tool = availableTools[index % availableTools.length]!
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
kind,
|
|
261
|
+
chunks: [
|
|
262
|
+
[{ type: "reasoningDelta", text: "I should inspect the repository before making a claim." }],
|
|
263
|
+
[{ type: "textDelta", text: `I'll check ${task.target} and related code.` }],
|
|
264
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `call-${marker.slice(1, -1)}`, function: { name: tool.name, arguments: "{" } }] }, finish_reason: null }] } }],
|
|
265
|
+
[{ type: "raw", chunk: { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(tool.input).slice(1) } }] }, finish_reason: null }] } }],
|
|
266
|
+
[{ type: "raw", chunk: { choices: [], usage: { prompt_tokens: 180, completion_tokens: 30, total_tokens: 210 } } }],
|
|
267
|
+
],
|
|
268
|
+
finish: "tool-calls",
|
|
269
|
+
toolNames: [tool.name],
|
|
270
|
+
streamChunkTypes: ["chat.tool-call-start", "chat.tool-call-arguments", "chat.usage", "chat.finish.tool-calls"],
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
kind: "text",
|
|
274
|
+
chunks: [[{ type: "textDelta", text: `The repository observation is now part of the evidence. ${conclusion}` }]],
|
|
275
|
+
finish: "stop",
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { generateFlow } from "./generate.js"
|
|
2
|
+
export { defineProperty, flowProperties, isRunning } from "./properties.js"
|
|
3
|
+
export type { FlowProperty, FlowPropertyContext, TurnOutcome } from "./properties.js"
|
|
4
|
+
export type { FlowResponse, FlowResult, FlowScenario, FlowTurn, InteractionKind, ResponseKind } from "./types.js"
|
|
5
|
+
export { defaultWeights, parseWeightsFromArgs, pickWeighted, shouldGenerate } from "./weights.js"
|
|
6
|
+
export type { GenerationWeights } from "./weights.js"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BackendSimulationClient, SimulationClient, UiState } from "../../client/index.js"
|
|
2
|
+
import type { FlowTurn } from "./types.js"
|
|
3
|
+
|
|
4
|
+
export type TurnOutcome = "completed" | "interrupted" | "provider-error"
|
|
5
|
+
|
|
6
|
+
export interface FlowPropertyContext {
|
|
7
|
+
readonly turn: FlowTurn
|
|
8
|
+
readonly ui: SimulationClient
|
|
9
|
+
readonly backend: BackendSimulationClient
|
|
10
|
+
readonly outcome?: TurnOutcome
|
|
11
|
+
readonly waitFor: (label: string, check: () => Promise<boolean>) => Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FlowProperty {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly afterSubmit?: (context: FlowPropertyContext) => Promise<void>
|
|
17
|
+
readonly afterTerminal?: (context: FlowPropertyContext) => Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const defineProperty = (property: FlowProperty) => property
|
|
21
|
+
|
|
22
|
+
export const flowProperties: ReadonlyArray<FlowProperty> = [
|
|
23
|
+
defineProperty({
|
|
24
|
+
name: "submitted-turn-shows-running",
|
|
25
|
+
afterSubmit: (context) => {
|
|
26
|
+
if (context.turn.responses.some((response) => response.terminal === "invalid-provider-event" || response.terminal === "disconnect")) return Promise.resolve()
|
|
27
|
+
return context.waitFor("submitted turn to show running", async () => isRunning(await context.ui.state()))
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
defineProperty({
|
|
31
|
+
name: "turn-reaches-terminal-outcome",
|
|
32
|
+
afterTerminal: async (context) => {
|
|
33
|
+
if (context.outcome === undefined) throw new Error("turn has no terminal outcome")
|
|
34
|
+
await context.waitFor(
|
|
35
|
+
"terminal turn to have no provider exchange",
|
|
36
|
+
async () => (await context.backend.pendingExchanges()).exchanges.length === 0,
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
defineProperty({
|
|
41
|
+
name: "terminal-turn-clears-running",
|
|
42
|
+
afterTerminal: (context) =>
|
|
43
|
+
context.waitFor("terminal turn to stop showing running", async () => {
|
|
44
|
+
return !isRunning(await context.ui.state())
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
export function isRunning(state: UiState) {
|
|
50
|
+
return !state.focused.editor
|
|
51
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { BackendFinishReason, BackendItem, UiState } from "../../client/index.js"
|
|
2
|
+
|
|
3
|
+
export type ResponseKind = "text" | "chunked" | "reasoning" | "markdown" | "raw" | "tool"
|
|
4
|
+
export type InteractionKind = "normal" | "double-submit" | "steer" | "interrupt" | "provider-drop"
|
|
5
|
+
|
|
6
|
+
export interface FlowResponse {
|
|
7
|
+
readonly kind: ResponseKind
|
|
8
|
+
readonly chunks: ReadonlyArray<ReadonlyArray<BackendItem>>
|
|
9
|
+
readonly finish: BackendFinishReason
|
|
10
|
+
readonly toolNames?: ReadonlyArray<string>
|
|
11
|
+
readonly terminal?: "finish" | "invalid-provider-event" | "disconnect"
|
|
12
|
+
readonly streamChunkTypes?: ReadonlyArray<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FlowTurn {
|
|
16
|
+
readonly prompt: string
|
|
17
|
+
readonly marker: string
|
|
18
|
+
readonly interaction: InteractionKind
|
|
19
|
+
readonly steerPrompt?: string
|
|
20
|
+
readonly responses: ReadonlyArray<FlowResponse>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FlowScenario {
|
|
24
|
+
readonly version: 1
|
|
25
|
+
readonly seed: number
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly turns: ReadonlyArray<FlowTurn>
|
|
28
|
+
readonly coverage: {
|
|
29
|
+
readonly responseKinds: Readonly<Record<ResponseKind, number>>
|
|
30
|
+
readonly toolNames: Readonly<Record<string, number>>
|
|
31
|
+
readonly interactions: Readonly<Record<InteractionKind, number>>
|
|
32
|
+
readonly streamChunkTypes: ReadonlyArray<string>
|
|
33
|
+
readonly providerExchanges: number
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FlowResult {
|
|
38
|
+
readonly seed: number
|
|
39
|
+
readonly name: string
|
|
40
|
+
readonly turns: number
|
|
41
|
+
readonly assistantExchanges: number
|
|
42
|
+
readonly subagentExchanges: number
|
|
43
|
+
readonly titleExchanges: number
|
|
44
|
+
readonly traceRecords: number
|
|
45
|
+
readonly durationMs: number
|
|
46
|
+
readonly finalState: UiState
|
|
47
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weighted generation config for controlling what kinds of scenarios are generated.
|
|
3
|
+
* All weights are relative; they control probability distribution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface GenerationWeights {
|
|
7
|
+
// Response types
|
|
8
|
+
responseKinds: {
|
|
9
|
+
text: number
|
|
10
|
+
chunked: number
|
|
11
|
+
reasoning: number
|
|
12
|
+
markdown: number
|
|
13
|
+
raw: number
|
|
14
|
+
tool: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Interaction patterns
|
|
18
|
+
interactions: {
|
|
19
|
+
normal: number
|
|
20
|
+
"double-submit": number
|
|
21
|
+
steer: number
|
|
22
|
+
interrupt: number
|
|
23
|
+
"provider-drop": number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Failure modes (for raw responses)
|
|
27
|
+
failures: {
|
|
28
|
+
invalidProviderEvent: number // malformed streaming event
|
|
29
|
+
disconnect: number // abrupt connection drop
|
|
30
|
+
disconnectDuringToolInput: number // drop mid-tool-call
|
|
31
|
+
disconnectAfterTools: number // drop after tool submission
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Tools to generate
|
|
35
|
+
toolSelection: {
|
|
36
|
+
glob: number
|
|
37
|
+
grep: number
|
|
38
|
+
read: number
|
|
39
|
+
todowrite: number
|
|
40
|
+
shell: number
|
|
41
|
+
write: number
|
|
42
|
+
edit: number
|
|
43
|
+
apply_patch: number
|
|
44
|
+
webfetch: number
|
|
45
|
+
websearch: number
|
|
46
|
+
skill: number
|
|
47
|
+
subagent: number
|
|
48
|
+
question: number
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default weights: balanced distribution across all options
|
|
54
|
+
*/
|
|
55
|
+
export const defaultWeights: GenerationWeights = {
|
|
56
|
+
responseKinds: {
|
|
57
|
+
text: 1,
|
|
58
|
+
chunked: 1,
|
|
59
|
+
reasoning: 1,
|
|
60
|
+
markdown: 1,
|
|
61
|
+
raw: 1,
|
|
62
|
+
tool: 1,
|
|
63
|
+
},
|
|
64
|
+
interactions: {
|
|
65
|
+
normal: 3,
|
|
66
|
+
"double-submit": 1,
|
|
67
|
+
steer: 1,
|
|
68
|
+
interrupt: 1,
|
|
69
|
+
"provider-drop": 1,
|
|
70
|
+
},
|
|
71
|
+
failures: {
|
|
72
|
+
invalidProviderEvent: 1,
|
|
73
|
+
disconnect: 1,
|
|
74
|
+
disconnectDuringToolInput: 1,
|
|
75
|
+
disconnectAfterTools: 1,
|
|
76
|
+
},
|
|
77
|
+
toolSelection: {
|
|
78
|
+
glob: 1,
|
|
79
|
+
grep: 1,
|
|
80
|
+
read: 1,
|
|
81
|
+
todowrite: 1,
|
|
82
|
+
shell: 1,
|
|
83
|
+
write: 1,
|
|
84
|
+
edit: 1,
|
|
85
|
+
apply_patch: 1,
|
|
86
|
+
webfetch: 1,
|
|
87
|
+
websearch: 1,
|
|
88
|
+
skill: 1,
|
|
89
|
+
subagent: 1,
|
|
90
|
+
question: 1,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse weights from CLI flags like --weight-text=5 --weight-disconnect=10
|
|
96
|
+
* Format: --weight-<key>=<number>
|
|
97
|
+
* Keys can use dashes or underscores (both map to the same key)
|
|
98
|
+
*/
|
|
99
|
+
export function parseWeightsFromArgs(args: string[]): GenerationWeights {
|
|
100
|
+
const weights = structuredClone(defaultWeights)
|
|
101
|
+
const keyMap = new Map<string, [category: keyof GenerationWeights, key: string]>()
|
|
102
|
+
|
|
103
|
+
// Build map of all known keys
|
|
104
|
+
const typedWeights = weights as Record<keyof GenerationWeights, Record<string, number>>
|
|
105
|
+
Object.entries(typedWeights).forEach(([category, items]) => {
|
|
106
|
+
if (typeof items === "object" && !Array.isArray(items)) {
|
|
107
|
+
Object.keys(items).forEach((key) => {
|
|
108
|
+
keyMap.set(key, [category as keyof GenerationWeights, key])
|
|
109
|
+
keyMap.set(key.replace(/_/g, "-"), [category as keyof GenerationWeights, key])
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
for (const arg of args) {
|
|
115
|
+
if (!arg.startsWith("--weight-")) continue
|
|
116
|
+
const [keyStr, valueStr] = arg.slice(9).split("=")
|
|
117
|
+
if (!keyStr || !valueStr) continue
|
|
118
|
+
|
|
119
|
+
const value = parseFloat(valueStr)
|
|
120
|
+
if (!isFinite(value) || value < 0) {
|
|
121
|
+
console.warn(`Invalid weight value for --weight-${keyStr}: ${valueStr}`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const found = keyMap.get(keyStr)
|
|
126
|
+
if (!found) {
|
|
127
|
+
console.warn(`Unknown weight key: --weight-${keyStr}`)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const [category, key] = found
|
|
132
|
+
typedWeights[category][key] = value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return weights
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Pick from weighted options using an RNG
|
|
140
|
+
*/
|
|
141
|
+
export function pickWeighted<T extends readonly string[]>(
|
|
142
|
+
options: T,
|
|
143
|
+
weights: readonly number[],
|
|
144
|
+
random: number,
|
|
145
|
+
): T[number] {
|
|
146
|
+
const total = weights.reduce((sum, w) => sum + w, 0)
|
|
147
|
+
let accumulated = 0
|
|
148
|
+
const target = random * total
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < options.length; i++) {
|
|
151
|
+
accumulated += weights[i]!
|
|
152
|
+
if (target <= accumulated) return options[i]!
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return options[options.length - 1]!
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get weighted probability for a single option from a set
|
|
160
|
+
*/
|
|
161
|
+
export function shouldGenerate(weight: number, total: number, random: number): boolean {
|
|
162
|
+
return random < weight / total
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Filter tools based on enabled set from CLI
|
|
167
|
+
* Usage: --tools=glob,grep,read or --tools glob,grep,read
|
|
168
|
+
*/
|
|
169
|
+
export function parseEnabledTools(args: string[]): Set<keyof GenerationWeights["toolSelection"]> | null {
|
|
170
|
+
// Look for --tools= format
|
|
171
|
+
let toolsArg = args.find(arg => arg.startsWith("--tools="))?.slice(8)
|
|
172
|
+
|
|
173
|
+
// Look for --tools <value> format
|
|
174
|
+
if (!toolsArg) {
|
|
175
|
+
const toolsIndex = args.indexOf("--tools")
|
|
176
|
+
if (toolsIndex !== -1 && toolsIndex + 1 < args.length) {
|
|
177
|
+
toolsArg = args[toolsIndex + 1]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!toolsArg) return null
|
|
182
|
+
|
|
183
|
+
const toolList = toolsArg.split(",").map(t => t.trim()).filter(t => t.length > 0)
|
|
184
|
+
if (toolList.length === 0) return null
|
|
185
|
+
|
|
186
|
+
const validTools: Set<keyof GenerationWeights["toolSelection"]> = new Set()
|
|
187
|
+
const allTools = new Set(Object.keys(defaultWeights.toolSelection) as Array<keyof GenerationWeights["toolSelection"]>)
|
|
188
|
+
|
|
189
|
+
for (const tool of toolList) {
|
|
190
|
+
if (allTools.has(tool as keyof GenerationWeights["toolSelection"])) {
|
|
191
|
+
validTools.add(tool as keyof GenerationWeights["toolSelection"])
|
|
192
|
+
} else {
|
|
193
|
+
console.warn(`Unknown tool: ${tool}`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return validTools.size > 0 ? validTools : null
|
|
198
|
+
}
|