From 7eb37f77e2f9b291bb3c84b8d012002cf898b041 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:26:10 -0700 Subject: [PATCH 1/3] fix(opencode): retry a stale saved session as a fresh turn (TASK-276) A seat switched from another adapter carries a Claude session id, which OpenCode rejects with 'Session not found' on every resume. Retry once without -s when that specific error comes back; other failures still surface unchanged. Follows pi #1726. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- cli/__tests__/adapters.opencode.test.mjs | 47 ++++++++++++++++++++++++ cli/src/lib/adapters/opencode.js | 15 ++++++++ 2 files changed, 62 insertions(+) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index 28014763f3..241e156904 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -1394,6 +1394,53 @@ describe('opencode adapter — spawn()', () => { }); }); + test('a saved session OpenCode does not know starts one fresh turn, and other failures do not retry', async () => { + await withTemp(async (root) => { + const workspace = join(root, 'workspace'); + const xdgData = join(root, 'operator-data'); + const appData = join(xdgData, 'opencode'); + await mkdir(workspace, { recursive: true }); + await mkdir(appData, { recursive: true }); + await writeFile(join(appData, 'auth.json'), JSON.stringify({ provider: 'dummy-auth' })); + const notFound = `${JSON.stringify({ + type: 'error', sessionID: 'claude-uuid', error: { data: { message: 'Session not found' } }, + })}\n`; + const calls = []; + const impl = (cmd, args) => { + calls.push(args); + const failing = args.includes('-s') && calls.length === 1; + return fakeChild(failing + ? { chunks: [notFound], code: 1 } + : { chunks: sampleEvents('ses-fresh') }); + }; + const baseContext = { + sessionId: 'claude-uuid', + cwd: workspace, + env: { ...process.env, XDG_DATA_HOME: xdgData }, + environment: { model: 'anthropic/claude-sonnet-4-5' }, + agentName: 'kai-seat', + metadata: { event: { podId: 'pod-1' } }, + _binaryPath: '/usr/local/bin/opencode', + _spawnImpl: impl, + }; + await expect(opencode.spawn('resumed turn', baseContext)).resolves.toMatchObject({ + text: 'Hello from OpenCode.', newSessionId: 'ses-fresh', + }); + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual(expect.arrayContaining(['-s', 'claude-uuid'])); + expect(calls[1]).not.toContain('-s'); + + calls.length = 0; + const failingImpl = (cmd, args) => { + calls.push(args); + return fakeChild({ stderr: 'provider rejected the request', code: 1 }); + }; + await expect(opencode.spawn('resumed turn', { ...baseContext, _spawnImpl: failingImpl })) + .rejects.toThrow(/provider rejected/); + expect(calls).toHaveLength(1); + }); + }); + test('a public spawn wraps the CLI in the host sandbox and denies prompt-only tools', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index c2f0fee799..c43f83acf8 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -301,6 +301,9 @@ const validateProviderEnvironment = async (environment, workspacePath) => { return resolveProviderKeyFile(environment.provider, workspacePath); }; +const STALE_SESSION_PATTERN = /session not found/i; +const isStaleSessionError = (err) => STALE_SESSION_PATTERN.test(String(err?.message || '')); + const makeEventParser = () => { let buffer = ''; let sessionId = null; @@ -650,6 +653,18 @@ export default { }, async spawn(prompt, rawCtx = {}) { + try { + return await this.spawnTurn(prompt, rawCtx); + } catch (err) { + if (!rawCtx.sessionId || !isStaleSessionError(err)) throw err; + // The saved id is one OpenCode does not know: a seat switched from another + // adapter carries a Claude id, and a pruned session fails the same way. Nothing + // ran, so one fresh turn is safe (pi #1726 does the same fallback). + return this.spawnTurn(prompt, { ...rawCtx, sessionId: null }); + } + }, + + async spawnTurn(prompt, rawCtx = {}) { const seatModel = requireSeatModel(rawCtx.environment, 'opencode'); const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox); refuseWorkspaceTrust(sandbox, 'opencode'); From 57ec71a55df6962cf513804990975fb50ee7a2d3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:30:12 -0700 Subject: [PATCH 2/3] fix(opencode): retry a stale session only when the run produced no turn output (TASK-276) A 'session not found' phrase after the model acted (text, tool call or step result) must not replay the turn as a fresh one, or it posts twice. runOpenCode flags the rejection as stale only when the run had no turn output, and the retry keys on that flag. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- cli/__tests__/adapters.opencode.test.mjs | 31 ++++++++++++++++++++++++ cli/src/lib/adapters/opencode.js | 26 +++++++++++++++----- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index 241e156904..e7384a07a5 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -1441,6 +1441,37 @@ describe('opencode adapter — spawn()', () => { }); }); + test('a session-not-found phrase after the model acted is not retried, so the turn cannot post twice', async () => { + await withTemp(async (root) => { + const workspace = join(root, 'workspace'); + const xdgData = join(root, 'operator-data'); + const appData = join(xdgData, 'opencode'); + await mkdir(workspace, { recursive: true }); + await mkdir(appData, { recursive: true }); + await writeFile(join(appData, 'auth.json'), JSON.stringify({ provider: 'dummy-auth' })); + const acted = [ + event('step_start', 'ses-resumed', { type: 'step-start' }), + event('tool_use', 'ses-resumed', { tool: 'commonly_post_message', state: { status: 'completed' } }), + ].join(''); + const calls = []; + const impl = (cmd, args) => { + calls.push(args); + return fakeChild({ chunks: [acted], stderr: 'Session not found', code: 1 }); + }; + await expect(opencode.spawn('resumed turn', { + sessionId: 'ses-resumed', + cwd: workspace, + env: { ...process.env, XDG_DATA_HOME: xdgData }, + environment: { model: 'anthropic/claude-sonnet-4-5' }, + agentName: 'kai-seat', + metadata: { event: { podId: 'pod-1' } }, + _binaryPath: '/usr/local/bin/opencode', + _spawnImpl: impl, + })).rejects.toThrow(/Session not found/); + expect(calls).toHaveLength(1); + }); + }); + test('a public spawn wraps the CLI in the host sandbox and denies prompt-only tools', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index c43f83acf8..f14a9b1039 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -302,7 +302,9 @@ const validateProviderEnvironment = async (environment, workspacePath) => { }; const STALE_SESSION_PATTERN = /session not found/i; -const isStaleSessionError = (err) => STALE_SESSION_PATTERN.test(String(err?.message || '')); +// Set by runOpenCode only when the rejected run produced no turn output, so a +// phrase that shows up after the model acted cannot replay the turn. +const isStaleSessionError = (err) => err?.staleSession === true; const makeEventParser = () => { let buffer = ''; @@ -310,6 +312,8 @@ const makeEventParser = () => { let text = ''; let usage = null; let errorMessage = null; + // Any text, tool call or step result means the model acted this run. + let turnOutput = false; // Every tool call that came back as an error, for the public-seat check in // spawn (publicToolFailure). const toolErrors = []; @@ -319,10 +323,15 @@ const makeEventParser = () => { const event = JSON.parse(line); if (event.sessionID) sessionId = event.sessionID; if (event.type === 'text' && typeof event.part?.text === 'string') { + turnOutput = true; text += event.part.text; } else if (event.type === 'step_finish' && event.part?.tokens) { + turnOutput = true; usage = event.part.tokens; - } else if (event.type === 'tool_use' && event.part?.state?.status === 'error') { + } else if (event.type === 'tool_use') { + turnOutput = true; + } + if (event.type === 'tool_use' && event.part?.state?.status === 'error') { const { error } = event.part.state; toolErrors.push({ tool: String(event.part.tool || ''), @@ -337,6 +346,7 @@ const makeEventParser = () => { } }; return { + get turnOutput() { return turnOutput; }, consume(chunk) { buffer += chunk.toString(); let newline; @@ -431,14 +441,18 @@ const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl = events.flush(); if (timedOut) return reject(new Error(`opencode timed out after ${timeoutMs}ms`)); if (events.errorMessage) { - return reject(adapterFailure('opencode turn failed', events.errorMessage, { + const err = adapterFailure('opencode turn failed', events.errorMessage, { credentials, limit: 2000, - })); + }); + err.staleSession = !events.turnOutput && STALE_SESSION_PATTERN.test(events.errorMessage); + return reject(err); } if (code !== 0) { - return reject(adapterFailure('opencode', stderr.trim(), { + const err = adapterFailure('opencode', stderr.trim(), { credentials, exitCode: code, limit: 500, - })); + }); + err.staleSession = !events.turnOutput && STALE_SESSION_PATTERN.test(stderr); + return reject(err); } resolve({ sessionId: events.sessionId, text: events.text, usage: events.usage, toolErrors: events.toolErrors, From 3fa8b7edc9356ac10d8e73c6141304e415892cbd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:37:39 -0700 Subject: [PATCH 3/3] fix(opencode): step events block the stale-session retry; cover the sessionId guard (TASK-276) Any step_* event now counts as turn output, so a run that started a turn is never replayed. Two tests added: a stale error with no saved session is not retried, and a step-only failure is not retried. Removing either guard fails its test. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- cli/__tests__/adapters.opencode.test.mjs | 58 ++++++++++++++++++++++++ cli/src/lib/adapters/opencode.js | 5 +- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index e7384a07a5..e77eb53e9f 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -1472,6 +1472,64 @@ describe('opencode adapter — spawn()', () => { }); }); + test('a session-not-found failure with no saved session is not retried', async () => { + await withTemp(async (root) => { + const workspace = join(root, 'workspace'); + const xdgData = join(root, 'operator-data'); + const appData = join(xdgData, 'opencode'); + await mkdir(workspace, { recursive: true }); + await mkdir(appData, { recursive: true }); + await writeFile(join(appData, 'auth.json'), JSON.stringify({ provider: 'dummy-auth' })); + const notFound = `${JSON.stringify({ + type: 'error', sessionID: 'ses-fresh', error: { data: { message: 'Session not found' } }, + })}\n`; + const calls = []; + const impl = (cmd, args) => { + calls.push(args); + return fakeChild({ chunks: [notFound], code: 1 }); + }; + await expect(opencode.spawn('fresh turn', { + sessionId: null, + cwd: workspace, + env: { ...process.env, XDG_DATA_HOME: xdgData }, + environment: { model: 'anthropic/claude-sonnet-4-5' }, + agentName: 'kai-seat', + metadata: { event: { podId: 'pod-1' } }, + _binaryPath: '/usr/local/bin/opencode', + _spawnImpl: impl, + })).rejects.toThrow(/Session not found/); + expect(calls).toHaveLength(1); + }); + }); + + test('a session-not-found failure after only a step event is not retried', async () => { + await withTemp(async (root) => { + const workspace = join(root, 'workspace'); + const xdgData = join(root, 'operator-data'); + const appData = join(xdgData, 'opencode'); + await mkdir(workspace, { recursive: true }); + await mkdir(appData, { recursive: true }); + await writeFile(join(appData, 'auth.json'), JSON.stringify({ provider: 'dummy-auth' })); + const stepOnly = event('step_start', 'ses-resumed', { type: 'step-start' }); + const calls = []; + const impl = (cmd, args) => { + calls.push(args); + return fakeChild({ chunks: [stepOnly], stderr: 'Session not found', code: 1 }); + }; + await expect(opencode.spawn('resumed turn', { + sessionId: 'ses-resumed', + cwd: workspace, + env: { ...process.env, XDG_DATA_HOME: xdgData }, + environment: { model: 'anthropic/claude-sonnet-4-5' }, + agentName: 'kai-seat', + metadata: { event: { podId: 'pod-1' } }, + _binaryPath: '/usr/local/bin/opencode', + _spawnImpl: impl, + })).rejects.toThrow(/Session not found/); + expect(calls).toHaveLength(1); + }); + }); + test('a public spawn wraps the CLI in the host sandbox and denies prompt-only tools', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index f14a9b1039..2f5687bf2d 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -322,14 +322,13 @@ const makeEventParser = () => { try { const event = JSON.parse(line); if (event.sessionID) sessionId = event.sessionID; + // Any step event means the run started a turn, so it is not safe to replay. + if (String(event.type || '').startsWith('step_') || event.type === 'tool_use') turnOutput = true; if (event.type === 'text' && typeof event.part?.text === 'string') { turnOutput = true; text += event.part.text; } else if (event.type === 'step_finish' && event.part?.tokens) { - turnOutput = true; usage = event.part.tokens; - } else if (event.type === 'tool_use') { - turnOutput = true; } if (event.type === 'tool_use' && event.part?.state?.status === 'error') { const { error } = event.part.state;