Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions cli/__tests__/adapters.opencode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,142 @@ 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 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 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');
Expand Down
38 changes: 33 additions & 5 deletions cli/src/lib/adapters/opencode.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,19 @@ const validateProviderEnvironment = async (environment, workspacePath) => {
return resolveProviderKeyFile(environment.provider, workspacePath);
};

const STALE_SESSION_PATTERN = /session not found/i;
// 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 = '';
let sessionId = null;
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 = [];
Expand All @@ -315,11 +322,15 @@ 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) {
usage = event.part.tokens;
} else if (event.type === 'tool_use' && event.part?.state?.status === 'error') {
}
if (event.type === 'tool_use' && event.part?.state?.status === 'error') {
const { error } = event.part.state;
toolErrors.push({
tool: String(event.part.tool || ''),
Expand All @@ -334,6 +345,7 @@ const makeEventParser = () => {
}
};
return {
get turnOutput() { return turnOutput; },
consume(chunk) {
buffer += chunk.toString();
let newline;
Expand Down Expand Up @@ -428,14 +440,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,
Expand Down Expand Up @@ -650,6 +666,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 });
Comment thread
lilyshen0722 marked this conversation as resolved.
Resolved
}
},

async spawnTurn(prompt, rawCtx = {}) {
const seatModel = requireSeatModel(rawCtx.environment, 'opencode');
const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox);
refuseWorkspaceTrust(sandbox, 'opencode');
Expand Down
Loading