Session directory resolution had no precedence contract: the selection-time directory short-circuited every lookup, and a persisted runtime value was consulted before the authoritative record. A worktree session selected before its directory store bootstrapped kept the active-directory fallback, and that guess was persisted, so it survived reloads and restarts. Directory resolution now lives in one module and orders sources by whether the server confirmed the path, not by whether the value is local or synced: authoritative (the child store that holds the session) > server-confirmed selection > worktree attachment/metadata (the requested path, pre-canonical) > remembered. A guessed selection is no longer persisted, remembered, or ranked. Chips read the same resolution the composer used, so queue keys cannot diverge. Queued auto-send could strand an item indefinitely: backoff, missing send configuration, and the recent-abort window all returned without scheduling a wake-up, so the queue only retried when an unrelated status or directory change re-ran the effect. A retry scheduler now wakes it at the earliest known time. A rejected send rolls the optimistic message back while the composer stays silent for transport failures, which makes it indistinguishable from nothing happening. Failures are now recorded to a bounded in-memory log surfaced in the About diagnostics report, alongside a directory-resolution breakdown, plus __opencodeDebug.diagnoseSessionDirectory() and getRecentSendFailures(). Prompted by a report of worktree prompting silently failing. That failure was not reproduced locally, so the diagnostics are what will identify it.
241 lines
7.0 KiB
TypeScript
241 lines
7.0 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
import type { Agent } from '@opencode-ai/sdk/v2';
|
|
import type { QueuedMessage } from '../stores/messageQueueStore';
|
|
|
|
let visibleAgents: Agent[] = [];
|
|
const sendMessageCalls: unknown[][] = [];
|
|
|
|
const getVisibleAgentsMock = mock(() => visibleAgents);
|
|
|
|
mock.module('@/stores/useConfigStore', () => ({
|
|
useConfigStore: {
|
|
getState: () => ({
|
|
getVisibleAgents: getVisibleAgentsMock,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
mock.module('@/sync/session-ui-store', () => ({
|
|
useSessionUIStore: {
|
|
getState: () => ({
|
|
sendMessage: (...args: unknown[]) => {
|
|
sendMessageCalls.push(args);
|
|
return Promise.resolve();
|
|
},
|
|
sessionAbortFlags: new Map(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import {
|
|
buildQueuedAutoSendPayload,
|
|
createQueuedAutoSendRetryScheduler,
|
|
getQueuedAutoSendRetryDelayMs,
|
|
isQueuedAutoSendBackedOff,
|
|
sendQueuedAutoSendPayload,
|
|
shouldDispatchQueuedAutoSend,
|
|
} from './useQueuedMessageAutoSend';
|
|
|
|
describe('queued auto-send retry scheduler', () => {
|
|
test('wakes the queue when backoff expires', () => {
|
|
const callbacks = new Map<number, () => void>();
|
|
let nextTimer = 0;
|
|
let wakeups = 0;
|
|
const scheduler = createQueuedAutoSendRetryScheduler(
|
|
() => { wakeups += 1; },
|
|
() => 1_000,
|
|
(callback, delay) => {
|
|
callbacks.set(++nextTimer, callback);
|
|
expect(delay).toBe(500);
|
|
return nextTimer as unknown as ReturnType<typeof setTimeout>;
|
|
},
|
|
(timer) => { callbacks.delete(timer as unknown as number); },
|
|
);
|
|
|
|
scheduler.schedule(1_500);
|
|
expect(callbacks.size).toBe(1);
|
|
callbacks.values().next().value?.();
|
|
expect(wakeups).toBe(1);
|
|
});
|
|
|
|
test('keeps the earliest retry and cancels it on dispose', () => {
|
|
const callbacks = new Map<number, () => void>();
|
|
let nextTimer = 0;
|
|
const delays: number[] = [];
|
|
const scheduler = createQueuedAutoSendRetryScheduler(
|
|
() => undefined,
|
|
() => 1_000,
|
|
(callback, delay) => {
|
|
callbacks.set(++nextTimer, callback);
|
|
delays.push(delay);
|
|
return nextTimer as unknown as ReturnType<typeof setTimeout>;
|
|
},
|
|
(timer) => { callbacks.delete(timer as unknown as number); },
|
|
);
|
|
|
|
scheduler.schedule(3_000);
|
|
scheduler.schedule(4_000);
|
|
scheduler.schedule(2_000);
|
|
|
|
expect(delays).toEqual([2_000, 1_000]);
|
|
expect(callbacks.size).toBe(1);
|
|
scheduler.dispose();
|
|
expect(callbacks.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('shouldDispatchQueuedAutoSend', () => {
|
|
test('dispatches only after an active session becomes idle', () => {
|
|
expect(shouldDispatchQueuedAutoSend('busy', 'idle', false)).toBe(true);
|
|
expect(shouldDispatchQueuedAutoSend('retry', 'idle', false)).toBe(true);
|
|
});
|
|
|
|
test('does not dispatch when idle is only first seen or status is missing', () => {
|
|
expect(shouldDispatchQueuedAutoSend(undefined, 'idle', false)).toBe(false);
|
|
expect(shouldDispatchQueuedAutoSend('idle', 'idle', false)).toBe(false);
|
|
});
|
|
|
|
test('dispatches when idle→idle and queue has items', () => {
|
|
expect(shouldDispatchQueuedAutoSend('idle', 'idle', true)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('queued auto-send retry backoff', () => {
|
|
test('delay grows exponentially and is capped', () => {
|
|
expect(getQueuedAutoSendRetryDelayMs(1)).toBe(2000);
|
|
expect(getQueuedAutoSendRetryDelayMs(2)).toBe(4000);
|
|
expect(getQueuedAutoSendRetryDelayMs(3)).toBe(8000);
|
|
expect(getQueuedAutoSendRetryDelayMs(10)).toBe(60000);
|
|
expect(getQueuedAutoSendRetryDelayMs(100)).toBe(60000);
|
|
});
|
|
|
|
test('backs off only the failed message within its window', () => {
|
|
const failure = { messageId: 'queued-1', failures: 1, nextAttemptAt: 10_000 };
|
|
|
|
expect(isQueuedAutoSendBackedOff(failure, 'queued-1', 9_999)).toBe(true);
|
|
expect(isQueuedAutoSendBackedOff(failure, 'queued-1', 10_000)).toBe(false);
|
|
expect(isQueuedAutoSendBackedOff(failure, 'queued-2', 9_999)).toBe(false);
|
|
expect(isQueuedAutoSendBackedOff(undefined, 'queued-1', 0)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildQueuedAutoSendPayload', () => {
|
|
beforeEach(() => {
|
|
visibleAgents = [];
|
|
sendMessageCalls.length = 0;
|
|
});
|
|
|
|
test('returns only the first queued message for auto-send', () => {
|
|
const queue: QueuedMessage[] = [
|
|
{
|
|
id: 'queued-1',
|
|
content: 'first queued message',
|
|
createdAt: 1,
|
|
},
|
|
{
|
|
id: 'queued-2',
|
|
content: 'second queued message',
|
|
createdAt: 2,
|
|
},
|
|
];
|
|
|
|
const payload = buildQueuedAutoSendPayload(queue);
|
|
|
|
expect(payload).not.toBeNull();
|
|
expect(payload?.queuedMessageId).toBe('queued-1');
|
|
expect(payload?.primaryText).toBe('first queued message');
|
|
expect(payload?.primaryAttachments).toEqual([]);
|
|
});
|
|
|
|
test('uses the configured visible agents when parsing queued mentions', () => {
|
|
visibleAgents = [
|
|
{
|
|
name: 'Builder',
|
|
mode: 'subagent',
|
|
permission: [],
|
|
options: {},
|
|
} as Agent,
|
|
];
|
|
|
|
const queue: QueuedMessage[] = [
|
|
{
|
|
id: 'queued-mention',
|
|
content: '@Builder please take this',
|
|
createdAt: 1,
|
|
},
|
|
];
|
|
|
|
const payload = buildQueuedAutoSendPayload(queue);
|
|
|
|
expect(payload).not.toBeNull();
|
|
expect(payload?.agentMentionName).toBe('Builder');
|
|
expect(payload?.primaryText).toBe('@Builder please take this');
|
|
});
|
|
|
|
test('preserves attachment-only queued messages as sendable payloads', () => {
|
|
const queue: QueuedMessage[] = [
|
|
{
|
|
id: 'queued-attachments',
|
|
content: '',
|
|
createdAt: 1,
|
|
attachments: [
|
|
{
|
|
id: 'file-1',
|
|
filename: 'notes.txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
source: 'local',
|
|
file: new File(['hello'], 'notes.txt', { type: 'text/plain' }),
|
|
dataUrl: 'data:text/plain;base64,aGVsbG8=',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'queued-2',
|
|
content: 'later queued message',
|
|
createdAt: 2,
|
|
},
|
|
];
|
|
|
|
const payload = buildQueuedAutoSendPayload(queue);
|
|
|
|
expect(payload).not.toBeNull();
|
|
expect(payload?.queuedMessageId).toBe('queued-attachments');
|
|
expect(payload?.primaryText).toBe('');
|
|
expect(payload?.primaryAttachments).toHaveLength(1);
|
|
expect(payload?.primaryAttachments[0]?.filename).toBe('notes.txt');
|
|
});
|
|
|
|
test('auto-send targets the queued session explicitly', async () => {
|
|
const payload = buildQueuedAutoSendPayload([
|
|
{
|
|
id: 'queued-1',
|
|
content: 'queued message',
|
|
createdAt: 1,
|
|
},
|
|
]);
|
|
|
|
expect(payload).not.toBeNull();
|
|
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
|
|
providerID: 'provider-1',
|
|
modelID: 'model-1',
|
|
agent: 'agent-1',
|
|
variant: 'variant-1',
|
|
});
|
|
|
|
expect(sendMessageCalls.length).toBe(1);
|
|
expect(sendMessageCalls[0]).toEqual([
|
|
'queued message',
|
|
'provider-1',
|
|
'model-1',
|
|
'agent-1',
|
|
[],
|
|
undefined,
|
|
undefined,
|
|
'variant-1',
|
|
'normal',
|
|
{ sessionId: 'session-original', directory: '/repo' },
|
|
]);
|
|
});
|
|
});
|