diff --git a/packages/opencode/src/cli/cmd/tui/simulate.ts b/packages/opencode/src/cli/cmd/tui/simulate.ts index fc2f3412e..5e0aebdc6 100644 --- a/packages/opencode/src/cli/cmd/tui/simulate.ts +++ b/packages/opencode/src/cli/cmd/tui/simulate.ts @@ -17,6 +17,7 @@ import type { EventSource } from "./context/sdk" import type { SimulationMcpRuntimeState } from "./simulation-mcp" import type { rpc } from "./worker" import { SimulationDebugLog } from "../../../testing/simulation/debug-log" +import { SimulationNetworkLog } from "./simulation-network-log" import { fileURLToPath } from "url" import { writeHeapSnapshot } from "v8" @@ -49,18 +50,52 @@ function createWorkerFetch(client: RpcClient): typeof fetch { const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = new Request(input, init) const body = request.body ? await request.text() : undefined + const headers = Object.fromEntries(request.headers.entries()) SimulationDebugLog.write("simulate.fetch.start", { method: request.method, url: request.url }) - const result = await client.call("fetch", { - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - body, - }) - SimulationDebugLog.write("simulate.fetch.end", { method: request.method, url: request.url, status: result.status }) - return new Response(result.body, { - status: result.status, - headers: result.headers, - }) + const startedAt = Date.now() + const startedAtIso = new Date(startedAt).toISOString() + try { + const result = await client.call("fetch", { + url: request.url, + method: request.method, + headers, + body, + }) + SimulationDebugLog.write("simulate.fetch.end", { + method: request.method, + url: request.url, + status: result.status, + }) + SimulationNetworkLog.record({ + time: startedAtIso, + method: request.method, + url: request.url, + status: result.status, + durationMs: Date.now() - startedAt, + requestHeaders: headers, + requestBody: body, + responseHeaders: result.headers, + responseBody: result.body, + }) + return new Response(result.body, { + status: result.status, + headers: result.headers, + }) + } catch (err) { + SimulationNetworkLog.record({ + time: startedAtIso, + method: request.method, + url: request.url, + status: 0, + durationMs: Date.now() - startedAt, + requestHeaders: headers, + requestBody: body, + responseHeaders: {}, + responseBody: "", + error: err instanceof Error ? err.message : String(err), + }) + throw err + } } return fn as typeof fetch } diff --git a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts index 1da1e6a4a..873f3580c 100644 --- a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts +++ b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts @@ -1,4 +1,5 @@ import { SimulationActions } from "@/testing/simulation/actions" +import { SimulationNetworkLog } from "./simulation-network-log" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" @@ -548,6 +549,39 @@ function createServer(options: Options) { toolResult(await control(options, "GET", "/experimental/simulation/snapshot")), ) + server.registerTool( + "simulation_network_log_get", + { + description: + "Return the persistent network log: every HTTP request the simulated TUI sent to the backend, with method, URL, status code, headers, and response body (body truncated at 32KB per entry). Capped to the last 500 entries.", + inputSchema: z.object({ + limit: z.number().int().min(1).max(500).optional(), + urlIncludes: z.string().optional(), + statusMin: z.number().int().optional(), + statusMax: z.number().int().optional(), + }), + }, + async (input) => { + let entries = SimulationNetworkLog.snapshot() + if (input.urlIncludes) entries = entries.filter((e) => e.url.includes(input.urlIncludes!)) + if (typeof input.statusMin === "number") entries = entries.filter((e) => e.status >= input.statusMin!) + if (typeof input.statusMax === "number") entries = entries.filter((e) => e.status <= input.statusMax!) + if (typeof input.limit === "number") entries = entries.slice(-input.limit) + return toolResult({ entries, total: entries.length }) + }, + ) + + server.registerTool( + "simulation_network_log_clear", + { + description: "Clear the simulated TUI's persistent network log. Use between scripted runs to isolate observations.", + }, + async () => { + SimulationNetworkLog.clear() + return toolResult({ cleared: true }) + }, + ) + if (masterEnabled()) { server.registerTool( "simulation_instances_discover", diff --git a/packages/opencode/src/cli/cmd/tui/simulation-network-log.ts b/packages/opencode/src/cli/cmd/tui/simulation-network-log.ts new file mode 100644 index 000000000..44275fec7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/simulation-network-log.ts @@ -0,0 +1,46 @@ +// Per-process network log for the simulated TUI. Captures every HTTP request +// the TUI sends to the worker-backed backend (via `createWorkerFetch`). +// Exposed through the simulation MCP server so tests / agents can inspect what +// the running TUI is actually doing. + +export interface NetworkLogEntry { + readonly id: number + readonly time: string + readonly method: string + readonly url: string + readonly status: number + readonly durationMs: number + readonly requestHeaders: Record + readonly requestBody?: string + readonly responseHeaders: Record + readonly responseBody: string + readonly responseTruncated: boolean + readonly error?: string +} + +const MAX_ENTRIES = 500 +const MAX_BODY_BYTES = 32_768 + +const entries: NetworkLogEntry[] = [] +let nextId = 1 + +function truncate(text: string): { body: string; truncated: boolean } { + if (text.length <= MAX_BODY_BYTES) return { body: text, truncated: false } + return { body: text.slice(0, MAX_BODY_BYTES), truncated: true } +} + +export function record(entry: Omit & { responseBody: string }) { + const { body, truncated } = truncate(entry.responseBody) + entries.push({ ...entry, id: nextId++, responseBody: body, responseTruncated: truncated }) + if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES) +} + +export function snapshot(): NetworkLogEntry[] { + return entries.slice() +} + +export function clear() { + entries.length = 0 +} + +export * as SimulationNetworkLog from "./simulation-network-log" diff --git a/packages/opencode/src/testing/simulation/provider.ts b/packages/opencode/src/testing/simulation/provider.ts index 081f1b4e7..055d9f5ef 100644 --- a/packages/opencode/src/testing/simulation/provider.ts +++ b/packages/opencode/src/testing/simulation/provider.ts @@ -13,7 +13,10 @@ import { SimulationDebugLog } from "./debug-log" import { Simulation, type LLMScript } from "./service" const providerID = ProviderID.make("simulation") -const modelID = ModelID.make("mock") +// Use a model id that contains "gpt-" (and not "oss" / "gpt-4") so the tool +// registry's GPT-style gate enables `apply_patch` in the simulated chain. +// See registry.ts:319-322 for the gating logic. +const modelID = ModelID.make("gpt-mock") const model: Provider.Model = { id: modelID, diff --git a/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json b/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json index 9f6441b07..5f0757c08 100644 --- a/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json +++ b/packages/opencode/test/testing/simulation/scripts/08_real_tool_call_patch.json @@ -12,16 +12,14 @@ { "type": "text", "content": "Looking at `src/greeting.ts`, the function currently returns `\"Hi, …\"`. " }, { "type": "text", "content": "You asked for a friendlier greeting, so I'll change the prefix from `Hi` to `Hello`. " }, { "type": "text", "content": "I'll keep the template literal and the `name` interpolation untouched. " }, - { "type": "text", "content": "Here's the plan: I'll use the `edit` tool to replace the single return line. " }, + { "type": "text", "content": "Here's the plan: I'll use the `apply_patch` tool to replace the single return line. " }, { "type": "text", "content": "Patching `src/greeting.ts` now." }, { "type": "tool-call", "toolCallId": "patch-greeting-1", - "toolName": "edit", + "toolName": "apply_patch", "input": { - "filePath": "/opencode/src/greeting.ts", - "oldString": "return `Hi, ${name}`", - "newString": "return `Hello, ${name}`" + "patchText": "*** Begin Patch\n*** Update File: /opencode/src/greeting.ts\n@@\n- return `Hi, ${name}`\n+ return `Hello, ${name}`\n*** End Patch\n" } } ]