James Long 3 月之前
父节点
当前提交
13ea36f575

+ 8 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -168,6 +168,7 @@ export function tui(input: {
   renderer?: CliRenderer
   mode?: "dark" | "light"
   onReady?: (ctx: { renderer: CliRenderer }) => void | Promise<void | { simulationMcpUrl?: string }>
+  onStop?: (stop: () => Promise<void>) => void
 }) {
   // promise to prevent immediate exit
   // oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
@@ -236,7 +237,7 @@ export function tui(input: {
                                             <PromptHistoryProvider>
                                               <PromptRefProvider>
                                                 <EditorContextProvider>
-                                                  <App onSnapshot={input.onSnapshot} />
+                                                  <AppLifecycle onSnapshot={input.onSnapshot} onStop={input.onStop} />
                                                 </EditorContextProvider>
                                               </PromptRefProvider>
                                             </PromptHistoryProvider>
@@ -265,6 +266,12 @@ export function tui(input: {
   })
 }
 
+function AppLifecycle(props: { onSnapshot?: () => Promise<string[]>; onStop?: (stop: () => Promise<void>) => void }) {
+  const exit = useExit()
+  props.onStop?.(() => exit())
+  return <App onSnapshot={props.onSnapshot} />
+}
+
 function App(props: { onSnapshot?: () => Promise<string[]> }) {
   const tuiConfig = useTuiConfig()
   const route = useRoute()

+ 286 - 0
packages/opencode/src/cli/cmd/tui/simulate.ts

@@ -0,0 +1,286 @@
+import { cmd } from "@/cli/cmd/cmd"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { Filesystem } from "@/util/filesystem"
+import { Rpc } from "@/util/rpc"
+import { errorMessage } from "@/util/error"
+import { withTimeout } from "@/util/timeout"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import * as Log from "@opencode-ai/core/util/log"
+import {
+  OPENCODE_PROCESS_ROLE,
+  OPENCODE_RUN_ID,
+  ensureRunID,
+  sanitizedProcessEnv,
+} from "@opencode-ai/core/util/opencode-process"
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
+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 { fileURLToPath } from "url"
+import { writeHeapSnapshot } from "v8"
+
+type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
+const simulatedDirectory = "/"
+const simulatedCwdEnv = "OPENCODE_SIMULATION_CWD"
+
+function fakeCwd(directory: string) {
+  process.env.PWD = directory
+  Object.defineProperty(process, "cwd", {
+    value: () => directory,
+    configurable: true,
+  })
+}
+
+interface Transport {
+  readonly url: string
+  readonly fetch: typeof fetch
+  readonly events: EventSource
+}
+
+interface RunningInstance {
+  readonly client: RpcClient
+  readonly done: Promise<void>
+  readonly stopTui: () => Promise<void>
+  readonly stopWorker: () => Promise<void>
+}
+
+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
+    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,
+    })
+  }
+  return fn as typeof fetch
+}
+
+function createEventSource(client: RpcClient, onSubscribe?: () => void): EventSource {
+  return {
+    subscribe: async (handler) => {
+      // SimulationDebugLog.write("simulate.events.subscribe")
+      onSubscribe?.()
+      return client.on<GlobalEvent>("global.event", (e) => {
+        // SimulationDebugLog.write("simulate.events.received", {
+        //   directory: e.directory,
+        //   workspace: e.workspace,
+        //   type: e.payload?.type,
+        //   sync: e.payload?.type === "sync",
+        // })
+        handler(e)
+      })
+    },
+  }
+}
+
+async function target() {
+  const workerPath = Reflect.get(globalThis, "OPENCODE_WORKER_PATH")
+  if (typeof workerPath === "string") return workerPath
+  const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
+  if (await Filesystem.exists(fileURLToPath(dist))) return dist
+  return new URL("./worker.ts", import.meta.url)
+}
+
+export const SimulateCommand = cmd({
+  command: "simulate",
+  describe: "start restartable simulated opencode tui",
+  handler: async () => {
+    SimulationDebugLog.reset()
+    fakeCwd(simulatedDirectory)
+    const file = await target()
+    const cwd = simulatedDirectory
+    const config = await TuiConfig.get()
+    const simulationMcpMode = Flag.OPENCODE_SIMULATION ? "stdio" : "remote"
+
+    let currentInstance: RunningInstance | undefined
+    let currentRuntime: SimulationMcpRuntimeState | undefined
+    let restartRequested = false
+    let restartWaiter:
+      | {
+          readonly promise: Promise<{ restarted: true }>
+          readonly resolve: (value: { restarted: true }) => void
+          readonly reject: (error: unknown) => void
+        }
+      | undefined
+
+    const error = (e: unknown) => {
+      Log.Default.error("process error", { error: errorMessage(e) })
+    }
+    const reload = () => {
+      currentInstance?.client.call("reload", undefined).catch((err) => {
+        Log.Default.warn("worker reload failed", {
+          error: errorMessage(err),
+        })
+      })
+    }
+    process.on("uncaughtException", error)
+    process.on("unhandledRejection", error)
+    process.on("SIGUSR2", reload)
+
+    const simulationMcpModule = await import("./simulation-mcp")
+    const simulationMcp = await simulationMcpModule.TuiSimulationMcp.createSimulationMcpServer({
+      mode: simulationMcpMode,
+      runtime: {
+        current: () => currentRuntime,
+        restart: async () => {
+          if (restartWaiter) return restartWaiter.promise
+          if (!currentInstance) throw new Error("Simulation TUI is not ready")
+          restartRequested = true
+          let resolve!: (value: { restarted: true }) => void
+          let reject!: (error: unknown) => void
+          const promise = new Promise<{ restarted: true }>((resolvePromise, rejectPromise) => {
+            resolve = resolvePromise
+            reject = rejectPromise
+          })
+          restartWaiter = { promise, resolve, reject }
+          currentInstance.stopTui().catch((err) => restartWaiter?.reject(err))
+          return promise
+        },
+      },
+    })
+
+    const stopWorker = async (client: RpcClient, worker: Worker) => {
+      await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => {
+        Log.Default.warn("worker shutdown failed", {
+          error: errorMessage(err),
+        })
+      })
+      worker.terminate()
+    }
+
+    const start = async (): Promise<RunningInstance> => {
+      const worker = new Worker(file, {
+        env: sanitizedProcessEnv({
+          [OPENCODE_PROCESS_ROLE]: "worker",
+          [OPENCODE_RUN_ID]: ensureRunID(),
+          PWD: simulatedDirectory,
+          [simulatedCwdEnv]: simulatedDirectory,
+        }),
+      })
+      worker.onerror = (e) => {
+        Log.Default.error("thread error", {
+          message: e.message,
+          filename: e.filename,
+          lineno: e.lineno,
+          colno: e.colno,
+          error: e.error,
+        })
+      }
+
+      const client = Rpc.client<typeof rpc>(worker)
+      let eventSubscribedResolve!: () => void
+      const eventSubscribed = new Promise<void>((resolve) => {
+        eventSubscribedResolve = resolve
+      })
+      const transport: Transport = {
+        url: "http://opencode.internal",
+        fetch: createWorkerFetch(client),
+        events: createEventSource(client, eventSubscribedResolve),
+      }
+
+      const { tui } = await import("./app")
+      const simulationRenderer = Flag.OPENCODE_SIMULATION
+        ? await (await import("./simulation")).TuiSimulation.createSimulationRenderer()
+        : undefined
+
+      let stopTui = async () => {}
+      let stopReadyResolve!: () => void
+      const stopReady = new Promise<void>((resolve) => {
+        stopReadyResolve = resolve
+      })
+      let readyResolve!: () => void
+      let readyReject!: (error: unknown) => void
+      const ready = new Promise<void>((resolve, reject) => {
+        readyResolve = resolve
+        readyReject = reject
+      })
+      const done = tui({
+        url: transport.url,
+        async onSnapshot() {
+          const tui = writeHeapSnapshot("tui.heapsnapshot")
+          const server = await client.call("snapshot", undefined)
+          return [tui, server]
+        },
+        config,
+        directory: cwd,
+        fetch: transport.fetch,
+        events: transport.events,
+        renderer: simulationRenderer?.renderer,
+        mode: simulationRenderer ? "dark" : undefined,
+        onStop: (stop) => {
+          stopTui = stop
+          stopReadyResolve()
+        },
+        onReady: async (ctx) => {
+          try {
+            currentRuntime = {
+              harness: simulationRenderer
+                ? simulationMcpModule.TuiSimulationMcp.harnessFromSimulationRenderer(simulationRenderer)
+                : simulationMcpModule.TuiSimulationMcp.harnessFromRenderer(ctx.renderer),
+              controlUrl: transport.url,
+              controlFetch: transport.fetch,
+            }
+            if (simulationRenderer) await simulationRenderer.renderOnce()
+            readyResolve()
+            return { simulationMcpUrl: simulationMcp.url }
+          } catch (err) {
+            readyReject(err)
+            throw err
+          }
+        },
+        args: {},
+      })
+
+      await Promise.all([ready, stopReady, eventSubscribed])
+        return {
+          client,
+          done,
+          stopTui: () => stopTui(),
+          stopWorker: async () => {
+            simulationRenderer?.destroy()
+            await stopWorker(client, worker)
+          },
+        }
+      }
+
+    try {
+      while (true) {
+        try {
+          currentInstance = await start()
+          restartWaiter?.resolve({ restarted: true })
+          restartWaiter = undefined
+          restartRequested = false
+        } catch (err) {
+          restartWaiter?.reject(err)
+          throw err
+        }
+
+        await currentInstance.done
+        currentRuntime = undefined
+        await currentInstance.stopWorker()
+        currentInstance = undefined
+        if (!restartRequested) break
+      }
+    } finally {
+      currentRuntime = undefined
+      await currentInstance?.stopTui().catch(() => {})
+      await currentInstance?.stopWorker()
+      await simulationMcp.stop()
+      process.off("uncaughtException", error)
+      process.off("unhandledRejection", error)
+      process.off("SIGUSR2", reload)
+    }
+  },
+})
+
+export * as TuiSimulate from "./simulate"

