diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 0ae2fbe26..bfd15614f 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -2,7 +2,12 @@ "$schema": "https://opencode.ai/config.json", "provider": {}, "permission": {}, - "mcp": {}, + "mcp": { + "opencode": { + "type": "remote", + "url": "http://127.0.0.1:43110/mcp" + } + }, "tools": { "github-triage": false, "github-pr-search": false, diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f9b8f68e5..94da038fa 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -6,6 +6,7 @@ function truthy(key: string) { } const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") +const OPENCODE_SIMULATION = truthy("OPENCODE_SIMULATION") const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] export const Flag = { @@ -29,6 +30,8 @@ export const Flag = { OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], OPENCODE_SIMULATION: truthy("OPENCODE_SIMULATION"), + OPENCODE_SIMULATION, + OPENCODE_SIMULATION_BACKEND: OPENCODE_SIMULATION || truthy("OPENCODE_SIMULATION_BACKEND"), // Experimental OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( diff --git a/packages/opencode/specs/property-based-tui-testing-working-notes.md b/packages/opencode/specs/property-based-tui-testing-working-notes.md index fa8957ec8..31d98ff0f 100644 --- a/packages/opencode/specs/property-based-tui-testing-working-notes.md +++ b/packages/opencode/specs/property-based-tui-testing-working-notes.md @@ -24,6 +24,8 @@ - `mockInput` supports interactions like `typeText`, `pressEnter`, and `pressArrow`. - Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`. - `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`. +- Backend route assembly checks `OPENCODE_SIMULATION_BACKEND`; `OPENCODE_SIMULATION` implies this flag. +- `OPENCODE_SIMULATION_BACKEND=1` can run a real frontend against a simulated backend. - `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer. ## OpenTUI Action APIs diff --git a/packages/opencode/specs/property-based-tui-testing.md b/packages/opencode/specs/property-based-tui-testing.md index 76b7ecce8..e6be716f0 100644 --- a/packages/opencode/specs/property-based-tui-testing.md +++ b/packages/opencode/specs/property-based-tui-testing.md @@ -14,6 +14,7 @@ Build these pieces first: - Mock LLM provider controlled by the endpoint. - OpenTUI fake renderer/screen-buffer/interactable-element access. - Basic action generator that drives the TUI forward. +- Simulation-only embedded MCP server that lets agents observe and drive the TUI. ## Non-Goals @@ -34,6 +35,9 @@ Build these pieces first: - Run local simulation under `sandbox-exec` using the old branch setup as the starting point. - Use `sandbox-exec` as the safety boundary, not as the normal simulated I/O mechanism. - First built-in property: the app does not crash. +- Only two simulation flags exist: `OPENCODE_SIMULATION` and `OPENCODE_SIMULATION_BACKEND`. +- `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over stdio and implies backend simulation. +- `OPENCODE_SIMULATION_BACKEND` without `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over loopback HTTP; in the TUI it shows the URL in the home screen. ## Target End-To-End Flow @@ -59,7 +63,9 @@ Implementation shape: - Seed it from JSON fixtures supplied through the simulation endpoint or runner config. - Serialize it into replay traces. - Fail unsupported operations with typed simulation errors instead of silently falling back to host FS. -- Enable with `OPENCODE_SIMULATION` for initial startup wiring. +- Enable the full simulation runner with `OPENCODE_SIMULATION`. +- Enable only backend simulation with `OPENCODE_SIMULATION_BACKEND`. +- `OPENCODE_SIMULATION` implies `OPENCODE_SIMULATION_BACKEND`. - Use a fixed virtual root, not `process.cwd()`, so host paths are denied by default. - Use the old branch's Bun preload/plugin redirection only for code paths that bypass `AppFileSystem.Service`. - Let `sandbox-exec` catch any remaining direct `fs`, `Bun.file`, or process-level filesystem access. @@ -89,7 +95,7 @@ Todos: - [x] Define mock filesystem data model and fixture JSON format. - [x] Implement the `AppFileSystem.Service` layer. - [x] Add typed errors for unsupported operations and host-FS escapes. -- [x] Add activation path from startup through `OPENCODE_SIMULATION`. +- [x] Add activation path from startup through `OPENCODE_SIMULATION` / `OPENCODE_SIMULATION_BACKEND`. - [x] Add a tiny fixture that includes `opencode.json`, a workspace root, and a few files. - [ ] Verify read/glob/grep/write/edit use the mock filesystem. - [ ] Verify sandbox denies host writes when a bypass is introduced. @@ -259,7 +265,9 @@ Implementation shape: - Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer. - Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`. +- `OPENCODE_SIMULATION_BACKEND` leaves the frontend real but makes backend route assembly use simulated services. - Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner. +- In simulation MCP modes, the TUI side starts an MCP server documented in `simulation-mcp-server.md`. - Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`. - OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution. - Do not render to a real terminal in simulation mode. @@ -279,6 +287,39 @@ Todos: - [ ] Verify TUI starts in fake renderer with no real terminal output. - [ ] Verify screen buffer can be captured after a render. +## Simulation MCP Server + +Goal: let agents discover, inspect, and drive the simulated TUI through MCP without adding production remote-control behavior. + +Design document: + +- `packages/opencode/specs/simulation-mcp-server.md` + +Implementation shape: + +- Start in one of two modes: local stdio (`OPENCODE_SIMULATION=1`) or remote loopback HTTP (`OPENCODE_SIMULATION_BACKEND=1` without `OPENCODE_SIMULATION`). +- Live in the TUI/frontend process so it can access the OpenTUI renderer. +- Use stdio for the local agent-launched MCP mode. +- Bind remote streamable HTTP MCP servers to `127.0.0.1` on an ephemeral port. +- Print the URL to stdout for remote headless mode. +- Show the URL at the bottom of the home screen for remote visible TUI mode. +- Expose screen/spans/UI-state tools and resources. +- Execute UI driving through `SimulationActions.execute(...)`, not a second action path. +- Proxy filesystem/network/LLM/reset/snapshot operations to the backend simulation control endpoint. + +Todos: + +- [x] Add design doc and first-pass todo list. +- [x] Implement TUI-side MCP server startup and shutdown. +- [x] Add local stdio mode. +- [x] Add remote loopback mode. +- [x] Print remote headless URL to stdout. +- [x] Show remote visible TUI URL on the home screen. +- [x] Expose observation tools/resources. +- [x] Expose generated action execution tools. +- [x] Expose backend control proxy tools. +- [x] Add a smoke test that connects with the MCP client and calls one observation tool. + ## Basic Action Generator Goal: drive the TUI forward with generated actions and assert only that the app does not crash. @@ -329,6 +370,7 @@ The first milestone is one deterministic run that: - Submits an ordinary prompt through the TUI. - Receives a mocked model response through the real session pipeline. - Captures a screen buffer. +- Starts the simulation MCP server in the selected transport mode. - Passes the no-crash property. ## First-Pass Todos @@ -339,6 +381,7 @@ The first milestone is one deterministic run that: - [ ] Mock provider/model consumes endpoint scripts through the real LLM path. - [ ] TUI runs with fake renderer. - [ ] Runner can inspect screen buffer. +- [x] Simulation MCP server exposes screen/UI/actions/control to agents. - [ ] Runner can identify at least one interactable path to submit a prompt. - [ ] Basic action generator executes multiple deterministic steps. - [ ] No-crash property runs after each step. diff --git a/packages/opencode/specs/simulation-mcp-server.md b/packages/opencode/specs/simulation-mcp-server.md new file mode 100644 index 000000000..81dfdef7b --- /dev/null +++ b/packages/opencode/specs/simulation-mcp-server.md @@ -0,0 +1,113 @@ +# Simulation MCP Server + +Status: first-pass implementation plan. + +The simulation MCP server gives agents a simulation-only control surface for the TUI. It lives in the TUI process because only the frontend has direct access to the OpenTUI renderer, captured screen buffer, focused editor, interactable elements, and input/mouse drivers. + +## Goals + +- Support local stdio mode for agents that launch opencode as an MCP server. +- Support remote loopback HTTP mode for users that want to run the server themselves. +- Expose current TUI state to agents: screen text, structured spans, interactable elements, focused editor, and generated actions. +- Let agents drive the UI through the same `SimulationActions` execution path used by property tests. +- Proxy backend simulation control operations through the existing `/experimental/simulation/*` endpoint. + +## Non-Goals + +- Do not start this server outside simulation modes. +- Do not add a general remote-control API to production TUI mode. +- Do not replace the property-test action generator; MCP should call the same action generator. +- Do not expose arbitrary host filesystem or network access. + +## Location + +- Server module: `packages/opencode/src/cli/cmd/tui/simulation-mcp.ts`. +- Startup: `packages/opencode/src/cli/cmd/tui/thread.ts`, next to fake renderer creation. +- Action/state source: `packages/opencode/src/testing/simulation/actions.ts`. +- Backend mutation source: existing simulation control endpoints. + +## Modes + +Local stdio mode: + +- Enabled by `OPENCODE_SIMULATION=1`. +- Uses an OpenTUI test renderer and stdio MCP transport. +- Prints nothing to stdout except MCP protocol messages. +- Intended for agent MCP configs where the agent launches `opencode` as a local command. + +Remote headless mode: + +- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set. +- Uses an OpenTUI test renderer and streamable HTTP MCP transport. +- Prints the running MCP URL to stdout once. +- Intended for users or harnesses that want to start opencode and connect to the URL manually. + +Remote visible TUI mode: + +- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set and the user is running the TUI. +- Uses the normal visible TUI renderer and streamable HTTP MCP transport. +- Does not print the URL to stdout because stdout belongs to the TUI. +- Shows `Simulation mode MCP: ` at the bottom of the home screen. + +## Transport + +- Local stdio mode uses `StdioServerTransport`. +- Remote modes use streamable HTTP over loopback. +- Remote modes bind host `127.0.0.1`. +- Remote port is ephemeral by default and configurable through `OPENCODE_SIMULATION_MCP_PORT`. + +## Initial Tools + +Observation: + +- `simulation_screen_get`: return the current captured character frame. +- `simulation_spans_get`: return OpenTUI captured spans. +- `simulation_ui_state_get`: return elements, available generated actions, and focus state. + +Driving: + +- `simulation_action_execute`: execute one generated action and render once. +- `simulation_action_sequence_execute`: execute a bounded sequence and return the final state. +- `simulation_render_once`: force one render and return the screen/state. + +Backend control proxy: + +- `simulation_control_reset` +- `simulation_control_filesystem_seed` +- `simulation_control_network_register` +- `simulation_control_llm_enqueue` +- `simulation_control_snapshot` + +## Initial Resources + +- `simulation://screen` +- `simulation://spans` +- `simulation://ui-state` +- `simulation://backend-snapshot` + +## Initial Prompt + +- `simulation-driver`: short instructions for agents to inspect state, choose available generated actions, drive the UI, then inspect again. + +## Safety + +- Guard startup with `OPENCODE_SIMULATION` or `OPENCODE_SIMULATION_BACKEND`. +- Bind to loopback only. +- Close the MCP server before destroying the renderer. +- Keep backend state changes routed through the existing simulation control endpoint. + +## Todos + +- [x] Add this design document. +- [x] Implement a first-pass TUI-side MCP server. +- [x] Support local stdio mode. +- [x] Support remote loopback mode. +- [x] Print remote headless URL to stdout. +- [x] Show remote background TUI URL on the home screen. +- [x] Expose screen/spans/UI-state observation tools. +- [x] Expose action execution tools using `SimulationActions.execute`. +- [x] Expose backend control proxy tools. +- [x] Add an automated smoke test that starts the MCP server and calls `tools/list` plus one observation tool. +- [ ] Add richer action generation with generated text and bounded sequence traces. +- [ ] Add trace capture for every MCP-driven action. +- [ ] Add protocol-level docs for external agent authors. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e47d10f05..a6e8cf741 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -167,7 +167,7 @@ export function tui(input: { events?: EventSource renderer?: CliRenderer mode?: "dark" | "light" - onReady?: (ctx: { renderer: CliRenderer }) => void + onReady?: (ctx: { renderer: CliRenderer }) => void | Promise }) { // promise to prevent immediate exit // oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve @@ -186,6 +186,7 @@ export function tui(input: { } const renderer = input.renderer ?? (await createCliRenderer(rendererConfig(input.config))) + const [simulationMcpUrl, setSimulationMcpUrl] = createSignal() // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = input.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark" @@ -201,7 +202,7 @@ export function tui(input: { )} > - + @@ -259,7 +260,8 @@ export function tui(input: { ) }, renderer) - input.onReady?.({ renderer }) + const ready = await input.onReady?.({ renderer }) + if (ready?.simulationMcpUrl) setSimulationMcpUrl(ready.simulationMcpUrl) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 8a229ffab..311e4ad46 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -4,6 +4,7 @@ export interface Args { model?: string agent?: string prompt?: string + simulationMcpUrl?: () => string | undefined continue?: boolean sessionID?: string fork?: boolean diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 43a52082b..624d982c1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createEffect, createSignal, onMount } from "solid-js" +import { createEffect, createSignal, onMount, Show } from "solid-js" import { Logo } from "../component/logo" import { useProject } from "../context/project" import { useSync } from "../context/sync" @@ -88,7 +88,10 @@ export function Home() { - + + + {(url) => Simulation mode MCP: {url()}} + diff --git a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts new file mode 100644 index 000000000..70a2ff102 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts @@ -0,0 +1,353 @@ +import { SimulationActions } from "@/testing/simulation/actions" +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import type { CapturedFrame, CliRenderer } from "@opentui/core" +import { createMockKeys, createMockMouse } from "@opentui/core/testing" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import z from "zod/v4" +import type { SimulationRenderer } from "./simulation" + +export type SimulationMcpMode = "stdio" | "remote" + +export interface SimulationMcpHarness { + readonly renderer: CliRenderer + readonly mockInput: SimulationActions.MockInput + readonly mockMouse: SimulationActions.MockMouse + readonly renderOnce: () => Promise + readonly screen: () => string + readonly spans: () => CapturedFrame +} + +export interface SimulationMcpOptions { + readonly mode: SimulationMcpMode + readonly harness: SimulationMcpHarness + readonly controlUrl: string + readonly controlFetch?: typeof fetch +} + +export interface SimulationMcpServer { + readonly mode: SimulationMcpMode + readonly url?: string + readonly stop: () => Promise +} + +const DefaultRemotePort = 43110 +const MaxPortAttempts = 100 + +type RenderBuffer = { + readonly width: number + readonly height: number + getRealCharBytes(includeAnsi?: boolean): Uint8Array + getSpanLines(): CapturedFrame["lines"] +} + +const decoder = new TextDecoder() + +const ActionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("typeText"), text: z.string() }), + z.object({ type: z.literal("pressEnter") }), + z.object({ type: z.literal("pressArrow"), direction: z.enum(["up", "down", "left", "right"]) }), + z.object({ type: z.literal("focus"), target: z.number() }), + z.object({ type: z.literal("click"), target: z.number(), x: z.number(), y: z.number() }), +]) satisfies z.ZodType + +const FileContentSchema = z.union([ + z.string(), + z.object({ encoding: z.literal("base64"), data: z.string() }), +]) + +const NetworkRegistrationSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("json"), + url: z.string(), + method: z.string().optional(), + status: z.number().optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.unknown(), + }), + z.object({ + kind: z.literal("text"), + url: z.string(), + method: z.string().optional(), + status: z.number().optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.string(), + }), + z.object({ + kind: z.literal("status"), + url: z.string(), + method: z.string().optional(), + status: z.number(), + headers: z.record(z.string(), z.string()).optional(), + }), +]) + +const LlmScriptActionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), content: z.string() }), + z.object({ type: z.literal("thinking"), content: z.string() }), + z.object({ type: z.literal("error"), message: z.string() }), +]) + +const LlmScriptSchema = z.object({ + steps: z.array(z.array(LlmScriptActionSchema)), + usage: z + .object({ + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + }) + .optional(), + finish: z.enum(["stop", "tool-calls", "error", "length", "unknown"]).optional(), +}) + +function currentBuffer(renderer: CliRenderer): RenderBuffer { + return Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer +} + +function remotePort() { + const port = Number(process.env.OPENCODE_SIMULATION_MCP_PORT) + if (Number.isInteger(port) && port > 0 && port <= 65535) return port + return DefaultRemotePort +} + +function isPortUnavailable(error: unknown) { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use") +} + +function serveRemote( + fetch: (request: Request) => Response | Promise, + port = remotePort(), + attempts = MaxPortAttempts, +): ReturnType { + try { + return Bun.serve({ hostname: "127.0.0.1", port, idleTimeout: 0, fetch }) + } catch (error) { + if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error + return serveRemote(fetch, port + 1, attempts - 1) + } +} + +export function harnessFromSimulationRenderer(renderer: SimulationRenderer): SimulationMcpHarness { + return renderer +} + +export function harnessFromRenderer(renderer: CliRenderer): SimulationMcpHarness { + return { + renderer, + mockInput: createMockKeys(renderer), + mockMouse: createMockMouse(renderer), + renderOnce: async () => { + renderer.requestRender() + await renderer.idle() + }, + screen: () => decoder.decode(currentBuffer(renderer).getRealCharBytes(true)), + spans: () => { + const buffer = currentBuffer(renderer) + const cursor = renderer.getCursorState() + return { + cols: buffer.width, + rows: buffer.height, + cursor: [cursor.x, cursor.y] as [number, number], + lines: buffer.getSpanLines(), + } + }, + } +} + +function toolResult(value: unknown) { + return { + content: [ + { + type: "text" as const, + text: typeof value === "string" ? value : JSON.stringify(value, null, 2), + }, + ], + } +} + +function state(options: SimulationMcpOptions) { + return { + focused: { + renderable: options.harness.renderer.currentFocusedRenderable?.num, + editor: Boolean(options.harness.renderer.currentFocusedEditor), + }, + elements: SimulationActions.elements(options.harness.renderer), + actions: SimulationActions.actions(options.harness.renderer), + } +} + +function snapshot(options: SimulationMcpOptions) { + return { + screen: options.harness.screen(), + spans: options.harness.spans(), + ui: state(options), + } +} + +async function control(options: SimulationMcpOptions, method: string, pathname: string, body?: unknown) { + const response = await (options.controlFetch ?? fetch)(new URL(pathname, options.controlUrl), { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + const data = text ? JSON.parse(text) : undefined + if (response.ok) return data + throw new Error(typeof data?.error === "string" ? data.error : `Simulation control request failed: ${response.status}`) +} + +function createServer(options: SimulationMcpOptions) { + const server = new McpServer( + { name: "opencode-simulation", version: InstallationVersion }, + { + instructions: + "Use simulation_ui_state_get before acting. Prefer generated actions and execute them with simulation_action_execute. Inspect state after each action. Use control tools to seed filesystem, network, and LLM state.", + }, + ) + + server.registerResource("screen", "simulation://screen", { mimeType: "text/plain" }, () => ({ + contents: [{ uri: "simulation://screen", mimeType: "text/plain", text: options.harness.screen() }], + })) + server.registerResource("spans", "simulation://spans", { mimeType: "application/json" }, () => ({ + contents: [{ uri: "simulation://spans", mimeType: "application/json", text: JSON.stringify(options.harness.spans()) }], + })) + server.registerResource("ui-state", "simulation://ui-state", { mimeType: "application/json" }, () => ({ + contents: [{ uri: "simulation://ui-state", mimeType: "application/json", text: JSON.stringify(state(options)) }], + })) + server.registerResource("backend-snapshot", "simulation://backend-snapshot", { mimeType: "application/json" }, async () => ({ + contents: [ + { + uri: "simulation://backend-snapshot", + mimeType: "application/json", + text: JSON.stringify(await control(options, "GET", "/experimental/simulation/snapshot")), + }, + ], + })) + + server.registerPrompt("simulation-driver", { description: "Instructions for driving the simulated TUI." }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: "Inspect simulation_ui_state_get, choose one generated action, call simulation_action_execute, then inspect again. Use control tools to seed deterministic backend state.", + }, + }, + ], + })) + + server.registerTool("simulation_screen_get", { description: "Get the current TUI screen buffer." }, () => + toolResult({ screen: options.harness.screen() }), + ) + server.registerTool("simulation_spans_get", { description: "Get the current structured TUI spans." }, () => + toolResult(options.harness.spans()), + ) + server.registerTool("simulation_ui_state_get", { description: "Get elements, focus state, and generated actions." }, () => + toolResult(state(options)), + ) + server.registerTool("simulation_render_once", { description: "Force one render and return current state." }, async () => { + await options.harness.renderOnce() + return toolResult(snapshot(options)) + }) + server.registerTool( + "simulation_action_execute", + { + description: "Execute one generated simulation action and render once.", + inputSchema: z.object({ action: ActionSchema }), + }, + async (input) => { + await SimulationActions.execute(options.harness, input.action) + return toolResult(snapshot(options)) + }, + ) + server.registerTool( + "simulation_action_sequence_execute", + { + description: "Execute a bounded sequence of simulation actions and return final state.", + inputSchema: z.object({ actions: z.array(ActionSchema).max(50) }), + }, + async (input) => { + for (const action of input.actions) await SimulationActions.execute(options.harness, action) + return toolResult(snapshot(options)) + }, + ) + + server.registerTool("simulation_control_reset", { description: "Reset backend simulation state." }, async () => + toolResult(await control(options, "POST", "/experimental/simulation/reset")), + ) + server.registerTool( + "simulation_control_filesystem_seed", + { + description: "Seed backend simulated filesystem files.", + inputSchema: z.object({ files: z.record(z.string(), FileContentSchema) }), + }, + async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/seed", input)), + ) + server.registerTool( + "simulation_control_network_register", + { + description: "Register one backend simulated network response.", + inputSchema: NetworkRegistrationSchema, + }, + async (input) => toolResult(await control(options, "POST", "/experimental/simulation/network/register", input)), + ) + server.registerTool( + "simulation_control_llm_enqueue", + { + description: "Queue backend mock LLM scripts.", + inputSchema: z.object({ scripts: z.array(LlmScriptSchema) }), + }, + async (input) => toolResult(await control(options, "POST", "/experimental/simulation/llm/enqueue", input)), + ) + server.registerTool("simulation_control_snapshot", { description: "Get backend simulation state snapshot." }, async () => + toolResult(await control(options, "GET", "/experimental/simulation/snapshot")), + ) + + return server +} + +export async function createSimulationMcpServer(options: SimulationMcpOptions): Promise { + if (options.mode === "stdio") { + const server = createServer(options) + const transport = new StdioServerTransport() + await server.connect(transport) + return { + mode: options.mode, + stop: () => server.close(), + } + } + + const servers = new Set() + + const http = serveRemote( + async (request) => { + if (new URL(request.url).pathname !== "/mcp") return new Response("Not found", { status: 404 }) + const server = createServer(options) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + servers.add(server) + request.signal.addEventListener("abort", () => { + servers.delete(server) + void server.close() + }) + await server.connect(transport) + return transport.handleRequest(request) + }, + ) + + return { + mode: options.mode, + url: `http://${http.hostname}:${http.port}/mcp`, + stop: async () => { + http.stop(true) + await Promise.all([...servers].map((server) => server.close())) + servers.clear() + }, + } +} + +export * as TuiSimulationMcp from "./simulation-mcp" diff --git a/packages/opencode/src/cli/cmd/tui/simulation.ts b/packages/opencode/src/cli/cmd/tui/simulation.ts index 7bd777423..04322d0d5 100644 --- a/packages/opencode/src/cli/cmd/tui/simulation.ts +++ b/packages/opencode/src/cli/cmd/tui/simulation.ts @@ -1,8 +1,11 @@ import type { CliRenderer } from "@opentui/core" import type { CapturedFrame } from "@opentui/core" +import type { SimulationActions } from "@/testing/simulation/actions" export interface SimulationRenderer { readonly renderer: CliRenderer + readonly mockInput: SimulationActions.MockInput + readonly mockMouse: SimulationActions.MockMouse readonly renderOnce: () => Promise readonly screen: () => string readonly spans: () => CapturedFrame @@ -20,6 +23,8 @@ export async function createSimulationRenderer(): Promise { return { renderer: setup.renderer, + mockInput: setup.mockInput, + mockMouse: setup.mockMouse, renderOnce: setup.renderOnce, screen: setup.captureCharFrame, spans: setup.captureSpans, diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 981ccad51..5dfe067e7 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -232,6 +232,12 @@ export const TuiThreadCommand = cmd({ try { const { tui } = await import("./app") const simulationRenderer = Flag.OPENCODE_SIMULATION ? await TuiSimulation.createSimulationRenderer() : undefined + const simulationMcpMode = Flag.OPENCODE_SIMULATION + ? "stdio" + : Flag.OPENCODE_SIMULATION_BACKEND + ? "remote" + : undefined + let simulationMcp: Awaited> | undefined try { await tui({ url: transport.url, @@ -246,11 +252,26 @@ export const TuiThreadCommand = cmd({ events: transport.events, renderer: simulationRenderer?.renderer, mode: simulationRenderer ? "dark" : undefined, - onReady: simulationRenderer - ? async () => { - await simulationRenderer.renderOnce() + onReady: simulationMcpMode + ? async (ctx) => { + const module = await import("./simulation-mcp") + const harness = simulationRenderer + ? module.TuiSimulationMcp.harnessFromSimulationRenderer(simulationRenderer) + : module.TuiSimulationMcp.harnessFromRenderer(ctx.renderer) + if (simulationRenderer) await simulationRenderer.renderOnce() + simulationMcp = await module.TuiSimulationMcp.createSimulationMcpServer({ + mode: simulationMcpMode, + harness, + controlUrl: transport.url, + controlFetch: transport.fetch, + }) + return { simulationMcpUrl: simulationMcp.url } } - : undefined, + : simulationRenderer + ? async () => { + await simulationRenderer.renderOnce() + } + : undefined, args: { continue: args.continue, sessionID: args.session, @@ -261,6 +282,7 @@ export const TuiThreadCommand = cmd({ }, }) } finally { + await simulationMcp?.stop() simulationRenderer?.destroy() } } finally { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 938cae6cb..f92e496b1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -11,6 +11,7 @@ import { } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { Account } from "@/account/account" import { AccountRepo } from "@/account/repo" @@ -29,6 +30,7 @@ import { Format } from "@/format" import { RuntimeFlags } from "@/effect/runtime-flags" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" +import { McpAuth } from "@/mcp/auth" import { Permission } from "@/permission" import { Installation } from "@/installation" import { InstanceLayer } from "@/project/instance-layer" @@ -45,6 +47,8 @@ import { Question } from "@/question" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" import { SessionPrompt } from "@/session/prompt" +import { SessionProcessor } from "@/session/processor" +import { Instruction } from "@/session/instruction" import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" @@ -53,10 +57,15 @@ import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" import { ShareNext } from "@/share/share-next" import { EventV2Bridge } from "@/event-v2-bridge" +import { LLM } from "@/session/llm" +import { SystemPrompt } from "@/session/system" import { Skill } from "@/skill" +import { Discovery } from "@/skill/discovery" import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool/truncate" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" @@ -259,69 +268,90 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType { + test("exposes simulation tools over MCP", async () => { + const renderer = await TuiSimulation.createSimulationRenderer() + const server = await TuiSimulationMcp.createSimulationMcpServer({ + mode: "remote", + harness: TuiSimulationMcp.harnessFromSimulationRenderer(renderer), + controlUrl: "http://127.0.0.1:1", + }) + const client = new Client({ name: "simulation-test", version: "0.0.0" }) + const transport = new StreamableHTTPClientTransport(new URL(server.url!)) + + try { + await client.connect(transport) + const tools = await client.listTools() + expect(tools.tools.map((tool) => tool.name)).toContain("simulation_ui_state_get") + expect(tools.tools.map((tool) => tool.name)).toContain("simulation_control_llm_enqueue") + + const screen = await client.callTool({ name: "simulation_screen_get", arguments: {} }) + expect(screen.content).toBeArray() + } finally { + await client.close() + await server.stop() + renderer.destroy() + } + }) +})