add simulation network log

This commit is contained in:
James Long
2026-05-17 21:49:29 -04:00
parent 9d11b62ff0
commit 53d45b2894
5 changed files with 133 additions and 17 deletions
+46 -11
View File
@@ -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<Response> => {
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
}
@@ -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",
@@ -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<string, string>
readonly requestBody?: string
readonly responseHeaders: Record<string, string>
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<NetworkLogEntry, "id" | "responseTruncated"> & { 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"
@@ -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,
@@ -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"
}
}
]