+ 53 - 19
packages/opencode/src/cli/cmd/tui/simulation-mcp.ts

@@ -26,6 +26,22 @@ export interface SimulationMcpOptions {
   readonly controlFetch?: typeof fetch
 }
 
+export interface SimulationMcpRuntimeState {
+  readonly harness: SimulationMcpHarness
+  readonly controlUrl: string
+  readonly controlFetch?: typeof fetch
+}
+
+export interface RestartableSimulationMcpOptions {
+  readonly mode: SimulationMcpMode
+  readonly runtime: {
+    readonly current: () => SimulationMcpRuntimeState | undefined
+    readonly restart: () => Promise<unknown>
+  }
+}
+
+type Options = SimulationMcpOptions | RestartableSimulationMcpOptions
+
 export interface SimulationMcpServer {
   readonly mode: SimulationMcpMode
   readonly url?: string
@@ -167,27 +183,39 @@ function toolResult(value: unknown) {
   }
 }
 
-function state(options: SimulationMcpOptions) {
+function current(options: Options) {
+  if ("runtime" in options) {
+    const value = options.runtime.current()
+    if (value) return value
+    throw new Error("Simulation TUI is not ready")
+  }
+  return options
+}
+
+function state(options: Options) {
+  const running = current(options)
   return {
     focused: {
-      renderable: options.harness.renderer.currentFocusedRenderable?.num,
-      editor: Boolean(options.harness.renderer.currentFocusedEditor),
+      renderable: running.harness.renderer.currentFocusedRenderable?.num,
+      editor: Boolean(running.harness.renderer.currentFocusedEditor),
     },
-    elements: SimulationActions.elements(options.harness.renderer),
-    actions: SimulationActions.actions(options.harness.renderer),
+    elements: SimulationActions.elements(running.harness.renderer),
+    actions: SimulationActions.actions(running.harness.renderer),
   }
 }
 
-function snapshot(options: SimulationMcpOptions) {
+function snapshot(options: Options) {
+  const running = current(options)
   return {
-    screen: options.harness.screen(),
-    spans: options.harness.spans(),
+    screen: running.harness.screen(),
+    spans: running.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), {
+async function control(options: Options, method: string, pathname: string, body?: unknown) {
+  const running = current(options)
+  const response = await (running.controlFetch ?? fetch)(new URL(pathname, running.controlUrl), {
     method,
     headers: body === undefined ? undefined : { "content-type": "application/json" },
     body: body === undefined ? undefined : JSON.stringify(body),
@@ -198,7 +226,7 @@ async function control(options: SimulationMcpOptions, method: string, pathname:
   throw new Error(typeof data?.error === "string" ? data.error : `Simulation control request failed: ${response.status}`)
 }
 
-function createServer(options: SimulationMcpOptions) {
+function createServer(options: Options) {
   const server = new McpServer(
     { name: "opencode-simulation", version: InstallationVersion },
     {
@@ -208,10 +236,10 @@ function createServer(options: SimulationMcpOptions) {
   )
 
   server.registerResource("screen", "simulation://screen", { mimeType: "text/plain" }, () => ({
-    contents: [{ uri: "simulation://screen", mimeType: "text/plain", text: options.harness.screen() }],
+    contents: [{ uri: "simulation://screen", mimeType: "text/plain", text: current(options).harness.screen() }],
   }))
   server.registerResource("spans", "simulation://spans", { mimeType: "application/json" }, () => ({
-    contents: [{ uri: "simulation://spans", mimeType: "application/json", text: JSON.stringify(options.harness.spans()) }],
+    contents: [{ uri: "simulation://spans", mimeType: "application/json", text: JSON.stringify(current(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)) }],
@@ -239,16 +267,16 @@ function createServer(options: SimulationMcpOptions) {
   }))
 
   server.registerTool("simulation_screen_get", { description: "Get the current TUI screen buffer." }, () =>
-    toolResult({ screen: options.harness.screen() }),
+    toolResult({ screen: current(options).harness.screen() }),
   )
   server.registerTool("simulation_spans_get", { description: "Get the current structured TUI spans." }, () =>
-    toolResult(options.harness.spans()),
+    toolResult(current(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()
+    await current(options).harness.renderOnce()
     return toolResult(snapshot(options))
   })
   server.registerTool(
@@ -258,7 +286,7 @@ function createServer(options: SimulationMcpOptions) {
       inputSchema: z.object({ action: ActionSchema }),
     },
     async (input) => {
-      await SimulationActions.execute(options.harness, input.action)
+      await SimulationActions.execute(current(options).harness, input.action)
       return toolResult(snapshot(options))
     },
   )
@@ -269,11 +297,17 @@ function createServer(options: SimulationMcpOptions) {
       inputSchema: z.object({ actions: z.array(ActionSchema).max(50) }),
     },
     async (input) => {
-      for (const action of input.actions) await SimulationActions.execute(options.harness, action)
+      for (const action of input.actions) await SimulationActions.execute(current(options).harness, action)
       return toolResult(snapshot(options))
     },
   )
 
+  if ("runtime" in options) {
+    server.registerTool("simulation_restart", { description: "Restart the simulated TUI and backend while keeping MCP alive." }, async () =>
+      toolResult(await options.runtime.restart()),
+    )
+  }
+
   server.registerTool("simulation_control_reset", { description: "Reset backend simulation state." }, async () =>
     toolResult(await control(options, "POST", "/experimental/simulation/reset")),
   )
@@ -308,7 +342,7 @@ function createServer(options: SimulationMcpOptions) {
   return server
 }
 
-export async function createSimulationMcpServer(options: SimulationMcpOptions): Promise<SimulationMcpServer> {
+export async function createSimulationMcpServer(options: Options): Promise<SimulationMcpServer> {
   if (options.mode === "stdio") {
     const server = createServer(options)
     const transport = new StdioServerTransport()

+ 20 - 56
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -21,8 +21,6 @@ import {
   sanitizedProcessEnv,
 } from "@opencode-ai/core/util/opencode-process"
 import { validateSession } from "./validate-session"
-import { Flag } from "@opencode-ai/core/flag/flag"
-import { TuiSimulation } from "./simulation"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -231,60 +229,26 @@ 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<ReturnType<typeof import("./simulation-mcp").TuiSimulationMcp.createSimulationMcpServer>> | undefined
-        try {
-          await tui({
-            url: transport.url,
-            async onSnapshot() {
-              const tui = writeHeapSnapshot("tui.heapsnapshot")
-              const server = await client.call("snapshot", undefined)
-              return [tui, server]
-            },
-            config,
-            directory: cwd,
-            fetch: transport.fetch,
-            events: transport.events,
-            renderer: simulationRenderer?.renderer,
-            mode: simulationRenderer ? "dark" : undefined,
-            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 }
-                }
-              : simulationRenderer
-                ? async () => {
-                    await simulationRenderer.renderOnce()
-                  }
-                : undefined,
-            args: {
-              continue: args.continue,
-              sessionID: args.session,
-              agent: args.agent,
-              model: args.model,
-              prompt,
-              fork: args.fork,
-            },
-          })
-        } finally {
-          await simulationMcp?.stop()
-          simulationRenderer?.destroy()
-        }
+        await tui({
+          url: transport.url,
+          async onSnapshot() {
+            const tui = writeHeapSnapshot("tui.heapsnapshot")
+            const server = await client.call("snapshot", undefined)
+            return [tui, server]
+          },
+          config,
+          directory: cwd,
+          fetch: transport.fetch,
+          events: transport.events,
+          args: {
+            continue: args.continue,
+            sessionID: args.session,
+            agent: args.agent,
+            model: args.model,
+            prompt,
+            fork: args.fork,
+          },
+        })
       } finally {
         await stop()
       }

+ 18 - 0
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -13,8 +13,18 @@ import { AppRuntime } from "@/effect/app-runtime"
 import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
 import { Effect } from "effect"
 import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
+import { SimulationDebugLog } from "../../../testing/simulation/debug-log"
+import fs from "fs/promises"
 
 ensureProcessMetadata("worker")
+if (process.env.OPENCODE_SIMULATION_CWD) {
+  process.env.PWD = process.env.OPENCODE_SIMULATION_CWD
+  Object.defineProperty(process, "cwd", {
+    value: () => process.env.OPENCODE_SIMULATION_CWD!,
+    configurable: true,
+  })
+}
+void fs.writeFile("/tmp/opencode-http-errors.log", "")
 
 await Log.init({
   print: process.argv.includes("--print-logs"),
@@ -41,6 +51,12 @@ process.on("uncaughtException", (e) => {
 
 // Subscribe to global events and forward them via RPC
 GlobalBus.on("event", (event) => {
+  SimulationDebugLog.write("worker.global.event", {
+    directory: event.directory,
+    workspace: event.workspace,
+    type: event.payload?.type,
+    syncType: event.payload?.syncEvent?.type,
+  })
   Rpc.emit("global.event", event)
 })
 
@@ -48,6 +64,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
 
 export const rpc = {
   async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
+    SimulationDebugLog.write("worker.fetch.start", { method: input.method, url: input.url })
     const headers = { ...input.headers }
     const auth = ServerAuth.header()
     if (auth && !headers["authorization"] && !headers["Authorization"]) {
@@ -60,6 +77,7 @@ export const rpc = {
     })
     const response = await Server.Default().app.fetch(request)
     const body = await response.text()
+    SimulationDebugLog.write("worker.fetch.end", { method: input.method, url: input.url, status: response.status })
     return {
       status: response.status,
       headers: Object.fromEntries(response.headers.entries()),

+ 5 - 1
packages/opencode/src/index.ts

@@ -24,6 +24,7 @@ import { ExportCommand } from "./cli/cmd/export"
 import { ImportCommand } from "./cli/cmd/import"
 import { AttachCommand } from "./cli/cmd/tui/attach"
 import { TuiThreadCommand } from "./cli/cmd/tui/thread"
+import { SimulateCommand } from "./cli/cmd/tui/simulate"
 import { AcpCommand } from "./cli/cmd/acp"
 import { EOL } from "os"
 import { WebCommand } from "./cli/cmd/web"
@@ -40,6 +41,7 @@ import { Heap } from "./cli/heap"
 import { drizzle } from "drizzle-orm/bun-sqlite"
 import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
 import { isRecord } from "@/util/record"
+import { Flag } from "@opencode-ai/core/flag/flag"
 
 const processMetadata = ensureProcessMetadata("main")
 
@@ -67,7 +69,7 @@ function show(out: string) {
   process.stderr.write(out)
 }
 
-const cli = yargs(args)
+const baseCli = yargs(args)
   .parserConfiguration({ "populate--": true })
   .scriptName("opencode")
   .wrap(100)
@@ -178,6 +180,8 @@ const cli = yargs(args)
   .command(SessionCommand)
   .command(PluginCommand)
   .command(DbCommand)
+
+const cli = (Flag.OPENCODE_SIMULATION || Flag.OPENCODE_SIMULATION_BACKEND ? baseCli.command(SimulateCommand) : baseCli)
   .fail((msg, err) => {
     if (
       msg?.startsWith("Unknown argument") ||

+ 14 - 0
packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

@@ -3,8 +3,22 @@ import * as Log from "@opencode-ai/core/util/log"
 import { ConfigError } from "@/config/error"
 import { Cause, Effect } from "effect"
 import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
+import fs from "fs/promises"
 
 const log = Log.create({ service: "server" })
+const errorLogPath = "/tmp/opencode-http-errors.log"
+
+function writeHttpError(cause: Cause.Cause<unknown>, error: unknown) {
+  void fs.appendFile(
+    errorLogPath,
+    JSON.stringify({
+      time: new Date().toISOString(),
+      error: error instanceof Error ? error.message : String(error),
+      stack: error instanceof Error ? error.stack : undefined,
+      cause: Cause.pretty(cause),
+    }) + "\n",
+  )
+}
 
 // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
 export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>

+ 13 - 1
packages/opencode/src/session/llm.ts

@@ -23,6 +23,7 @@ import { EffectBridge } from "@/effect/bridge"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
+import { SimulationDebugLog } from "../testing/simulation/debug-log"
 
 const log = Log.create({ service: "llm" })
 export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
@@ -408,14 +409,25 @@ const live: Layer.Layer<
       Stream.scoped(
         Stream.unwrap(
           Effect.gen(function* () {
+            SimulationDebugLog.write("llm.stream.start", {
+              providerID: input.model.providerID,
+              modelID: input.model.id,
+              small: input.small,
+              messages: input.messages.length,
+              tools: Object.keys(input.tools).length,
+              stack: new Error().stack,
+            })
             const ctrl = yield* Effect.acquireRelease(
               Effect.sync(() => new AbortController()),
               (ctrl) => Effect.sync(() => ctrl.abort()),
             )
 
             const result = yield* run({ ...input, abort: ctrl.signal })
+            SimulationDebugLog.write("llm.stream.result")
 
-            return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
+            return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e)))).pipe(
+              Stream.tap((event) => Effect.sync(() => SimulationDebugLog.write("llm.stream.event", { type: event }))),
+            )
           }),
         ),
       )

+ 10 - 0
packages/opencode/src/session/processor.ts

@@ -28,6 +28,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import * as DateTime from "effect/DateTime"
 import { RuntimeFlags } from "@/effect/runtime-flags"
+import { SimulationDebugLog } from "../testing/simulation/debug-log"
 
 const DOOM_LOOP_THRESHOLD = 3
 const log = Log.create({ service: "session.processor" })
@@ -212,6 +213,7 @@ export const layer = Layer.effect(
       })
 
       const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
+        SimulationDebugLog.write("processor.handleEvent", { type: value.type })
         switch (value.type) {
           case "start":
             yield* status.set(ctx.sessionID, { type: "busy" })
@@ -719,15 +721,23 @@ export const layer = Layer.effect(
       })
 
       const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
+        SimulationDebugLog.write("processor.process.start", {
+          providerID: streamInput.model.providerID,
+          modelID: streamInput.model.id,
+        })
         slog.info("process")
         ctx.needsCompaction = false
         ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
 
+        SimulationDebugLog.write("JWL processing")
+
         return yield* Effect.gen(function* () {
           yield* Effect.gen(function* () {
+            SimulationDebugLog.write("JWL inside")
             ctx.currentText = undefined
             ctx.reasoningMap = {}
             const stream = llm.stream(streamInput)
+            SimulationDebugLog.write("processor.stream.created")
 
             yield* stream.pipe(
               Stream.tap((event) => handleEvent(event)),

+ 10 - 1
packages/opencode/src/session/prompt.ts

@@ -38,6 +38,7 @@ import { Tool } from "@/tool/tool"
 import { Permission } from "@/permission"
 import { SessionStatus } from "./status"
 import { LLM } from "./llm"
+import { errorMessage } from "@/util/error"
 import { Shell } from "@/shell/shell"
 import { ShellID } from "@/tool/shell/id"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -1812,7 +1813,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
             const [skills, env, instructions, modelMsgs] = yield* Effect.all([
               sys.skills(agent),
               sys.environment(model),
-              instruction.system().pipe(Effect.orDie),
+              instruction.system().pipe(
+                Effect.tapError((error) =>
+                  bus.publish(Session.Event.Error, {
+                    sessionID,
+                    error: new NamedError.Unknown({ message: errorMessage(error) }, { cause: error }).toObject(),
+                  }),
+                ),
+                Effect.orDie,
+              ),
               MessageV2.toModelMessagesEffect(msgs, model),
             ])
             const system = [...env, ...instructions, ...(skills ? [skills] : [])]

+ 0 - 1
packages/opencode/src/snapshot/.#index.ts

@@ -1 +0,0 @@
-james@james-6.local.82438:1777694013

+ 14 - 0
packages/opencode/src/testing/simulation/debug-log.ts

@@ -0,0 +1,14 @@
+import fs from "fs/promises"
+
+const file = "/tmp/opencode-simulation-stream.log"
+
+export function reset() {
+  void fs.writeFile(file, "")
+}
+
+export function write(event: string, data?: unknown) {
+  const line = JSON.stringify({ time: new Date().toISOString(), event, data }) + "\n"
+  void fs.appendFile(file, line)
+}
+
+export * as SimulationDebugLog from "./debug-log"

+ 4 - 0
packages/opencode/src/testing/simulation/provider.ts

@@ -3,6 +3,7 @@ import { simulateReadableStream } from "ai"
 import { Effect, Layer } from "effect"
 import { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
+import { SimulationDebugLog } from "./debug-log"
 import { Simulation, type LLMScript } from "./service"
 
 const providerID = ProviderID.make("simulation")
@@ -81,6 +82,7 @@ function stream(script: LLMScript) {
     )
   }
   chunks.push({ type: "finish", finishReason: finishReason(script), usage: usage(script) })
+  SimulationDebugLog.write("provider.stream.chunks", { chunks: chunks.map((chunk) => chunk.type) })
 
   return simulateReadableStream({
     chunks,
@@ -128,7 +130,9 @@ function language(simulation: Simulation.Interface): LanguageModelV3 {
       }
     },
     async doStream(_options: LanguageModelV3CallOptions) {
+      SimulationDebugLog.write("provider.doStream.start")
       const script = await nextScript(simulation)
+      SimulationDebugLog.write("provider.doStream.script", { steps: script.steps.map((step) => step.map((item) => item.type)) })
       return { stream: stream(script) }
     },
   }