mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
feat(opencode): open a fresh session on clear (#1300)
* test(opencode): specify fresh-session clear * feat(opencode): open a fresh session on clear * fix(opencode): release clear latch on cancel * fix(opencode): retry transient clear handoffs * fix(opencode): confirm clear archive delivery * fix(web): preserve superseded session access * fix(clear): invalidate transferred schedules * fix(runner): restore live spawn dedupe * fix(clear): preserve latched scheduled prompts * fix(runner): quarantine unverified children * fix(clear): retain handoff retry ownership * fix(runner): release recovered spawn dedupe * fix(clear): retain archive retry ownership * fix(clear): settle rejected immediate prompts * fix(clear): block reopening replaced sources * fix(clear): settle prompts when clear is cancelled * fix(clear): make fresh-session handoff durable * fix(clear): finalize only after native cleanup * fix(clear): abort failed native handoffs * fix(clear): gate recovery on cleanup proof * fix(clear): retry metadata persistence failures * fix(clear): preserve handoff ownership through teardown * fix(clear): abort incomplete cleanup reservations * fix(clear): require explicit exit before abort * fix(clear): verify owner exit before recovery * fix(clear): guard recovery handoff races * fix(clear): serialize cleanup callbacks * fix(clear): make callback retries idempotent * fix(clear): bind callbacks to reservations * fix(clear): recover pending spawns * fix(clear): deduplicate held prompts * fix(clear): validate redirect ownership * fix(clear): replay prompts in FIFO order * fix(clear): gate replacement delivery
This commit is contained in:
@@ -25,6 +25,9 @@ interface OpencodeLoopOptions {
|
||||
onSessionReady?: (session: OpencodeSession) => void;
|
||||
onReasoningEffortRollback?: (effort: string | null) => void;
|
||||
onCompactAvailabilityChange?: (available: boolean) => void;
|
||||
onClearRequested?: () => Promise<void>;
|
||||
onClearCleanupComplete?: () => Promise<void>;
|
||||
onClearCleanupFailed?: () => Promise<void>;
|
||||
// Consumes (delete-and-return) whether the given localId was cancelled
|
||||
// after already being dequeued — needed because a queued /compact can
|
||||
// still be running (its REST call can take minutes) by the time a
|
||||
@@ -80,7 +83,10 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
|
||||
runRemote: (instance) => opencodeRemoteLauncher(instance, {
|
||||
onReasoningEffortRollback: opts.onReasoningEffortRollback,
|
||||
onCompactAvailabilityChange: opts.onCompactAvailabilityChange,
|
||||
isLocalIdCancelled: opts.isLocalIdCancelled
|
||||
isLocalIdCancelled: opts.isLocalIdCancelled,
|
||||
onClearRequested: opts.onClearRequested,
|
||||
onClearCleanupComplete: opts.onClearCleanupComplete,
|
||||
onClearCleanupFailed: opts.onClearCleanupFailed
|
||||
}),
|
||||
onSessionReady: opts.onSessionReady
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const harness = vi.hoisted(() => ({
|
||||
refreshSessionInfoCalls: [] as Array<{ sessionId: string; cwd: string }>,
|
||||
bridgeOptions: null as { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } } | null,
|
||||
events: [] as string[],
|
||||
cleanupEvents: [] as string[],
|
||||
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>),
|
||||
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
|
||||
thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> },
|
||||
@@ -29,7 +30,10 @@ const harness = vi.hoisted(() => ({
|
||||
// registered once initialization finishes), so that race can only be
|
||||
// reproduced via the terminal UI's onExit/onSwitchToLocal callbacks,
|
||||
// not rpcHandlers.
|
||||
newSessionImpl: null as null | (() => Promise<string>)
|
||||
newSessionImpl: null as null | (() => Promise<string>),
|
||||
disconnectImpl: null as null | (() => Promise<void>),
|
||||
permissionCancelError: null as Error | null,
|
||||
serverStopError: null as Error | null
|
||||
}));
|
||||
|
||||
// Captures the RemoteLauncherDisplayContext (including onExit/
|
||||
@@ -108,7 +112,12 @@ vi.mock('./utils/opencodeBackend', () => ({
|
||||
harness.refreshSessionInfoCalls.push({ sessionId, cwd });
|
||||
}),
|
||||
onPermissionRequest: vi.fn(),
|
||||
disconnect: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(async () => {
|
||||
harness.cleanupEvents.push('cleanup:disconnect');
|
||||
if (harness.disconnectImpl) {
|
||||
await harness.disconnectImpl();
|
||||
}
|
||||
}),
|
||||
getSessionModelsMetadata: vi.fn(() => harness.sessionModelsMetadata),
|
||||
getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined),
|
||||
// Real AcpSdkBackend.suppressUpdatesDuring swaps out the message
|
||||
@@ -123,7 +132,7 @@ vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
|
||||
buildHapiMcpBridge: async (_client: unknown, options?: { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } }) => {
|
||||
harness.bridgeOptions = options ?? null;
|
||||
return {
|
||||
server: { stop: () => {} },
|
||||
server: { stop: () => { harness.cleanupEvents.push('cleanup:server-stop'); if (harness.serverStopError) throw harness.serverStopError; } },
|
||||
mcpServers: {}
|
||||
};
|
||||
}
|
||||
@@ -131,7 +140,7 @@ vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
|
||||
|
||||
vi.mock('./utils/permissionHandler', () => ({
|
||||
OpencodePermissionHandler: class {
|
||||
async cancelAll(): Promise<void> {}
|
||||
async cancelAll(): Promise<void> { harness.cleanupEvents.push('cleanup:permission'); if (harness.permissionCancelError) throw harness.permissionCancelError; }
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -300,6 +309,13 @@ function createCompactMode(model?: string): OpencodeMode {
|
||||
};
|
||||
}
|
||||
|
||||
function createClearMode(): OpencodeMode {
|
||||
return {
|
||||
permissionMode: 'default' as PermissionMode,
|
||||
operation: 'clear'
|
||||
};
|
||||
}
|
||||
|
||||
describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
afterEach(() => {
|
||||
harness.setModelArgs = [];
|
||||
@@ -309,6 +325,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
harness.refreshSessionInfoCalls = [];
|
||||
harness.bridgeOptions = null;
|
||||
harness.events = [];
|
||||
harness.cleanupEvents = [];
|
||||
harness.setModelImpl = null;
|
||||
harness.setConfigOptionImpl = null;
|
||||
harness.thoughtLevelOption = null;
|
||||
@@ -322,9 +339,109 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
harness.sessionModelsMetadata = undefined;
|
||||
harness.cancelPromptImpl = null;
|
||||
harness.newSessionImpl = null;
|
||||
harness.disconnectImpl = null;
|
||||
harness.permissionCancelError = null;
|
||||
harness.serverStopError = null;
|
||||
inkHarness.lastRenderProps = null;
|
||||
});
|
||||
|
||||
it('reaches /clear only after the earlier prompt settles, without starting another OpenCode turn', async () => {
|
||||
let resolvePrompt: (() => void) | null = null;
|
||||
harness.promptImpl = () => new Promise<void>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
});
|
||||
const onClearRequested = vi.fn();
|
||||
const onClearCleanupComplete = vi.fn(async () => {});
|
||||
const { session } = createSessionStub([
|
||||
{ message: 'before-clear', mode: createMode() },
|
||||
{ message: '', mode: createClearMode() }
|
||||
]);
|
||||
|
||||
const launcherPromise = opencodeRemoteLauncher(session as never, { onClearRequested, onClearCleanupComplete });
|
||||
while (!harness.events.includes('prompt:start')) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(onClearRequested).not.toHaveBeenCalled();
|
||||
|
||||
resolvePrompt!();
|
||||
await launcherPromise;
|
||||
|
||||
expect(harness.events).toEqual(['prompt:start', 'prompt:end']);
|
||||
expect(harness.promptCount).toBe(1);
|
||||
expect(onClearRequested).toHaveBeenCalledTimes(1);
|
||||
expect(onClearCleanupComplete).toHaveBeenCalledTimes(1);
|
||||
// The sibling compact test below intentionally inspects its first
|
||||
// factory result; do not leave this test's backend instance behind.
|
||||
const backendModule = await import('./utils/opencodeBackend');
|
||||
(backendModule.createOpencodeBackend as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
});
|
||||
|
||||
it('reserves before native cleanup but does not complete the transition when cleanup fails', async () => {
|
||||
harness.disconnectImpl = async () => {
|
||||
throw new Error('disconnect failed');
|
||||
};
|
||||
const onClearRequested = vi.fn();
|
||||
const onClearCleanupComplete = vi.fn(async () => {});
|
||||
const onClearCleanupFailed = vi.fn(async () => {});
|
||||
const { session } = createSessionStub([
|
||||
{ message: '', mode: createClearMode() }
|
||||
]);
|
||||
|
||||
await expect(opencodeRemoteLauncher(session as never, { onClearRequested, onClearCleanupComplete, onClearCleanupFailed })).rejects.toThrow('disconnect failed');
|
||||
expect(onClearRequested).toHaveBeenCalledTimes(1);
|
||||
expect(onClearCleanupComplete).not.toHaveBeenCalled();
|
||||
expect(onClearCleanupFailed).toHaveBeenCalledTimes(1);
|
||||
const backendModule = await import('./utils/opencodeBackend');
|
||||
(backendModule.createOpencodeBackend as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
});
|
||||
|
||||
it.each(['permission', 'server'] as const)('aborts clear when %s cleanup fails', async (stage) => {
|
||||
if (stage === 'permission') harness.permissionCancelError = new Error('permission cleanup failed');
|
||||
else harness.serverStopError = new Error('server cleanup failed');
|
||||
const onClearRequested = vi.fn(async () => {});
|
||||
const onClearCleanupComplete = vi.fn(async () => {});
|
||||
const onClearCleanupFailed = vi.fn(async () => {});
|
||||
const { session } = createSessionStub([{ message: '', mode: createClearMode() }]);
|
||||
await expect(opencodeRemoteLauncher(session as never, {
|
||||
onClearRequested, onClearCleanupComplete, onClearCleanupFailed
|
||||
})).rejects.toThrow('cleanup failed');
|
||||
expect(onClearCleanupFailed).toHaveBeenCalledTimes(1);
|
||||
expect(onClearCleanupComplete).not.toHaveBeenCalled();
|
||||
expect(harness.cleanupEvents).toEqual(expect.arrayContaining([
|
||||
'cleanup:permission', 'cleanup:disconnect', 'cleanup:server-stop'
|
||||
]));
|
||||
});
|
||||
|
||||
it('reaches /clear only after an in-flight /compact has completed', async () => {
|
||||
harness.sessionModelsMetadata = { currentModelId: 'ollama/x', availableModels: [] };
|
||||
let resolveCompact: (() => void) | null = null;
|
||||
compactHarness.triggerImpl = () => new Promise((resolve) => {
|
||||
resolveCompact = () => resolve({ ok: true });
|
||||
});
|
||||
const onClearRequested = vi.fn();
|
||||
const { session } = createSessionStub([
|
||||
{ message: '', mode: createCompactMode('ollama/x') },
|
||||
{ message: '', mode: createClearMode() }
|
||||
]);
|
||||
|
||||
const launcherPromise = opencodeRemoteLauncher(session as never, {
|
||||
onCompactAvailabilityChange: () => {},
|
||||
onClearRequested
|
||||
});
|
||||
while (compactHarness.calls.length === 0) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(onClearRequested).not.toHaveBeenCalled();
|
||||
|
||||
resolveCompact!();
|
||||
await launcherPromise;
|
||||
|
||||
expect(compactHarness.calls).toHaveLength(1);
|
||||
expect(onClearRequested).toHaveBeenCalledTimes(1);
|
||||
const backendModule = await import('./utils/opencodeBackend');
|
||||
(backendModule.createOpencodeBackend as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
});
|
||||
|
||||
it('processes a queued /compact operation only after an earlier queued prompt has finished', async () => {
|
||||
let resolvePrompt: (() => void) | null = null;
|
||||
harness.promptImpl = () => new Promise<void>((resolve) => {
|
||||
@@ -533,7 +650,11 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
setSessionInfoUpdateListener: vi.fn(),
|
||||
refreshSessionInfo: vi.fn(async () => {}),
|
||||
onPermissionRequest: vi.fn(),
|
||||
disconnect: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(async () => {
|
||||
if (harness.disconnectImpl) {
|
||||
await harness.disconnectImpl();
|
||||
}
|
||||
}),
|
||||
getSessionModelsMetadata: vi.fn(() => ({
|
||||
currentModelId: 'ollama/qwen3.6:35b-a3b-q8_0-mtp',
|
||||
availableModels: []
|
||||
@@ -1600,7 +1721,11 @@ describe('opencodeRemoteLauncher inline model switch', () => {
|
||||
setSessionInfoUpdateListener: vi.fn(),
|
||||
refreshSessionInfo: vi.fn(async () => {}),
|
||||
onPermissionRequest: vi.fn(),
|
||||
disconnect: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(async () => {
|
||||
if (harness.disconnectImpl) {
|
||||
await harness.disconnectImpl();
|
||||
}
|
||||
}),
|
||||
getSessionModelsMetadata: vi.fn((sessionId: string) => {
|
||||
if (sessionId === 'acp-session-1') {
|
||||
return { availableModels: fixtureModels, currentModelId: 'ollama/exaone:4.5-33b-q8' };
|
||||
|
||||
@@ -32,6 +32,12 @@ type OpencodeRemoteLauncherOptions = {
|
||||
// call (and summary lookup) settles, so a cancelled request's result
|
||||
// doesn't surface for an action the user no longer expects a reply from.
|
||||
isLocalIdCancelled?: (localId: string) => boolean;
|
||||
// Called only after /clear reaches its FIFO position *and* this
|
||||
// launcher has disconnected its OpenCode backend. The caller then performs
|
||||
// the source lifecycle cleanup before requesting the fresh process.
|
||||
onClearRequested?: () => Promise<void>;
|
||||
onClearCleanupComplete?: () => Promise<void>;
|
||||
onClearCleanupFailed?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type AbortStatusDecision = {
|
||||
@@ -77,6 +83,10 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
private baseUrl: string | null = null;
|
||||
private permissionHandler: OpencodePermissionHandler | null = null;
|
||||
private happyServer: { stop: () => void } | null = null;
|
||||
// Becomes true when the FIFO loop reaches /clear. Its callback is deferred
|
||||
// until cleanup() completes so a failed OpenCode disconnect cannot create a
|
||||
// replacement while the source backend may still be live.
|
||||
private clearRequested = false;
|
||||
private abortController = new AbortController();
|
||||
// Set by the dequeue loop as soon as a batch is identified as a
|
||||
// `operation:'compact'` one — deliberately *before* that batch's inline
|
||||
@@ -303,6 +313,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
break;
|
||||
}
|
||||
|
||||
// /clear is deliberately a queue operation rather than a direct
|
||||
// slash side effect: every prompt and /compact ahead of it has
|
||||
// completed before this point. In particular, do not route this
|
||||
// through handleAbort(true): that method exists to interrupt an
|
||||
// in-flight compact, while clear can only run after one finishes.
|
||||
if (batch.mode.operation === 'clear') {
|
||||
await this.options.onClearRequested?.();
|
||||
this.clearRequested = true;
|
||||
await this.requestExit('exit', async () => {})
|
||||
break;
|
||||
}
|
||||
|
||||
// Created here — before the model/effort switch below — rather
|
||||
// than inside runCompactOperation(), so it already exists for
|
||||
// handleAbort() to act on during that switch. backend.setModel()/
|
||||
@@ -601,21 +623,45 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
|
||||
protected async cleanup(): Promise<void> {
|
||||
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
|
||||
|
||||
const failures: unknown[] = [];
|
||||
if (this.permissionHandler) {
|
||||
await this.permissionHandler.cancelAll('Session ended');
|
||||
this.permissionHandler = null;
|
||||
try {
|
||||
await this.permissionHandler.cancelAll('Session ended');
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
this.permissionHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.backend) {
|
||||
await this.backend.disconnect();
|
||||
this.backend = null;
|
||||
try {
|
||||
await this.backend.disconnect();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
this.backend = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.happyServer) {
|
||||
this.happyServer.stop();
|
||||
this.happyServer = null;
|
||||
try {
|
||||
this.happyServer.stop();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
this.happyServer = null;
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
if (this.clearRequested) await this.options.onClearCleanupFailed?.();
|
||||
throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'OpenCode cleanup failed');
|
||||
}
|
||||
if (this.clearRequested) await this.options.onClearCleanupComplete?.();
|
||||
|
||||
// Signal the runner only after the native backend is gone. If an
|
||||
// awaited teardown above fails, RemoteLauncherBase propagates that
|
||||
// failure and this callback never runs; runOpencode then archives the
|
||||
// source as an error rather than spawning a potentially concurrent
|
||||
// replacement.
|
||||
}
|
||||
|
||||
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildCliArgs } from '@/runner/run';
|
||||
import { parseRemoteAgentCommandOptions } from '@/commands/agentCommandOptions';
|
||||
import { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes';
|
||||
|
||||
const mockOpencodeSession = vi.hoisted(() => ({
|
||||
setModel: vi.fn(),
|
||||
@@ -21,10 +24,18 @@ const mockOpencodeSession = vi.hoisted(() => ({
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
bootstrapArgs: [] as Array<Record<string, unknown>>,
|
||||
bootstrapExistingArgs: [] as Array<Record<string, unknown>>,
|
||||
opencodeLoopArgs: [] as Array<Record<string, unknown>>,
|
||||
opencodeLoopError: null as Error | null,
|
||||
triggerClear: false,
|
||||
triggerCleanupFailure: false,
|
||||
clearOpenCodeSession: vi.fn(async () => 'fresh-session'),
|
||||
reserveOpenCodeClearSession: vi.fn(async () => 'fresh-session'),
|
||||
confirmOpenCodeClearCleanup: vi.fn(async () => 'fresh-session'),
|
||||
abortOpenCodeClearSession: vi.fn(async () => 'source-session'),
|
||||
listSlashCommands: vi.fn(async (..._args: unknown[]) => [] as Array<unknown>),
|
||||
session: {
|
||||
sessionId: 'source-session',
|
||||
onUserMessage: vi.fn(),
|
||||
onCancelQueuedMessage: vi.fn(),
|
||||
sendAgentMessage: vi.fn(),
|
||||
@@ -44,7 +55,14 @@ vi.mock('@/agent/sessionFactory', () => ({
|
||||
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
|
||||
harness.bootstrapArgs.push(options);
|
||||
return {
|
||||
api: {},
|
||||
api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession },
|
||||
session: harness.session
|
||||
};
|
||||
}),
|
||||
bootstrapExistingSession: vi.fn(async (options: Record<string, unknown>) => {
|
||||
harness.bootstrapExistingArgs.push(options);
|
||||
return {
|
||||
api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession },
|
||||
session: harness.session
|
||||
};
|
||||
})
|
||||
@@ -60,6 +78,17 @@ vi.mock('./loop', () => ({
|
||||
if (onSessionReady) {
|
||||
onSessionReady(mockOpencodeSession);
|
||||
}
|
||||
if (harness.triggerClear) {
|
||||
const onClearRequested = options.onClearRequested as (() => void) | undefined;
|
||||
await onClearRequested?.();
|
||||
const onClearCleanupComplete = options.onClearCleanupComplete as (() => Promise<void>) | undefined;
|
||||
await onClearCleanupComplete?.();
|
||||
}
|
||||
if (harness.triggerCleanupFailure) {
|
||||
await (options.onClearRequested as (() => Promise<void>))();
|
||||
await (options.onClearCleanupFailed as (() => Promise<void>))();
|
||||
throw new Error('disconnect failed');
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -69,6 +98,8 @@ vi.mock('@/claude/registerKillSessionHandler', () => ({
|
||||
|
||||
const lifecycleMock = vi.hoisted(() => ({
|
||||
registerProcessHandlers: vi.fn(),
|
||||
cleanup: vi.fn(async () => {}),
|
||||
cleanupConfirmed: vi.fn(async () => {}),
|
||||
cleanupAndExit: vi.fn(async () => {}),
|
||||
markCrash: vi.fn(),
|
||||
setExitCode: vi.fn(),
|
||||
@@ -109,8 +140,17 @@ import { runOpencode } from './runOpencode';
|
||||
describe('runOpencode set-session-config handler', () => {
|
||||
beforeEach(() => {
|
||||
harness.bootstrapArgs.length = 0;
|
||||
harness.bootstrapExistingArgs.length = 0;
|
||||
harness.opencodeLoopArgs.length = 0;
|
||||
harness.opencodeLoopError = null;
|
||||
harness.triggerClear = false;
|
||||
harness.triggerCleanupFailure = false;
|
||||
harness.clearOpenCodeSession.mockReset();
|
||||
harness.clearOpenCodeSession.mockResolvedValue('fresh-session');
|
||||
harness.reserveOpenCodeClearSession.mockReset();
|
||||
harness.reserveOpenCodeClearSession.mockResolvedValue('fresh-session');
|
||||
harness.abortOpenCodeClearSession.mockReset();
|
||||
harness.abortOpenCodeClearSession.mockResolvedValue('source-session');
|
||||
mockOpencodeSession.setModel.mockReset();
|
||||
mockOpencodeSession.setPermissionMode.mockReset();
|
||||
mockOpencodeSession.setModelReasoningEffort.mockReset();
|
||||
@@ -127,6 +167,9 @@ describe('runOpencode set-session-config handler', () => {
|
||||
harness.listSlashCommands.mockReset();
|
||||
harness.listSlashCommands.mockResolvedValue([]);
|
||||
lifecycleMock.registerProcessHandlers.mockClear();
|
||||
lifecycleMock.cleanup.mockClear();
|
||||
lifecycleMock.cleanupConfirmed.mockReset();
|
||||
lifecycleMock.cleanupConfirmed.mockResolvedValue(undefined);
|
||||
lifecycleMock.cleanupAndExit.mockClear();
|
||||
lifecycleMock.markCrash.mockClear();
|
||||
lifecycleMock.setExitCode.mockClear();
|
||||
@@ -143,6 +186,24 @@ describe('runOpencode set-session-config handler', () => {
|
||||
return configHandler![1] as (payload: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
it('carries the runner preallocated id from CLI args through parse into bootstrapExistingSession', async () => {
|
||||
const runnerArgs = buildCliArgs('opencode', {
|
||||
directory: '/tmp/project',
|
||||
existingSessionId: 'preallocated-hapi-id'
|
||||
});
|
||||
const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES);
|
||||
|
||||
await runOpencode({ ...parsed, workingDirectory: '/tmp/project' });
|
||||
|
||||
expect(harness.bootstrapArgs).toEqual([]);
|
||||
expect(harness.bootstrapExistingArgs).toEqual([{
|
||||
sessionId: 'preallocated-hapi-id',
|
||||
flavor: 'opencode',
|
||||
startedBy: 'runner',
|
||||
workingDirectory: '/tmp/project'
|
||||
}]);
|
||||
});
|
||||
|
||||
it('rejects plan mode for local OpenCode startup', async () => {
|
||||
await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow(
|
||||
'OpenCode plan mode is only supported in remote mode'
|
||||
@@ -331,6 +392,251 @@ describe('runOpencode set-session-config handler', () => {
|
||||
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues runner-backed /clear as its own FIFO operation without acknowledging it early', async () => {
|
||||
await runOpencode({ startedBy: 'runner' });
|
||||
|
||||
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as
|
||||
{ queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> };
|
||||
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
||||
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
|
||||
| undefined;
|
||||
|
||||
userMessageHandler!({ content: { text: '/clear' } }, 'local-clear');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(messageQueue.queue).toEqual([
|
||||
{
|
||||
message: '',
|
||||
mode: expect.objectContaining({ operation: 'clear' }),
|
||||
modeHash: expect.any(String),
|
||||
localId: 'local-clear',
|
||||
isolate: true
|
||||
}
|
||||
]);
|
||||
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a prompt uninvoked when it arrives during the clear latch so scheduled rows can transfer', async () => {
|
||||
let resolveCommands: ((commands: Array<unknown>) => void) | undefined;
|
||||
harness.listSlashCommands.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveCommands = resolve;
|
||||
}));
|
||||
await runOpencode({ startedBy: 'runner' });
|
||||
|
||||
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as
|
||||
{ queue: Array<{ mode: { operation?: string }; localId?: string }> };
|
||||
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
||||
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void);
|
||||
|
||||
userMessageHandler({ content: { text: '/clear' } }, 'first-clear');
|
||||
userMessageHandler({ content: { text: 'must not reach the source session' } }, 'follow-up');
|
||||
userMessageHandler({ content: { text: 'redelivered scheduled prompt' } }, 'follow-up');
|
||||
await Promise.resolve();
|
||||
resolveCommands?.([]);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(messageQueue.queue).toEqual([expect.objectContaining({
|
||||
mode: expect.objectContaining({ operation: 'clear' }),
|
||||
localId: 'first-clear'
|
||||
})]);
|
||||
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith(
|
||||
['follow-up'],
|
||||
expect.anything()
|
||||
);
|
||||
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases the clear transition latch when the queued /clear is cancelled', async () => {
|
||||
await runOpencode({ startedBy: 'runner' });
|
||||
|
||||
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as
|
||||
{ queue: Array<{ message: string; mode: { operation?: string }; localId?: string }> };
|
||||
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
||||
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void);
|
||||
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as
|
||||
((localId: string) => boolean);
|
||||
|
||||
userMessageHandler({ content: { text: '/clear' } }, 'queued-clear');
|
||||
userMessageHandler({ content: { text: 'rejected while clear is queued' } }, 'rejected-before-cancel');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
expect(cancelHandler('queued-clear')).toBe(true);
|
||||
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith(
|
||||
['rejected-before-cancel'], expect.anything()
|
||||
);
|
||||
|
||||
userMessageHandler({ content: { text: 'continue in the source session' } }, 'after-cancel');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(messageQueue.queue).toEqual([
|
||||
expect.objectContaining({ message: 'rejected while clear is queued', localId: 'rejected-before-cancel' }),
|
||||
expect.objectContaining({ message: 'continue in the source session', localId: 'after-cancel' })
|
||||
]);
|
||||
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes an individually cancelled prompt held behind queued clear', async () => {
|
||||
await runOpencode({ startedBy: 'runner' });
|
||||
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ localId?: string }> };
|
||||
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
||||
((msg: { content: { text: string } }, localId?: string) => void);
|
||||
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean);
|
||||
userMessageHandler({ content: { text: '/clear' } }, 'queued-clear');
|
||||
userMessageHandler({ content: { text: 'keep me' } }, 'held-keep');
|
||||
userMessageHandler({ content: { text: 'cancel me' } }, 'held-cancel');
|
||||
for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(cancelHandler('held-cancel')).toBe(true);
|
||||
expect(cancelHandler('queued-clear')).toBe(true);
|
||||
expect(messageQueue.queue.map((item) => item.localId)).toEqual(['held-keep']);
|
||||
});
|
||||
|
||||
it('archives the source before asking the hub to spawn the fresh OpenCode process', async () => {
|
||||
harness.triggerClear = true;
|
||||
const order: string[] = [];
|
||||
lifecycleMock.cleanupConfirmed.mockImplementationOnce(async () => { order.push('cleanup'); });
|
||||
harness.reserveOpenCodeClearSession.mockImplementationOnce(async () => { order.push('reserve'); return 'fresh-session'; });
|
||||
harness.clearOpenCodeSession.mockImplementationOnce(async () => {
|
||||
order.push('spawn');
|
||||
return 'fresh-session';
|
||||
});
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
|
||||
try {
|
||||
await runOpencode({ startedBy: 'runner' });
|
||||
} finally {
|
||||
exit.mockRestore();
|
||||
}
|
||||
|
||||
expect(lifecycleMock.setArchiveReason).toHaveBeenCalledWith('Cleared by /clear');
|
||||
expect(lifecycleMock.setSessionEndReason).toHaveBeenCalledWith('cleared');
|
||||
expect(harness.clearOpenCodeSession).toHaveBeenCalledWith('source-session');
|
||||
expect(harness.confirmOpenCodeClearCleanup).toHaveBeenCalledWith('source-session', 'fresh-session');
|
||||
expect(order).toEqual(['reserve', 'cleanup', 'spawn']);
|
||||
});
|
||||
|
||||
it('keeps archive-confirmation ownership beyond the old finite budget', async () => {
|
||||
vi.useFakeTimers();
|
||||
harness.triggerClear = true;
|
||||
const timeout = Object.assign(new Error('archive acknowledgement timed out'), { code: 'ETIMEDOUT' });
|
||||
for (let i = 0; i < 13; i++) {
|
||||
lifecycleMock.cleanupConfirmed.mockRejectedValueOnce(timeout);
|
||||
}
|
||||
lifecycleMock.cleanupConfirmed.mockResolvedValueOnce(undefined);
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
|
||||
try {
|
||||
const run = runOpencode({ startedBy: 'runner' });
|
||||
await vi.runAllTimersAsync();
|
||||
await run;
|
||||
expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(14);
|
||||
expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
exit.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries a lost durable-reservation response before native teardown', async () => {
|
||||
vi.useFakeTimers();
|
||||
harness.triggerClear = true;
|
||||
const transient = Object.assign(new Error('connection reset after commit'), { code: 'ECONNRESET' });
|
||||
harness.reserveOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('fresh-session');
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
try {
|
||||
const run = runOpencode({ startedBy: 'runner' });
|
||||
await vi.runAllTimersAsync();
|
||||
await run;
|
||||
expect(harness.reserveOpenCodeClearSession).toHaveBeenCalledTimes(2);
|
||||
expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
exit.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a fresh-session handoff failure instead of exiting cleanly after archival', async () => {
|
||||
harness.triggerClear = true;
|
||||
harness.clearOpenCodeSession.mockRejectedValueOnce(new Error('replacement link failed'));
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
|
||||
try {
|
||||
await expect(runOpencode({ startedBy: 'runner' })).rejects.toThrow('replacement link failed');
|
||||
} finally {
|
||||
exit.mockRestore();
|
||||
}
|
||||
|
||||
expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalled();
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a transient abort notification before releasing cleanup-failure ownership', async () => {
|
||||
vi.useFakeTimers();
|
||||
harness.triggerCleanupFailure = true;
|
||||
const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' });
|
||||
harness.abortOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('source-session');
|
||||
try {
|
||||
const run = runOpencode({ startedBy: 'runner' });
|
||||
await vi.runAllTimersAsync();
|
||||
await run;
|
||||
expect(harness.abortOpenCodeClearSession).toHaveBeenCalledTimes(2);
|
||||
expect(harness.abortOpenCodeClearSession).toHaveBeenLastCalledWith('source-session', 'fresh-session');
|
||||
expect(harness.clearOpenCodeSession).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps retry ownership beyond the old finite budget until the archived-source handoff succeeds', async () => {
|
||||
vi.useFakeTimers();
|
||||
harness.triggerClear = true;
|
||||
const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' });
|
||||
harness.clearOpenCodeSession
|
||||
.mockRejectedValueOnce(transient)
|
||||
.mockRejectedValueOnce(transient)
|
||||
.mockRejectedValueOnce(transient)
|
||||
.mockRejectedValueOnce(transient)
|
||||
.mockRejectedValueOnce(transient)
|
||||
.mockRejectedValueOnce(transient)
|
||||
.mockResolvedValueOnce('fresh-session');
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
|
||||
try {
|
||||
const run = runOpencode({ startedBy: 'runner' });
|
||||
await vi.runAllTimersAsync();
|
||||
await run;
|
||||
expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(7);
|
||||
expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(1, 'source-session');
|
||||
expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(7, 'source-session');
|
||||
expect(exit).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
exit.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps terminal-backed /clear explicit rather than archiving a session with no runner machine', async () => {
|
||||
await runOpencode({ startedBy: 'terminal' });
|
||||
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
||||
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
|
||||
| undefined;
|
||||
|
||||
userMessageHandler!({ content: { text: '/clear' } }, 'local-terminal-clear');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(harness.session.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: '/clear is available only for runner-backed OpenCode sessions.'
|
||||
}));
|
||||
});
|
||||
|
||||
it('queues /compact like a prompt while a remote-mode session is still initializing (ACP backend not ready yet), instead of rejecting it as not-yet-supported', async () => {
|
||||
// Reproduces a hostile-review finding: compactSupported alone
|
||||
// conflates "genuinely local mode" with "remote mode, but ACP
|
||||
|
||||
@@ -16,6 +16,8 @@ import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
import { listSlashCommands } from '@/modules/common/slashCommands';
|
||||
import { resolveOpencodeSlashCommand } from './utils/slashCommands';
|
||||
import { isRetryableConnectionError } from '@/utils/errorUtils';
|
||||
import { withRetry } from '@/utils/time';
|
||||
|
||||
export async function runOpencode(opts: {
|
||||
startedBy?: 'runner' | 'terminal';
|
||||
@@ -108,6 +110,14 @@ export async function runOpencode(opts: {
|
||||
// onLeavingRemote exists to protect, since `mode` stays 'remote'
|
||||
// throughout it.
|
||||
let compactSupported = false;
|
||||
let clearRequested = false;
|
||||
let clearReplacementSessionId: string | null = null;
|
||||
// Once a runner-backed /clear is accepted, hold later payloads until the
|
||||
// transition commits or the queued clear is cancelled. The hub redirects
|
||||
// their durable rows to the reserved replacement on success.
|
||||
let clearTransitionLatched = false;
|
||||
let queuedClearLocalId: string | null = null;
|
||||
const heldDuringClear: Array<{ message: Parameters<Parameters<typeof session.onUserMessage>[0]>[0]; localId?: string }> = [];
|
||||
// True from the moment onCompactAvailabilityChange(false) fires (which,
|
||||
// per onLeavingRemote's contract, only ever happens because remote mode
|
||||
// is being left — never because remote just started) until this session
|
||||
@@ -236,6 +246,11 @@ export async function runOpencode(opts: {
|
||||
};
|
||||
try {
|
||||
if (wasCancelled()) return;
|
||||
if (clearTransitionLatched) {
|
||||
heldDuringClear.push({ message, localId });
|
||||
sessionWrapperRef.current?.onThinkingChange(false);
|
||||
return;
|
||||
}
|
||||
let text = message.content.text;
|
||||
const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []);
|
||||
if (wasCancelled()) return;
|
||||
@@ -246,6 +261,30 @@ export async function runOpencode(opts: {
|
||||
modelReasoningEffort: sessionModelReasoningEffort
|
||||
});
|
||||
|
||||
if (slash.kind === 'clear') {
|
||||
if (startedBy !== 'runner') {
|
||||
if (localId) {
|
||||
session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true });
|
||||
}
|
||||
session.sendAgentMessage({
|
||||
type: 'message',
|
||||
message: '/clear is available only for runner-backed OpenCode sessions.',
|
||||
id: randomUUID()
|
||||
});
|
||||
sessionWrapperRef.current?.onThinkingChange(false);
|
||||
return;
|
||||
}
|
||||
// Latch before enqueueing. userMessageChain serializes later
|
||||
// messages behind this resolver, including the async
|
||||
// listSlashCommands race, so they take the rejection path.
|
||||
clearTransitionLatched = true;
|
||||
queuedClearLocalId = localId ?? null;
|
||||
// A clear is isolated but retains its FIFO position:
|
||||
// older prompts and native /compact work finish first.
|
||||
messageQueue.pushIsolated('', { ...buildMode(), operation: 'clear' }, localId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slash.kind === 'compact') {
|
||||
// `compactSupported` alone conflates two different
|
||||
// situations: a genuinely local-mode session (compact
|
||||
@@ -399,6 +438,19 @@ export async function runOpencode(opts: {
|
||||
session.onCancelQueuedMessage((localId) => {
|
||||
const removedFromQueue = messageQueue.cancelByLocalId(localId);
|
||||
if (removedFromQueue) {
|
||||
if (queuedClearLocalId === localId) {
|
||||
queuedClearLocalId = null;
|
||||
clearTransitionLatched = false;
|
||||
for (const held of heldDuringClear) {
|
||||
const formattedText = formatMessageWithAttachments(held.message.content.text, held.message.content.attachments);
|
||||
messageQueue.push(formattedText, {
|
||||
permissionMode: currentPermissionMode,
|
||||
model: sessionModel,
|
||||
modelReasoningEffort: sessionModelReasoningEffort
|
||||
}, held.localId);
|
||||
}
|
||||
heldDuringClear.length = 0;
|
||||
}
|
||||
logger.debug(`[opencode] cancelByLocalId(${localId}): removed from queue`);
|
||||
return true;
|
||||
}
|
||||
@@ -407,6 +459,11 @@ export async function runOpencode(opts: {
|
||||
logger.debug(`[opencode] cancelByLocalId(${localId}): marked for cancellation before enqueue`);
|
||||
return true;
|
||||
}
|
||||
const heldIndex = heldDuringClear.findIndex((held) => held.localId === localId);
|
||||
if (heldIndex >= 0) {
|
||||
heldDuringClear.splice(heldIndex, 1);
|
||||
return true;
|
||||
}
|
||||
// Not in the queue and not in the pre-enqueue preparing window. As
|
||||
// explained where `cancelledDequeuedLocalIds` is declared above, the
|
||||
// hub only calls this at all while its own row is still queued, so
|
||||
@@ -491,6 +548,24 @@ export async function runOpencode(opts: {
|
||||
compactTeardownInProgress = true;
|
||||
}
|
||||
},
|
||||
onClearRequested: async () => {
|
||||
clearReplacementSessionId = await withRetry(() => api.reserveOpenCodeClearSession(session.sessionId), {
|
||||
minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError
|
||||
});
|
||||
},
|
||||
onClearCleanupComplete: async () => {
|
||||
if (!clearReplacementSessionId) throw new Error('OpenCode clear cleanup completed without a reservation')
|
||||
await withRetry(() => api.confirmOpenCodeClearCleanup(session.sessionId, clearReplacementSessionId!), {
|
||||
minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError
|
||||
});
|
||||
clearRequested = true;
|
||||
},
|
||||
onClearCleanupFailed: async () => {
|
||||
if (!clearReplacementSessionId) throw new Error('OpenCode clear cleanup failed without a reservation')
|
||||
await withRetry(() => api.abortOpenCodeClearSession(session.sessionId, clearReplacementSessionId!), {
|
||||
minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError
|
||||
});
|
||||
},
|
||||
isLocalIdCancelled: (localId) => cancelledDequeuedLocalIds.delete(localId)
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -499,13 +574,55 @@ export async function runOpencode(opts: {
|
||||
logger.debug('[opencode] Loop error:', error);
|
||||
} finally {
|
||||
const localFailure = sessionWrapperRef.current?.localLaunchFailure;
|
||||
if (localFailure?.exitReason === 'exit') {
|
||||
if (clearRequested) {
|
||||
lifecycle.setArchiveReason('Cleared by /clear');
|
||||
lifecycle.setSessionEndReason('cleared');
|
||||
} else if (localFailure?.exitReason === 'exit') {
|
||||
lifecycle.setExitCode(1);
|
||||
lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`);
|
||||
lifecycle.setSessionEndReason('error');
|
||||
} else if (!crashed) {
|
||||
lifecycle.setSessionEndReason('completed');
|
||||
}
|
||||
await lifecycle.cleanupAndExit();
|
||||
if (!clearRequested) {
|
||||
await lifecycle.cleanupAndExit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the source socket open until the hub acknowledges the ordered
|
||||
// archive/session-end boundary. A transient disconnect must not turn
|
||||
// the following clear request into a non-retryable active-source 409.
|
||||
await withRetry(
|
||||
() => lifecycle.cleanupConfirmed({ timeoutMs: 5_000 }),
|
||||
{
|
||||
minDelay: 500,
|
||||
maxDelay: 30_000,
|
||||
shouldRetry: isRetryableConnectionError,
|
||||
onRetry: (error, attempt, nextDelayMs) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`[opencode] Session archive confirmation failed (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
try {
|
||||
await withRetry(
|
||||
() => api.clearOpenCodeSession(session.sessionId),
|
||||
{
|
||||
minDelay: 500,
|
||||
maxDelay: 30_000,
|
||||
shouldRetry: isRetryableConnectionError,
|
||||
onRetry: (error, attempt, nextDelayMs) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`[opencode] Fresh-session clear handoff failed (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Only non-retryable failures reach here. Retryable transport and
|
||||
// hub failures retain ownership in the loop above until recovery.
|
||||
logger.debug('[opencode] Fresh-session clear spawn failed', error);
|
||||
throw error;
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface OpencodeMode {
|
||||
// calling `backend.prompt()`, which keeps /compact from "cutting in
|
||||
// line" ahead of prompts that were already queued when it arrived.
|
||||
// `undefined` for normal prompts.
|
||||
operation?: 'compact';
|
||||
operation?: 'compact' | 'clear';
|
||||
}
|
||||
|
||||
export type OpencodeHookEvent = {
|
||||
|
||||
@@ -119,11 +119,8 @@ describe('resolveOpencodeSlashCommand', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a not-yet-supported message for /clear', () => {
|
||||
expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({
|
||||
kind: 'handled',
|
||||
message: '/clear is not yet supported in HAPI OpenCode sessions.'
|
||||
});
|
||||
it('resolves builtin /clear to the dedicated fresh-session operation', () => {
|
||||
expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({ kind: 'clear' });
|
||||
});
|
||||
|
||||
it('resolves /compact to a dedicated kind so the launcher can bridge to native compaction asynchronously', () => {
|
||||
@@ -168,7 +165,7 @@ describe('resolveOpencodeSlashCommand', () => {
|
||||
expect(help.message).toContain('/plan');
|
||||
expect(help.message).toContain('/permissions');
|
||||
expect(help.message).toContain('/compact` — compact (summarize) the OpenCode session context (remote sessions only)');
|
||||
expect(help.message).toContain('/clear` is not yet supported');
|
||||
expect(help.message).toContain('/clear` — archive this HAPI session and open a fresh OpenCode session');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export type OpencodeSlashResolution =
|
||||
// synchronous 'handled' shape below. The launcher (runOpencode.ts)
|
||||
// intercepts this kind and drives that flow itself.
|
||||
| { kind: 'compact' }
|
||||
// /clear exits the current runner-backed HAPI process after its FIFO
|
||||
// predecessors finish, then asks the hub to spawn a fresh OpenCode one.
|
||||
| { kind: 'clear' }
|
||||
| {
|
||||
kind: 'handled';
|
||||
message: string;
|
||||
@@ -174,10 +177,7 @@ export function resolveOpencodeSlashCommand(
|
||||
}
|
||||
|
||||
if (command === 'clear') {
|
||||
return {
|
||||
kind: 'handled',
|
||||
message: `/${command} is not yet supported in HAPI OpenCode sessions.`
|
||||
};
|
||||
return { kind: 'clear' };
|
||||
}
|
||||
|
||||
if (command === 'init') {
|
||||
@@ -204,12 +204,11 @@ export function resolveOpencodeSlashCommand(
|
||||
'- `/default` — return to default permission mode',
|
||||
'- `/init [extra]` — generate or refresh AGENTS.md for this project',
|
||||
'- `/compact` — compact (summarize) the OpenCode session context (remote sessions only)',
|
||||
'- `/clear` — archive this HAPI session and open a fresh OpenCode session',
|
||||
'',
|
||||
'Model, reasoning effort, and permission mode have dedicated buttons in the composer. ' +
|
||||
'You can still type `/model`, `/reasoning`, or `/permissions` if you prefer.',
|
||||
'',
|
||||
'`/clear` is not yet supported in HAPI OpenCode sessions.',
|
||||
'',
|
||||
'Custom commands from `~/.config/opencode/command` or `.opencode/command` are expanded before sending.'
|
||||
].join('\n')
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user