mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-12 02:28:05 +00:00
feat(opencode): support OpenCode v2 alongside v1 sessions (#2042)
This commit is contained in:
@@ -1,6 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const constructorCalls: Array<{ command: string; args?: string[]; env?: Record<string, string> }> = [];
|
||||
const spawnCalls: Array<{ command: string; args?: string[]; options?: Record<string, unknown> }> = [];
|
||||
|
||||
type FakeChild = {
|
||||
stdout: { on: (event: string, cb: (chunk: string) => void) => void };
|
||||
on: (event: string, cb: (arg?: unknown) => void) => void;
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
let fakeSpawnImpl: ((command: string, args: string[]) => FakeChild) | null = null;
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:child_process')>()),
|
||||
spawn: (command: string, args: string[], options?: Record<string, unknown>) => {
|
||||
spawnCalls.push({ command, args, options });
|
||||
if (!fakeSpawnImpl) {
|
||||
throw new Error('spawn not configured');
|
||||
}
|
||||
return fakeSpawnImpl(command, args);
|
||||
}
|
||||
}));
|
||||
|
||||
function makeFakeChild(stdoutText: string, opts?: { error?: Error; noClose?: boolean }): FakeChild {
|
||||
const handlers = new Map<string, Array<(arg?: unknown) => void>>();
|
||||
const stdoutHandlers = new Map<string, Array<(chunk: string) => void>>();
|
||||
const child: FakeChild = {
|
||||
stdout: {
|
||||
on: (event, cb) => {
|
||||
const list = stdoutHandlers.get(event) ?? [];
|
||||
list.push(cb);
|
||||
stdoutHandlers.set(event, list);
|
||||
}
|
||||
},
|
||||
on: (event, cb) => {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(cb);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
kill: () => {}
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
for (const cb of stdoutHandlers.get('data') ?? []) cb(stdoutText);
|
||||
if (opts?.error) {
|
||||
for (const cb of handlers.get('error') ?? []) cb(opts.error);
|
||||
} else if (!opts?.noClose) {
|
||||
for (const cb of handlers.get('close') ?? []) cb();
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
function configureSpawn(stdoutText: string, opts?: { error?: Error }): void {
|
||||
fakeSpawnImpl = () => makeFakeChild(stdoutText, opts);
|
||||
}
|
||||
|
||||
vi.mock('@/agent/backends/acp', () => ({
|
||||
AcpSdkBackend: vi.fn().mockImplementation(function (
|
||||
@@ -12,7 +65,57 @@ vi.mock('@/agent/backends/acp', () => ({
|
||||
})
|
||||
}));
|
||||
|
||||
import { allocateFreePort, createOpencodeBackend } from './opencodeBackend';
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||
readdir: vi.fn(async () => ['self'])
|
||||
}));
|
||||
|
||||
import { allocateFreePort, createOpencodeBackend, detectOpencodeFlavorVersion, discoverOpencodeV2RestPort, listChildPids, listLoopbackListeners, listLoopbackListenersForPids, parseLsofListeners, parseSsListeners, parseSsPidListeners } from './opencodeBackend';
|
||||
|
||||
describe('detectOpencodeFlavorVersion', () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
fakeSpawnImpl = null;
|
||||
});
|
||||
|
||||
it('detects v2 from "opencode v2.0.29" stdout', async () => {
|
||||
configureSpawn('opencode v2.0.29\n');
|
||||
await expect(detectOpencodeFlavorVersion('myopencode')).resolves.toBe('v2');
|
||||
expect(spawnCalls).toEqual([{ command: 'myopencode', args: ['--version'], options: expect.objectContaining({ stdio: ['ignore', 'pipe', 'ignore'] }) }]);
|
||||
});
|
||||
|
||||
it('detects v1 from "1.19.0" stdout', async () => {
|
||||
configureSpawn('1.19.0\n');
|
||||
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v1');
|
||||
});
|
||||
|
||||
it('detects v2 from "opencode 2.1.0" stdout without v prefix', async () => {
|
||||
configureSpawn('opencode 2.1.0\n');
|
||||
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v2');
|
||||
});
|
||||
|
||||
it('falls back to v1 when stdout is empty', async () => {
|
||||
configureSpawn('');
|
||||
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v1');
|
||||
});
|
||||
|
||||
it('falls back to v1 when spawn errors', async () => {
|
||||
configureSpawn('', { error: new Error('spawn failed') });
|
||||
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v1');
|
||||
});
|
||||
|
||||
it('spawns through a shell on Windows so .cmd shims resolve', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
try {
|
||||
configureSpawn('opencode v2.0.29\n');
|
||||
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v2');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
}
|
||||
expect(spawnCalls[spawnCalls.length - 1]?.options).toMatchObject({ shell: true, windowsHide: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('allocateFreePort', () => {
|
||||
it('resolves a bindable loopback port number', async () => {
|
||||
@@ -52,4 +155,387 @@ describe('createOpencodeBackend', () => {
|
||||
createOpencodeBackend({ cwd: '/tmp/x' });
|
||||
expect(constructorCalls[0]?.args).toEqual(['acp', '--cwd', '/tmp/x']);
|
||||
});
|
||||
|
||||
it('v1: keeps existing args even when majorVersion is explicitly v1', () => {
|
||||
createOpencodeBackend({ cwd: '/tmp/x', port: 5555, hostname: '127.0.0.1', majorVersion: 'v1' });
|
||||
expect(constructorCalls[0]?.args).toEqual([
|
||||
'acp', '--cwd', '/tmp/x', '--port', '5555', '--hostname', '127.0.0.1'
|
||||
]);
|
||||
});
|
||||
|
||||
it('v2: passes only [acp] args with spawnCwd and config-option model switch', () => {
|
||||
createOpencodeBackend({ cwd: '/tmp/x', majorVersion: 'v2' });
|
||||
const call = constructorCalls[0] as unknown as {
|
||||
args?: string[];
|
||||
spawnCwd?: string;
|
||||
acpModelSwitchViaConfigOption?: boolean;
|
||||
};
|
||||
expect(call.args).toEqual(['acp']);
|
||||
expect(call.spawnCwd).toBe('/tmp/x');
|
||||
expect(call.acpModelSwitchViaConfigOption).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises OpenCode child session updates only for v2', () => {
|
||||
createOpencodeBackend({ cwd: '/tmp/x', majorVersion: 'v2' });
|
||||
createOpencodeBackend({ cwd: '/tmp/x', majorVersion: 'v1' });
|
||||
const calls = constructorCalls as unknown as Array<{ childSessionUpdates?: boolean }>;
|
||||
expect(calls[0]?.childSessionUpdates).toBe(true);
|
||||
expect(calls[1]?.childSessionUpdates).toBeUndefined();
|
||||
});
|
||||
|
||||
it('v2: defaults spawnCwd to the invoked cwd when opts.cwd is absent', () => {
|
||||
createOpencodeBackend({ majorVersion: 'v2' });
|
||||
const call = constructorCalls[0] as unknown as { spawnCwd?: string };
|
||||
expect(typeof call.spawnCwd).toBe('string');
|
||||
expect(call.spawnCwd?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSsListeners', () => {
|
||||
it('extracts only 127.0.0.1 LISTEN ports from ss -tln output', () => {
|
||||
const output = [
|
||||
'State Recv-Q Send-Q Local Address:Port Peer Address:Port Process',
|
||||
'LISTEN 0 128 127.0.0.1:40941 0.0.0.0:*',
|
||||
'LISTEN 0 511 [::1]:40942 [::]:*',
|
||||
'LISTEN 0 4096 0.0.0.0:22 0.0.0.0:*',
|
||||
'LISTEN 0 128 127.0.0.1:39999 0.0.0.0:*'
|
||||
].join('\n');
|
||||
expect(parseSsListeners(output)).toEqual(new Set([40941, 39999]));
|
||||
});
|
||||
|
||||
it('returns empty set for output without loopback listeners', () => {
|
||||
expect(parseSsListeners('State Recv-Q Send-Q\nLISTEN 0 128 0.0.0.0:22 0.0.0.0:*')).toEqual(new Set());
|
||||
expect(parseSsListeners('')).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLsofListeners', () => {
|
||||
it('extracts only 127.0.0.1 LISTEN ports from lsof output', () => {
|
||||
const output = [
|
||||
'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
|
||||
'node 12345 lupin 24u IPv4 0x1234567890abcdef 0t0 TCP 127.0.0.1:40941 (LISTEN)',
|
||||
'node 12345 lupin 25u IPv6 0x1234567890abcdee 0t0 TCP [::1]:40942 (LISTEN)',
|
||||
'sshd 1000 root 3u IPv4 0x1234567890abcddf 0t0 TCP *:22 (LISTEN)'
|
||||
].join('\n');
|
||||
expect(parseLsofListeners(output)).toEqual(new Set([40941]));
|
||||
});
|
||||
|
||||
it('returns empty set for output without loopback listeners', () => {
|
||||
expect(parseLsofListeners('COMMAND PID USER\nsshd 1000 root 3u IPv4 *:22 (LISTEN)')).toEqual(new Set());
|
||||
expect(parseLsofListeners('')).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe('listLoopbackListeners', () => {
|
||||
it('parses ss output on linux', async () => {
|
||||
const listeners = await listLoopbackListeners({
|
||||
execImpl: async (command, args) => {
|
||||
expect(command).toBe('ss');
|
||||
expect(args).toEqual(['-tln']);
|
||||
return 'LISTEN 0 128 127.0.0.1:41000 0.0.0.0:*';
|
||||
}
|
||||
});
|
||||
expect(listeners).toEqual(new Set([41000]));
|
||||
});
|
||||
|
||||
it('falls back to lsof on darwin', async () => {
|
||||
const listeners = await listLoopbackListeners({
|
||||
platform: 'darwin',
|
||||
execImpl: async (command, args) => {
|
||||
expect(command).toBe('lsof');
|
||||
expect(args).toEqual(['-iTCP', '-sTCP:LISTEN', '-n', '-P']);
|
||||
return 'node 1 u 4u IPv4 TCP 127.0.0.1:41001 (LISTEN)';
|
||||
}
|
||||
});
|
||||
expect(listeners).toEqual(new Set([41001]));
|
||||
});
|
||||
|
||||
it('returns empty set when both commands fail', async () => {
|
||||
const listeners = await listLoopbackListeners({
|
||||
execImpl: async () => {
|
||||
throw new Error('no ss, no lsof');
|
||||
}
|
||||
});
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverOpencodeV2RestPort', () => {
|
||||
it('returns the first port newly bound since the before snapshot', async () => {
|
||||
const beforeSnapshot = new Set([1000, 2000]);
|
||||
let call = 0;
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot,
|
||||
pollIntervalMs: 1,
|
||||
scanImpl: async () => {
|
||||
call++;
|
||||
return call === 1 ? new Set([1000, 2000]) : new Set([1000, 2000, 41234]);
|
||||
}
|
||||
});
|
||||
expect(port).toBe(41234);
|
||||
});
|
||||
|
||||
it('prefers the lowest new port when several appear', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set(),
|
||||
pollIntervalMs: 1,
|
||||
scanImpl: async () => new Set([55555, 40000, 47777])
|
||||
});
|
||||
expect(port).toBe(40000);
|
||||
});
|
||||
|
||||
it('returns null on timeout when no new port appears', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([1000]),
|
||||
timeoutMs: 30,
|
||||
pollIntervalMs: 10,
|
||||
scanImpl: async () => new Set([1000])
|
||||
});
|
||||
expect(port).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when scanning always fails', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set(),
|
||||
timeoutMs: 30,
|
||||
pollIntervalMs: 10,
|
||||
scanImpl: async () => {
|
||||
throw new Error('scan failed');
|
||||
}
|
||||
});
|
||||
expect(port).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('parseSsPidListeners', () => {
|
||||
it('extracts 127.0.0.1 ports owned by the given pid from ss -tlnp output', () => {
|
||||
const output = [
|
||||
'State Recv-Q Send-Q Local Address:Port Peer Address:Port Process',
|
||||
'LISTEN 0 128 127.0.0.1:41234 0.0.0.0:* users:(("opencode",pid=4242,fd=18))',
|
||||
'LISTEN 0 128 127.0.0.1:41999 0.0.0.0:* users:(("other",pid=999,fd=11))',
|
||||
'LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=4242,fd=3))'
|
||||
].join('\n');
|
||||
expect(parseSsPidListeners(output, 4242)).toEqual(new Set([41234]));
|
||||
});
|
||||
|
||||
it('returns empty set when the pid owns no loopback listener', () => {
|
||||
const output = 'LISTEN 0 128 127.0.0.1:41999 0.0.0.0:* users:(("other",pid=999,fd=11))';
|
||||
expect(parseSsPidListeners(output, 4242)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe('listLoopbackListenersForPids', () => {
|
||||
it('parses ss -tlnp output filtered by pid on linux', async () => {
|
||||
const listeners = await listLoopbackListenersForPids([4242], {
|
||||
platform: 'linux',
|
||||
execImpl: async (command, args) => {
|
||||
expect(command).toBe('ss');
|
||||
expect(args).toEqual(['-tlnp']);
|
||||
return 'LISTEN 0 128 127.0.0.1:41234 0.0.0.0:* users:(("opencode",pid=4242,fd=18))';
|
||||
}
|
||||
});
|
||||
expect(listeners).toEqual(new Set([41234]));
|
||||
});
|
||||
|
||||
it('falls back to lsof -a -p when ss fails', async () => {
|
||||
const listeners = await listLoopbackListenersForPids([4242], {
|
||||
execImpl: async (command, args) => {
|
||||
if (command === 'ss') {
|
||||
throw new Error('ss missing');
|
||||
}
|
||||
expect(command).toBe('lsof');
|
||||
expect(args).toEqual(['-a', '-p', '4242', '-iTCP', '-sTCP:LISTEN', '-n', '-P']);
|
||||
return 'node 4242 u 20u IPv4 TCP 127.0.0.1:41235 (LISTEN)';
|
||||
}
|
||||
});
|
||||
expect(listeners).toEqual(new Set([41235]));
|
||||
});
|
||||
|
||||
it('throws when ss fails and lsof is missing (caller falls back to the diff heuristic)', async () => {
|
||||
await expect(listLoopbackListenersForPids([4242], {
|
||||
execImpl: async (command) => {
|
||||
if (command === 'lsof') {
|
||||
const err: NodeJS.ErrnoException = new Error('lsof not found');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
throw new Error('ss failed');
|
||||
}
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('treats a lsof no-match (non-ENOENT failure) as an empty set so a listening sibling survives', async () => {
|
||||
// macOS: no ss; lsof exits 1 when the pid owns no socket. The ACP
|
||||
// parent often owns no listener — its failure must not discard the
|
||||
// serve child's result.
|
||||
const listeners = await listLoopbackListenersForPids([111, 222], {
|
||||
execImpl: async (command, args) => {
|
||||
if (command === 'ss') throw new Error('ss missing');
|
||||
expect(args).toEqual(['-a', '-p', expect.any(String), '-iTCP', '-sTCP:LISTEN', '-n', '-P']);
|
||||
// Decisive: pid 111's scan must never contribute a port, so
|
||||
// the pre-fix Promise.all rejection path (discarding pid 222's
|
||||
// result and falling back to the diff heuristic) and any
|
||||
// misattribution both fail this assertion.
|
||||
if (args[2] === '111') throw new Error('lsof exited 1: no match');
|
||||
if (args[2] === '222') return 'node 222 u 20u IPv4 TCP 127.0.0.1:41236 (LISTEN)';
|
||||
throw new Error('unexpected lsof invocation');
|
||||
}
|
||||
});
|
||||
expect(listeners).toEqual(new Set([41236]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverOpencodeV2RestPort ownerPid preference', () => {
|
||||
it('prefers a post-snapshot owned port over a pre-existing one owned by the same tree', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([41234]),
|
||||
pollIntervalMs: 1,
|
||||
ownerPids: [4242],
|
||||
scanImpl: async () => new Set([41234, 40000, 47777]),
|
||||
scanOwnedImpl: async () => new Set([41234, 47777])
|
||||
});
|
||||
expect(port).toBe(47777);
|
||||
});
|
||||
|
||||
it('returns the pid-owned port even when it was already in the before snapshot', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([41234]),
|
||||
pollIntervalMs: 1,
|
||||
ownerPid: 4242,
|
||||
scanImpl: async () => new Set(),
|
||||
scanOwnedImpl: async () => new Set([41234])
|
||||
});
|
||||
expect(port).toBe(41234);
|
||||
});
|
||||
|
||||
it('falls back to the diff heuristic when the owned scan fails', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([1000]),
|
||||
pollIntervalMs: 1,
|
||||
ownerPid: 4242,
|
||||
scanImpl: async () => new Set([1000, 41999]),
|
||||
scanOwnedImpl: async () => {
|
||||
throw new Error('tools missing');
|
||||
}
|
||||
});
|
||||
expect(port).toBe(41999);
|
||||
});
|
||||
|
||||
it('keeps polling the owned scan and does not fall back while it succeeds but is empty', async () => {
|
||||
let ownedCalls = 0;
|
||||
let diffCalls = 0;
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([1000]),
|
||||
timeoutMs: 60,
|
||||
pollIntervalMs: 10,
|
||||
ownerPid: 4242,
|
||||
scanImpl: async () => {
|
||||
diffCalls++;
|
||||
return new Set([1000, 55555]);
|
||||
},
|
||||
scanOwnedImpl: async () => {
|
||||
ownedCalls++;
|
||||
return ownedCalls >= 2 ? new Set([47777]) : new Set();
|
||||
}
|
||||
});
|
||||
expect(port).toBe(47777);
|
||||
expect(ownedCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(diffCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listChildPids', () => {
|
||||
it('parses NUL/space-separated children from a /proc children fixture', async () => {
|
||||
const pids = await listChildPids(4242, {
|
||||
platform: 'linux',
|
||||
readChildrenFile: async (pid: number, task: string) => {
|
||||
expect(pid).toBe(4242);
|
||||
expect(typeof task).toBe('string');
|
||||
return '2017934\u00002017935 2017936';
|
||||
}
|
||||
});
|
||||
expect(pids).toEqual([2017934, 2017935, 2017936]);
|
||||
});
|
||||
|
||||
it('runs pgrep -P on non-linux platforms', async () => {
|
||||
const pids = await listChildPids(4242, {
|
||||
platform: 'darwin',
|
||||
execImpl: async (command, args) => {
|
||||
expect(command).toBe('pgrep');
|
||||
expect(args).toEqual(['-P', '4242']);
|
||||
return '2017934\n2017936\n';
|
||||
}
|
||||
});
|
||||
expect(pids).toEqual([2017934, 2017936]);
|
||||
});
|
||||
|
||||
it('returns an empty array when reading fails (linux)', async () => {
|
||||
const pids = await listChildPids(4242, {
|
||||
platform: 'linux',
|
||||
readChildrenFile: async () => {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
});
|
||||
expect(pids).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when pgrep fails (darwin)', async () => {
|
||||
const pids = await listChildPids(4242, {
|
||||
platform: 'darwin',
|
||||
execImpl: async () => {
|
||||
throw new Error('no such process');
|
||||
}
|
||||
});
|
||||
expect(pids).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverOpencodeV2RestPort ownerPids union', () => {
|
||||
it('returns the lowest port owned by any of the given pids', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set(),
|
||||
pollIntervalMs: 1,
|
||||
ownerPids: [1000, 4242],
|
||||
scanImpl: async () => new Set(),
|
||||
scanOwnedImpl: async (pids) => {
|
||||
expect(pids).toEqual([1000, 4242]);
|
||||
return new Set([47777, 34019]);
|
||||
}
|
||||
});
|
||||
expect(port).toBe(34019);
|
||||
});
|
||||
|
||||
it('falls back to the diff heuristic when the owned scan fails', async () => {
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([1000]),
|
||||
pollIntervalMs: 1,
|
||||
ownerPids: [4242],
|
||||
scanImpl: async () => new Set([1000, 41999]),
|
||||
scanOwnedImpl: async () => {
|
||||
throw new Error('tools missing');
|
||||
}
|
||||
});
|
||||
expect(port).toBe(41999);
|
||||
});
|
||||
|
||||
it('keeps polling when the owned scan succeeds but is empty', async () => {
|
||||
let ownedCalls = 0;
|
||||
let diffCalls = 0;
|
||||
const port = await discoverOpencodeV2RestPort({
|
||||
beforeSnapshot: new Set([1000]),
|
||||
timeoutMs: 60,
|
||||
pollIntervalMs: 10,
|
||||
ownerPids: [4242],
|
||||
scanImpl: async () => {
|
||||
diffCalls++;
|
||||
return new Set([1000, 55555]);
|
||||
},
|
||||
scanOwnedImpl: async () => {
|
||||
ownedCalls++;
|
||||
return ownedCalls >= 2 ? new Set([47777]) : new Set();
|
||||
}
|
||||
});
|
||||
expect(port).toBe(47777);
|
||||
expect(ownedCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(diffCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function readFileUtf8(path: string): Promise<string> {
|
||||
return readFile(path, 'utf8');
|
||||
}
|
||||
|
||||
import { AcpSdkBackend } from '@/agent/backends/acp';
|
||||
import { buildOpencodeEnv } from './config';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand';
|
||||
|
||||
export type OpencodeFlavorVersion = 'v1' | 'v2';
|
||||
|
||||
/**
|
||||
* Detects whether the installed `opencode` binary speaks the v1 (≤1.x) or
|
||||
* v2 (≥2.x) CLI contract. v2 dropped the `opencode acp` `--cwd/--port/
|
||||
* --hostname` flags and redesigned the internal HTTP API (see the
|
||||
* opencode-v2-compat plan), so the launcher needs the major version before
|
||||
* spawning. Runs `command --version` (5s timeout) and parses the first
|
||||
* `v?N.` from stdout; any spawn failure, timeout or unparseable output
|
||||
* falls back to 'v1' — the pre-v2-aware behavior.
|
||||
*/
|
||||
export async function detectOpencodeFlavorVersion(command = getAgentLaunchCommand('opencode')): Promise<'v1' | 'v2'> {
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
// shell/windowsHide mirror buildAcpStdioSpawnOptions: on Windows,
|
||||
// npm-style opencode.cmd shims require a shell to resolve, and a
|
||||
// bare spawn would fail the probe and misclassify v2 as v1.
|
||||
const child = spawn(command, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32'
|
||||
});
|
||||
let out = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error('opencode --version timed out'));
|
||||
}, 5000);
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
out += String(chunk);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
child.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(out);
|
||||
});
|
||||
});
|
||||
const match = /v?(\d+)\./.exec(stdout);
|
||||
const major = match ? Number.parseInt(match[1]!, 10) : Number.NaN;
|
||||
return major === 2 ? 'v2' : 'v1';
|
||||
} catch {
|
||||
return 'v1';
|
||||
}
|
||||
}
|
||||
|
||||
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
@@ -51,8 +108,24 @@ export function createOpencodeBackend(opts: {
|
||||
cwd?: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
majorVersion?: 'v1' | 'v2';
|
||||
}): AcpSdkBackend {
|
||||
const env = buildOpencodeEnv();
|
||||
if (opts.majorVersion === 'v2') {
|
||||
// opencode v2 rejects `acp --cwd/--port/--hostname` ("Unrecognized
|
||||
// flag"); the working directory is taken from the spawn cwd instead,
|
||||
// and the internal HTTP API port is bound randomly (post-discovered).
|
||||
return new AcpSdkBackend({
|
||||
command: getAgentLaunchCommand('opencode'),
|
||||
args: ['acp'],
|
||||
env: filterEnv(env),
|
||||
spawnCwd: opts.cwd ?? getInvokedCwd(),
|
||||
acpModelSwitchViaConfigOption: true,
|
||||
childSessionUpdates: true,
|
||||
textChunkMode: 'delta',
|
||||
flavor: 'opencode',
|
||||
});
|
||||
}
|
||||
const args = ['acp', '--cwd', opts.cwd ?? getInvokedCwd()];
|
||||
if (opts.port !== undefined) {
|
||||
args.push('--port', String(opts.port));
|
||||
@@ -69,3 +142,374 @@ export function createOpencodeBackend(opts: {
|
||||
flavor: 'opencode',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses `ss -tln` output and returns the set of ports bound to 127.0.0.1.
|
||||
* Accepts the real header row and arbitrary column spacing; non-loopback
|
||||
* (0.0.0.0, [::1], [::]) listeners are excluded.
|
||||
*/
|
||||
export function parseSsListeners(output: string): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const line of output.split('\n')) {
|
||||
if (!/\bLISTEN\b/.test(line)) continue;
|
||||
const match = /127\.0\.0\.1:(\d+)/.exec(line);
|
||||
if (match) {
|
||||
ports.add(Number.parseInt(match[1]!, 10));
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `lsof -iTCP -sTCP:LISTEN -n -P` output (macOS fallback) and returns
|
||||
* the set of ports bound to 127.0.0.1.
|
||||
*/
|
||||
export function parseLsofListeners(output: string): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const line of output.split('\n')) {
|
||||
const match = /127\.0\.0\.1:(\d+)/.exec(line);
|
||||
if (match) {
|
||||
ports.add(Number.parseInt(match[1]!, 10));
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the child pids of the given process. Linux reads the per-task
|
||||
* `children` files under `/proc/<pid>/task/` (NUL/space-separated); darwin
|
||||
* shells out to `pgrep -P`. Any failure (process gone, tool missing,
|
||||
* unparsable output) yields an empty array — callers treat that as "no
|
||||
* children known", never as an error.
|
||||
*/
|
||||
export async function listChildPids(pid: number, opts?: {
|
||||
platform?: NodeJS.Platform;
|
||||
readChildrenFile?: (pid: number, task: string) => Promise<string>;
|
||||
execImpl?: (command: string, args: string[]) => Promise<string>;
|
||||
maxDepth?: number;
|
||||
}): Promise<number[]> {
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
const maxDepth = opts?.maxDepth ?? 4;
|
||||
try {
|
||||
if (platform === 'linux') {
|
||||
const readChildren = opts?.readChildrenFile ?? (async (p: number, task: string) =>
|
||||
await readFileUtf8(`/proc/${p}/task/${task}/children`));
|
||||
const tasks = await readdir(`/proc/${pid}/task`);
|
||||
// Union children across ALL tasks: threads can each hold a
|
||||
// different child (e.g. a helper forked from one task and the
|
||||
// serve child from another) — stopping at the first nonempty
|
||||
// task would drop the rest.
|
||||
const union = new Set<number>();
|
||||
const direct: number[] = [];
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
const children = await readChildren(pid, task);
|
||||
const pids = children.trim().split(/[\s\u0000]+/).filter(Boolean);
|
||||
for (const value of pids) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) union.add(parsed);
|
||||
direct.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Try the next task; fall through to [] if none yields.
|
||||
}
|
||||
}
|
||||
// Descendants too: the npm wrapper spawns the native binary which
|
||||
// spawns `serve` — the listener owner can be a grandchild.
|
||||
if (maxDepth > 1) {
|
||||
for (const child of direct) {
|
||||
const grandChildren = await listChildPids(child, { ...opts, maxDepth: maxDepth - 1 });
|
||||
for (const grandChild of grandChildren) union.add(grandChild);
|
||||
}
|
||||
}
|
||||
return [...union];
|
||||
}
|
||||
const run = async (command: string, args: string[]): Promise<string> => {
|
||||
if (opts?.execImpl) {
|
||||
return await opts.execImpl(command, args);
|
||||
}
|
||||
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
|
||||
return stdout;
|
||||
};
|
||||
const output = await run('pgrep', ['-P', String(pid)]);
|
||||
const direct = output.trim().split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
// Descendants too: the npm wrapper spawns the native binary which
|
||||
// spawns `serve` — the listener owner can be a grandchild.
|
||||
const union = new Set<number>(direct);
|
||||
if (maxDepth > 1) {
|
||||
for (const child of direct) {
|
||||
const grandChildren = await listChildPids(child, { ...opts, maxDepth: maxDepth - 1 });
|
||||
for (const grandChild of grandChildren) union.add(grandChild);
|
||||
}
|
||||
}
|
||||
return [...union];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `ss -tlnp` output and returns the set of 127.0.0.1 ports whose
|
||||
* process line contains `pid=<pid>` (a line may list several pids).
|
||||
*/
|
||||
export function parseSsPidListeners(output: string, pid: number): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const line of output.split('\n')) {
|
||||
if (!/\bLISTEN\b/.test(line)) continue;
|
||||
const pids = [...line.matchAll(/pid=(\d+)/g)].map((match) => Number.parseInt(match[1]!, 10));
|
||||
if (!pids.includes(pid)) continue;
|
||||
const match = /127\.0\.0\.1:(\d+)/.exec(line);
|
||||
if (match) {
|
||||
ports.add(Number.parseInt(match[1]!, 10));
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists loopback TCP listeners owned by the given pid: `ss -tlnp` with
|
||||
* `pid=<N>` matching first, `lsof -a -p <pid> -iTCP -sTCP:LISTEN -n -P`
|
||||
* (whose -p filter already scopes the output) as fallback. Throws when both
|
||||
* tools are unavailable/unparsable so callers can fall back to the
|
||||
* snapshot-diff heuristic; an empty set means the tools worked and the pid
|
||||
* owns no loopback listener (yet).
|
||||
*/
|
||||
export async function listLoopbackListenersForPids(pids: number[], opts?: {
|
||||
platform?: NodeJS.Platform;
|
||||
execImpl?: (command: string, args: string[]) => Promise<string>;
|
||||
}): Promise<Set<number>> {
|
||||
if (pids.length === 0) return new Set();
|
||||
const run = async (command: string, args: string[]): Promise<string> => {
|
||||
if (opts?.execImpl) {
|
||||
return await opts.execImpl(command, args);
|
||||
}
|
||||
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
|
||||
return stdout;
|
||||
};
|
||||
const ports = new Set<number>();
|
||||
try {
|
||||
const output = await run('ss', ['-tlnp']);
|
||||
for (const pid of pids) {
|
||||
for (const port of parseSsPidListeners(output, pid)) {
|
||||
ports.add(port);
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
} catch {
|
||||
// -a -p <pid> -iTCP -sTCP:LISTEN -n -P is portable across darwin/linux lsof builds.
|
||||
// Per-pid aggregation: lsof exits non-zero when a pid owns no socket
|
||||
// (macOS commonly has no ss; the ACP parent often owns no listener).
|
||||
// A no-match result is an empty set for that pid — discarding it via
|
||||
// Promise.all rejection would lose a sibling's successful scan and
|
||||
// push discovery onto the racy snapshot-diff heuristic.
|
||||
const owned = await Promise.all(pids.map(async (pid) => {
|
||||
const lsofArgs = ['-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN', '-n', '-P'];
|
||||
try {
|
||||
return parseLsofListeners(await run('lsof', lsofArgs));
|
||||
} catch (error) {
|
||||
// A missing lsof binary is a genuine tool failure: rethrow so
|
||||
// the caller falls back to the snapshot-diff heuristic.
|
||||
if (error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
return new Set<number>();
|
||||
}
|
||||
}));
|
||||
for (const set of owned) {
|
||||
for (const port of set) {
|
||||
ports.add(port);
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists loopback TCP listeners via `ss -tln` (Linux), falling back to
|
||||
* `lsof -iTCP -sTCP:LISTEN -n -P` (darwin). Both failing yields an empty
|
||||
* set — callers treat that as "no new ports will ever be discovered" and
|
||||
* take the compact-unavailable fallback.
|
||||
*/
|
||||
export async function listLoopbackListeners(opts?: {
|
||||
platform?: NodeJS.Platform;
|
||||
execImpl?: (command: string, args: string[]) => Promise<string>;
|
||||
}): Promise<Set<number>> {
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
const run = async (command: string, args: string[]): Promise<string> => {
|
||||
if (opts?.execImpl) {
|
||||
return await opts.execImpl(command, args);
|
||||
}
|
||||
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
|
||||
return stdout;
|
||||
};
|
||||
if (platform === 'darwin') {
|
||||
try {
|
||||
return parseLsofListeners(await run('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return parseSsListeners(await run('ss', ['-tln']));
|
||||
} catch {
|
||||
try {
|
||||
return parseLsofListeners(await run('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds the descendant process that actually serves the v2 REST API: its
|
||||
* command line contains the standalone `serve` argument with `--stdio`.
|
||||
* Identifying it lets port discovery and credential lookup use a single,
|
||||
* unambiguous process instead of picking the lowest listener across a tree
|
||||
* that may also own unrelated listeners (e.g. configured MCP servers).
|
||||
* Returns null when no descendant matches (caller falls back to the
|
||||
* tree-wide scan).
|
||||
*/
|
||||
export async function findOpencodeServePid(opts: {
|
||||
rootPids: number[];
|
||||
platform?: NodeJS.Platform;
|
||||
readCmdlineFile?: (pid: number) => Promise<string>;
|
||||
execImpl?: (command: string, args: string[]) => Promise<string>;
|
||||
maxDepth?: number;
|
||||
}): Promise<number | null> {
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
const isServeCommand = (cmdline: string): boolean =>
|
||||
/\bserve\b/.test(cmdline) && /--stdio/.test(cmdline);
|
||||
try {
|
||||
if (platform === 'linux') {
|
||||
const readCmdline = opts?.readCmdlineFile ?? (async (p: number) =>
|
||||
await readFileUtf8(`/proc/${p}/cmdline`));
|
||||
const seen = new Set<number>();
|
||||
const queue = [...opts.rootPids];
|
||||
let depth = 0;
|
||||
while (queue.length > 0 && depth <= (opts?.maxDepth ?? 4)) {
|
||||
const pid = queue.shift()!;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
try {
|
||||
if (isServeCommand(await readCmdline(pid))) return pid;
|
||||
} catch {
|
||||
// Unreadable pid: skip, keep walking.
|
||||
}
|
||||
if (depth < (opts?.maxDepth ?? 4)) {
|
||||
for (const child of await listChildPids(pid, { platform })) {
|
||||
if (!seen.has(child)) queue.push(child);
|
||||
}
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const run = async (command: string, args: string[]): Promise<string> => {
|
||||
if (opts?.execImpl) {
|
||||
return await opts.execImpl(command, args);
|
||||
}
|
||||
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
|
||||
return stdout;
|
||||
};
|
||||
const seen = new Set<number>();
|
||||
const queue = [...opts.rootPids];
|
||||
let depth = 0;
|
||||
while (queue.length > 0 && depth <= (opts?.maxDepth ?? 4)) {
|
||||
const pid = queue.shift()!;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
try {
|
||||
// ps Eww embeds the environment after COMMAND; isolate the
|
||||
// command portion before matching (mirrors the credential
|
||||
// reader's env-substring guard).
|
||||
const psOutput = await run('ps', ['Eww', '-p', String(pid)]);
|
||||
const commandPortion = psOutput.split(/\s+[A-Za-z_][A-Za-z0-9_]*=/)[0] ?? psOutput;
|
||||
if (isServeCommand(commandPortion)) return pid;
|
||||
} catch {
|
||||
// Unreadable pid: skip, keep walking.
|
||||
}
|
||||
if (depth < (opts?.maxDepth ?? 4)) {
|
||||
for (const child of await listChildPids(pid, { platform, execImpl: opts?.execImpl })) {
|
||||
if (!seen.has(child)) queue.push(child);
|
||||
}
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers the random loopback port opencode v2 bound for its internal HTTP
|
||||
* API. v2 (unlike v1's `--port` flag) picks the port itself and exposes it
|
||||
* nowhere (verified 2026-10-15: stdout/stderr/ACP responses all lack it), so
|
||||
* the caller snapshots listeners before spawning and this polls for the
|
||||
* first 127.0.0.1 port that appeared since. Returns null on timeout.
|
||||
*/
|
||||
export async function discoverOpencodeV2RestPort(opts: {
|
||||
beforeSnapshot: Set<number>;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
ownerPid?: number;
|
||||
ownerPids?: number[];
|
||||
scanImpl?: () => Promise<Set<number>>;
|
||||
scanOwnedImpl?: (pids: number[]) => Promise<Set<number>>;
|
||||
}): Promise<number | null> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 500;
|
||||
const scan = opts.scanImpl ?? (() => listLoopbackListeners());
|
||||
const ownerPids = opts.ownerPids ?? (opts.ownerPid != null ? [opts.ownerPid] : null);
|
||||
const scanOwned = opts.scanOwnedImpl ?? (ownerPids != null
|
||||
? () => listLoopbackListenersForPids(ownerPids)
|
||||
: null);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
// Prefer ownership-verified ports: a hit here cannot belong to any
|
||||
// other process, even one that bound after the before-snapshot. Only
|
||||
// an ownership check that is impossible (missing tools / unparsable
|
||||
// output) falls back to the diff heuristic below; a successful but
|
||||
// empty owned scan just means the server has not bound yet.
|
||||
if (scanOwned) {
|
||||
try {
|
||||
const owned = [...await scanOwned(ownerPids!)].sort((a, b) => a - b);
|
||||
if (owned.length > 0) {
|
||||
// Ownership already excludes other processes, but prefer a
|
||||
// port that is NEW since the snapshot: our tree could own
|
||||
// more than one listener, and the oldest one may predate
|
||||
// this compaction's REST surface.
|
||||
const fresh = owned.filter((port) => !opts.beforeSnapshot.has(port));
|
||||
return (fresh[0] ?? owned[0])!;
|
||||
}
|
||||
// Ownership check works but the pid owns nothing yet — keep
|
||||
// polling it; the diff heuristic could only misattribute a
|
||||
// port another process bound after the snapshot.
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
continue;
|
||||
} catch {
|
||||
// Ownership check unavailable — fall through to the diff.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const current = await scan();
|
||||
const newPorts = [...current]
|
||||
.filter((port) => !opts.beforeSnapshot.has(port))
|
||||
.sort((a, b) => a - b);
|
||||
if (newPorts.length > 0) {
|
||||
return newPorts[0]!;
|
||||
}
|
||||
} catch {
|
||||
// Transient scan failures (e.g. command missing) are retried until
|
||||
// the deadline; a persistently broken scan path times out to null.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { captureCompactionMarkerSnapshot, fetchCompactionResult, splitProviderModel, triggerOpencodeCompact } from './opencodeCompactBridge';
|
||||
import { captureCompactionMarkerSnapshot, fetchCompactionResult, fetchCompactionResultV2, pollCompactionResultV2, readOpencodeV2ServerPassword, splitProviderModel, triggerOpencodeCompact, triggerOpencodeCompactV2 } from './opencodeCompactBridge';
|
||||
|
||||
// `signal` is a required field (see OpencodeCompactCallOpts's doc comment) —
|
||||
// most tests below don't exercise abort behavior at all, so this is a
|
||||
@@ -353,3 +353,719 @@ describe('fetchCompactionResult', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerOpencodeCompactV2', () => {
|
||||
it('posts to /api/session/:id/compact with Basic auth and the v2.0.29 payload shape', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const signal = new AbortController().signal;
|
||||
await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_abc/1',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-sonnet-4-5',
|
||||
password: 'secret-pw',
|
||||
signal,
|
||||
fetchImpl
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('http://127.0.0.1:48273/api/session/ses_abc%2F1/compact');
|
||||
expect(init.method).toBe('POST');
|
||||
expect((init.headers as Record<string, string>).authorization)
|
||||
.toBe('Basic ' + Buffer.from('opencode:secret-pw').toString('base64'));
|
||||
expect((init.headers as Record<string, string>)['content-type']).toBe('application/json');
|
||||
// v2.0.29 payload schema is {id?, delivery?} — the session's current
|
||||
// model governs server-side, so no provider/model fields are sent.
|
||||
expect(JSON.parse(String(init.body))).toEqual({});
|
||||
});
|
||||
|
||||
it('encodes the Basic credentials from the OPENCODE_PASSWORD value', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'user:pass@with/special',
|
||||
signal: new AbortController().signal,
|
||||
fetchImpl
|
||||
});
|
||||
const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).authorization)
|
||||
.toBe('Basic ' + Buffer.from('opencode:user:pass@with/special').toString('base64'));
|
||||
});
|
||||
|
||||
it('reports kind rejected with status and body on non-2xx responses', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('denied', { status: 401 }));
|
||||
const rejected = await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'pw',
|
||||
signal: new AbortController().signal,
|
||||
fetchImpl
|
||||
});
|
||||
expect(rejected).toEqual({ kind: 'rejected', error: expect.stringMatching(/401/) });
|
||||
const second = await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'pw',
|
||||
signal: new AbortController().signal,
|
||||
fetchImpl
|
||||
});
|
||||
expect(second.kind).toBe('rejected');
|
||||
});
|
||||
|
||||
it('treats 5xx as admission-uncertain (recovery, not definitive failure)', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('boom', { status: 503 }));
|
||||
const result = await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'pw',
|
||||
signal: new AbortController().signal,
|
||||
fetchImpl
|
||||
});
|
||||
expect(result.kind).toBe('transport-error');
|
||||
});
|
||||
|
||||
it('treats 4xx as definitive rejection', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('denied', { status: 400 }));
|
||||
const result = await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'pw',
|
||||
signal: new AbortController().signal,
|
||||
fetchImpl
|
||||
});
|
||||
expect(result.kind).toBe('rejected');
|
||||
});
|
||||
|
||||
it('reports kind transport-error on fetch failures (admission uncertain)', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('connection refused');
|
||||
});
|
||||
const result = await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'pw',
|
||||
signal: new AbortController().signal,
|
||||
fetchImpl
|
||||
});
|
||||
expect(result).toEqual({ kind: 'transport-error', message: 'connection refused' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readOpencodeV2ServerPassword', () => {
|
||||
it('extracts OPENCODE_PASSWORD from a parsed /proc environ payload (linux)', async () => {
|
||||
const environ = 'PATH=/usr/bin\0HOME=/home/u\0OPENCODE_PASSWORD=abc123\0TERM=xterm\0';
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'linux',
|
||||
listDescendantPids: async () => [5150],
|
||||
readChildEnviron: async () => environ,
|
||||
readChildCmdline: async () => 'opencode\0serve\0--stdio'
|
||||
});
|
||||
expect(password).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns null when the environ payload has no OPENCODE_PASSWORD', async () => {
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'linux',
|
||||
listDescendantPids: async () => [5150],
|
||||
readChildEnviron: async () => 'PATH=/usr/bin\0HOME=/home/u\0'
|
||||
});
|
||||
expect(password).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when environ parsing fails (read error)', async () => {
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'linux',
|
||||
listDescendantPids: async () => [5150],
|
||||
readChildEnviron: async () => {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
});
|
||||
expect(password).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts OPENCODE_PASSWORD from ps Eww output on non-linux platforms (descendants)', async () => {
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'darwin',
|
||||
listDescendantPids: async () => [5150],
|
||||
execImpl: async (command, args) => {
|
||||
expect(command).toBe('ps');
|
||||
expect(args).toEqual(['Eww', '-p', '5150']);
|
||||
return ' PID TTY STAT TIME COMMAND\n 5150 ? Ss 0:00 /bin/opencode serve --stdio OPENCODE_PASSWORD=darwin-pw PATH=/usr/bin';
|
||||
}
|
||||
});
|
||||
expect(password).toBe('darwin-pw');
|
||||
});
|
||||
|
||||
it('linux path prefers the serve descendant (cmdline) over an inherited-password native child', async () => {
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'linux',
|
||||
listDescendantPids: async () => [111, 222],
|
||||
readChildEnviron: async (pid) => pid === 111
|
||||
? 'OPENCODE_PASSWORD=inherited-pw\0PATH=/usr/bin'
|
||||
: 'OPENCODE_PASSWORD=generated-pw\0PATH=/usr/bin',
|
||||
readChildCmdline: async (pid) => pid === 111
|
||||
? 'opencode\0acp'
|
||||
: 'opencode\0serve\0--stdio'
|
||||
});
|
||||
expect(password).toBe('generated-pw');
|
||||
});
|
||||
|
||||
it('linux serve detection requires the exact serve argument, not an executable path substring', async () => {
|
||||
// Bot round-5 Minor: an executable under /home/server/ must not flag
|
||||
// the acp child as serve.
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'linux',
|
||||
listDescendantPids: async () => [111, 222],
|
||||
readChildEnviron: async (pid) => pid === 111
|
||||
? 'OPENCODE_PASSWORD=inherited-pw\0PATH=/usr/bin'
|
||||
: 'OPENCODE_PASSWORD=generated-pw\0PATH=/usr/bin',
|
||||
readChildCmdline: async (pid) => pid === 111
|
||||
? '/home/server/bin/opencode\0acp'
|
||||
: 'opencode\0serve\0--stdio'
|
||||
});
|
||||
expect(password).toBe('generated-pw');
|
||||
});
|
||||
|
||||
it('prefers the serve descendant over one that merely inherited OPENCODE_PASSWORD', async () => {
|
||||
// Bot round-4 Minor: an npm wrapper tree is wrapper → native acp →
|
||||
// serve; a user-exported OPENCODE_PASSWORD is inherited by the
|
||||
// native child while serve gets the freshly generated one.
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'darwin',
|
||||
listDescendantPids: async () => [111, 222],
|
||||
execImpl: async (_command, args) => {
|
||||
if (args[2] === '111') {
|
||||
return ' PID TTY STAT TIME COMMAND\n 111 ? Ss 0:00 /bin/opencode acp OPENCODE_PASSWORD=inherited-pw PATH=/usr/bin';
|
||||
}
|
||||
return ' PID TTY STAT TIME COMMAND\n 222 ? Ss 0:00 /bin/opencode serve --stdio OPENCODE_PASSWORD=generated-pw PATH=/usr/bin';
|
||||
}
|
||||
});
|
||||
expect(password).toBe('generated-pw');
|
||||
});
|
||||
|
||||
it('darwin serve detection matches the command portion only, not inherited env values', async () => {
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'darwin',
|
||||
listDescendantPids: async () => [111, 222],
|
||||
execImpl: async (_command, args) => {
|
||||
if (args[2] === '111') {
|
||||
// Inherited env contains a `serve` substring (e.g. a path
|
||||
// segment) AND the executable path contains 'server' —
|
||||
// neither may flag the acp child as the serve process.
|
||||
return ' PID TTY STAT TIME COMMAND\n 111 ? Ss 0:00 /home/server/bin/opencode acp OPENCODE_PASSWORD=inherited-pw PRESERVE_DIR=/x/serve-cache PATH=/usr/bin';
|
||||
}
|
||||
return ' PID TTY STAT TIME COMMAND\n 222 ? Ss 0:00 /bin/opencode serve --stdio OPENCODE_PASSWORD=generated-pw PATH=/usr/bin';
|
||||
}
|
||||
});
|
||||
expect(password).toBe('generated-pw');
|
||||
});
|
||||
|
||||
it('returns null when pgrep/ps path yields nothing', async () => {
|
||||
const password = await readOpencodeV2ServerPassword(4242, {
|
||||
platform: 'darwin',
|
||||
listDescendantPids: async () => [],
|
||||
execImpl: async () => {
|
||||
throw new Error('no such process');
|
||||
}
|
||||
});
|
||||
expect(password).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerOpencodeCompactV2 signal contract', () => {
|
||||
it('forwards the caller abort signal to the fetch init', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
await triggerOpencodeCompactV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
password: 'pw',
|
||||
signal: controller.signal,
|
||||
fetchImpl
|
||||
});
|
||||
const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(init.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCompactionResultV2', () => {
|
||||
const authHeader = () => 'Basic ' + Buffer.from('opencode:pw').toString('base64');
|
||||
|
||||
function fixtureEntry(...entries: unknown[]): Response {
|
||||
return new Response(JSON.stringify({ data: entries }), { status: 200 });
|
||||
}
|
||||
|
||||
// v2 compaction shape (verified 2026-10-16 against opencode 2.0.29): the
|
||||
// compaction entry itself carries `status` and the full `summary` text.
|
||||
const completedEntry = (text: string) => ({
|
||||
type: 'compaction',
|
||||
status: 'completed',
|
||||
reason: 'manual',
|
||||
summary: text
|
||||
});
|
||||
|
||||
it('GETs /api/session/:id/message with Basic auth and unwraps the {data: [...]} response', async () => {
|
||||
const fetchImpl = vi.fn(async () => fixtureEntry(completedEntry('v2 summary text')));
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_x/2',
|
||||
password: 'pw',
|
||||
fetchImpl
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'v2 summary text' });
|
||||
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('http://127.0.0.1:48273/api/session/ses_x%2F2/message');
|
||||
expect(init.method).toBe('GET');
|
||||
expect((init.headers as Record<string, string>).authorization).toBe(authHeader());
|
||||
});
|
||||
|
||||
it('reports failed when the latest compaction entry carries an error status', async () => {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
fetchImpl: async () => fixtureEntry({ type: 'compaction', status: 'error', reason: 'manual', error: { data: { message: 'provider exploded' } } })
|
||||
});
|
||||
expect(result).toEqual({ status: 'failed', reason: 'provider exploded' });
|
||||
});
|
||||
|
||||
it('reports failed when the completed entry has an empty summary', async () => {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
fetchImpl: async () => fixtureEntry(completedEntry(' '))
|
||||
});
|
||||
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
|
||||
});
|
||||
|
||||
it('reports unverified on non-2xx responses', async () => {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
fetchImpl: async () => new Response('nope', { status: 401 })
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
});
|
||||
|
||||
it('reports unverified when the response wrapper has no data array', async () => {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
fetchImpl: async () => new Response(JSON.stringify({ data: null }), { status: 200 })
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
});
|
||||
|
||||
it('reports unverified when no compaction entry exists yet (pending state)', async () => {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
fetchImpl: async () => fixtureEntry({ type: 'compaction', status: 'pending', reason: 'manual' })
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
});
|
||||
|
||||
it('correlates by expectedCompactionId when an older completed compaction precedes it (descending order)', async () => {
|
||||
// Measured 2026-10-16: the messages endpoint returns entries newest
|
||||
// first, and the compact POST echoes the admitted compaction id.
|
||||
const olderCompleted = { id: 'msg_old', type: 'compaction', status: 'completed', reason: 'manual', summary: 'stale summary' };
|
||||
const newerPending = { id: 'msg_new', type: 'compaction', status: 'pending', reason: 'manual' };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: 'msg_new',
|
||||
// Descending (newest first), as measured.
|
||||
fetchImpl: async () => fixtureEntry(newerPending, olderCompleted)
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
});
|
||||
|
||||
it('without a correlation id but with an exclude list, evaluates the single new compaction entry', async () => {
|
||||
const olderCompleted = { id: 'msg_old', type: 'compaction', status: 'completed', reason: 'manual', summary: 'stale summary' };
|
||||
const newerCompleted = { id: 'msg_new', type: 'compaction', status: 'completed', reason: 'manual', summary: 'fresh summary' };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: null,
|
||||
excludeCompactionIds: ['msg_old'],
|
||||
// Descending (newest first), as measured.
|
||||
fetchImpl: async () => fixtureEntry(newerCompleted, olderCompleted)
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'fresh summary' });
|
||||
});
|
||||
|
||||
it('a completed entry with an empty summary but a providerContext is a native-compaction success', async () => {
|
||||
// Upstream v2 native provider compaction (verified against the v2
|
||||
// binary: model.compaction?.type === "native" publishes
|
||||
// providerContext instead of summary text) legitimately completes
|
||||
// with summary: "".
|
||||
const entry = { id: 'msg_native', type: 'compaction', status: 'completed', reason: 'manual', summary: '', providerContext: { version: 1, messages: [] } };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: 'msg_native',
|
||||
fetchImpl: async () => fixtureEntry(entry)
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: '' });
|
||||
});
|
||||
|
||||
it('a completed entry with an empty summary but a native window is a 2.1-native-compaction success', async () => {
|
||||
// OpenCode 2.1 stores a native compaction's replacement window in
|
||||
// native.messages (retained messages plus the assistant checkpoint)
|
||||
// and still completes with summary: "", which must not be reported as
|
||||
// an empty summary.
|
||||
const entry = {
|
||||
id: 'msg_native21',
|
||||
type: 'compaction',
|
||||
status: 'completed',
|
||||
reason: 'manual',
|
||||
summary: '',
|
||||
native: { messages: [{ role: 'assistant', content: [{ type: 'text', text: 'checkpoint' }] }] }
|
||||
};
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: 'msg_native21',
|
||||
fetchImpl: async () => fixtureEntry(entry)
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: '' });
|
||||
});
|
||||
|
||||
it('a completed entry with an empty summary and a native record without a window is a failure', async () => {
|
||||
// OpenCode 2.1 only treats a compaction as native when
|
||||
// native.messages is present; other native metadata alone does not
|
||||
// replace the summary.
|
||||
const entry = { id: 'msg_native21_empty', type: 'compaction', status: 'completed', reason: 'manual', summary: '', native: {} };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: 'msg_native21_empty',
|
||||
fetchImpl: async () => fixtureEntry(entry)
|
||||
});
|
||||
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
|
||||
});
|
||||
|
||||
it('reads the v2 top-level error message ({type, message}) as the failure reason', async () => {
|
||||
// v2.0.29 session-error shape: { type, message } — safeErrorReason
|
||||
// must read the top-level message, not only the v1 data.message.
|
||||
const entry = { id: 'msg_fail', type: 'compaction', status: 'failed', reason: 'manual', error: { type: 'compaction.unavailable', message: 'Nothing to compact yet' } };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: 'msg_fail',
|
||||
fetchImpl: async () => fixtureEntry(entry)
|
||||
});
|
||||
expect(result).toEqual({ status: 'failed', reason: 'Nothing to compact yet' });
|
||||
});
|
||||
|
||||
it('a completed entry with an empty summary and NO providerContext stays failed', async () => {
|
||||
const entry = { id: 'msg_empty', type: 'compaction', status: 'completed', reason: 'manual', summary: ' ' };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: 'msg_empty',
|
||||
fetchImpl: async () => fixtureEntry(entry)
|
||||
});
|
||||
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
|
||||
});
|
||||
|
||||
it('an empty exclude snapshot is a valid basis — a fresh session evaluates the single new entry', async () => {
|
||||
const entry = { id: 'msg_only', type: 'compaction', status: 'completed', reason: 'manual', summary: 'fresh summary' };
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: null,
|
||||
excludeCompactionIds: [],
|
||||
fetchImpl: async () => fixtureEntry(entry)
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'fresh summary' });
|
||||
});
|
||||
|
||||
it('exclude recovery never settles a server auto-compaction as ours', async () => {
|
||||
// GLM finding: an auto compaction (reason != manual) racing between
|
||||
// admission and the first poll must not be mistaken for ours — its
|
||||
// summary would end the wait while OUR manual compaction is pending.
|
||||
const autoCompleted = { id: 'msg_auto', type: 'compaction', status: 'completed', reason: 'auto', summary: 'auto summary' };
|
||||
let calls = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
calls++;
|
||||
return calls === 1
|
||||
? new Response(JSON.stringify({ data: [autoCompleted] }), { status: 200 })
|
||||
: new Response(JSON.stringify({ data: [
|
||||
autoCompleted,
|
||||
{ id: 'msg_mine', type: 'compaction', status: 'completed', reason: 'manual', summary: 'mine' }
|
||||
] }), { status: 200 });
|
||||
});
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: null,
|
||||
excludeCompactionIds: [],
|
||||
pollIntervalMs: 1,
|
||||
maxAttempts: 5,
|
||||
delayImpl: async () => {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'mine' });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('ambiguous recovery ends the poll once ALL candidates are terminal (sawPending must not hold forever)', async () => {
|
||||
// Bot round-4 Minor: with a prior pending observation, sawPending
|
||||
// disables the stall cap — an all-terminal ambiguous state would
|
||||
// poll forever. onAmbiguousTerminal ends it.
|
||||
let calls = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
calls++;
|
||||
return calls <= 2
|
||||
? new Response(JSON.stringify({ data: [
|
||||
{ id: 'msg_a', type: 'compaction', status: 'pending', reason: 'manual' },
|
||||
{ id: 'msg_b', type: 'compaction', status: 'pending', reason: 'manual' }
|
||||
] }), { status: 200 })
|
||||
: new Response(JSON.stringify({ data: [
|
||||
{ id: 'msg_a', type: 'compaction', status: 'completed', reason: 'manual', summary: 'a done' },
|
||||
{ id: 'msg_b', type: 'compaction', status: 'completed', reason: 'manual', summary: 'b done' }
|
||||
] }), { status: 200 });
|
||||
});
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: null,
|
||||
excludeCompactionIds: [],
|
||||
pollIntervalMs: 1,
|
||||
maxAttempts: 3,
|
||||
delayImpl: async () => {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it('with an exclude list and multiple new entries, stays unverified with pending progress', async () => {
|
||||
const first = { id: 'msg_a', type: 'compaction', status: 'pending', reason: 'manual' };
|
||||
const second = { id: 'msg_b', type: 'compaction', status: 'pending', reason: 'manual' };
|
||||
let pendingCalled = false;
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
expectedCompactionId: null,
|
||||
excludeCompactionIds: ['msg_old'],
|
||||
onMatchedPending: () => { pendingCalled = true; },
|
||||
fetchImpl: async () => fixtureEntry(second, first, { id: 'msg_old', type: 'compaction', status: 'completed', reason: 'manual', summary: 'stale' })
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
expect(pendingCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('reports unverified when the fetch itself throws', async () => {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
fetchImpl: async () => {
|
||||
throw new Error('connection refused');
|
||||
}
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollCompactionResultV2', () => {
|
||||
const authHeader = () => 'Basic ' + Buffer.from('opencode:pw').toString('base64');
|
||||
|
||||
function summaryResponse(text: string): Response {
|
||||
return new Response(JSON.stringify({
|
||||
data: [{ type: 'compaction', status: 'completed', reason: 'manual', summary: text }]
|
||||
}), { status: 200 });
|
||||
}
|
||||
|
||||
const emptyResponse = () => new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
|
||||
it('threads the abort signal into every messages GET', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect((init as { signal?: AbortSignal }).signal).toBe(controller.signal);
|
||||
return summaryResponse('done!');
|
||||
});
|
||||
await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
signal: controller.signal,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('polls until the status is not unverified (unverified → unverified → success)', async () => {
|
||||
const fetchImpl = vi.fn(async () => emptyResponse())
|
||||
.mockImplementationOnce(async () => emptyResponse())
|
||||
.mockImplementationOnce(async () => emptyResponse())
|
||||
.mockImplementationOnce(async () => summaryResponse('done!'));
|
||||
const delays: number[] = [];
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:48273',
|
||||
sessionId: 'ses_x',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 5,
|
||||
delayImpl: async (ms) => {
|
||||
delays.push(ms);
|
||||
},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'done!' });
|
||||
expect(delays).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
it('returns immediately when the signal aborts while waiting', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = vi.fn(async () => emptyResponse());
|
||||
const resultPromise = pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 5,
|
||||
signal: controller.signal,
|
||||
delayImpl: async () => {
|
||||
controller.abort();
|
||||
},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('unverified');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const fetchImpl = vi.fn(async () => emptyResponse());
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 5,
|
||||
signal: controller.signal,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result.status).toBe('unverified');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('pending progress resets the stall cap — visible server work is not force-released', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
calls++;
|
||||
// Pending for the first 4 rounds, terminal on the 5th — beyond a
|
||||
// cap of 3, which must only count rounds WITHOUT pending progress.
|
||||
return calls >= 5
|
||||
? new Response(JSON.stringify({ data: [{ id: 'msg_1', type: 'compaction', status: 'completed', reason: 'manual', summary: 'done' }] }), { status: 200 })
|
||||
: new Response(JSON.stringify({ data: [{ id: 'msg_1', type: 'compaction', status: 'pending', reason: 'manual' }] }), { status: 200 });
|
||||
});
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 1,
|
||||
maxAttempts: 3,
|
||||
delayImpl: async () => {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'done' });
|
||||
expect(calls).toBe(5);
|
||||
});
|
||||
|
||||
it('once pending is observed the stall cap never releases the barrier (terminal or teardown ends it)', async () => {
|
||||
// GPT finding 2: observation failures after a confirmed pending must
|
||||
// not accumulate toward the cap — confirmed server work holds the
|
||||
// barrier until terminal or teardown.
|
||||
let calls = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
calls++;
|
||||
return calls === 1
|
||||
? new Response(JSON.stringify({ data: [{ id: 'msg_1', type: 'compaction', status: 'pending', reason: 'manual' }] }), { status: 200 })
|
||||
: new Response('server exploded', { status: 503 });
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const resultPromise = pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 1,
|
||||
maxAttempts: 3,
|
||||
signal: controller.signal,
|
||||
delayImpl: async () => {
|
||||
if (calls >= 6) controller.abort();
|
||||
},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('unverified');
|
||||
expect(calls).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('gives up with the last unverified result after maxAttempts (barrier escape)', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
|
||||
const delays: number[] = [];
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 1,
|
||||
maxAttempts: 3,
|
||||
delayImpl: async (ms) => { delays.push(ms); },
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(delays).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('polls indefinitely without a signal until a terminal status arrives', async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
calls++;
|
||||
return calls >= 4 ? summaryResponse('late summary') : emptyResponse();
|
||||
});
|
||||
const result = await pollCompactionResultV2({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionId: 's',
|
||||
password: 'pw',
|
||||
pollIntervalMs: 1,
|
||||
delayImpl: async () => {},
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
});
|
||||
expect(result).toEqual({ status: 'success', text: 'late summary' });
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { listChildPids } from './opencodeBackend';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function readFileUtf8(path: string): Promise<string> {
|
||||
return readFile(path, 'utf8');
|
||||
}
|
||||
|
||||
export type OpencodeCompactResult =
|
||||
| { ok: true; summaryText?: string }
|
||||
| { ok: false; error: string };
|
||||
@@ -133,8 +144,277 @@ export async function triggerOpencodeCompact(opts: OpencodeCompactCallOpts & {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 variant of `triggerOpencodeCompact` for opencode 2.x: the internal API
|
||||
* was redesigned (verified 2026-10-15 against 2.0.29) — `POST /api/session/
|
||||
* :id/compact` replaces `/session/:id/summarize`, and Basic Auth is now
|
||||
* required (username `opencode`, password from the acp parent's
|
||||
* `OPENCODE_PASSWORD` env). Non-2xx responses throw (the launcher's v2
|
||||
* compact path has no result-verification GET to coordinate with, unlike the
|
||||
* v1 `{ok:false}` contract).
|
||||
*
|
||||
* Like the v1 path, `timeout: false` (Bun-only fetch option) disables Bun's
|
||||
* hardcoded ~5min idle timeout — compaction can legitimately take minutes.
|
||||
*/
|
||||
export type OpencodeCompactV2Admission =
|
||||
| { kind: 'admitted'; compactionId: string | null }
|
||||
| { kind: 'rejected'; error: string }
|
||||
| { kind: 'transport-error'; message: string };
|
||||
|
||||
export async function triggerOpencodeCompactV2(opts: OpencodeCompactCallOpts & {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
password: string;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<OpencodeCompactV2Admission> {
|
||||
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}/compact`;
|
||||
const init: BunFetchInit = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64'),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
// v2.0.29 compact payload schema is {id?, delivery?} with
|
||||
// additionalProperties: false (packages/protocol/openapi.json) — the
|
||||
// session's current model governs server-side, so no provider/model
|
||||
// fields are sent.
|
||||
body: JSON.stringify({}),
|
||||
// Bun-specific: disables Bun's hardcoded ~5min fetch timeout.
|
||||
timeout: false,
|
||||
signal: opts.signal
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(url, init as RequestInit);
|
||||
} catch (error) {
|
||||
// Transport-level failure: admission is UNCERTAIN — the server may
|
||||
// have accepted the request before the connection died. The caller
|
||||
// must run identity recovery instead of treating this as a
|
||||
// definitive rejection.
|
||||
return { kind: 'transport-error', message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
if (!response.ok) {
|
||||
// 4xx: the server answered and refused — definitive, nothing was
|
||||
// admitted. 5xx: the server may have admitted the compaction and
|
||||
// then failed while processing it (side effects already created) —
|
||||
// admission is UNCERTAIN, so recovery (not failure) applies.
|
||||
const text = await response.text().catch(() => '');
|
||||
if (response.status >= 500) {
|
||||
return { kind: 'transport-error', message: `OpenCode v2 compact request failed after admission risk (${response.status}): ${text.slice(0, 300)}` };
|
||||
}
|
||||
return { kind: 'rejected', error: `OpenCode v2 compact request failed (${response.status}): ${text.slice(0, 300)}` };
|
||||
}
|
||||
// The admitted compaction message echoes back in the response body
|
||||
// (measured 2026-10-16: { data: { id: 'msg_…', type: 'compaction', … } }).
|
||||
// Its id is the correlation key for verification polling.
|
||||
try {
|
||||
const payload: unknown = await response.json();
|
||||
const record = isObjectRecord(payload) && isObjectRecord(payload.data) ? payload.data : null;
|
||||
const id = record && typeof record.id === 'string' ? record.id : null;
|
||||
return { kind: 'admitted', compactionId: id };
|
||||
} catch {
|
||||
return { kind: 'admitted', compactionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 compact POST returns as soon as the compaction marker is created; the
|
||||
* summary itself is produced asynchronously by the server (verified
|
||||
* 2026-10-16 against 2.0.29: the immediate follow-up GET carries no
|
||||
* assistant result). So, unlike the v1 path — whose POST blocks until
|
||||
* completion and needs a single verification GET — the v2 result must be
|
||||
* polled until it stops being `unverified`. There is no hard timeout,
|
||||
* mirroring the v1 philosophy of waiting indefinitely for real compaction
|
||||
* (compaction can legitimately take minutes). `signal` aborts the wait
|
||||
* immediately with the current status (the launcher's isCancelled check
|
||||
* suppresses the result on abort anyway); without a signal this just polls.
|
||||
*/
|
||||
export async function pollCompactionResultV2(opts: {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
password: string;
|
||||
signal?: AbortSignal;
|
||||
/** Correlation id of the admitted compaction (see fetchCompactionResultV2). */
|
||||
expectedCompactionId?: string | null;
|
||||
/** Pre-POST compaction ids excluded from identity recovery (see fetchCompactionResultV2). */
|
||||
excludeCompactionIds?: string[] | null;
|
||||
pollIntervalMs?: number;
|
||||
/**
|
||||
* Stall cap, not a total cap. The poll runs inside the FIFO barrier
|
||||
* (phase 'summarize'), so an entry that never appears must not hold the
|
||||
* session forever: past this many consecutive rounds WITHOUT observing
|
||||
* the matched compaction (no pending progress signal), the loop gives up
|
||||
* with the last unverified result (~10 minutes at the default interval),
|
||||
* matching v1 where a stalled blocking POST eventually errors out. A
|
||||
* visible pending compaction is genuine server work — it resets the
|
||||
* stall counter, and the escape hatch remains switch/exit.
|
||||
*/
|
||||
maxAttempts?: number;
|
||||
delayImpl?: (ms: number) => Promise<void>;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<CompactionResult> {
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
||||
const maxAttempts = opts.maxAttempts ?? 120;
|
||||
const wait = opts.delayImpl ?? (async (ms: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
});
|
||||
let stalledRounds = 0;
|
||||
let ambiguousTerminal = false;
|
||||
// Once the admitted compaction has been OBSERVED in a non-terminal
|
||||
// state, server work is confirmed running: the stall cap must not
|
||||
// license releasing the barrier onto it — only a terminal outcome or
|
||||
// an explicit teardown ends the wait (escape hatch: switch/exit).
|
||||
let sawPending = false;
|
||||
for (;;) {
|
||||
const result = await fetchCompactionResultV2({
|
||||
baseUrl: opts.baseUrl,
|
||||
sessionId: opts.sessionId,
|
||||
password: opts.password,
|
||||
signal: opts.signal,
|
||||
expectedCompactionId: opts.expectedCompactionId,
|
||||
excludeCompactionIds: opts.excludeCompactionIds,
|
||||
onMatchedPending: () => { stalledRounds = 0; sawPending = true; },
|
||||
onAmbiguousTerminal: () => { ambiguousTerminal = true; },
|
||||
fetchImpl: opts.fetchImpl
|
||||
});
|
||||
if (result.status !== 'unverified') {
|
||||
return result;
|
||||
}
|
||||
if (ambiguousTerminal) {
|
||||
return result;
|
||||
}
|
||||
stalledRounds += 1;
|
||||
if (opts.signal?.aborted) {
|
||||
return result;
|
||||
}
|
||||
if (!sawPending && stalledRounds >= maxAttempts) {
|
||||
return result;
|
||||
}
|
||||
await wait(pollIntervalMs);
|
||||
// Abort during the wait must not trigger another fetch round.
|
||||
if (opts.signal?.aborted) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the OPENCODE_PASSWORD value from a NUL-separated `/proc/<pid>/
|
||||
* environ` payload. Exported for fixture tests.
|
||||
*/
|
||||
export function parseOpencodeEnvironPassword(environ: string): string | null {
|
||||
for (const entry of environ.split('\0')) {
|
||||
if (entry.startsWith('OPENCODE_PASSWORD=')) {
|
||||
const value = entry.slice('OPENCODE_PASSWORD='.length);
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the OPENCODE_PASSWORD value from `ps Eww -p <pid>` output (the
|
||||
* non-Linux fallback; Eww prints the full environment in COMMAND). Exported
|
||||
* for fixture tests.
|
||||
*/
|
||||
export function parsePsEnvironPassword(output: string): string | null {
|
||||
const match = /(?:^|\s)OPENCODE_PASSWORD=([^\s]+)/.exec(output);
|
||||
if (!match) return null;
|
||||
return match[1]!.length > 0 ? match[1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OPENCODE_PASSWORD the v2 `opencode acp` parent generated and
|
||||
* injected into its `serve --stdio` child's environment (verified
|
||||
* 2026-10-15: the parent acp process's own env does not carry it — only the
|
||||
* child's). On Linux, resolves the first child via the per-task `children`
|
||||
* files under `/proc/<acpPid>/task/`; elsewhere falls back to `pgrep -P` +
|
||||
* `ps Eww`. Returns null on
|
||||
* any failure — callers disable the v2 compact bridge rather than guessing.
|
||||
*/
|
||||
export async function readOpencodeV2ServerPassword(acpPid: number, opts?: {
|
||||
platform?: NodeJS.Platform;
|
||||
listDescendantPids?: (acpPid: number) => Promise<number[]>;
|
||||
readChildEnviron?: (childPid: number) => Promise<string>;
|
||||
readChildCmdline?: (childPid: number) => Promise<string>;
|
||||
execImpl?: (command: string, args: string[]) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
// Preference order among descendants carrying OPENCODE_PASSWORD: the
|
||||
// `serve` process. With an npm wrapper the tree is wrapper → native acp
|
||||
// → serve, and a user-exported OPENCODE_PASSWORD is INHERITED by the
|
||||
// native child while serve gets the freshly generated one — accepting
|
||||
// the first match would report the inherited credential and every
|
||||
// /compact would 401.
|
||||
const preferServe = (candidates: Array<{ password: string; isServe: boolean }>): string | null => {
|
||||
const serve = candidates.find((candidate) => candidate.isServe);
|
||||
return (serve ?? candidates[0])?.password ?? null;
|
||||
};
|
||||
try {
|
||||
const descendants = opts?.listDescendantPids ?? (async (pid: number) =>
|
||||
await listChildPids(pid, { platform }));
|
||||
if (platform === 'linux') {
|
||||
const readEnviron = opts?.readChildEnviron ?? (async (childPid: number) =>
|
||||
await readFile(`/proc/${childPid}/environ`, 'utf8'));
|
||||
const readCmdline = opts?.readChildCmdline ?? (async (childPid: number) =>
|
||||
await readFile(`/proc/${childPid}/cmdline`, 'utf8'));
|
||||
const candidates: Array<{ password: string; isServe: boolean }> = [];
|
||||
for (const childPid of await descendants(acpPid)) {
|
||||
try {
|
||||
const password = parseOpencodeEnvironPassword(await readEnviron(childPid));
|
||||
if (!password) continue;
|
||||
const cmdline = await readCmdline(childPid).catch(() => '');
|
||||
// Exact serve argument, not a substring: an executable
|
||||
// under /home/server/ must not flag an `acp` child.
|
||||
candidates.push({ password, isServe: /\bserve\b/.test(cmdline) && /--stdio/.test(cmdline) });
|
||||
} catch {
|
||||
// Fall through if this child yields nothing.
|
||||
}
|
||||
}
|
||||
return preferServe(candidates);
|
||||
}
|
||||
const run = async (command: string, args: string[]): Promise<string> => {
|
||||
if (opts?.execImpl) {
|
||||
return await opts.execImpl(command, args);
|
||||
}
|
||||
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
|
||||
return stdout;
|
||||
};
|
||||
// Iterate EVERY descendant (recursive — the npm wrapper makes serve a
|
||||
// grandchild) via a single `ps Eww` per pid, which carries both the
|
||||
// environment and the command line.
|
||||
const candidates: Array<{ password: string; isServe: boolean }> = [];
|
||||
for (const child of await descendants(acpPid)) {
|
||||
try {
|
||||
const psOutput = await run('ps', ['Eww', '-p', String(child)]);
|
||||
const password = parsePsEnvironPassword(psOutput);
|
||||
if (!password) continue;
|
||||
// `ps Eww` embeds the environment after COMMAND; match `serve`
|
||||
// against the command portion only, or an inherited env value
|
||||
// containing the substring would misflag the candidate.
|
||||
const commandPortion = psOutput.split(/\s+[A-Za-z_][A-Za-z0-9_]*=/)[0] ?? psOutput;
|
||||
candidates.push({ password, isServe: /\bserve\b/.test(commandPortion) && /--stdio/.test(commandPortion) });
|
||||
} catch {
|
||||
// Try the next descendant.
|
||||
}
|
||||
}
|
||||
return preferServe(candidates);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type OpencodeMessagePart = { type?: unknown; text?: unknown; auto?: unknown };
|
||||
type OpencodeMessageEntry = {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
status?: unknown;
|
||||
reason?: unknown;
|
||||
summary?: unknown;
|
||||
providerContext?: unknown;
|
||||
native?: unknown;
|
||||
error?: unknown;
|
||||
info?: {
|
||||
id?: unknown;
|
||||
role?: unknown;
|
||||
@@ -190,11 +470,16 @@ function safeErrorReason(value: unknown): string | null {
|
||||
// OpenCode persists provider failures as `{ name, data: { message, ... } }`.
|
||||
// Read only that scalar message: serializing error/data would risk exposing
|
||||
// request headers, provider metadata, or other sensitive fields.
|
||||
// v2 compaction errors (verified against v2.0.29,
|
||||
// packages/schema/src/session-error.ts) are `{ type, message }` — read
|
||||
// the top-level message scalar the same way.
|
||||
const message = typeof value === 'string'
|
||||
? value
|
||||
: isObjectRecord(value) && isObjectRecord(value.data) && typeof value.data.message === 'string'
|
||||
? value.data.message
|
||||
: null;
|
||||
: isObjectRecord(value) && typeof value.message === 'string'
|
||||
? value.message
|
||||
: isObjectRecord(value) && isObjectRecord(value.data) && typeof value.data.message === 'string'
|
||||
? value.data.message
|
||||
: null;
|
||||
if (!message) return 'OpenCode reported a compaction error.';
|
||||
const normalized = message.trim().replace(/\s+/g, ' ');
|
||||
return normalized ? normalized.slice(0, 200) : 'OpenCode reported a compaction error.';
|
||||
@@ -281,3 +566,211 @@ export async function fetchCompactionResult(opts: OpencodeCompactCallOpts & {
|
||||
|
||||
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the ids of compaction entries that already exist BEFORE a compact
|
||||
* POST — the v2 analogue of v1's `captureCompactionMarkerSnapshot` boundary.
|
||||
* Used to recover identity when the POST response cannot be read: any
|
||||
* compaction entry appearing afterwards must be ours. Null = the snapshot
|
||||
* itself failed (caller then has no recovery basis).
|
||||
*/
|
||||
export async function captureCompactionIdsV2(opts: {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
password: string;
|
||||
signal?: AbortSignal;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<string[] | null> {
|
||||
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}/message`;
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'GET',
|
||||
signal: opts.signal,
|
||||
headers: {
|
||||
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64')
|
||||
}
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const entries = Array.isArray(payload)
|
||||
? payload as OpencodeMessageEntry[]
|
||||
: isObjectRecord(payload) && Array.isArray(payload.data)
|
||||
? payload.data as OpencodeMessageEntry[]
|
||||
: null;
|
||||
if (!entries) return null;
|
||||
return entries
|
||||
.filter((entry) => entry.type === 'compaction')
|
||||
.map((entry) => (isObjectRecord(entry) && typeof entry.id === 'string' ? entry.id : null))
|
||||
.filter((id): id is string => id !== null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 counterpart of `fetchCompactionResult`. opencode v2 redesigned the
|
||||
* messages endpoint (verified 2026-10-15): `GET /api/session/:id/message`
|
||||
* with Basic auth, wrapping the message entries in a `{ data: [...] }`
|
||||
* envelope. There is no pre-POST marker snapshot on the v2 path (the v1
|
||||
* `captureCompactionMarkerSnapshot` boundary does not exist there), so the
|
||||
* newest manual compaction marker in the list is treated as this request's
|
||||
* marker and the same v1 parsing rules apply: exactly one assistant result
|
||||
* linked to it, terminal only, summary flag required; anything else stays
|
||||
* `unverified` rather than becoming a false completion.
|
||||
*/
|
||||
export async function fetchCompactionResultV2(opts: {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
password: string;
|
||||
/** Aborts an in-flight messages GET. Required for the poll loop (see pollCompactionResultV2). */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Correlation id of the compaction this request admitted (echoed by the
|
||||
* compact POST response). The messages endpoint returns entries newest
|
||||
* first (measured 2026-10-16), so an older completed compaction must not
|
||||
* be mistaken for this request's outcome. Null = the POST response could
|
||||
* not be read; recovery then runs through `excludeCompactionIds`.
|
||||
*/
|
||||
expectedCompactionId?: string | null;
|
||||
/**
|
||||
* Compaction entry ids that already existed BEFORE the POST (the v2
|
||||
* analogue of v1's marker snapshot). Used only when
|
||||
* `expectedCompactionId` is null: candidates are the entries absent from
|
||||
* this list — compactions the POST could have admitted. One candidate is
|
||||
* evaluated; several mean ours is among them (pending-progress, keep
|
||||
* waiting); none means no visibility yet (stall counting applies).
|
||||
*/
|
||||
excludeCompactionIds?: string[] | null;
|
||||
/**
|
||||
* Invoked when the matched compaction entry exists but has not reached a
|
||||
* terminal outcome (pending/running). The poll loop treats this as
|
||||
* progress evidence: visible server work keeps the barrier legitimately
|
||||
* held, so the attempt cap counts consecutive rounds WITHOUT such a
|
||||
* signal rather than total rounds.
|
||||
*/
|
||||
onMatchedPending?: () => void;
|
||||
/** Invoked when snapshot-recovery candidates ALL reached terminal states — ends the poll (see pollCompactionResultV2). */
|
||||
onAmbiguousTerminal?: () => void;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<CompactionResult> {
|
||||
const unverified: CompactionResult = { status: 'unverified', reason: 'Compaction result could not be verified.' };
|
||||
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}/message`;
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'GET',
|
||||
signal: opts.signal,
|
||||
headers: {
|
||||
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64')
|
||||
}
|
||||
});
|
||||
if (!response.ok) return unverified;
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const entries = Array.isArray(payload)
|
||||
? payload as OpencodeMessageEntry[]
|
||||
: isObjectRecord(payload) && Array.isArray(payload.data)
|
||||
? payload.data as OpencodeMessageEntry[]
|
||||
: null;
|
||||
if (!entries) return unverified;
|
||||
|
||||
// v2 compaction shape (verified 2026-10-16/18 against opencode
|
||||
// v2.0.29, release commit 042d0832f; schema:
|
||||
// packages/protocol/openapi.json → Session.Message.Compaction.*):
|
||||
// the compaction entry itself carries the outcome inline —
|
||||
// `{ type: 'compaction', status: 'running'|'completed'|'failed',
|
||||
// reason: 'auto'|'manual', summary, providerContext }`.
|
||||
// OpenCode 2.1 dropped providerContext in favor of a generic `native`
|
||||
// record (packages/schema/src/session-message.ts at v2.1.2). There is
|
||||
// no assistant message linked via parentID like v1, so the v1 parsing
|
||||
// rules do not apply here.
|
||||
const compactionEntries = entries.filter((entry) => entry.type === 'compaction');
|
||||
let candidates = compactionEntries;
|
||||
if (opts.expectedCompactionId) {
|
||||
candidates = compactionEntries.filter((entry) => isObjectRecord(entry) && entry.id === opts.expectedCompactionId);
|
||||
} else if (opts.excludeCompactionIds) {
|
||||
// Identity recovery: only compactions that did not exist before
|
||||
// the POST can be ours. An EMPTY snapshot is a valid basis too
|
||||
// (fresh session — every entry afterwards is ours).
|
||||
const exclude = new Set(opts.excludeCompactionIds);
|
||||
candidates = compactionEntries.filter((entry) => !(isObjectRecord(entry) && typeof entry.id === 'string' && exclude.has(entry.id)));
|
||||
// Server-initiated auto-compactions (reason !== 'manual') racing
|
||||
// between admission and the first poll must never be settled as
|
||||
// ours: their summary would end the wait while OUR compaction is
|
||||
// still running. When the entries carry a reason field at all,
|
||||
// restrict candidates to manual ones; an auto-only pool means no
|
||||
// visibility into ours yet (stall counting applies).
|
||||
// v2.0.29 always stamps reason (auto|manual — verified against
|
||||
// the release schema); entries without one are an unknown shape,
|
||||
// so settlement is refused rather than guessed.
|
||||
const withReason = candidates.filter((entry) => isObjectRecord(entry) && typeof entry.reason === 'string');
|
||||
if (withReason.length > 0) {
|
||||
candidates = candidates.filter((entry) => (entry as { reason?: unknown }).reason === 'manual');
|
||||
} else {
|
||||
candidates = [];
|
||||
}
|
||||
}
|
||||
const latestCompaction = candidates[0] ?? null;
|
||||
if (!latestCompaction) return unverified;
|
||||
if (candidates.length > 1) {
|
||||
// Ambiguous: ours is among the new entries. Pending progress (and
|
||||
// thus a stall-counter reset) is only honest while at least one
|
||||
// candidate is still non-terminal. Once ALL of them reached a
|
||||
// terminal state the outcome can no longer change — signal the
|
||||
// poll to END (otherwise sawPending would hold the barrier
|
||||
// forever on entries that will never change).
|
||||
const anyNonTerminal = candidates.some((entry) => {
|
||||
const record = isObjectRecord(entry) ? entry : null;
|
||||
const candidateStatus = record && typeof record.status === 'string' ? record.status : undefined;
|
||||
// Same whitelist as the single-candidate path: an unrecognized
|
||||
// status must not claim pending progress.
|
||||
return candidateStatus === 'pending' || candidateStatus === 'running';
|
||||
});
|
||||
if (anyNonTerminal) {
|
||||
opts.onMatchedPending?.();
|
||||
} else {
|
||||
opts.onAmbiguousTerminal?.();
|
||||
}
|
||||
return unverified;
|
||||
}
|
||||
|
||||
const entryRecord = isObjectRecord(latestCompaction) ? latestCompaction : null;
|
||||
const status = entryRecord && typeof entryRecord.status === 'string' ? entryRecord.status : undefined;
|
||||
const summary = entryRecord && typeof entryRecord.summary === 'string' ? entryRecord.summary : undefined;
|
||||
|
||||
if (status === 'error' || status === 'failed') {
|
||||
const reason = safeErrorReason(latestCompaction.error) ?? 'OpenCode compaction failed.';
|
||||
return { status: 'failed', reason };
|
||||
}
|
||||
if (status === 'completed') {
|
||||
if (summary && summary.trim().length > 0) {
|
||||
return { status: 'success', text: summary };
|
||||
}
|
||||
// Native provider compaction (verified 2026-10-18 against the v2
|
||||
// binary: `model.compaction?.type === "native"` skips the summary
|
||||
// prompt and publishes providerContext instead) legitimately
|
||||
// completes with an empty summary — the context checkpoint lives
|
||||
// in providerContext. OpenCode 2.1 stores the replacement window in
|
||||
// `native.messages` instead and recognizes a native compaction by
|
||||
// `native.messages !== undefined` (isNativeCompaction in
|
||||
// packages/core/src/session/history.ts at v2.1.2), so accept either
|
||||
// shape with the same test.
|
||||
const nativeRecord = isObjectRecord(entryRecord?.native) ? entryRecord.native : null;
|
||||
if (isObjectRecord(entryRecord?.providerContext) || nativeRecord?.messages !== undefined) {
|
||||
return { status: 'success', text: '' };
|
||||
}
|
||||
return { status: 'failed', reason: 'OpenCode returned an empty compaction summary.' };
|
||||
}
|
||||
|
||||
// Matched but not terminal. Progress credit (stall-counter reset) is
|
||||
// only honest for KNOWN non-terminal statuses: an unrecognized status
|
||||
// string (or a missing one) must not hold the barrier forever — the
|
||||
// stall cap then bounds the hold instead.
|
||||
if (status === 'pending' || status === 'running') {
|
||||
opts.onMatchedPending?.();
|
||||
}
|
||||
return unverified;
|
||||
} catch {
|
||||
return unverified;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ function subscribe(options: {
|
||||
* stop the loop itself.
|
||||
*/
|
||||
stopAfterDelays?: number;
|
||||
headers?: Record<string, string>;
|
||||
mode?: 'v1' | 'v2';
|
||||
}) {
|
||||
let subscription: { close: () => void } | null = null;
|
||||
subscription = subscribeToOpencodeEvents({
|
||||
@@ -94,6 +96,8 @@ function subscribe(options: {
|
||||
directory: SESSION_CWD,
|
||||
sessionId: 'ses_ours',
|
||||
onRetry: options.onRetry ?? (() => {}),
|
||||
headers: options.headers,
|
||||
mode: options.mode,
|
||||
fetchImpl: options.fetchImpl,
|
||||
sleep: async (ms: number) => {
|
||||
options.delays?.push(ms);
|
||||
@@ -160,6 +164,46 @@ describe('subscribeToOpencodeEvents', () => {
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('v2 mode: hits /api/event and unwraps the {data} envelope before retry parsing', async () => {
|
||||
// Measured 2026-10-16 (opencode 2.0.29): /event answers with the SPA
|
||||
// HTML fallback; the real stream is /api/event and every payload is
|
||||
// { id, type, data: { …v1-shaped body… } }.
|
||||
const seen: Array<string | null> = [];
|
||||
const sse = createSseStream();
|
||||
const fetchImpl = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
seen.push(url);
|
||||
return new Response(sse.stream, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
||||
});
|
||||
const retries: number[] = [];
|
||||
const subscription = subscribe({ fetchImpl, onRetry: (retry) => retries.push(retry.attempt), mode: 'v2' });
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
|
||||
expect(seen[0]).toBe(EXPECTED_URL.replace('/event?', '/api/event?'));
|
||||
|
||||
// Real v2 shape (measured): the body inside `data` has NO type
|
||||
// field — the discriminator lives on the envelope only.
|
||||
sse.push({
|
||||
id: 'evt_a',
|
||||
type: 'session.status',
|
||||
data: { sessionID: 'ses_ours', status: { type: 'retry', attempt: 1, message: 'Rate limit exceeded: free-models-per-day.', next: NOW + 30_000 } }
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(retries).toEqual([1]));
|
||||
await subscription.close();
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('merges caller headers into every connect attempt (v2 Basic auth)', async () => {
|
||||
const sse = createSseStream();
|
||||
const fetchImpl = vi.fn(async (_url: string, _init?: RequestInit) => new Response(sse.stream, { status: 200 }));
|
||||
const auth = 'Basic ' + Buffer.from('opencode:pw').toString('base64');
|
||||
const subscription = subscribe({ fetchImpl, headers: { authorization: auth } });
|
||||
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
|
||||
expect(fetchImpl.mock.calls[0][1]?.headers).toMatchObject({ authorization: auth, accept: 'text/event-stream' });
|
||||
await subscription.close();
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('reports every new retry attempt once, ignoring repeats of the same attempt', async () => {
|
||||
const sse = createSseStream();
|
||||
const fetchImpl = vi.fn(async (_url: string, _init?: RequestInit) => new Response(sse.stream, { status: 200 }));
|
||||
|
||||
@@ -37,6 +37,19 @@ export type OpencodeEventStreamOptions = {
|
||||
* web timeline folds), not as a failure — see its call site.
|
||||
*/
|
||||
onRetry: (retry: OpencodeRetryStatus) => void;
|
||||
/**
|
||||
* Extra request headers merged into every (re)connect attempt. Required
|
||||
* for opencode v2: the internal server is Basic-auth protected, and a
|
||||
* headerless subscription is rejected with 401 before any event arrives.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Wire dialect. v2 (measured 2026-10-16 against opencode 2.0.29): the
|
||||
* stream lives at `/api/event` (the v1 `/event` path answers with the
|
||||
* SPA HTML fallback) and every JSON payload wraps its v1-shaped body in
|
||||
* a `data` envelope (`{ id, type, data: { … } }`). Default v1.
|
||||
*/
|
||||
mode?: 'v1' | 'v2';
|
||||
fetchImpl?: FetchLike;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
};
|
||||
@@ -226,6 +239,8 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
|
||||
directory,
|
||||
sessionId,
|
||||
onRetry,
|
||||
headers = {},
|
||||
mode = 'v1',
|
||||
fetchImpl = fetch as FetchLike,
|
||||
sleep = (ms: number) => new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
@@ -233,7 +248,7 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
|
||||
})
|
||||
} = options;
|
||||
|
||||
const url = `${baseUrl}/event?directory=${encodeURIComponent(directory)}`;
|
||||
const url = `${baseUrl}${mode === 'v2' ? '/api/event' : '/event'}?directory=${encodeURIComponent(directory)}`;
|
||||
const controller = new AbortController();
|
||||
let closed = false;
|
||||
// Structurally typed to just what close() needs: the global and
|
||||
@@ -348,6 +363,18 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
|
||||
logger.debug('[opencode-events] ignoring unparsable event payload', error);
|
||||
return;
|
||||
}
|
||||
// v2 wraps the v1-shaped body in an envelope: { id, type, data },
|
||||
// where `type` lives on the ENVELOPE and `data` carries only the
|
||||
// body (measured: { id, type: 'session.status', data: { sessionID,
|
||||
// status } }). Re-attach the envelope type so the downstream v1
|
||||
// handling (event.type + properties) works unchanged on both
|
||||
// dialects; unwrapping `data` alone would strip the discriminator
|
||||
// and every retry event would be silently rejected. The v2 body also
|
||||
// carries its fields directly (no v1 `properties` wrapper), so the
|
||||
// body becomes `properties`.
|
||||
if (mode === 'v2' && isObject(payload) && isObject(payload.data)) {
|
||||
payload = { type: payload.type, properties: payload.data };
|
||||
}
|
||||
// Dispatched outside the parse guard on purpose: everything
|
||||
// downstream of handleEvent is HAPI's own code (the launcher's
|
||||
// report, the hub client), and a throw from there is a bug in this
|
||||
@@ -425,6 +452,7 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
accept: 'text/event-stream',
|
||||
...headers,
|
||||
...(lastEventId ? { 'last-event-id': lastEventId } : {})
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user