diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 549c7c34a7..024c89fdc4 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -42,6 +42,7 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
+import { Mock } from "./mock"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -803,6 +804,7 @@ function App() {
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
+
diff --git a/packages/opencode/src/cli/cmd/tui/mock.tsx b/packages/opencode/src/cli/cmd/tui/mock.tsx
new file mode 100644
index 0000000000..d50bf8d866
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/mock.tsx
@@ -0,0 +1,109 @@
+import { Flag } from "@/flag/flag"
+import { Filesystem } from "@/util/filesystem"
+import { onMount } from "solid-js"
+import { useLocal } from "./context/local"
+import { usePromptRef } from "./context/prompt"
+import { useRoute } from "./context/route"
+import { useSync } from "./context/sync"
+import { useToast } from "./ui/toast"
+
+const model = {
+ providerID: "mock",
+ modelID: "mock-model",
+}
+
+function parse(raw: string) {
+ try {
+ const json = JSON.parse(raw)
+ return Array.isArray(json) ? json : []
+ } catch {
+ return []
+ }
+}
+
+async function load() {
+ if (Flag.OPENCODE_TUI_RUNNER_FILE) {
+ return parse(await Filesystem.readText(Flag.OPENCODE_TUI_RUNNER_FILE).catch(() => "[]"))
+ }
+
+ return parse(Flag.OPENCODE_TUI_RUNNER_STEPS ?? "[]")
+}
+
+function text(step: unknown) {
+ return typeof step === "string" ? step : JSON.stringify(step)
+}
+
+async function wait(test: () => T | false | undefined, timeout = 30_000) {
+ const start = Date.now()
+
+ while (Date.now() - start < timeout) {
+ const value = test()
+ if (value) return value
+ await Bun.sleep(100)
+ }
+
+ throw new Error("Mock runner timed out")
+}
+
+export function Mock() {
+ const local = useLocal()
+ const prompt = usePromptRef()
+ const route = useRoute()
+ const sync = useSync()
+ const toast = useToast()
+
+ onMount(() => {
+ if (Flag.OPENCODE_TUI_RUNNER !== "mock") return
+
+ void (async () => {
+ const steps = await load()
+ if (!steps.length) {
+ toast.show({
+ message: "Mock runner has no steps",
+ variant: "warning",
+ duration: 3000,
+ })
+ return
+ }
+
+ await wait(() => sync.ready && local.model.ready)
+ await wait(() => !!sync.data.provider.find((item) => item.id === model.providerID)?.models[model.modelID])
+ local.model.set(model, { recent: true })
+
+ for (const step of steps) {
+ await wait(() => !!prompt.current)
+ local.model.set(model)
+
+ const prev = route.data.type === "session" ? route.data.sessionID : undefined
+ const count = prev ? sync.data.message[prev]?.length ?? 0 : 0
+
+ prompt.current!.set({
+ input: text(step),
+ parts: [],
+ })
+ prompt.current!.submit()
+
+ const sid = await wait(() => (route.data.type === "session" ? route.data.sessionID : undefined))
+ await wait(() => {
+ const list = sync.data.message[sid] ?? []
+ if (list.length <= (sid === prev ? count : 0)) return false
+ return sync.session.status(sid) === "idle" && list.at(-1)?.role !== "user"
+ }, 120000)
+ }
+
+ toast.show({
+ message: `Mock runner sent ${steps.length} prompt(s)`,
+ variant: "success",
+ duration: 3000,
+ })
+ })().catch((err) => {
+ toast.show({
+ message: err instanceof Error ? err.message : "Mock runner failed",
+ variant: "error",
+ duration: 5000,
+ })
+ })
+ })
+
+ return <>>
+}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 0c55187b9d..a9aed9dfd5 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -16,6 +16,9 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
+ export const OPENCODE_TUI_RUNNER = process.env["OPENCODE_TUI_RUNNER"]
+ export const OPENCODE_TUI_RUNNER_STEPS = process.env["OPENCODE_TUI_RUNNER_STEPS"]
+ export const OPENCODE_TUI_RUNNER_FILE = process.env["OPENCODE_TUI_RUNNER_FILE"]
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
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/PROTOCOL.md b/packages/opencode/src/provider/sdk/mock/PROTOCOL.md
new file mode 100644
index 0000000000..6a9c0b917f
--- /dev/null
+++ b/packages/opencode/src/provider/sdk/mock/PROTOCOL.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/README.md b/packages/opencode/src/provider/sdk/mock/README.md
new file mode 100644
index 0000000000..4808dd3e14
--- /dev/null
+++ b/packages/opencode/src/provider/sdk/mock/README.md
@@ -0,0 +1,19 @@
+I got it to the point where it can run a full mock session
+
+Run the server with `./src/provider/sdk/mock/run`. It will run it sandboxes to make sure it doesn't interact with the outside world unexpectedly.
+
+Then run `bun run src/provider/sdk/mock/runner/index.ts` to drive a session and get a log
+
+There is also `bun run src/provider/sdk/mock/runner/diff.ts` which will drive two sessions at once and compare them. This is annoying right now because you have to run two servers. This would let you compare the differences between versions though
+
+## Coverage
+
+I also have an experiment in `serve.test.ts` which runs the server as a bun test, which gives us access to coverage info. Run it like this:
+
+```
+bun test --coverage --coverage-reporter=lcov --timeout 0 src/provider/sdk/mock/runner/serve.test.ts
+```
+
+That will give you a `lcov.info` file. Convert it to HTML with this:
+
+genhtml coverage/lcov.info -o coverage/html && open coverage/html/index.html
\ No newline at end of file
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..03d76b1a95
--- /dev/null
+++ b/packages/opencode/src/provider/sdk/mock/runner/core.ts
@@ -0,0 +1,346 @@
+/**
+ * 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