From 117f7f662d0baf6a1a955c8beacad840eca6b23a Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 19 May 2026 11:09:01 +0300 Subject: [PATCH] fix: preserve opencode session creation events OpenCode emits the real session id asynchronously on its first JSON output. The runner registered that id from a helper that could not see the spawned process because the process reference was scoped inside the model-resolution callback. That ReferenceError was swallowed by the generic JSON parse fallback, so the client never received session_created. Without that event, a new OpenCode chat stayed on / and the assistant stream was not attached to the new session view. Keep the process reference in the outer spawn scope so registration can update the active-process map and websocket writer as soon as OpenCode announces the session id. Split JSON parsing from event processing so malformed non-JSON output can still stream as raw text, while registration or adapter failures are surfaced as real errors instead of being hidden as assistant content. Add a fake opencode executable regression test to lock in the expected lifecycle ordering: session_created must be sent before live assistant messages, and the same session id must carry through stream_end and complete. --- server/opencode-cli.js | 31 +++++++++--- server/opencode-cli.test.js | 95 +++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 server/opencode-cli.test.js diff --git a/server/opencode-cli.js b/server/opencode-cli.js index ceabc082..c386d475 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -29,6 +29,7 @@ async function spawnOpenCode(command, options = {}, ws) { let sessionCreatedSent = false; let stdoutLineBuffer = ''; let terminalNotificationSent = false; + let opencodeProcess = null; const notifyTerminalState = ({ code = null, error = null } = {}) => { if (terminalNotificationSent) { @@ -63,11 +64,13 @@ async function spawnOpenCode(command, options = {}, ws) { } capturedSessionId = nextSessionId; - if (processKey !== capturedSessionId) { + if (processKey !== capturedSessionId && opencodeProcess) { activeOpenCodeProcesses.delete(processKey); activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess); } - opencodeProcess.sessionId = capturedSessionId; + if (opencodeProcess) { + opencodeProcess.sessionId = capturedSessionId; + } if (ws.setSessionId && typeof ws.setSessionId === 'function') { ws.setSessionId(capturedSessionId); @@ -89,8 +92,20 @@ async function spawnOpenCode(command, options = {}, ws) { return; } + let response; + try { + response = JSON.parse(line); + } catch { + ws.send(createNormalizedMessage({ + kind: 'stream_delta', + content: line, + sessionId: capturedSessionId || sessionId || null, + provider: 'opencode', + })); + return; + } + try { - const response = JSON.parse(line); registerSession(readOpenCodeSessionId(response)); const normalized = sessionsService.normalizeMessage( 'opencode', @@ -100,10 +115,12 @@ async function spawnOpenCode(command, options = {}, ws) { for (const msg of normalized) { ws.send(msg); } - } catch { + } catch (error) { + const errorContent = error instanceof Error ? error.message : String(error); + console.error('[OpenCode] Failed to process JSON output:', errorContent); ws.send(createNormalizedMessage({ - kind: 'stream_delta', - content: line, + kind: 'error', + content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'opencode', })); @@ -122,7 +139,7 @@ async function spawnOpenCode(command, options = {}, ws) { args.push(command.trim()); } - const opencodeProcess = spawnFunction('opencode', args, { + opencodeProcess = spawnFunction('opencode', args, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, diff --git a/server/opencode-cli.test.js b/server/opencode-cli.test.js new file mode 100644 index 00000000..082451c3 --- /dev/null +++ b/server/opencode-cli.test.js @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { spawnOpenCode } from './opencode-cli.js'; + +const findEnvKey = (name) => + Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name; + +async function createFakeOpenCodeExecutable(binDir) { + const scriptPath = path.join(binDir, 'opencode.js'); + await writeFile(scriptPath, ` +const events = [ + { type: 'text', sessionID: 'open-live-1', text: 'assistant response' }, + { type: 'step_finish', sessionID: 'open-live-1' }, +]; + +for (const event of events) { + console.log(JSON.stringify(event)); +} +`, 'utf8'); + + if (process.platform === 'win32') { + const commandPath = path.join(binDir, 'opencode.cmd'); + await writeFile(commandPath, '@echo off\r\nnode "%~dp0opencode.js" %*\r\n', 'utf8'); + return; + } + + const commandPath = path.join(binDir, 'opencode'); + await writeFile(commandPath, '#!/bin/sh\nnode "$(dirname "$0")/opencode.js" "$@"\n', 'utf8'); + await chmod(commandPath, 0o755); +} + +test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-')); + const pathKey = findEnvKey('PATH'); + const pathExtKey = findEnvKey('PATHEXT'); + const previousPath = process.env[pathKey]; + const previousPathExt = process.env[pathExtKey]; + const messages = []; + const writer = { + userId: null, + sessionId: null, + send(message) { + messages.push(message); + }, + setSessionId(sessionId) { + this.sessionId = sessionId; + }, + }; + + try { + await createFakeOpenCodeExecutable(tempRoot); + process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`; + if (process.platform === 'win32') { + process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD') + ? previousPathExt + : `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`; + } + + await spawnOpenCode('Hi', { cwd: tempRoot }, writer); + + const sessionCreatedIndex = messages.findIndex((message) => message.kind === 'session_created'); + const assistantDeltaIndex = messages.findIndex((message) => + message.kind === 'stream_delta' && message.content === 'assistant response', + ); + const streamEnd = messages.find((message) => message.kind === 'stream_end'); + const complete = messages.find((message) => message.kind === 'complete'); + + assert.notEqual(sessionCreatedIndex, -1); + assert.notEqual(assistantDeltaIndex, -1); + assert.ok(sessionCreatedIndex < assistantDeltaIndex); + assert.equal(messages[sessionCreatedIndex].newSessionId, 'open-live-1'); + assert.equal(writer.sessionId, 'open-live-1'); + assert.equal(streamEnd?.sessionId, 'open-live-1'); + assert.equal(complete?.sessionId, 'open-live-1'); + assert.equal(messages.some((message) => message.kind === 'error'), false); + } finally { + if (previousPath === undefined) { + delete process.env[pathKey]; + } else { + process.env[pathKey] = previousPath; + } + + if (previousPathExt === undefined) { + delete process.env[pathExtKey]; + } else { + process.env[pathExtKey] = previousPathExt; + } + + await rm(tempRoot, { recursive: true, force: true }); + } +});