mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-09 01:51:39 +00:00
* feat(opencode): support mid-turn steering Register the per-message Queue/Steer RPC on the OpenCode remote launcher so a waiting-queue row can be delivered into the active turn as a concurrent ACP session/prompt (soft inject, no cancel, no mode/model/effort mutation), reusing the existing reservation/delivery-state protocol shared with Codex and Cursor ACP. The steer gate is closed synchronously by every cancelling or terminal path (normal-prompt finally, abort, switch/exit, teardown, stderr stall cancel), each steer operation is tracked from the moment its reservation is taken until completion plus all restore/consumed bookkeeping settles, and the foreground prompt drains those operations — abort-aware through the original turn signal — before ready, thinking=false or the next batch. steerability is published through the existing agentState.steeringActive flag so the web's queued Steer button is reachable. Gate the feature on the shared steering flavors (now including opencode) and update the hub's supported-flavor wording. * fix(opencode): acknowledge native steering acceptance * fix(opencode): preserve steering delivery outcomes * docs(opencode): remove obsolete abort-race comment * fix(opencode): preserve steering resolution ownership * fix(opencode): isolate ambiguous steering attempts * fix(opencode): compare resolved steering settings * fix(opencode): reconcile steering receipts after dispatch * fix(opencode): distinguish pending steering delivery outcomes * fix(opencode): restore receipts after successful native responses Track native input requests separately from detached steer operations. Resume receipt correlation after inputs quarantined by cancellation return their own successful responses, while keeping uncertain writes on completion-based confirmation. * fix(opencode): bound receipt proof to unambiguous native inputs * feat(opencode): steer v2 inputs through native inbox * fix(opencode): preserve v2 restoration and cancellation ownership * docs(opencode): describe v2 inbox steering confirmation * fix(steer): expire Claude confirmation with its owning worker
935 lines
44 KiB
TypeScript
935 lines
44 KiB
TypeScript
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';
|
|
import type { MessageQueue2 } from '@/utils/MessageQueue2';
|
|
import type { OpencodeMode } from './types';
|
|
|
|
const mockOpencodeSession = vi.hoisted(() => ({
|
|
setModel: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
setModelReasoningEffort: vi.fn(),
|
|
pushKeepAlive: vi.fn(),
|
|
thinking: false,
|
|
stopKeepAlive: vi.fn(),
|
|
onThinkingChange: vi.fn(),
|
|
// Mirrors AgentSessionBase's own `mode` field ('local' | 'remote',
|
|
// flipped synchronously by onModeChange before either launcher
|
|
// starts/finishes) — settable per test to simulate a session that
|
|
// started in remote mode and is still initializing (ACP backend not
|
|
// ready yet, so onCompactAvailabilityChange(true) hasn't fired), as
|
|
// opposed to a genuinely local-mode session. This becomes
|
|
// sessionWrapperRef.current in runOpencode.ts via the mocked
|
|
// opencodeLoop's onSessionReady callback below.
|
|
mode: 'local' as 'local' | 'remote'
|
|
}));
|
|
|
|
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(),
|
|
sendSessionEvent: vi.fn(),
|
|
emitMessagesConsumed: vi.fn(),
|
|
// Needed for createModeChangeHandler(session) (real, unmocked) to
|
|
// run without throwing when a test invokes the real onModeChange
|
|
// wrapper passed to opencodeLoop.
|
|
updateAgentState: vi.fn(),
|
|
rpcHandlerManager: {
|
|
registerHandler: vi.fn()
|
|
}
|
|
}
|
|
}));
|
|
|
|
vi.mock('@/agent/sessionFactory', () => ({
|
|
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
|
|
harness.bootstrapArgs.push(options);
|
|
return {
|
|
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
|
|
};
|
|
})
|
|
}));
|
|
|
|
vi.mock('./loop', () => ({
|
|
opencodeLoop: vi.fn(async (options: Record<string, unknown>) => {
|
|
harness.opencodeLoopArgs.push(options);
|
|
if (harness.opencodeLoopError) {
|
|
throw harness.opencodeLoopError;
|
|
}
|
|
const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined;
|
|
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');
|
|
}
|
|
})
|
|
}));
|
|
|
|
vi.mock('@/claude/registerKillSessionHandler', () => ({
|
|
registerKillSessionHandler: vi.fn()
|
|
}));
|
|
|
|
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(),
|
|
setArchiveReason: vi.fn(),
|
|
setSessionEndReason: vi.fn(),
|
|
hasExplicitSessionEndReason: vi.fn(() => false)
|
|
}));
|
|
|
|
vi.mock('@/agent/runnerLifecycle', () => ({
|
|
createModeChangeHandler: vi.fn(() => vi.fn()),
|
|
createRunnerLifecycle: vi.fn(() => lifecycleMock),
|
|
setControlledByUser: vi.fn()
|
|
}));
|
|
|
|
vi.mock('./utils/startOpencodeHookServer', () => ({
|
|
startOpencodeHookServer: vi.fn(async () => ({
|
|
port: 4242,
|
|
stop: vi.fn()
|
|
}))
|
|
}));
|
|
|
|
vi.mock('@/ui/logger', () => ({
|
|
logger: {
|
|
debug: vi.fn()
|
|
}
|
|
}));
|
|
|
|
vi.mock('@/utils/attachmentFormatter', () => ({
|
|
formatMessageWithAttachments: vi.fn((text: string) => text)
|
|
}));
|
|
|
|
vi.mock('@/modules/common/slashCommands', () => ({
|
|
listSlashCommands: (agent: string, projectDir?: string) => harness.listSlashCommands(agent, projectDir)
|
|
}));
|
|
|
|
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();
|
|
mockOpencodeSession.pushKeepAlive.mockReset();
|
|
mockOpencodeSession.onThinkingChange.mockReset();
|
|
mockOpencodeSession.mode = 'local';
|
|
harness.session.onUserMessage.mockReset();
|
|
harness.session.onCancelQueuedMessage.mockReset();
|
|
harness.session.sendAgentMessage.mockReset();
|
|
harness.session.sendSessionEvent.mockReset();
|
|
harness.session.emitMessagesConsumed.mockReset();
|
|
harness.session.updateAgentState.mockReset();
|
|
harness.session.rpcHandlerManager.registerHandler.mockReset();
|
|
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();
|
|
lifecycleMock.setArchiveReason.mockClear();
|
|
lifecycleMock.setSessionEndReason.mockClear();
|
|
});
|
|
|
|
function getConfigHandler(): (payload: unknown) => Promise<unknown> {
|
|
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
|
|
const configHandler = registerCalls.find(
|
|
(call: unknown[]) => call[0] === 'set-session-config'
|
|
);
|
|
expect(configHandler).toBeDefined();
|
|
return configHandler![1] as (payload: unknown) => Promise<unknown>;
|
|
}
|
|
|
|
it('routes reservedSessionId from buildCliArgs into bootstrapSession (adopt, not reopen)', async () => {
|
|
const runnerArgs = buildCliArgs('opencode', {
|
|
directory: '/tmp/project',
|
|
reservedSessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
|
});
|
|
const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES);
|
|
|
|
expect(parsed.reservedSessionId).toBe('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee');
|
|
expect(parsed.existingSessionId).toBeUndefined();
|
|
|
|
await runOpencode({ ...parsed, workingDirectory: '/tmp/project' });
|
|
|
|
expect(harness.bootstrapExistingArgs).toEqual([]);
|
|
expect(harness.bootstrapArgs).toEqual([expect.objectContaining({
|
|
flavor: 'opencode',
|
|
startedBy: 'runner',
|
|
workingDirectory: '/tmp/project',
|
|
reservedSessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
|
})]);
|
|
});
|
|
|
|
it('routes --existing-session-id reopen into bootstrapExistingSession', async () => {
|
|
const runnerArgs = buildCliArgs('opencode', {
|
|
directory: '/tmp/project',
|
|
existingSessionId: 'live-hub-row'
|
|
});
|
|
const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES);
|
|
|
|
await runOpencode({ ...parsed, workingDirectory: '/tmp/project' });
|
|
|
|
expect(harness.bootstrapArgs).toEqual([]);
|
|
expect(harness.bootstrapExistingArgs).toEqual([{
|
|
sessionId: 'live-hub-row',
|
|
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'
|
|
);
|
|
expect(harness.opencodeLoopArgs).toEqual([]);
|
|
});
|
|
|
|
it('allows plan mode for remote OpenCode startup', async () => {
|
|
await runOpencode({ permissionMode: 'plan', startingMode: 'remote' });
|
|
|
|
expect(harness.opencodeLoopArgs[0]?.permissionMode).toBe('plan');
|
|
expect(harness.opencodeLoopArgs[0]?.startingMode).toBe('remote');
|
|
});
|
|
|
|
it('applies model change via set-session-config RPC', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
const result = await handler({ model: 'ollama/exaone:4.5-33b-q8' }) as Record<string, unknown>;
|
|
const applied = result.applied as Record<string, unknown>;
|
|
expect(applied.model).toBe('ollama/exaone:4.5-33b-q8');
|
|
});
|
|
|
|
it('pushes a keepAlive immediately after a config change so the hub UI reflects it', async () => {
|
|
await runOpencode({});
|
|
|
|
// Reset to ignore pushKeepAlive fired from initial onSessionReady setup
|
|
mockOpencodeSession.pushKeepAlive.mockClear();
|
|
|
|
const handler = getConfigHandler();
|
|
await handler({ model: 'ollama/exaone:4.5-33b-q8' });
|
|
|
|
expect(mockOpencodeSession.pushKeepAlive).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('stores the chosen model on the session for keepalive runtime metadata', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
await handler({ model: 'mlx/qwen3:0.6b' });
|
|
|
|
expect(mockOpencodeSession.setModel).toHaveBeenLastCalledWith('mlx/qwen3:0.6b');
|
|
});
|
|
|
|
it('accepts null model (Default) and forwards null to the session', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
const result = await handler({ model: null }) as Record<string, unknown>;
|
|
const applied = result.applied as Record<string, unknown>;
|
|
|
|
expect(applied.model).toBeNull();
|
|
expect(mockOpencodeSession.setModel).toHaveBeenLastCalledWith(null);
|
|
});
|
|
|
|
it('rejects non-string, non-null model values', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
await expect(handler({ model: 123 })).rejects.toThrow();
|
|
await expect(handler({ model: '' })).rejects.toThrow();
|
|
await expect(handler({ model: ' ' })).rejects.toThrow();
|
|
});
|
|
|
|
it('only includes changed fields in applied response', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
const result = await handler({ permissionMode: 'default' }) as Record<string, unknown>;
|
|
const applied = result.applied as Record<string, unknown>;
|
|
expect(applied.permissionMode).toBe('default');
|
|
expect(applied).not.toHaveProperty('model');
|
|
});
|
|
|
|
it('still applies permissionMode-only payloads (no model field)', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
const result = await handler({ permissionMode: 'yolo' }) as Record<string, unknown>;
|
|
const applied = result.applied as Record<string, unknown>;
|
|
expect(applied.permissionMode).toBe('yolo');
|
|
});
|
|
|
|
it('accepts plan mode via set-session-config RPC', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
const result = await handler({ permissionMode: 'plan' }) as Record<string, unknown>;
|
|
const applied = result.applied as Record<string, unknown>;
|
|
|
|
expect(applied.permissionMode).toBe('plan');
|
|
expect(mockOpencodeSession.setPermissionMode).toHaveBeenLastCalledWith('plan');
|
|
});
|
|
|
|
|
|
|
|
it('accepts model reasoning effort via set-session-config RPC', async () => {
|
|
await runOpencode({});
|
|
|
|
const handler = getConfigHandler();
|
|
const result = await handler({ modelReasoningEffort: 'high' }) as Record<string, unknown>;
|
|
const applied = result.applied as Record<string, unknown>;
|
|
|
|
expect(applied.modelReasoningEffort).toBe('high');
|
|
expect(mockOpencodeSession.setModelReasoningEffort).toHaveBeenLastCalledWith('high');
|
|
});
|
|
|
|
it('passes initial model from opts through to the loop', async () => {
|
|
await runOpencode({ model: 'ollama/exaone:4.5-33b-q8' });
|
|
|
|
expect(harness.opencodeLoopArgs[0]?.model).toBe('ollama/exaone:4.5-33b-q8');
|
|
});
|
|
|
|
it('opts in to clearQueuedThinkingGrace when acking a handled slash command', async () => {
|
|
await runOpencode({});
|
|
|
|
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
|
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
|
|
| undefined;
|
|
expect(userMessageHandler).toBeDefined();
|
|
|
|
userMessageHandler!({ content: { text: '/status' } }, 'local-status');
|
|
// Drain microtasks so the chain runs and acks the slash command.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(harness.session.emitMessagesConsumed).toHaveBeenCalledWith(
|
|
['local-status'],
|
|
{ clearQueuedThinkingGrace: true }
|
|
);
|
|
// The slash reply should still have gone out as a separate message.
|
|
expect(harness.session.sendAgentMessage).toHaveBeenCalled();
|
|
});
|
|
|
|
it('queues a /compact request (isolated, with operation:"compact") once compact becomes available', async () => {
|
|
await runOpencode({});
|
|
|
|
const onCompactAvailabilityChange = harness.opencodeLoopArgs[0]?.onCompactAvailabilityChange as
|
|
((available: boolean) => void) | undefined;
|
|
expect(onCompactAvailabilityChange).toBeDefined();
|
|
onCompactAvailabilityChange!(true);
|
|
|
|
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;
|
|
expect(userMessageHandler).toBeDefined();
|
|
|
|
userMessageHandler!({ content: { text: '/compact' } }, 'local-compact');
|
|
// Drain microtasks across the async chain: listSlashCommands -> slash
|
|
// resolve -> messageQueue.pushIsolated(...).
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
// No manual emitMessagesConsumed call here (unlike the synchronous
|
|
// 'handled' branch) — the ack now happens automatically at dequeue
|
|
// time via MessageQueue2's onBatchConsumed (wired in
|
|
// AgentSessionBase's constructor onto session.queue, same as any
|
|
// regular prompt), not synchronously when queuing. A manual call
|
|
// right here used to exist and fire immediately regardless of FIFO
|
|
// position — that's what let the hub mark a still-queued /compact
|
|
// "invoked" before it was actually dequeued, breaking cancellation
|
|
// of it while queued (see the comment on this branch in
|
|
// runOpencode.ts and opencodeRemoteLauncher.test.ts's "cancelling a
|
|
// /compact operation while it is still queued behind another
|
|
// prompt" test for the fix this enables).
|
|
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled();
|
|
// The actual REST call, "Compaction started/completed" status events,
|
|
// and Reasoning-block summary now all happen inside
|
|
// opencodeRemoteLauncher.ts's dequeue loop once this item reaches the
|
|
// front of the queue (covered by opencodeRemoteLauncher.test.ts) —
|
|
// runOpencode.ts's job for a supported /compact is only to queue it
|
|
// in its correct FIFO position, never to run it directly.
|
|
expect(messageQueue.queue).toEqual([
|
|
{
|
|
message: '',
|
|
mode: expect.objectContaining({ operation: 'compact' }),
|
|
modeHash: expect.any(String),
|
|
localId: 'local-compact',
|
|
isolate: true
|
|
}
|
|
]);
|
|
expect(harness.session.sendSessionEvent).not.toHaveBeenCalled();
|
|
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('delegates native v2 cancellation before generic queue removal and clears the delegate on teardown', async () => {
|
|
await runOpencode({ startedBy: 'runner' });
|
|
const bind = harness.opencodeLoopArgs[0].onSteerCancelHandlerChange as
|
|
(handler: import('./utils/opencodeV2Steer').OpencodeSteerCancelHandler | null) => void;
|
|
const cancel = harness.session.onCancelQueuedMessage.mock.calls[0][0] as
|
|
(id: string) => boolean | Promise<unknown>;
|
|
const native = vi.fn((id: string) => id === 'native' ? Promise.resolve('in-flight' as const) : undefined);
|
|
bind(native);
|
|
await expect(cancel('native')).resolves.toBe('in-flight');
|
|
expect(native).toHaveBeenCalledWith('native');
|
|
bind(null);
|
|
expect(cancel('missing')).toBe(false);
|
|
});
|
|
|
|
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
|
|
// initialize + session load/new hasn't finished yet" — a regular
|
|
// prompt sent in that exact same startup window queues normally and
|
|
// just waits, but /compact used to get an immediate not-yet-supported
|
|
// reply instead, even though the session is (or is about to be) in
|
|
// remote mode. sessionWrapperRef.current?.mode (mocked here via
|
|
// mockOpencodeSession.mode, delivered through onSessionReady) is what
|
|
// now distinguishes the two — deliberately never call
|
|
// onCompactAvailabilityChange(true) here, since the whole point is
|
|
// that this must queue even while it's still false.
|
|
mockOpencodeSession.mode = 'remote';
|
|
await runOpencode({});
|
|
|
|
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;
|
|
expect(userMessageHandler).toBeDefined();
|
|
|
|
userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-pending');
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
// Must not get the not-yet-supported reply.
|
|
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
|
|
// Must be queued exactly like the compactSupported===true case above.
|
|
expect(messageQueue.queue).toEqual([
|
|
{
|
|
message: '',
|
|
mode: expect.objectContaining({ operation: 'compact' }),
|
|
modeHash: expect.any(String),
|
|
localId: 'local-compact-pending',
|
|
isolate: true
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('falls back to a not-yet-supported message for /compact when compact is not available (e.g. local mode)', async () => {
|
|
await runOpencode({});
|
|
// Deliberately do not call onCompactAvailabilityChange(true) — this
|
|
// is the state a local-mode session stays in (loop.ts resets it to
|
|
// false on every local entry and opencodeLocalLauncher never sets it
|
|
// true).
|
|
|
|
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
|
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
|
|
| undefined;
|
|
userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-none');
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
const messages = harness.session.sendAgentMessage.mock.calls.map((call) => (call[0] as { message: string }).message);
|
|
expect(messages).toEqual(['/compact is not yet supported in HAPI OpenCode sessions.']);
|
|
|
|
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: unknown[] };
|
|
expect(messageQueue.queue).toEqual([]);
|
|
});
|
|
|
|
it('stops queuing /compact once availability is reset to false (e.g. a remote->local handoff mid-session)', async () => {
|
|
await runOpencode({});
|
|
|
|
// Faithful to real production timing: session.mode stays 'remote'
|
|
// throughout the whole teardown window (onLeavingRemote firing
|
|
// synchronously as the very first action of requestExit(), long
|
|
// before runMainLoop() actually returns and mode flips back to
|
|
// 'local') — see AgentSessionBase.onModeChange and
|
|
// OpencodeRemoteLauncher.onLeavingRemote's doc comments. A hostile
|
|
// review found that omitting this from the mock let a real
|
|
// regression slip through: the mode-based /compact queuing fix
|
|
// for the *startup* window (mode:'remote' but not yet ready) also
|
|
// accidentally re-opened queuing during *this* teardown window,
|
|
// since both look identical if you only check `mode !== 'remote'`.
|
|
mockOpencodeSession.mode = 'remote';
|
|
|
|
const onCompactAvailabilityChange = harness.opencodeLoopArgs[0]?.onCompactAvailabilityChange as
|
|
((available: boolean) => void) | undefined;
|
|
onCompactAvailabilityChange!(true);
|
|
onCompactAvailabilityChange!(false);
|
|
|
|
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
|
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
|
|
| undefined;
|
|
userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-reset');
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
const messages = harness.session.sendAgentMessage.mock.calls.map((call) => (call[0] as { message: string }).message);
|
|
expect(messages).toEqual(['/compact is not yet supported in HAPI OpenCode sessions.']);
|
|
|
|
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: unknown[] };
|
|
expect(messageQueue.queue).toEqual([]);
|
|
});
|
|
|
|
it('resumes queuing /compact once a torn-down session re-enters remote mode (compactTeardownInProgress resets on the next remote entry)', async () => {
|
|
// Locks in the other half of compactTeardownInProgress's contract:
|
|
// it must not get stuck true forever after one teardown, or every
|
|
// later remote re-entry's startup window (the case the "queues
|
|
// /compact like a prompt while..." test above covers) would
|
|
// incorrectly reject /compact too.
|
|
//
|
|
// Round 2 of the review-cycle that produced this test found the
|
|
// first version didn't actually discriminate the fix: it left
|
|
// `mockOpencodeSession.mode` at 'remote' throughout, so the gate's
|
|
// `mode !== 'remote'` clause was permanently false and the test
|
|
// would have passed identically even if compactTeardownInProgress
|
|
// never reset (or didn't exist at all). Faithfully modeling the
|
|
// real local interlude between the two remote attempts — mode
|
|
// actually flips to 'local' once the first remote launcher's
|
|
// runMainLoop() fully unwinds, per AgentSessionBase.onModeChange —
|
|
// is what makes this test sensitive to the reset specifically.
|
|
mockOpencodeSession.mode = 'remote';
|
|
|
|
await runOpencode({});
|
|
|
|
const onCompactAvailabilityChange = harness.opencodeLoopArgs[0]?.onCompactAvailabilityChange as
|
|
((available: boolean) => void) | undefined;
|
|
const onModeChange = harness.opencodeLoopArgs[0]?.onModeChange as
|
|
((mode: 'local' | 'remote') => void) | undefined;
|
|
expect(onModeChange).toBeDefined();
|
|
|
|
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;
|
|
|
|
// First remote attempt becomes ready, then tears down.
|
|
onCompactAvailabilityChange!(true);
|
|
onCompactAvailabilityChange!(false);
|
|
|
|
// Local interlude — mode genuinely flips to 'local' here in
|
|
// production. Sanity-check /compact is still correctly rejected
|
|
// during it (proves the interlude is real, not cosmetic).
|
|
mockOpencodeSession.mode = 'local';
|
|
userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-interlude');
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
expect(harness.session.sendAgentMessage).toHaveBeenCalledTimes(1);
|
|
expect(messageQueue.queue).toEqual([]);
|
|
|
|
// The next remote attempt begins: mode flips back to 'remote' and
|
|
// onModeChange fires on that exact transition — this is what
|
|
// compactTeardownInProgress's reset actually depends on.
|
|
mockOpencodeSession.mode = 'remote';
|
|
onModeChange!('remote');
|
|
|
|
userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-reentry');
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
// Still only the one not-yet-supported reply from the interlude
|
|
// above — the re-entry /compact must queue, not get a second reply.
|
|
expect(harness.session.sendAgentMessage).toHaveBeenCalledTimes(1);
|
|
expect(messageQueue.queue).toEqual([
|
|
{
|
|
message: '',
|
|
mode: expect.objectContaining({ operation: 'compact' }),
|
|
modeHash: expect.any(String),
|
|
localId: 'local-compact-reentry',
|
|
isolate: true
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('cancels a slash command that is cancelled before listSlashCommands resolves', async () => {
|
|
let releaseListSlashCommands: () => void = () => {};
|
|
const slashCommandsPromise = new Promise<unknown[]>((resolve) => {
|
|
releaseListSlashCommands = () => resolve([]);
|
|
});
|
|
harness.listSlashCommands.mockReset();
|
|
harness.listSlashCommands.mockReturnValue(slashCommandsPromise);
|
|
|
|
await runOpencode({});
|
|
|
|
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
|
|
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
|
|
| undefined;
|
|
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as
|
|
((localId: string) => boolean) | undefined;
|
|
expect(userMessageHandler).toBeDefined();
|
|
expect(cancelHandler).toBeDefined();
|
|
|
|
userMessageHandler!({ content: { text: '/status' } }, 'local-1');
|
|
// Cancel arrives while listSlashCommands is still pending — the queue
|
|
// is empty, so without the preparing-localIds bookkeeping the cancel
|
|
// would return false and the slash reply would still fire when the
|
|
// chain resumes.
|
|
expect(cancelHandler!('local-1')).toBe(true);
|
|
releaseListSlashCommands();
|
|
// Drain microtasks so the chain runs the cancellation short-circuit.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
|
|
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('bounds the unmatched-cancel tracking Set so it cannot grow unboundedly over a long session', async () => {
|
|
await runOpencode({});
|
|
|
|
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as
|
|
((localId: string) => boolean) | undefined;
|
|
const isLocalIdCancelled = harness.opencodeLoopArgs[0]?.isLocalIdCancelled as
|
|
((localId: string) => boolean) | undefined;
|
|
expect(cancelHandler).toBeDefined();
|
|
expect(isLocalIdCancelled).toBeDefined();
|
|
|
|
// None of these localIds are in the queue or in the pre-enqueue
|
|
// preparing window, so every call falls into the fallback branch
|
|
// that records it as a possible dequeued-compact cancel. Simulate
|
|
// far more of these than could ever realistically be in flight at
|
|
// once (see the comment on `cancelledDequeuedLocalIds` in
|
|
// runOpencode.ts for why this branch is only reachable during a
|
|
// brief per-message ack race) to prove the tracking Set evicts its
|
|
// oldest entries instead of growing forever.
|
|
const localIds = Array.from({ length: 200 }, (_, i) => `unmatched-${i}`);
|
|
for (const localId of localIds) {
|
|
cancelHandler!(localId);
|
|
}
|
|
|
|
// The earliest entries must have been evicted...
|
|
expect(isLocalIdCancelled!('unmatched-0')).toBe(false);
|
|
// ...while a recent one is still tracked (delete-and-return: true
|
|
// once, then gone).
|
|
const lastLocalId = localIds[localIds.length - 1]!;
|
|
expect(isLocalIdCancelled!(lastLocalId)).toBe(true);
|
|
expect(isLocalIdCancelled!(lastLocalId)).toBe(false);
|
|
});
|
|
it('preserves dispatch and consumption outcomes through the registered cancel callback', async () => {
|
|
await runOpencode({});
|
|
const queue = harness.opencodeLoopArgs[0]?.messageQueue as MessageQueue2<OpencodeMode>;
|
|
const cancel = harness.session.onCancelQueuedMessage.mock.calls[0][0] as
|
|
(id: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed';
|
|
queue.push('correction', { permissionMode: 'default', model: null }, 'reserved');
|
|
const reservation = queue.takeByLocalId('reserved')!;
|
|
expect(queue.beginReservationDispatch(reservation)).toBe(true);
|
|
expect(cancel('reserved')).toBe('in-flight');
|
|
expect(cancel('reserved')).toBe('in-flight');
|
|
expect(queue.commitReservation(reservation)).toBe(true);
|
|
expect(cancel('reserved')).toBe('consumed');
|
|
vi.spyOn(queue, 'cancelByLocalId').mockReturnValueOnce('indeterminate');
|
|
expect(cancel('unresolved')).toBe('indeterminate');
|
|
queue.push('user-resolvable', { permissionMode: 'default', model: null }, 'unknown');
|
|
const unknown = queue.takeByLocalId('unknown')!;
|
|
queue.beginReservationDispatch(unknown);
|
|
queue.markReservationIndeterminate(unknown);
|
|
expect(cancel('unknown')).toBe(true);
|
|
});
|
|
|
|
});
|