diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a6e8cf741..5e42b189b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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 + onStop?: (stop: () => Promise) => 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: { - + @@ -265,6 +266,12 @@ export function tui(input: { }) } +function AppLifecycle(props: { onSnapshot?: () => Promise; onStop?: (stop: () => Promise) => void }) { + const exit = useExit() + props.onStop?.(() => exit()) + return +} + function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() const route = useRoute() diff --git a/packages/opencode/src/cli/cmd/tui/simulate.ts b/packages/opencode/src/cli/cmd/tui/simulate.ts new file mode 100644 index 000000000..d0d0bb2b6 --- /dev/null +++ b/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> +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 + readonly stopTui: () => Promise + readonly stopWorker: () => Promise +} + +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 + 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("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 => { + 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(worker) + let eventSubscribedResolve!: () => void + const eventSubscribed = new Promise((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((resolve) => { + stopReadyResolve = resolve + }) + let readyResolve!: () => void + let readyReject!: (error: unknown) => void + const ready = new Promise((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" diff --git a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts b/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts index 70a2ff102..566b2aee2 100644 --- a/packages/opencode/src/cli/cmd/tui/simulation-mcp.ts +++ b/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 + } +} + +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 { +export async function createSimulationMcpServer(options: Options): Promise { if (options.mode === "stdio") { const server = createServer(options) const transport = new StdioServerTransport() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 5dfe067e7..7230dae16 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/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> | 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() } diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 31ead18cf..a47f36353 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/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> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; 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()), diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d20f29dd4..0c3cb3000 100644 --- a/packages/opencode/src/index.ts +++ b/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") || diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 7b5643fd6..b12c9b356 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/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, 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) => diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0cf3a2398..5d299849e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/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 }))), + ) }), ), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index aac893075..29761c384 100644 --- a/packages/opencode/src/session/processor.ts +++ b/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)), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba9a4d6f1..7333cc560 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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] : [])] diff --git a/packages/opencode/src/snapshot/.#index.ts b/packages/opencode/src/snapshot/.#index.ts deleted file mode 120000 index 97de5d3b9..000000000 --- a/packages/opencode/src/snapshot/.#index.ts +++ /dev/null @@ -1 +0,0 @@ -james@james-6.local.82438:1777694013 \ No newline at end of file diff --git a/packages/opencode/src/testing/simulation/debug-log.ts b/packages/opencode/src/testing/simulation/debug-log.ts new file mode 100644 index 000000000..13b5fe077 --- /dev/null +++ b/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" diff --git a/packages/opencode/src/testing/simulation/provider.ts b/packages/opencode/src/testing/simulation/provider.ts index 0c61ca083..bba69b5f5 100644 --- a/packages/opencode/src/testing/simulation/provider.ts +++ b/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) } }, }