From 8d5c84d516004f43a116077f4ddb04b73f512da7 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 24 Mar 2026 15:17:39 -0400 Subject: [PATCH] Fuzzer --- packages/opencode/src/global/index.ts | 16 +- packages/opencode/src/provider/models.ts | 5 +- packages/opencode/src/provider/provider.ts | 43 ++ .../opencode/src/provider/sdk/mock/README.md | 208 +++++ .../opencode/src/provider/sdk/mock/index.ts | 24 + .../opencode/src/provider/sdk/mock/model.ts | 244 ++++++ .../opencode/src/provider/sdk/mock/plugin.ts | 18 + .../opencode/src/provider/sdk/mock/preload.ts | 2 + packages/opencode/src/provider/sdk/mock/run | 6 + .../src/provider/sdk/mock/runner/core.ts | 345 +++++++++ .../src/provider/sdk/mock/runner/diff.ts | 130 ++++ .../provider/sdk/mock/runner/errors/.gitkeep | 0 .../mock/runner/errors/8ccyk2my/diff.patch | 8 + .../runner/errors/8ccyk2my/messages_a.json | 716 ++++++++++++++++++ .../runner/errors/8ccyk2my/messages_b.json | 716 ++++++++++++++++++ .../runner/errors/8ccyk2my/normalized_a.json | 210 +++++ .../runner/errors/8ccyk2my/normalized_b.json | 210 +++++ .../mock/runner/errors/b99nxx7a/diff.patch | 8 + .../runner/errors/b99nxx7a/messages_a.json | 465 ++++++++++++ .../runner/errors/b99nxx7a/messages_b.json | 465 ++++++++++++ .../runner/errors/b99nxx7a/normalized_a.json | 143 ++++ .../runner/errors/b99nxx7a/normalized_b.json | 143 ++++ .../src/provider/sdk/mock/runner/index.ts | 88 +++ .../opencode/src/provider/sdk/mock/sandbox.sb | 17 + .../opencode/src/provider/sdk/mock/vfs.ts | 243 ++++++ packages/opencode/src/storage/db.ts | 1 + 26 files changed, 4466 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/provider/sdk/mock/README.md create mode 100644 packages/opencode/src/provider/sdk/mock/index.ts create mode 100644 packages/opencode/src/provider/sdk/mock/model.ts create mode 100644 packages/opencode/src/provider/sdk/mock/plugin.ts create mode 100644 packages/opencode/src/provider/sdk/mock/preload.ts create mode 100755 packages/opencode/src/provider/sdk/mock/run create mode 100644 packages/opencode/src/provider/sdk/mock/runner/core.ts create mode 100644 packages/opencode/src/provider/sdk/mock/runner/diff.ts create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/.gitkeep create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/8ccyk2my/diff.patch create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/8ccyk2my/messages_a.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/8ccyk2my/messages_b.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/8ccyk2my/normalized_a.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/8ccyk2my/normalized_b.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/b99nxx7a/diff.patch create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/b99nxx7a/messages_a.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/b99nxx7a/messages_b.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/b99nxx7a/normalized_a.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/errors/b99nxx7a/normalized_b.json create mode 100644 packages/opencode/src/provider/sdk/mock/runner/index.ts create mode 100644 packages/opencode/src/provider/sdk/mock/sandbox.sb create mode 100644 packages/opencode/src/provider/sdk/mock/vfs.ts diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 869019e2ce..435876b6a7 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -26,13 +26,15 @@ export namespace Global { } } -await Promise.all([ - fs.mkdir(Global.Path.data, { recursive: true }), - fs.mkdir(Global.Path.config, { recursive: true }), - fs.mkdir(Global.Path.state, { recursive: true }), - fs.mkdir(Global.Path.log, { recursive: true }), - fs.mkdir(Global.Path.bin, { recursive: true }), -]) +try { + await Promise.all([ + fs.mkdir(Global.Path.data, { recursive: true }), + fs.mkdir(Global.Path.config, { recursive: true }), + fs.mkdir(Global.Path.state, { recursive: true }), + fs.mkdir(Global.Path.log, { recursive: true }), + fs.mkdir(Global.Path.bin, { recursive: true }), + ]) +} catch (err) {} const CACHE_VERSION = "21" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index bae3317846..ef1f63b767 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -94,7 +94,10 @@ export namespace ModelsDev { .catch(() => undefined) if (snapshot) return snapshot if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - const json = await fetch(`${url()}/api.json`).then((x) => x.text()) + const json = await fetch(`${url()}/api.json`) + .then((x) => x.text()) + .catch(() => undefined) + if (!json) return {} return JSON.parse(json) }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6ab45d028b..33ce5cd26d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -30,6 +30,7 @@ import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider" import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot" +import { createMock } from "./sdk/mock" import { createXai } from "@ai-sdk/xai" import { createMistral } from "@ai-sdk/mistral" import { createGroq } from "@ai-sdk/groq" @@ -132,6 +133,7 @@ export namespace Provider { "gitlab-ai-provider": createGitLab, // @ts-ignore (TODO: kill this code so we dont have to maintain it) "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, + "@opencode/mock": createMock as any, } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise @@ -150,6 +152,11 @@ export namespace Provider { } const CUSTOM_LOADERS: Record = { + async mock() { + return { + autoload: true, + } + }, async anthropic() { return { autoload: false, @@ -920,6 +927,42 @@ export namespace Provider { const modelsDev = await ModelsDev.get() const database = mapValues(modelsDev, fromModelsDevProvider) + // Register the built-in mock provider for testing + database["mock"] = { + id: "mock", + name: "Mock", + source: "custom", + env: [], + options: {}, + models: { + "mock-model": { + id: "mock-model", + providerID: "mock", + name: "Mock Model", + api: { + id: "mock-model", + url: "", + npm: "@opencode/mock", + }, + status: "active", + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 4096 }, + options: {}, + headers: {}, + release_date: "2025-01-01", + }, + }, + } + const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null diff --git a/packages/opencode/src/provider/sdk/mock/README.md b/packages/opencode/src/provider/sdk/mock/README.md new file mode 100644 index 0000000000..6a9c0b917f --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/README.md @@ -0,0 +1,208 @@ +# Mock RPC + +Deterministic model scripting for tests. + +--- + +## Overview + +The mock provider lets test harnesses script exactly what the model should emit. Instead of hitting a real API, the user message contains a JSON object that describes each step of the conversation. This makes test scenarios fully deterministic and reproducible. + +--- + +## Understand the protocol + +The user message text is a JSON object with a `steps` array. Each step is an array of actions that the model emits on that turn. + +```json +{ + "steps": [ + [{ "type": "text", "content": "Hello" }], + [{ "type": "text", "content": "Goodbye" }] + ] +} +``` + +The mock model reads the **last** user message in the prompt to find this JSON. + +--- + +## Know how steps are selected + +The model picks which step to execute by counting messages with `role: "tool"` in the prompt. This count represents how many tool-result rounds have occurred. + +- **Step 0** runs on the first call (no tool results yet). +- **Step 1** runs after the first tool-result round. +- **Step N** runs after the Nth tool-result round. + +If the step index is out of bounds, the model emits an empty set of actions. + +--- + +## Use the `text` action + +Emits a text block. + +```json +{ "type": "text", "content": "Some response text" } +``` + +| Field | Type | Description | +|-----------|--------|----------------------| +| `content` | string | The text to emit. | + +--- + +## Use the `tool_call` action + +Calls a tool. The input object is passed as-is. + +```json +{ "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "hi" } } +``` + +| Field | Type | Description | +|---------|--------|---------------------------------| +| `name` | string | Name of the tool to call. | +| `input` | object | Arguments passed to the tool. | + +--- + +## Use the `thinking` action + +Emits a reasoning/thinking block. + +```json +{ "type": "thinking", "content": "Let me consider the options..." } +``` + +| Field | Type | Description | +|-----------|--------|----------------------------| +| `content` | string | The thinking text to emit. | + +--- + +## Use the `list_tools` action + +Responds with a JSON text block listing all available tools and their schemas. Useful for test scripts that need to discover tool names. No additional fields. + +```json +{ "type": "list_tools" } +``` + +--- + +## Use the `error` action + +Emits an error chunk. + +```json +{ "type": "error", "message": "something went wrong" } +``` + +| Field | Type | Description | +|-----------|--------|------------------------| +| `message` | string | The error message. | + +--- + +## Know the finish reason + +The finish reason is auto-inferred from the actions in the current step. If any action has `type: "tool_call"`, the finish reason is `"tool-calls"`. Otherwise it is `"stop"`. + +Token usage is always reported as `{ inputTokens: 10, outputTokens: 20, totalTokens: 30 }`. + +--- + +## Handle invalid JSON + +If the user message is not valid JSON or doesn't have a `steps` array, the model falls back to a default text response. This keeps backward compatibility with tests that don't use the RPC protocol. + +--- + +## Examples + +### Simple text response + +```json +{ + "steps": [ + [{ "type": "text", "content": "Hello from the mock model" }] + ] +} +``` + +### Tool discovery + +```json +{ + "steps": [ + [{ "type": "list_tools" }] + ] +} +``` + +### Single tool call + +```json +{ + "steps": [ + [{ "type": "tool_call", "name": "read", "input": { "filePath": "config.json" } }] + ] +} +``` + +### Multi-turn tool use + +Step 0 calls a tool. Step 1 runs after the tool result comes back and emits a text response. + +```json +{ + "steps": [ + [{ "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "hi" } }], + [{ "type": "text", "content": "Done writing the file." }] + ] +} +``` + +### Thinking and text + +```json +{ + "steps": [ + [ + { "type": "thinking", "content": "The user wants a greeting." }, + { "type": "text", "content": "Hey there!" } + ] + ] +} +``` + +### Multiple actions in one step + +A single step can contain any combination of actions. + +```json +{ + "steps": [ + [ + { "type": "text", "content": "I'll create two files." }, + { "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "aaa" } }, + { "type": "tool_call", "name": "write", "input": { "filePath": "b.txt", "content": "bbb" } } + ], + [ + { "type": "text", "content": "Both files created." } + ] + ] +} +``` + +### Error simulation + +```json +{ + "steps": [ + [{ "type": "error", "message": "rate limit exceeded" }] + ] +} +``` diff --git a/packages/opencode/src/provider/sdk/mock/index.ts b/packages/opencode/src/provider/sdk/mock/index.ts new file mode 100644 index 0000000000..4f68e4c353 --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/index.ts @@ -0,0 +1,24 @@ +import type { LanguageModelV2 } from "@ai-sdk/provider" +import { MockLanguageModel } from "./model" + +export { vfsPlugin } from "./plugin" +export { Filesystem as VFilesystem } from "./vfs" + +export interface MockProviderSettings { + name?: string +} + +export interface MockProvider { + (id: string): LanguageModelV2 + languageModel(id: string): LanguageModelV2 +} + +export function createMock(options: MockProviderSettings = {}): MockProvider { + const name = options.name ?? "mock" + + const create = (id: string) => new MockLanguageModel(id, { provider: name }) + + const provider = Object.assign((id: string) => create(id), { languageModel: create }) + + return provider +} diff --git a/packages/opencode/src/provider/sdk/mock/model.ts b/packages/opencode/src/provider/sdk/mock/model.ts new file mode 100644 index 0000000000..efd70abfae --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/model.ts @@ -0,0 +1,244 @@ +import type { + LanguageModelV2, + LanguageModelV2CallOptions, + LanguageModelV2FunctionTool, + LanguageModelV2StreamPart, +} from "@ai-sdk/provider" + +/** + * Mock Model RPC Protocol + * + * The user message text is a JSON object that scripts exactly what the mock + * model should emit. This lets test harnesses drive the model deterministically. + * + * Schema: + * ``` + * { + * "steps": [ + * // Step 0: executed on first call (no tool results yet) + * [ + * { "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "hi" } } + * ], + * // Step 1: executed after first tool-result round + * [ + * { "type": "text", "content": "Done!" } + * ] + * ] + * } + * ``` + * + * Supported actions: + * + * { "type": "text", "content": "string" } + * Emit a text block. + * + * { "type": "tool_call", "name": "toolName", "input": { ... } } + * Call a tool. The input object is passed as-is. + * + * { "type": "thinking", "content": "string" } + * Emit a reasoning/thinking block. + * + * { "type": "list_tools" } + * Respond with a JSON text block listing all available tools and their + * schemas. Useful for test scripts that need to discover tool names. + * + * { "type": "error", "message": "string" } + * Emit an error chunk. + * + * Finish reason is auto-inferred: "tool-calls" when any tool_call action + * exists in the step, "stop" otherwise. Override with a top-level "finish" + * field on the script object. + * + * If the user message is not valid JSON or doesn't match the schema, the + * model falls back to a default text response (backward compatible). + */ + +// ── Protocol types ────────────────────────────────────────────────────── + +type TextAction = { type: "text"; content: string } +type ToolCallAction = { type: "tool_call"; name: string; input: Record } +type ThinkingAction = { type: "thinking"; content: string } +type ListToolsAction = { type: "list_tools" } +type ErrorAction = { type: "error"; message: string } + +type Action = TextAction | ToolCallAction | ThinkingAction | ListToolsAction | ErrorAction + +type Script = { + steps: Action[][] +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +function text(options: LanguageModelV2CallOptions): string { + for (const msg of [...options.prompt].reverse()) { + if (msg.role !== "user") continue + for (const part of msg.content) { + if (part.type === "text") return part.text + } + } + return "" +} + +/** Count tool-result rounds since the last user message. */ +function round(options: LanguageModelV2CallOptions): number { + let count = 0 + for (const msg of [...options.prompt].reverse()) { + if (msg.role === "user") break + if (msg.role === "tool") count++ + } + return count +} + +function parse(raw: string): Script | undefined { + try { + const json = JSON.parse(raw) + if (!json || !Array.isArray(json.steps)) return undefined + return json as Script + } catch { + return undefined + } +} + +function tools(options: LanguageModelV2CallOptions): LanguageModelV2FunctionTool[] { + if (!options.tools) return [] + return options.tools.filter((t): t is LanguageModelV2FunctionTool => t.type === "function") +} + +function emit(actions: Action[], options: LanguageModelV2CallOptions): LanguageModelV2StreamPart[] { + const chunks: LanguageModelV2StreamPart[] = [] + let tid = 0 + let rid = 0 + let xid = 0 + + for (const action of actions) { + switch (action.type) { + case "text": { + const id = `mock-text-${xid++}` + chunks.push( + { type: "text-start", id }, + { type: "text-delta", id, delta: action.content }, + { type: "text-end", id }, + ) + break + } + + case "tool_call": { + const id = `mock-call-${tid++}` + const input = JSON.stringify(action.input) + chunks.push( + { type: "tool-input-start", id, toolName: action.name }, + { type: "tool-input-delta", id, delta: input }, + { type: "tool-input-end", id }, + { type: "tool-call" as const, toolCallId: id, toolName: action.name, input }, + ) + break + } + + case "thinking": { + const id = `mock-reasoning-${rid++}` + chunks.push( + { type: "reasoning-start", id }, + { type: "reasoning-delta", id, delta: action.content }, + { type: "reasoning-end", id }, + ) + break + } + + case "list_tools": { + const id = `mock-text-${xid++}` + const defs = tools(options).map((t) => ({ + name: t.name, + description: t.description, + input: t.inputSchema, + })) + chunks.push( + { type: "text-start", id }, + { type: "text-delta", id, delta: JSON.stringify(defs, null, 2) }, + { type: "text-end", id }, + ) + break + } + + case "error": { + chunks.push({ type: "error", error: new Error(action.message) }) + break + } + } + } + + return chunks +} + +// ── Model ─────────────────────────────────────────────────────────────── + +export class MockLanguageModel implements LanguageModelV2 { + readonly specificationVersion = "v2" as const + readonly provider: string + readonly modelId: string + readonly supportedUrls: Record = {} + + constructor( + id: string, + readonly options: { provider: string }, + ) { + this.modelId = id + this.provider = options.provider + } + + async doGenerate(options: LanguageModelV2CallOptions): Promise { + throw new Error("`doGenerate` not implemented") + } + + async doStream(options: LanguageModelV2CallOptions) { + const raw = text(options) + const script = parse(raw) + const r = round(options) + const actions = script ? (script.steps[r] ?? []) : undefined + + const chunks: LanguageModelV2StreamPart[] = [ + { type: "stream-start", warnings: [] }, + { + type: "response-metadata", + id: "mock-response", + modelId: this.modelId, + timestamp: new Date(), + }, + ] + + if (actions) { + chunks.push(...emit(actions, options)) + } else { + // Fallback: plain text response (backward compatible) + chunks.push( + { type: "text-start", id: "mock-text-0" }, + { + type: "text-delta", + id: "mock-text-0", + delta: `[mock] This is a streamed mock response from model "${this.modelId}". `, + }, + { + type: "text-delta", + id: "mock-text-0", + delta: "The mock provider does not call any real API.", + }, + { type: "text-end", id: "mock-text-0" }, + ) + } + + const called = actions?.some((a) => a.type === "tool_call") + chunks.push({ + type: "finish", + finishReason: called ? "tool-calls" : "stop", + usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + }) + + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }) + + return { stream } + } +} diff --git a/packages/opencode/src/provider/sdk/mock/plugin.ts b/packages/opencode/src/provider/sdk/mock/plugin.ts new file mode 100644 index 0000000000..0c8607ba8a --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/plugin.ts @@ -0,0 +1,18 @@ +import type { BunPlugin } from "bun" +import { Filesystem } from "./vfs" + +/** + * Bun plugin that intercepts all loads of `util/filesystem.ts` and replaces + * the real Filesystem namespace with the in-memory VFS implementation. + * + * Must be registered via preload before any application code runs. + */ +export const vfsPlugin: BunPlugin = { + name: "vfs", + setup(build) { + build.onLoad({ filter: /util\/filesystem\.ts$/ }, () => ({ + exports: { Filesystem }, + loader: "object", + })) + }, +} diff --git a/packages/opencode/src/provider/sdk/mock/preload.ts b/packages/opencode/src/provider/sdk/mock/preload.ts new file mode 100644 index 0000000000..638bd480de --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/preload.ts @@ -0,0 +1,2 @@ +import { vfsPlugin } from "./plugin" +Bun.plugin(vfsPlugin) diff --git a/packages/opencode/src/provider/sdk/mock/run b/packages/opencode/src/provider/sdk/mock/run new file mode 100755 index 0000000000..42da9a12b7 --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/run @@ -0,0 +1,6 @@ +#!/bin/sh + +ROOT="$(dirname "$0")" + +cd "$ROOT/../../../.." +sandbox-exec -f ./src/provider/sdk/mock/sandbox.sb -D HOME=$HOME bun --preload "$ROOT/preload.ts" "src/index.ts" serve \ No newline at end of file diff --git a/packages/opencode/src/provider/sdk/mock/runner/core.ts b/packages/opencode/src/provider/sdk/mock/runner/core.ts new file mode 100644 index 0000000000..d1f9b78a64 --- /dev/null +++ b/packages/opencode/src/provider/sdk/mock/runner/core.ts @@ -0,0 +1,345 @@ +/** + * Shared core for mock runners: HTTP, SSE, script generation, message handling. + */ + +import path from "path" + +// ── Types ─────────────────────────────────────────────────────────────── + +export type Tool = { + id: string + description: string + parameters: { + type: string + properties?: Record + required?: string[] + } +} + +export type Action = + | { type: "text"; content: string } + | { type: "tool_call"; name: string; input: Record } + | { type: "thinking"; content: string } + | { type: "list_tools" } + | { type: "error"; message: string } + +export type Script = { steps: Action[][] } +export type Event = { type: string; properties: Record } +export type Message = { info: Record; parts: Record[] } +type Listener = (event: Event) => void + +export type Instance = { + name: string + base: string + sse: AbortController +} + +// ── HTTP ──────────────────────────────────────────────────────────────── + +export async function api(base: string, method: string, path: string, body?: unknown): Promise { + const opts: RequestInit = { + method, + headers: { "Content-Type": "application/json" }, + } + if (body !== undefined) opts.body = JSON.stringify(body) + const res = await fetch(`${base}${path}`, opts) + if (!res.ok) { + const text = await res.text().catch(() => "") + throw new Error(`${method} ${path} → ${res.status}: ${text}`) + } + if (res.status === 204) return undefined as T + return res.json() as T +} + +// ── SSE ───────────────────────────────────────────────────────────────── + +const listeners = new Map() + +function subscribe(base: string, cb: Listener): AbortController { + const abort = new AbortController() + ;(async () => { + const res = await fetch(`${base}/event`, { + headers: { Accept: "text/event-stream" }, + signal: abort.signal, + }) + if (!res.ok || !res.body) { + log("SSE connect failed", base, res.status) + return + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = "" + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split("\n") + buf = lines.pop()! + for (const line of lines) { + if (!line.startsWith("data: ")) continue + try { + cb(JSON.parse(line.slice(6))) + } catch {} + } + } + })().catch(() => {}) + return abort +} + +export function startSSE(base: string): AbortController { + const ctrl = subscribe(base, (evt) => { + const fn = listeners.get(ctrl) + fn?.(evt) + }) + listeners.set(ctrl, () => {}) + return ctrl +} + +export function idle(sid: string, sse: AbortController, timeout = 60_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject(new Error(`session ${sid} did not become idle within ${timeout}ms`)) + }, timeout) + + const orig = listeners.get(sse) + const handler = (evt: Event) => { + orig?.(evt) + if (evt.type !== "session.status") return + if (evt.properties.sessionID !== sid) return + if (evt.properties.status?.type === "idle") { + cleanup() + resolve() + } + } + listeners.set(sse, handler) + + function cleanup() { + clearTimeout(timer) + if (orig) listeners.set(sse, orig) + } + }) +} + +// ── Tool discovery ────────────────────────────────────────────────────── + +let cachedTools: Tool[] | undefined + +export async function tools(base: string): Promise { + if (cachedTools) return cachedTools + cachedTools = await api(base, "GET", "/experimental/tool?provider=mock&model=mock-model") + return cachedTools +} + +// ── Random generators ─────────────────────────────────────────────────── + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] +} + +export function rand(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const WORDS = [ + "foo", + "bar", + "baz", + "qux", + "hello", + "world", + "test", + "alpha", + "beta", + "gamma", + "delta", + "src", + "lib", + "tmp", +] +const EXTS = [".ts", ".js", ".json", ".txt", ".md"] + +function word() { + return pick(WORDS) +} + +function sentence() { + const n = rand(3, 12) + return Array.from({ length: n }, () => word()).join(" ") +} + +function filepath() { + const depth = rand(1, 3) + const parts = Array.from({ length: depth }, () => word()) + return parts.join("/") + pick(EXTS) +} + +function fakeInput(tool: Tool): Record { + const result: Record = {} + const props = tool.parameters.properties ?? {} + for (const [key, schema] of Object.entries(props)) { + switch (schema.type) { + case "string": + if (key.toLowerCase().includes("path") || key.toLowerCase().includes("file")) { + result[key] = filepath() + } else if (key.toLowerCase().includes("pattern") || key.toLowerCase().includes("regex")) { + result[key] = word() + } else if (key.toLowerCase().includes("command") || key.toLowerCase().includes("cmd")) { + result[key] = `echo ${word()}` + } else { + result[key] = sentence() + } + break + case "number": + case "integer": + result[key] = rand(1, 100) + break + case "boolean": + result[key] = Math.random() > 0.5 + break + case "object": + result[key] = {} + break + case "array": + result[key] = [] + break + default: + result[key] = sentence() + } + } + return result +} + +// ── Action generators ─────────────────────────────────────────────────── + +const SAFE_TOOLS = new Set(["read", "glob", "grep", "todowrite", "webfetch", "websearch", "codesearch"]) +const WRITE_TOOLS = new Set(["write", "edit", "bash"]) + +function textAction(): Action { + return { type: "text", content: sentence() } +} + +function thinkingAction(): Action { + return { type: "thinking", content: sentence() } +} + +function errorAction(): Action { + return { type: "error", message: `mock error: ${word()}` } +} + +function listToolsAction(): Action { + return { type: "list_tools" } +} + +async function toolAction(base: string): Promise { + const all = await tools(base) + const safe = all.filter((t) => SAFE_TOOLS.has(t.id) || WRITE_TOOLS.has(t.id)) + if (!safe.length) return textAction() + const tool = pick(safe) + return { type: "tool_call", name: tool.id, input: fakeInput(tool) } +} + +// ── Script generation ─────────────────────────────────────────────────── + +export async function script(base: string): Promise