mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(web-shell): wait for session before checking subscription commands * fix(cli): keep signed-out subscription startup in the composer * fix(subscription): preserve model selection across sign-in * fix(subscription): align sign-in with model selection scope * fix(subscription): apply reasoning defaults across models * fix(subscription): tag Qwen web links by client * fix(subscription): prefix Qwen web client tags * fix(subscription): attribute Qwen web links by paywall scene * fix(subscription): recover historical sessions with saved API models * fix(cli): remove the signed-out subscription startup notice * fix(subscription): support standalone desktop activation and expose errors * fix(subscription): isolate activation and session model selection * fix(subscription): enforce auth policy and restore home login * test(cli): preserve the default initializer mock on restore --------- Co-authored-by: luoyun <[email protected]> Co-authored-by: 易良 <[email protected]>
8867 lines
300 KiB
TypeScript
8867 lines
300 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
DaemonClient,
|
|
DaemonHttpError,
|
|
DaemonAttachmentUploadError,
|
|
DaemonPendingPromptLimitError,
|
|
DaemonStandaloneCreationOutcomeUnknownError,
|
|
DaemonTransportClosedError,
|
|
type DaemonCapabilities,
|
|
type DaemonSessionBtwStreamEvent,
|
|
type DaemonContinueSessionResult,
|
|
DaemonSessionClient,
|
|
type DaemonSessionContextStatus,
|
|
type GoalSnapshotV2,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import { AcpHttpTransport } from '@qwen-code/sdk/daemon/transports';
|
|
import {
|
|
createDaemonSessionActions,
|
|
getConnectionAfterSessionClear,
|
|
getWorkspaceModelsAfterSessionClear,
|
|
resolveCreateWatchdogTimeoutMs,
|
|
resolveSessionRestoreTimeouts,
|
|
} from './actions';
|
|
import { applyConfigOptions, updateConnectionFromDaemonEvent } from './mappers';
|
|
import {
|
|
observeSessionConfig,
|
|
resetSessionConfigSync,
|
|
type SessionConfigSyncMap,
|
|
} from './session-config-sync';
|
|
import { createAndAttachSessionForPrompt } from '../../utils/sessionPreparation';
|
|
import type {
|
|
ActivePrompt,
|
|
DaemonActivePromptState,
|
|
DaemonConnectionState,
|
|
DaemonProductSessionContext,
|
|
PendingSessionLoad,
|
|
SettledPrompt,
|
|
} from './types';
|
|
|
|
describe('Generated UI action invalidation', () => {
|
|
it('revokes the cleared session before waiting for detach', async () => {
|
|
const session = createMockSession('original');
|
|
const detach = createDeferred<void>();
|
|
session.detach.mockReturnValue(detach.promise);
|
|
const onGeneratedUiInvalidated = vi.fn();
|
|
const h = createActionsHarness({ session, onGeneratedUiInvalidated });
|
|
const clearing = h.actions.clearSession();
|
|
expect(onGeneratedUiInvalidated).toHaveBeenCalledWith(session, 'cleared');
|
|
detach.resolve();
|
|
await clearing;
|
|
});
|
|
|
|
it.each(['release', 'rewind'] as const)(
|
|
'invalidates the original owner after successful %s, even after navigation',
|
|
async (operation) => {
|
|
const session = createMockSession('original');
|
|
const finished = createDeferred<unknown>();
|
|
session.client.closeSession.mockReturnValue(finished.promise);
|
|
session.rewind.mockReturnValue(finished.promise);
|
|
const onGeneratedUiInvalidated = vi.fn();
|
|
const h = createActionsHarness({ session, onGeneratedUiInvalidated });
|
|
const pending =
|
|
operation === 'release'
|
|
? h.actions.releaseSession('background')
|
|
: h.actions.rewindSession('prompt');
|
|
expect(onGeneratedUiInvalidated).not.toHaveBeenCalled();
|
|
h.sessionRef.current = createMockSession(
|
|
'replacement',
|
|
) as unknown as DaemonSessionClient;
|
|
finished.resolve({});
|
|
await pending;
|
|
if (operation === 'release')
|
|
expect(onGeneratedUiInvalidated).toHaveBeenCalledWith(
|
|
session,
|
|
'deleted',
|
|
'background',
|
|
);
|
|
else
|
|
expect(onGeneratedUiInvalidated).toHaveBeenCalledWith(
|
|
session,
|
|
'rewound',
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(['release', 'rewind'] as const)(
|
|
'does not report an unsuccessful %s as a deletion',
|
|
async (operation) => {
|
|
const session = createMockSession('original');
|
|
session.client.closeSession.mockRejectedValue(new Error('refused'));
|
|
session.rewind.mockRejectedValue(new Error('refused'));
|
|
const onGeneratedUiInvalidated = vi.fn();
|
|
const h = createActionsHarness({ session, onGeneratedUiInvalidated });
|
|
await expect(
|
|
operation === 'release'
|
|
? h.actions.releaseSession('background')
|
|
: h.actions.rewindSession('prompt'),
|
|
).rejects.toThrow('refused');
|
|
expect(onGeneratedUiInvalidated).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('context usage counter reconciliation', () => {
|
|
function snapshot() {
|
|
return {
|
|
v: 1,
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/workspace',
|
|
formattedText: '',
|
|
usage: {
|
|
modelName: 'model-a',
|
|
isEstimated: true,
|
|
totalTokens: 40,
|
|
contextWindowSize: 100,
|
|
breakdown: {
|
|
systemPrompt: 10,
|
|
builtinTools: 0,
|
|
mcpTools: 0,
|
|
memoryFiles: 0,
|
|
skills: 0,
|
|
messages: 30,
|
|
freeSpace: 50,
|
|
autocompactBuffer: 10,
|
|
},
|
|
builtinTools: [],
|
|
mcpTools: [],
|
|
memoryFiles: [],
|
|
skills: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
it.each([
|
|
[false, undefined],
|
|
[true, undefined],
|
|
[false, 1_000_000],
|
|
[true, 1_000_000],
|
|
] as const)(
|
|
'updates only the token count when requested (sync=%s, window=%s)',
|
|
async (syncCounters, contextWindow) => {
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage.mockResolvedValue(snapshot());
|
|
const tokenUsage = { inputTokens: 500, outputTokens: 100 };
|
|
const h = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'model-a',
|
|
tokenCount: 60,
|
|
contextWindow,
|
|
tokenUsage,
|
|
},
|
|
});
|
|
const result = await h.actions.getContextUsage({
|
|
detail: true,
|
|
syncCounters,
|
|
});
|
|
expect(result).toEqual(snapshot());
|
|
expect(h.getConnection().tokenCount).toBe(syncCounters ? 40 : 60);
|
|
expect(h.getConnection().contextWindow).toBe(contextWindow);
|
|
expect(h.getConnection().tokenUsage).toBe(tokenUsage);
|
|
expect(session.contextUsage).toHaveBeenCalledWith({ detail: true });
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
'usage',
|
|
'equal-count-usage',
|
|
'model',
|
|
'client',
|
|
'disconnect',
|
|
'wrong-snapshot',
|
|
'loadingTranscript',
|
|
'catchingUp',
|
|
'workspace',
|
|
'unavailable-snapshot',
|
|
'unknown-count',
|
|
'context-window',
|
|
'model-round-trip',
|
|
'connection-session',
|
|
'zero-window',
|
|
] as const)(
|
|
'does not overwrite a newer or different owner: %s',
|
|
async (change) => {
|
|
const session = createMockSession('session-a');
|
|
let resolve!: (value: ReturnType<typeof snapshot>) => void;
|
|
session.contextUsage.mockReturnValue(
|
|
new Promise<ReturnType<typeof snapshot>>((done) => {
|
|
resolve = done;
|
|
}),
|
|
);
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'model-a',
|
|
tokenCount: 60,
|
|
contextWindow: 100,
|
|
tokenUsage: { inputTokens: 60 },
|
|
};
|
|
const h = createActionsHarness({ session, connection });
|
|
const request = h.actions.getContextUsage({ syncCounters: true });
|
|
if (change === 'usage')
|
|
h.replaceConnection({ ...connection, tokenCount: 70 });
|
|
if (change === 'equal-count-usage')
|
|
h.replaceConnection({ ...connection, tokenUsage: { inputTokens: 60 } });
|
|
if (change === 'model')
|
|
h.replaceConnection({ ...connection, currentModel: 'model-b' });
|
|
if (change === 'client')
|
|
h.sessionRef.current = createMockSession(
|
|
'session-a',
|
|
'new-client',
|
|
) as unknown as DaemonSessionClient;
|
|
if (change === 'disconnect')
|
|
h.replaceConnection({ ...connection, status: 'error' });
|
|
if (change === 'loadingTranscript' || change === 'catchingUp')
|
|
h.replaceConnection({ ...connection, [change]: true });
|
|
if (change === 'workspace')
|
|
h.replaceConnection({ ...connection, workspaceCwd: '/other' });
|
|
if (change === 'connection-session')
|
|
h.replaceConnection({ ...connection, sessionId: 'session-b' });
|
|
if (change === 'context-window')
|
|
h.replaceConnection({ ...connection, contextWindow: 32_000 });
|
|
if (change === 'model-round-trip') {
|
|
await h.actions.setModel('model-b');
|
|
expect(h.getConnection().currentModel).toBe('model-b');
|
|
await h.actions.setModel('model-a');
|
|
expect(h.getConnection().currentModel).toBe('model-a');
|
|
}
|
|
const value = snapshot();
|
|
if (change === 'wrong-snapshot') value.sessionId = 'session-b';
|
|
if (change === 'unavailable-snapshot') {
|
|
value.usage.totalTokens = 0;
|
|
value.usage.contextWindowSize = 0;
|
|
}
|
|
if (change === 'unknown-count') {
|
|
value.usage.totalTokens = 0;
|
|
value.usage.breakdown.messages = 0;
|
|
value.usage.breakdown.freeSpace = 80;
|
|
}
|
|
if (change === 'zero-window') value.usage.contextWindowSize = 0;
|
|
resolve(value);
|
|
await request;
|
|
expect(h.getConnection().tokenCount).toBe(change === 'usage' ? 70 : 60);
|
|
expect(h.getConnection().contextWindow).toBe(
|
|
change === 'context-window' ? 32_000 : 100,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('sendPrompt admission notices', () => {
|
|
it.each([
|
|
{
|
|
name: 'MCP App response loss',
|
|
source: 'mcp-app' as const,
|
|
error: new TypeError('Failed to fetch'),
|
|
noticeCount: 0,
|
|
},
|
|
{
|
|
name: 'ordinary composer response loss',
|
|
source: undefined,
|
|
error: new TypeError('Failed to fetch'),
|
|
noticeCount: 1,
|
|
},
|
|
{
|
|
name: 'MCP App definite rejection',
|
|
source: 'mcp-app' as const,
|
|
error: new DaemonHttpError(
|
|
413,
|
|
{ code: 'prompt_too_large' },
|
|
'Prompt too large',
|
|
),
|
|
noticeCount: 0,
|
|
},
|
|
])(
|
|
'preserves the original error for $name',
|
|
async ({ source, error, noticeCount }) => {
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockRejectedValueOnce(error);
|
|
const addNotice = vi.fn();
|
|
const onAdmitted = vi.fn();
|
|
const { actions } = createActionsHarness({ session, addNotice });
|
|
|
|
await expect(
|
|
actions.sendPrompt('Analyze failures', {
|
|
...(source ? { source } : {}),
|
|
onAdmitted,
|
|
}),
|
|
).rejects.toBe(error);
|
|
|
|
expect(session.submitPrompt).toHaveBeenCalledOnce();
|
|
expect(onAdmitted).not.toHaveBeenCalled();
|
|
expect(addNotice).toHaveBeenCalledTimes(noticeCount);
|
|
if (noticeCount > 0) {
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: 'send_prompt',
|
|
message: 'Prompt failed: Failed to fetch',
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it('keeps notices for MCP App errors before admission starts', async () => {
|
|
const session = createMockSession('session-a');
|
|
const error = new Error('Admission preparation failed');
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ session, addNotice });
|
|
|
|
await expect(
|
|
actions.sendPrompt('Analyze failures', {
|
|
source: 'mcp-app',
|
|
onAdmissionStarted: () => {
|
|
throw error;
|
|
},
|
|
}),
|
|
).rejects.toBe(error);
|
|
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(addNotice).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
operation: 'send_prompt',
|
|
message: 'Prompt failed: Admission preparation failed',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps notices for MCP App errors after a receipt arrives', async () => {
|
|
const session = createMockSession('session-a');
|
|
const error = new Error('Turn connection lost');
|
|
const addNotice = vi.fn();
|
|
const onAdmitted = vi.fn();
|
|
const { actions, activePromptsRef } = createActionsHarness({
|
|
session,
|
|
addNotice,
|
|
});
|
|
const pending = actions
|
|
.sendPrompt('Analyze failures', {
|
|
source: 'mcp-app',
|
|
onAdmitted,
|
|
})
|
|
.catch((error: unknown) => error);
|
|
await vi.waitFor(() =>
|
|
expect(onAdmitted).toHaveBeenCalledExactlyOnceWith({
|
|
promptId: 'prompt-1',
|
|
}),
|
|
);
|
|
const active = activePromptsRef.current.get('session-a');
|
|
expect(active?.reject).toEqual(expect.any(Function));
|
|
active?.reject?.(error);
|
|
|
|
expect(await pending).toBe(error);
|
|
expect(addNotice).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
operation: 'send_prompt',
|
|
message: 'Prompt failed: Turn connection lost',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('sendPrompt client registration recovery', () => {
|
|
it('keeps a cancelled recovery request rejected and admits a fresh guarded request', async () => {
|
|
const resume = createDeferred<Response>();
|
|
const promptClientIds: Array<string | null> = [];
|
|
const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/resume')) return resume.promise;
|
|
if (url.endsWith('/prompt')) {
|
|
promptClientIds.push(
|
|
new Headers(init?.headers).get('X-Qwen-Client-Id'),
|
|
);
|
|
return new Response(
|
|
JSON.stringify(
|
|
promptClientIds.length === 1
|
|
? { code: 'invalid_client_id', error: 'unknown client' }
|
|
: { promptId: 'fresh-prompt', lastEventId: 0 },
|
|
),
|
|
{ status: promptClientIds.length === 1 ? 400 : 202 },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
const session = new DaemonSessionClient({
|
|
client: new DaemonClient({ baseUrl: 'http://daemon', fetch }),
|
|
session: {
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
},
|
|
});
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'model-a',
|
|
};
|
|
const h = createActionsHarness({ connection });
|
|
h.sessionRef.current = session;
|
|
const rpc = new AbortController();
|
|
const onAdmitted = vi.fn();
|
|
const beforeDispatch = vi.fn((clientId: string | undefined) => {
|
|
if (rpc.signal.aborted || clientId !== connection.clientId) {
|
|
throw new DOMException('Source expired', 'AbortError');
|
|
}
|
|
});
|
|
const cancelled = h.actions.sendPrompt('Analyze failures', {
|
|
beforeDispatch,
|
|
onAdmitted,
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
fetch.mock.calls.some(([url]) => String(url).endsWith('/resume')),
|
|
).toBe(true),
|
|
);
|
|
rpc.abort();
|
|
resume.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
sessionId: 'session-a',
|
|
clientId: 'client-recovered',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
state: {},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
|
|
expect(await cancelled).toEqual({ stopReason: 'cancelled' });
|
|
expect(promptClientIds).toEqual(['client-a']);
|
|
expect(beforeDispatch.mock.calls).toEqual([
|
|
['client-a'],
|
|
['client-recovered'],
|
|
]);
|
|
expect(onAdmitted).not.toHaveBeenCalled();
|
|
expect(h.getConnection()).toEqual({
|
|
...connection,
|
|
clientId: 'client-recovered',
|
|
});
|
|
|
|
const reviewedClientId = h.getConnection().clientId;
|
|
const freshGuard = vi.fn((clientId: string | undefined) => {
|
|
if (clientId !== reviewedClientId) {
|
|
throw new DOMException('Source expired', 'AbortError');
|
|
}
|
|
});
|
|
const fresh = h.actions.sendPrompt('Analyze failures again', {
|
|
beforeDispatch: freshGuard,
|
|
onAdmitted,
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(onAdmitted).toHaveBeenCalledExactlyOnceWith({
|
|
promptId: 'fresh-prompt',
|
|
}),
|
|
);
|
|
expect(freshGuard).toHaveBeenCalledExactlyOnceWith('client-recovered');
|
|
expect(promptClientIds).toEqual(['client-a', 'client-recovered']);
|
|
const active = h.activePromptsRef.current.get('session-a');
|
|
expect(active?.resolve).toEqual(expect.any(Function));
|
|
active?.resolve?.({ stopReason: 'end_turn' });
|
|
expect(await fresh).toEqual({ stopReason: 'end_turn' });
|
|
});
|
|
|
|
it.each(['session-b', 'session-a'])(
|
|
'does not publish a recovered client to a replacement SDK instance for %s',
|
|
async (replacementSessionId) => {
|
|
const resume = createDeferred<Response>();
|
|
let promptCount = 0;
|
|
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/resume')) return resume.promise;
|
|
if (url.endsWith('/prompt')) {
|
|
promptCount++;
|
|
return new Response(
|
|
JSON.stringify({
|
|
code: 'invalid_client_id',
|
|
error: 'unknown client',
|
|
}),
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
|
const session = new DaemonSessionClient({
|
|
client,
|
|
session: {
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
},
|
|
});
|
|
const h = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
},
|
|
});
|
|
h.sessionRef.current = session;
|
|
const onAdmitted = vi.fn();
|
|
const pending = h.actions.sendPrompt('Analyze failures', {
|
|
onAdmitted,
|
|
beforeDispatch: (clientId) => {
|
|
if (h.sessionRef.current !== session || clientId !== 'client-a') {
|
|
throw new DOMException('Source expired', 'AbortError');
|
|
}
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
fetch.mock.calls.some(([url]) => String(url).endsWith('/resume')),
|
|
).toBe(true),
|
|
);
|
|
const replacement = new DaemonSessionClient({
|
|
client,
|
|
session: {
|
|
sessionId: replacementSessionId,
|
|
clientId: 'client-replacement',
|
|
attached: true,
|
|
workspaceCwd: '/replacement',
|
|
},
|
|
});
|
|
h.sessionRef.current = replacement;
|
|
const replacementConnection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: replacementSessionId,
|
|
clientId: 'client-replacement',
|
|
workspaceCwd: '/replacement',
|
|
currentModel: 'model-replacement',
|
|
};
|
|
h.replaceConnection(replacementConnection);
|
|
resume.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
sessionId: 'session-a',
|
|
clientId: 'client-recovered',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
state: {},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
|
|
expect(await pending).toEqual({ stopReason: 'cancelled' });
|
|
expect(session.clientId).toBe('client-recovered');
|
|
expect(promptCount).toBe(1);
|
|
expect(onAdmitted).not.toHaveBeenCalled();
|
|
expect(h.getConnection()).toBe(replacementConnection);
|
|
expect(h.sessionRef.current).toBe(replacement);
|
|
},
|
|
);
|
|
|
|
it('preserves automatic recovery and admission without a dispatch guard', async () => {
|
|
const promptClientIds: Array<string | null> = [];
|
|
const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/resume')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
sessionId: 'session-a',
|
|
clientId: 'client-recovered',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
state: {},
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url.endsWith('/prompt')) {
|
|
promptClientIds.push(
|
|
new Headers(init?.headers).get('X-Qwen-Client-Id'),
|
|
);
|
|
return new Response(
|
|
JSON.stringify(
|
|
promptClientIds.length === 1
|
|
? { code: 'invalid_client_id', error: 'unknown client' }
|
|
: { promptId: 'recovered-prompt', lastEventId: 0 },
|
|
),
|
|
{ status: promptClientIds.length === 1 ? 400 : 202 },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
const session = new DaemonSessionClient({
|
|
client: new DaemonClient({ baseUrl: 'http://daemon', fetch }),
|
|
session: {
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
},
|
|
});
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'model-a',
|
|
};
|
|
const h = createActionsHarness({ connection });
|
|
h.sessionRef.current = session;
|
|
const onAdmitted = vi.fn();
|
|
const pending = h.actions.sendPrompt('Normal composer prompt', {
|
|
onAdmitted,
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(onAdmitted).toHaveBeenCalledExactlyOnceWith({
|
|
promptId: 'recovered-prompt',
|
|
}),
|
|
);
|
|
expect(promptClientIds).toEqual(['client-a', 'client-recovered']);
|
|
const active = h.activePromptsRef.current.get('session-a');
|
|
expect(active?.resolve).toEqual(expect.any(Function));
|
|
active?.resolve?.({ stopReason: 'end_turn' });
|
|
expect(await pending).toEqual({ stopReason: 'end_turn' });
|
|
expect(h.getConnection()).toEqual({
|
|
...connection,
|
|
clientId: 'client-recovered',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getConnectionAfterSessionClear', () => {
|
|
it('rechecks the MCP App source before an SDK recovery retry', async () => {
|
|
const resume = createDeferred<Response>();
|
|
let promptCount = 0;
|
|
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/resume')) return resume.promise;
|
|
if (url.endsWith('/prompt')) {
|
|
promptCount++;
|
|
return new Response(
|
|
JSON.stringify(
|
|
promptCount === 1
|
|
? { code: 'invalid_client_id', error: 'unknown client' }
|
|
: { promptId: 'must-not-be-admitted', lastEventId: 0 },
|
|
),
|
|
{ status: promptCount === 1 ? 400 : 202 },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
});
|
|
const session = new DaemonSessionClient({
|
|
client: new DaemonClient({ baseUrl: 'http://daemon', fetch }),
|
|
session: {
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
},
|
|
});
|
|
const addNotice = vi.fn();
|
|
const h = createActionsHarness({ addNotice });
|
|
h.sessionRef.current = session;
|
|
let sourceCurrent = true;
|
|
const onAdmissionStarted = vi.fn();
|
|
const onAdmitted = vi.fn(() => {
|
|
h.activePromptsRef.current
|
|
.get('session-a')
|
|
?.resolve?.({ stopReason: 'end_turn' });
|
|
});
|
|
const beforeDispatch = vi.fn((clientId: string | undefined) => {
|
|
if (!sourceCurrent || clientId !== 'client-a') {
|
|
throw new DOMException('Source expired', 'AbortError');
|
|
}
|
|
});
|
|
const result = h.actions
|
|
.sendPrompt('Analyze failures', {
|
|
source: 'mcp-app',
|
|
onAdmissionStarted,
|
|
onAdmitted,
|
|
beforeDispatch,
|
|
})
|
|
.catch((error: unknown) => error);
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
fetch.mock.calls.some(([url]) => String(url).endsWith('/resume')),
|
|
).toBe(true),
|
|
);
|
|
sourceCurrent = false;
|
|
resume.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
sessionId: 'session-a',
|
|
clientId: 'client-b',
|
|
attached: true,
|
|
workspaceCwd: '/workspace',
|
|
state: {},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
|
|
expect(await result).toMatchObject({ name: 'AbortError' });
|
|
expect(promptCount).toBe(1);
|
|
expect(beforeDispatch.mock.calls).toEqual([['client-a'], ['client-b']]);
|
|
expect(onAdmissionStarted).toHaveBeenCalledOnce();
|
|
expect(onAdmitted).not.toHaveBeenCalled();
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
expect(h.store.dispatch).toHaveBeenCalledWith({
|
|
type: 'assistant.done',
|
|
reason: 'cancelled',
|
|
});
|
|
expect(h.activePromptsRef.current.has('session-a')).toBe(false);
|
|
expect(h.setPromptStatus).toHaveBeenLastCalledWith('idle');
|
|
});
|
|
|
|
it('uses the current provider mode for mid-turn admission', async () => {
|
|
const session = {
|
|
...createMockSession('session-a'),
|
|
enqueueMidTurnMessage: vi.fn(async () => ({ accepted: true })),
|
|
};
|
|
let mode: 'full' | 'summary' = 'summary';
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
getEventDetailMode: () => mode,
|
|
});
|
|
await actions.enqueueMidTurnMessage('one');
|
|
mode = 'full';
|
|
await actions.enqueueMidTurnMessage('two', { messageId: 'two' });
|
|
expect(session.enqueueMidTurnMessage.mock.calls).toEqual([
|
|
['one', { eventDetailMode: 'summary' }],
|
|
['two', { messageId: 'two', eventDetailMode: 'full' }],
|
|
]);
|
|
});
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'preserves declared text before host and attachment expansion through %s',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({ session });
|
|
const pending = actions[method]('host-expanded request', {
|
|
submittedPrompt: ' original question\n',
|
|
});
|
|
await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled());
|
|
expect(session.submitPrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: [{ type: 'text', text: 'host-expanded request' }],
|
|
_meta: { 'qwen.submittedPrompt': ' original question\n' },
|
|
}),
|
|
...(method === 'sendPrompt' ? [expect.any(AbortSignal)] : []),
|
|
);
|
|
if (method === 'sendPrompt') await actions.cancel();
|
|
await pending;
|
|
},
|
|
);
|
|
|
|
it('does not promote reviewed Generated UI data to declared human input', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({ session });
|
|
const text =
|
|
'Captured parameters: {"kind":"qwen.generated-ui.parameters","title":"Workflow: deploy","inputs":{"task":{"value":"/workflow deploy","checked":false}}}';
|
|
const pending = actions.sendPrompt(text, { source: 'mcp-app' });
|
|
await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled());
|
|
const request = session.submitPrompt.mock.calls[0][0];
|
|
expect(request).toMatchObject({ prompt: [{ type: 'text', text }] });
|
|
expect(request).not.toHaveProperty('_meta');
|
|
expect(request).not.toHaveProperty('humanInput');
|
|
await actions.cancel();
|
|
await pending;
|
|
});
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'uses the current provider detail mode through %s',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
let mode: 'full' | 'summary' = 'full';
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
getEventDetailMode: () => mode,
|
|
});
|
|
for (const nextMode of ['full', 'summary'] as const) {
|
|
mode = nextMode;
|
|
session.submitPrompt.mockClear();
|
|
const pending = actions[method]('Review this');
|
|
await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled());
|
|
expect(session.submitPrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({ eventDetailMode: mode }),
|
|
...(method === 'sendPrompt' ? [expect.any(AbortSignal)] : []),
|
|
);
|
|
if (method === 'sendPrompt') await actions.cancel();
|
|
await pending;
|
|
}
|
|
},
|
|
);
|
|
|
|
it('clears session fields for the session being detached', () => {
|
|
const next = getConnectionAfterSessionClear(
|
|
{
|
|
status: 'disconnected',
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
displayName: 'Session A',
|
|
titleSource: 'manual',
|
|
tokenCount: 42,
|
|
goalState: { v: 2, goal: null, activity: 'idle' },
|
|
backgroundTurn: {
|
|
turnId: 'background-a',
|
|
taskId: 'task-a',
|
|
kind: 'agent',
|
|
startedAt: 100,
|
|
},
|
|
finishedBackgroundTurnId: 'background-old',
|
|
commands: [commandInfo('old-command')],
|
|
skills: ['old-skill'],
|
|
supportedCommands: supportedCommandsStatus('session-a'),
|
|
context: contextStatus('session-a'),
|
|
loadingTranscript: true,
|
|
catchingUp: true,
|
|
error: 'old error',
|
|
errorStatus: 404,
|
|
missingSession: true,
|
|
} as DaemonConnectionState,
|
|
'session-a',
|
|
);
|
|
|
|
expect(next).toMatchObject({
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
loadingTranscript: undefined,
|
|
catchingUp: undefined,
|
|
error: undefined,
|
|
errorStatus: undefined,
|
|
missingSession: false,
|
|
});
|
|
expect(next).not.toHaveProperty('sessionId');
|
|
expect(next).not.toHaveProperty('clientId');
|
|
expect(next).not.toHaveProperty('displayName');
|
|
expect(next).not.toHaveProperty('titleSource');
|
|
expect(next).not.toHaveProperty('tokenCount');
|
|
expect(next).not.toHaveProperty('goalState');
|
|
expect(next).not.toHaveProperty('backgroundTurn');
|
|
expect(next).not.toHaveProperty('finishedBackgroundTurnId');
|
|
expect(next).not.toHaveProperty('supportedCommands');
|
|
expect(next).not.toHaveProperty('context');
|
|
// Workspace-scoped slash commands and skills survive a clear so skill-backed
|
|
// commands (e.g. /review) keep autocompleting in the fresh deferred session
|
|
// before its first prompt creates a session (mirrors #6153 / #6066).
|
|
expect(next.commands).toEqual([commandInfo('old-command')]);
|
|
expect(next.skills).toEqual(['old-skill']);
|
|
});
|
|
|
|
it('handles commands and skills being undefined before clear', () => {
|
|
// Optional fields: clearing before the first available_commands_update
|
|
// (open the app, immediately start a new chat) leaves them absent. The
|
|
// delete calls are harmless no-ops and nothing is fabricated.
|
|
const next = getConnectionAfterSessionClear(
|
|
{
|
|
status: 'disconnected',
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-a',
|
|
clientId: 'client-a',
|
|
supportedCommands: supportedCommandsStatus('session-a'),
|
|
context: contextStatus('session-a'),
|
|
} as DaemonConnectionState,
|
|
'session-a',
|
|
);
|
|
|
|
expect(next).toMatchObject({
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
});
|
|
expect(next).not.toHaveProperty('sessionId');
|
|
expect(next).not.toHaveProperty('commands');
|
|
expect(next).not.toHaveProperty('skills');
|
|
expect(next).not.toHaveProperty('supportedCommands');
|
|
expect(next).not.toHaveProperty('context');
|
|
});
|
|
|
|
it('restores workspace model previews after clearing session context models', () => {
|
|
const next = getConnectionAfterSessionClear(
|
|
{
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-a',
|
|
context: contextStatus('session-a'),
|
|
models: [
|
|
{
|
|
id: 'qwen3.8-max',
|
|
baseModelId: 'qwen3.8-max',
|
|
label: 'Qwen 3.8 Max',
|
|
},
|
|
],
|
|
providers: {
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: true,
|
|
current: { modelId: 'qwen3.8-max' },
|
|
providers: [
|
|
{
|
|
kind: 'model_provider',
|
|
status: 'ok',
|
|
authType: 'qwen-oauth',
|
|
current: true,
|
|
models: [
|
|
{
|
|
modelId: 'qwen3.8-max',
|
|
baseModelId: 'qwen3.8-max',
|
|
name: 'Qwen 3.8 Max',
|
|
isCurrent: true,
|
|
isRuntime: false,
|
|
configOptions: [
|
|
{
|
|
id: 'reasoning_effort',
|
|
currentValue: 'xhigh',
|
|
options: [
|
|
{ value: 'none' },
|
|
{ value: 'low' },
|
|
{ value: 'medium' },
|
|
{ value: 'xhigh' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
} as DaemonConnectionState,
|
|
'session-a',
|
|
);
|
|
|
|
expect(next.models?.[0]?.reasoningPreview).toEqual({
|
|
enabled: true,
|
|
effort: 'xhigh',
|
|
efforts: ['low', 'medium', 'xhigh'],
|
|
});
|
|
});
|
|
|
|
it('keeps the prior model list when no workspace providers are loaded', () => {
|
|
// Older daemons without workspaceProviders support (or a rejected fetch)
|
|
// leave `providers` undefined; the pre-clear list must survive the clear.
|
|
const models = [
|
|
{
|
|
id: 'qwen3.8-max',
|
|
baseModelId: 'qwen3.8-max',
|
|
label: 'Qwen 3.8 Max',
|
|
},
|
|
];
|
|
const next = getConnectionAfterSessionClear(
|
|
{
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-a',
|
|
context: contextStatus('session-a'),
|
|
models,
|
|
} as DaemonConnectionState,
|
|
'session-a',
|
|
);
|
|
|
|
expect(next.models).toEqual(models);
|
|
});
|
|
|
|
it('preserves a concurrently loaded session', () => {
|
|
const next = getConnectionAfterSessionClear(
|
|
{
|
|
status: 'connecting',
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-b',
|
|
clientId: 'client-b',
|
|
displayName: 'Session B',
|
|
tokenCount: 7,
|
|
commands: [commandInfo('new-command')],
|
|
skills: ['new-skill'],
|
|
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
|
|
context: contextStatus('session-b'),
|
|
loadingTranscript: true,
|
|
catchingUp: true,
|
|
error: 'old error',
|
|
} as DaemonConnectionState,
|
|
'session-a',
|
|
);
|
|
|
|
expect(next).toMatchObject({
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-b',
|
|
clientId: 'client-b',
|
|
displayName: 'Session B',
|
|
tokenCount: 7,
|
|
commands: [commandInfo('new-command')],
|
|
skills: ['new-skill'],
|
|
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
|
|
context: contextStatus('session-b'),
|
|
loadingTranscript: undefined,
|
|
catchingUp: undefined,
|
|
error: undefined,
|
|
});
|
|
});
|
|
|
|
it('drops workspace and session previews when clearing a standalone session', () => {
|
|
const current: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'standalone-a',
|
|
sessionContext: { kind: 'standalone' },
|
|
commands: [commandInfo('old-command')],
|
|
skills: ['old-skill'],
|
|
models: [{ id: 'old-model', label: 'Old model' }],
|
|
providers: {
|
|
v: 1,
|
|
workspaceCwd: '/primary',
|
|
initialized: true,
|
|
providers: [],
|
|
},
|
|
gitBranch: 'main',
|
|
gitStatus: {} as never,
|
|
standaloneSession: {
|
|
projectlessOutputDirectory: '/output/standalone-a',
|
|
},
|
|
};
|
|
const next = getConnectionAfterSessionClear(current, 'standalone-a');
|
|
|
|
expect(next).toMatchObject({
|
|
status: 'connected',
|
|
sessionContext: { kind: 'standalone' },
|
|
});
|
|
expect(next).not.toHaveProperty('commands');
|
|
expect(next).not.toHaveProperty('skills');
|
|
expect(next).not.toHaveProperty('models');
|
|
expect(next).not.toHaveProperty('providers');
|
|
expect(next).not.toHaveProperty('gitBranch');
|
|
expect(next).not.toHaveProperty('gitStatus');
|
|
expect(next).not.toHaveProperty('standaloneSession');
|
|
expect(getWorkspaceModelsAfterSessionClear(current)).toBeUndefined();
|
|
});
|
|
|
|
it('drops the connection session context only when the clear asks for it', () => {
|
|
// Leaving Live has to be a real state change: `undefined` is the downstream
|
|
// sentinel for "inherit from the connection", so a cleared connection that
|
|
// still advertises { kind: 'live' } sends the next prompt straight back
|
|
// into Live, where createSession rejects a live context (#12620).
|
|
const current: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'live-a',
|
|
sessionContext: { kind: 'live' },
|
|
commands: [commandInfo('old-command')],
|
|
skills: ['old-skill'],
|
|
};
|
|
|
|
const dropped = getConnectionAfterSessionClear(
|
|
current,
|
|
'live-a',
|
|
undefined,
|
|
true,
|
|
);
|
|
|
|
expect(dropped).not.toHaveProperty('sessionContext');
|
|
expect(dropped).not.toHaveProperty('sessionId');
|
|
|
|
// The drop stays opt-in: every other call site keeps the 3-arg shape, so a
|
|
// context nobody chose to leave must survive the clear, and the incoming
|
|
// state is never mutated in place.
|
|
const kept = getConnectionAfterSessionClear(current, 'live-a');
|
|
expect(kept.sessionContext).toEqual({ kind: 'live' });
|
|
expect(current.sessionContext).toEqual({ kind: 'live' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A capabilities payload advertising a restore budget. Typed rather than cast
|
|
* so renaming or moving `limits.sessionRestoreTimeoutMs` fails typecheck here
|
|
* instead of silently falling back to the client defaults.
|
|
*/
|
|
function advertisingRestoreBudget(
|
|
sessionRestoreTimeoutMs: number,
|
|
): DaemonCapabilities {
|
|
return {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: [],
|
|
modelServices: [],
|
|
limits: { sessionRestoreTimeoutMs },
|
|
};
|
|
}
|
|
|
|
describe('resolveSessionRestoreTimeouts', () => {
|
|
it('uses 70s request and 75s watchdog defaults for old daemons', () => {
|
|
expect(resolveSessionRestoreTimeouts(undefined)).toEqual({
|
|
requestTimeoutMs: 70_000,
|
|
watchdogTimeoutMs: 75_000,
|
|
});
|
|
});
|
|
|
|
it('derives both client budgets from the advertised server timeout', () => {
|
|
expect(
|
|
resolveSessionRestoreTimeouts(advertisingRestoreBudget(90_000)),
|
|
).toEqual({
|
|
requestTimeoutMs: 100_000,
|
|
watchdogTimeoutMs: 105_000,
|
|
});
|
|
});
|
|
|
|
it('disables derived timers that exceed the JavaScript timer ceiling', () => {
|
|
expect(
|
|
resolveSessionRestoreTimeouts(advertisingRestoreBudget(2_147_483_647)),
|
|
).toEqual({ requestTimeoutMs: 0, watchdogTimeoutMs: undefined });
|
|
});
|
|
});
|
|
|
|
describe('resolveCreateWatchdogTimeoutMs', () => {
|
|
it('covers a preflight plus the default create budget for old daemons', () => {
|
|
expect(resolveCreateWatchdogTimeoutMs(undefined)).toBe(115_000);
|
|
});
|
|
|
|
it('grows with the advertised session budget', () => {
|
|
expect(
|
|
resolveCreateWatchdogTimeoutMs(advertisingRestoreBudget(90_000)),
|
|
).toBe(145_000);
|
|
});
|
|
|
|
it('does not let a short restore budget shrink the create half', () => {
|
|
// The SDK floors a spawn deadline at its generic request budget, so a 5s
|
|
// restore pin still yields a 30s create: 30s preflight + 30s + 15s.
|
|
expect(
|
|
resolveCreateWatchdogTimeoutMs(advertisingRestoreBudget(5_000)),
|
|
).toBe(75_000);
|
|
});
|
|
|
|
it('clamps to the JavaScript timer ceiling instead of firing at once', () => {
|
|
expect(
|
|
resolveCreateWatchdogTimeoutMs(advertisingRestoreBudget(2_147_483_647)),
|
|
).toBe(2_147_483_647);
|
|
});
|
|
});
|
|
|
|
describe('createDaemonSessionActions', () => {
|
|
describe('background live-state request ordering', () => {
|
|
const owner = { workspaceCwd: '/workspace', sessionId: 'session-1' };
|
|
const oldTurn = {
|
|
turnId: 'A',
|
|
taskId: 'task-A',
|
|
kind: 'agent' as const,
|
|
startedAt: 1,
|
|
};
|
|
const newTurn = {
|
|
turnId: 'B',
|
|
taskId: 'task-B',
|
|
kind: 'agent' as const,
|
|
startedAt: 10,
|
|
};
|
|
|
|
function startBackgroundTurn(previousActive?: boolean) {
|
|
const daemonActivePromptRef: {
|
|
current: DaemonActivePromptState | undefined;
|
|
} = {
|
|
current:
|
|
previousActive === undefined
|
|
? undefined
|
|
: { active: previousActive, ...owner },
|
|
};
|
|
const harness = createActionsHarness({
|
|
session: createMockSession(owner.sessionId),
|
|
daemonActivePromptRef,
|
|
connection: {
|
|
status: 'connected',
|
|
...owner,
|
|
finishedBackgroundTurnId: oldTurn.turnId,
|
|
},
|
|
});
|
|
const applyEvent = (
|
|
event: Parameters<typeof updateConnectionFromDaemonEvent>[0],
|
|
) => {
|
|
updateConnectionFromDaemonEvent(event, (update) => {
|
|
harness.replaceConnection(
|
|
typeof update === 'function'
|
|
? update(harness.getConnection())
|
|
: update,
|
|
);
|
|
});
|
|
};
|
|
applyEvent({
|
|
v: 1,
|
|
id: 1,
|
|
type: 'session_update',
|
|
data: {
|
|
update: {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
_meta: {
|
|
source: 'background_notification_turn_started',
|
|
backgroundTurn: newTurn,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(harness.getConnection().backgroundTurn).toEqual(newTurn);
|
|
const observedAt = harness.getConnection().backgroundTurnObservedAt;
|
|
expect(observedAt).toEqual(expect.any(Number));
|
|
return {
|
|
...harness,
|
|
applyEvent,
|
|
daemonActivePromptRef,
|
|
observedAt: observedAt!,
|
|
};
|
|
}
|
|
|
|
it.each([true, false])(
|
|
'ignores an older active=%s response after SSE starts B',
|
|
(active) => {
|
|
const harness = startBackgroundTurn(true);
|
|
const authorityBefore = harness.daemonActivePromptRef.current;
|
|
harness.actions.setDaemonActivePrompt(
|
|
active,
|
|
owner,
|
|
active ? oldTurn : undefined,
|
|
harness.observedAt - 1,
|
|
);
|
|
expect(harness.getConnection().backgroundTurn).toEqual(newTurn);
|
|
expect(harness.daemonActivePromptRef.current).toBe(authorityBefore);
|
|
expect(harness.setPromptStatus).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([undefined, true])(
|
|
'allows a newer idle response to settle B (previous authority: %s)',
|
|
(previousActive) => {
|
|
const harness = startBackgroundTurn(previousActive);
|
|
harness.actions.setDaemonActivePrompt(
|
|
false,
|
|
owner,
|
|
undefined,
|
|
harness.observedAt + 1,
|
|
);
|
|
expect(harness.getConnection().backgroundTurn).toBeUndefined();
|
|
expect(harness.daemonActivePromptRef.current).toMatchObject({
|
|
active: false,
|
|
});
|
|
expect(harness.setPromptStatus).toHaveBeenCalledWith('idle');
|
|
},
|
|
);
|
|
|
|
it('ignores an older active response after B has terminated', () => {
|
|
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
|
|
try {
|
|
const harness = startBackgroundTurn(false);
|
|
const authorityBefore = harness.daemonActivePromptRef.current;
|
|
expect(harness.observedAt).toBe(100);
|
|
clock.mockReturnValue(200);
|
|
harness.applyEvent({
|
|
v: 1,
|
|
id: 2,
|
|
type: 'turn_complete',
|
|
data: { promptId: newTurn.turnId },
|
|
});
|
|
expect(harness.getConnection().backgroundTurn).toBeUndefined();
|
|
expect(harness.getConnection().finishedBackgroundTurnId).toBe(
|
|
newTurn.turnId,
|
|
);
|
|
expect(harness.getConnection().backgroundTurnObservedAt).toBe(200);
|
|
harness.actions.setDaemonActivePrompt(true, owner, newTurn, 150);
|
|
expect(harness.getConnection().backgroundTurn).toBeUndefined();
|
|
expect(harness.daemonActivePromptRef.current).toBe(authorityBefore);
|
|
expect(harness.setPromptStatus).not.toHaveBeenCalled();
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([true, false])(
|
|
'ignores an older active=%s response after SSE starts B in a standalone session',
|
|
(active) => {
|
|
const harness = startBackgroundTurn(true);
|
|
harness.replaceConnection({
|
|
...harness.getConnection(),
|
|
sessionContext: { kind: 'standalone' },
|
|
workspaceCwd: undefined,
|
|
});
|
|
expect(harness.sessionRef.current?.workspaceCwd).toBe(
|
|
owner.workspaceCwd,
|
|
);
|
|
const authorityBefore = harness.daemonActivePromptRef.current;
|
|
harness.actions.setDaemonActivePrompt(
|
|
active,
|
|
owner,
|
|
active ? oldTurn : undefined,
|
|
harness.observedAt - 1,
|
|
);
|
|
expect(harness.getConnection().backgroundTurn).toEqual(newTurn);
|
|
expect(harness.daemonActivePromptRef.current).toBe(authorityBefore);
|
|
expect(harness.setPromptStatus).not.toHaveBeenCalled();
|
|
|
|
harness.actions.setDaemonActivePrompt(
|
|
false,
|
|
owner,
|
|
undefined,
|
|
harness.observedAt + 1,
|
|
);
|
|
expect(harness.getConnection().backgroundTurn).toBeUndefined();
|
|
expect(harness.setPromptStatus).toHaveBeenCalledWith('idle');
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ ...owner, sessionId: 'another-session' },
|
|
{ ...owner, workspaceCwd: '/another-workspace' },
|
|
])(
|
|
'does not settle a standalone background turn for mismatched owner %j',
|
|
(otherOwner) => {
|
|
const harness = startBackgroundTurn(true);
|
|
harness.replaceConnection({
|
|
...harness.getConnection(),
|
|
sessionContext: { kind: 'standalone' },
|
|
workspaceCwd: undefined,
|
|
});
|
|
harness.actions.setDaemonActivePrompt(
|
|
false,
|
|
otherOwner,
|
|
undefined,
|
|
harness.observedAt + 1,
|
|
);
|
|
expect(harness.getConnection().backgroundTurn).toEqual(newTurn);
|
|
expect(harness.setPromptStatus).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('ignores malformed live-state execution metadata', () => {
|
|
const harness = createActionsHarness({
|
|
session: createMockSession(owner.sessionId),
|
|
connection: { status: 'connected', ...owner },
|
|
});
|
|
harness.actions.setDaemonActivePrompt(true, owner, {
|
|
...newTurn,
|
|
startedAt: Number.NaN,
|
|
});
|
|
expect(harness.getConnection().backgroundTurn).toBeUndefined();
|
|
});
|
|
|
|
it('preserves settlement for legacy callers without request timing', () => {
|
|
const harness = startBackgroundTurn(true);
|
|
harness.actions.setDaemonActivePrompt(false, owner);
|
|
expect(harness.getConnection().backgroundTurn).toBeUndefined();
|
|
expect(harness.setPromptStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
});
|
|
describe('setDaemonActivePrompt (#9487)', () => {
|
|
it('settles the prompt state when the daemon reports the turn finished', () => {
|
|
const daemonActivePromptRef: {
|
|
current: DaemonActivePromptState | undefined;
|
|
} = {
|
|
current: undefined,
|
|
};
|
|
const { actions, setPromptStatus } = createActionsHarness({
|
|
daemonActivePromptRef,
|
|
session: createMockSession('session-1'),
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
expect(daemonActivePromptRef.current).toEqual({
|
|
active: true,
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-1',
|
|
});
|
|
expect(setPromptStatus).not.toHaveBeenCalled();
|
|
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(setPromptStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
|
|
it('settles when the authority itself goes unknown', () => {
|
|
// A dead daemon: the live-state channel stops answering, its retained
|
|
// snapshot is dropped, and the bridge publishes `undefined`. Nothing
|
|
// vouches for the turn any more, so the pane must be released instead of
|
|
// holding a running turn for the life of the tab.
|
|
const { actions, setPromptStatus } = createActionsHarness({
|
|
session: createMockSession('session-1'),
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
actions.setDaemonActivePrompt(undefined);
|
|
expect(setPromptStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
|
|
it('never revives a settled turn', () => {
|
|
// The live-state poll trails the event stream, so a stale `true`
|
|
// arriving after turn_complete must not flash the indicator back on.
|
|
// Gaining `true` is not a signal, and `false` with no restored prompt is
|
|
// also inert.
|
|
const { actions, setPromptStatus } = createActionsHarness({
|
|
session: createMockSession('session-1'),
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(false);
|
|
actions.setDaemonActivePrompt(true);
|
|
expect(setPromptStatus).not.toHaveBeenCalled();
|
|
|
|
actions.setDaemonActivePrompt(undefined);
|
|
actions.setDaemonActivePrompt(undefined);
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(setPromptStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not carry a true-to-false edge across sessions', () => {
|
|
const sessionA = createMockSession('session-a');
|
|
const sessionB = createMockSession('session-b');
|
|
const { actions, sessionRef, setPromptStatus } = createActionsHarness({
|
|
session: sessionA,
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
sessionRef.current = sessionB as unknown as DaemonSessionClient;
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(setPromptStatus).not.toHaveBeenCalled();
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(setPromptStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
|
|
it.each([
|
|
['a conversation turn', (sessionId: string) => sessionId],
|
|
['a shell command', (sessionId: string) => `${sessionId}:shell`],
|
|
])('leaves %s this browser submitted alone', (_label, toKey) => {
|
|
// This browser owns the prompt, so its own terminal handling settles it;
|
|
// a lagging live-state sample must not cut the turn short. Each prompt
|
|
// kind has its own active-prompt key, and every one of them counts.
|
|
const session = createMockSession('session-local');
|
|
const { actions, setPromptStatus } = createActionsHarness({
|
|
session,
|
|
activePrompts: new Map([
|
|
[toKey(session.sessionId), { controller: new AbortController() }],
|
|
]),
|
|
hasSessionActivePrompt: () => true,
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(setPromptStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('settles a restored prompt the event stream can no longer settle', () => {
|
|
// A refreshed page re-attached to a running prompt has no local terminal
|
|
// handling for it — the event stream is its only settle path. When the
|
|
// daemon reports the turn finished, the backstop must settle the prompt
|
|
// instead of deferring to a terminal event that never arrived (#9487).
|
|
const session = createMockSession('session-restored');
|
|
const settleRestoredActivePrompt = vi.fn(() => true);
|
|
const { actions, setPromptStatus } = createActionsHarness({
|
|
session,
|
|
hasSessionActivePrompt: () => true,
|
|
settleRestoredActivePrompt,
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(settleRestoredActivePrompt).toHaveBeenCalledTimes(1);
|
|
expect(setPromptStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
|
|
it('keeps the armed passive timer when no assistant block is active', () => {
|
|
// The transcript batch can still flush a block after this settle; the
|
|
// armed passive timer is then the only closer left, so the backstop
|
|
// must not cancel it (#9487).
|
|
const passiveAssistantDoneTimerRef = {
|
|
current: 123 as ReturnType<typeof setTimeout>,
|
|
};
|
|
const { actions, setPromptStatus, store } = createActionsHarness({
|
|
passiveAssistantDoneTimerRef,
|
|
session: createMockSession('session-1'),
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(setPromptStatus).toHaveBeenCalledWith('idle');
|
|
expect(store.dispatch).not.toHaveBeenCalled();
|
|
expect(passiveAssistantDoneTimerRef.current).toBe(123);
|
|
});
|
|
|
|
it('closes the active assistant block when settling', () => {
|
|
const passiveAssistantDoneTimerRef = {
|
|
current: 123 as ReturnType<typeof setTimeout>,
|
|
};
|
|
const { actions, setPromptStatus, store } = createActionsHarness({
|
|
getSnapshot: () => ({ activeAssistantBlockId: 'block-1' }),
|
|
passiveAssistantDoneTimerRef,
|
|
session: createMockSession('session-1'),
|
|
});
|
|
|
|
actions.setDaemonActivePrompt(true);
|
|
actions.setDaemonActivePrompt(false);
|
|
expect(store.dispatch).toHaveBeenCalledWith({
|
|
type: 'assistant.done',
|
|
reason: 'daemon_idle',
|
|
});
|
|
expect(passiveAssistantDoneTimerRef.current).toBeUndefined();
|
|
expect(setPromptStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
});
|
|
|
|
it('reports exact prompt admission and successful removal identities', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockResolvedValueOnce({ promptId: 'prompt-1' });
|
|
session.removePendingPrompt.mockResolvedValueOnce({ removed: true });
|
|
const onPromptAdmitted = vi.fn();
|
|
const onPromptRemoved = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
onPromptAdmitted,
|
|
onPromptRemoved,
|
|
});
|
|
|
|
await expect(actions.submitPrompt('exact label')).resolves.toEqual({
|
|
promptId: 'prompt-1',
|
|
});
|
|
await expect(actions.removePendingPrompt('prompt-1')).resolves.toEqual({
|
|
removed: true,
|
|
});
|
|
|
|
expect(onPromptAdmitted).toHaveBeenCalledWith(session, {
|
|
promptId: 'prompt-1',
|
|
label: 'exact label',
|
|
});
|
|
expect(onPromptRemoved).toHaveBeenCalledWith(session, 'prompt-1');
|
|
});
|
|
|
|
it('binds admission to a caller-supplied block when no optimistic message is appended', async () => {
|
|
const session = createMockSession('session-a');
|
|
const onPromptAdmitted = vi.fn();
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
onPromptAdmitted,
|
|
});
|
|
|
|
const prompt = actions.sendPrompt('retried text', {
|
|
optimisticUserMessage: false,
|
|
bindBlockId: 'block-failed',
|
|
});
|
|
|
|
await vi.waitFor(() => expect(onPromptAdmitted).toHaveBeenCalledOnce());
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
expect(onPromptAdmitted).toHaveBeenCalledWith(session, {
|
|
promptId: 'prompt-1',
|
|
label: 'retried text',
|
|
blockId: 'block-failed',
|
|
});
|
|
await actions.cancel();
|
|
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
|
|
});
|
|
|
|
it('notifies prompt removal on the stale-session branch with the foreign session id', async () => {
|
|
const session = createMockSession('session-current');
|
|
const clientRemovePendingPrompt = vi.fn(async () => ({ removed: true }));
|
|
(
|
|
session.client as unknown as {
|
|
removePendingPrompt: typeof clientRemovePendingPrompt;
|
|
}
|
|
).removePendingPrompt = clientRemovePendingPrompt;
|
|
const onPromptRemoved = vi.fn();
|
|
const { actions } = createActionsHarness({ session, onPromptRemoved });
|
|
|
|
await expect(
|
|
actions.removePendingPrompt('prompt-1', { sessionId: 'session-old' }),
|
|
).resolves.toEqual({ removed: true });
|
|
|
|
expect(clientRemovePendingPrompt).toHaveBeenCalledWith(
|
|
'session-old',
|
|
'prompt-1',
|
|
);
|
|
expect(onPromptRemoved).toHaveBeenCalledWith(
|
|
session,
|
|
'prompt-1',
|
|
'session-old',
|
|
);
|
|
});
|
|
|
|
it('does not retire a prompt whose stale-session removal was refused', async () => {
|
|
// A refused removal (`removed: false`) means the prompt is still running
|
|
// in the foreign session; the `removed` guard must keep `onPromptRemoved`
|
|
// from firing, or the settlement admission key is retired while the turn
|
|
// keeps running.
|
|
const session = createMockSession('session-current');
|
|
const clientRemovePendingPrompt = vi.fn(async () => ({ removed: false }));
|
|
(
|
|
session.client as unknown as {
|
|
removePendingPrompt: typeof clientRemovePendingPrompt;
|
|
}
|
|
).removePendingPrompt = clientRemovePendingPrompt;
|
|
const onPromptRemoved = vi.fn();
|
|
const { actions } = createActionsHarness({ session, onPromptRemoved });
|
|
|
|
await expect(
|
|
actions.removePendingPrompt('prompt-1', { sessionId: 'session-old' }),
|
|
).resolves.toEqual({ removed: false });
|
|
|
|
expect(clientRemovePendingPrompt).toHaveBeenCalledWith(
|
|
'session-old',
|
|
'prompt-1',
|
|
);
|
|
expect(onPromptRemoved).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not report a stats error while the session is disconnected', async () => {
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ addNotice });
|
|
|
|
await expect(actions.getStats()).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not report a stats error when the session disconnects in flight', async () => {
|
|
const addNotice = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
session.stats.mockRejectedValueOnce(new DaemonTransportClosedError());
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getStats()).rejects.toThrow(
|
|
'Transport connection closed',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
'fetch failed',
|
|
'Failed to fetch',
|
|
'NetworkError when attempting to fetch resource',
|
|
'Load failed',
|
|
])(
|
|
'does not report a stats error when fetch disconnects in flight: %s',
|
|
async (message) => {
|
|
const addNotice = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
session.stats.mockRejectedValueOnce(new TypeError(message));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getStats()).rejects.toThrow(message);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('reports non-disconnect stats errors', async () => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.stats.mockRejectedValueOnce(new Error('bad response'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getStats()).rejects.toThrow('bad response');
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'load_stats' }),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['reports', undefined, 2],
|
|
['does not report', true, 0],
|
|
] as const)(
|
|
'%s failed rewind calls when silent is %s',
|
|
async (_name, silent, notices) => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.getRewindSnapshots.mockRejectedValueOnce(new Error('no list'));
|
|
session.rewind.mockRejectedValueOnce(new Error('busy'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getRewindSnapshots({ silent })).rejects.toThrow(
|
|
'no list',
|
|
);
|
|
await expect(
|
|
actions.rewindSession('prompt-1', { rewindFiles: false, silent }),
|
|
).rejects.toThrow('busy');
|
|
expect(addNotice).toHaveBeenCalledTimes(notices);
|
|
},
|
|
);
|
|
|
|
it('only reports a missing context session for non-silent callers', async () => {
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ addNotice });
|
|
|
|
await expect(actions.getContext({ silent: true })).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
|
|
await expect(actions.getContext()).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: 'Load context failed: Daemon session is not connected',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('only reports an in-flight context disconnect for non-silent callers', async () => {
|
|
const addNotice = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
session.context.mockRejectedValue(new DaemonTransportClosedError());
|
|
const { actions, getConnection } = createActionsHarness({
|
|
addNotice,
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workflowModeStatus: 'ready',
|
|
},
|
|
});
|
|
|
|
await expect(actions.getContext({ silent: true })).rejects.toThrow(
|
|
'Transport connection closed',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
expect(getConnection().workflowModeStatus).toBe('unknown');
|
|
|
|
await expect(actions.getContext()).rejects.toThrow(
|
|
'Transport connection closed',
|
|
);
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'load_context' }),
|
|
);
|
|
});
|
|
|
|
it('does not report a context usage error while the session is disconnected', async () => {
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ addNotice });
|
|
|
|
await expect(actions.getContextUsage({ detail: true })).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not report a context usage error when the session disconnects in flight', async () => {
|
|
const addNotice = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage.mockRejectedValueOnce(
|
|
new DaemonTransportClosedError(),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('Transport connection closed');
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports a transient context usage error for non-silent callers', async () => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage.mockRejectedValueOnce(
|
|
new DaemonTransportClosedError(),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getContextUsage({ detail: false })).rejects.toThrow(
|
|
'Transport connection closed',
|
|
);
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'load_context_usage' }),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
'fetch failed',
|
|
'Failed to fetch',
|
|
'NetworkError when attempting to fetch resource',
|
|
'Load failed',
|
|
])(
|
|
'does not report a silent context usage error for a plain network blip: %s',
|
|
async (message) => {
|
|
const addNotice = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
// Plain Error, not TypeError: only the widened predicate matches these.
|
|
session.contextUsage.mockRejectedValueOnce(new Error(message));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow(message);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('records a notice for a silent non-transient context usage error', async () => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage.mockRejectedValueOnce(new Error('bad response'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('bad response');
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'load_context_usage' }),
|
|
);
|
|
});
|
|
|
|
it('dedupes repeated silent hard context usage failures to one notice', async () => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage
|
|
.mockRejectedValueOnce(new Error('bad response'))
|
|
.mockRejectedValueOnce(new Error('bad response'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('bad response');
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('bad response');
|
|
expect(addNotice).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('clears the silent context usage dedupe registry on session teardown', async () => {
|
|
const sessionA = createMockSession('session-a');
|
|
const sessionB = createMockSession('session-b');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
sessionA.contextUsage.mockRejectedValue(new Error('bad response'));
|
|
sessionB.contextUsage.mockRejectedValue(new Error('bad response'));
|
|
const { actions, sessionRef } = createActionsHarness({
|
|
addNotice,
|
|
session: sessionA,
|
|
});
|
|
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('bad response');
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('bad response');
|
|
await actions.clearSession();
|
|
sessionRef.current = sessionB as unknown as DaemonSessionClient;
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('bad response');
|
|
|
|
expect(addNotice).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('records a notice per non-silent context usage hard failure', async () => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage
|
|
.mockRejectedValueOnce(new Error('bad response'))
|
|
.mockRejectedValueOnce(new Error('bad response'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getContextUsage({ detail: true })).rejects.toThrow(
|
|
'bad response',
|
|
);
|
|
await expect(actions.getContextUsage({ detail: true })).rejects.toThrow(
|
|
'bad response',
|
|
);
|
|
expect(addNotice).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not report a silent context usage error for a name-only transport error', async () => {
|
|
const addNotice = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage.mockRejectedValueOnce(
|
|
Object.assign(new Error('serialized transport failure'), {
|
|
name: 'DaemonTransportClosedError',
|
|
}),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.getContextUsage({ detail: true, silent: true }),
|
|
).rejects.toThrow('serialized transport failure');
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports non-transient context usage errors', async () => {
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const session = createMockSession('session-a');
|
|
session.contextUsage.mockRejectedValueOnce(new Error('bad response'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getContextUsage({ detail: true })).rejects.toThrow(
|
|
'bad response',
|
|
);
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'load_context_usage' }),
|
|
);
|
|
});
|
|
|
|
it('clears the previous Goal before starting a fresh session', async () => {
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
goalState: { v: 2, goal: null, activity: 'idle' },
|
|
},
|
|
});
|
|
|
|
await actions.newSession();
|
|
|
|
expect(getConnection().goalState).toBeUndefined();
|
|
});
|
|
|
|
it('rejects a concurrent source-bound branch request', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const first = createDeferred<{
|
|
sessionId: string;
|
|
displayName: string;
|
|
clientId: string;
|
|
}>();
|
|
source.client.branchSession.mockReturnValueOnce(first.promise);
|
|
const { actions } = createActionsHarness({
|
|
session: source,
|
|
});
|
|
|
|
const firstBranch = actions.branchSession({ name: 'First' });
|
|
const secondBranch = actions.branchSession({ name: 'Second' });
|
|
await expect(secondBranch).rejects.toMatchObject({
|
|
name: 'InvalidStateError',
|
|
});
|
|
expect(source.client.branchSession).toHaveBeenCalledOnce();
|
|
|
|
first.resolve({
|
|
sessionId: 'session-b',
|
|
displayName: 'First',
|
|
clientId: 'client-b',
|
|
});
|
|
await expect(firstBranch).resolves.toEqual({
|
|
sessionId: 'session-b',
|
|
displayName: 'First',
|
|
switchStarted: true,
|
|
});
|
|
});
|
|
|
|
it('does not open a branch that resolves after its source is cleared', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const branched = createDeferred<{
|
|
sessionId: string;
|
|
displayName: string;
|
|
clientId: string;
|
|
}>();
|
|
source.client.branchSession.mockReturnValueOnce(branched.promise);
|
|
const { actions, pendingSessionLoadRef, sessionRef } = createActionsHarness(
|
|
{
|
|
session: source,
|
|
},
|
|
);
|
|
|
|
const pending = actions.branchSession({ name: 'Late branch' });
|
|
await actions.clearSession();
|
|
branched.resolve({
|
|
sessionId: 'session-b',
|
|
displayName: 'Late branch',
|
|
clientId: 'client-b',
|
|
});
|
|
|
|
await expect(pending).resolves.toEqual({
|
|
sessionId: 'session-b',
|
|
displayName: 'Late branch',
|
|
switchStarted: false,
|
|
});
|
|
await Promise.resolve();
|
|
expect(sessionRef.current).toBeUndefined();
|
|
expect(pendingSessionLoadRef.current).toBeUndefined();
|
|
expect(source.client.detachSession).toHaveBeenCalledWith(
|
|
'session-b',
|
|
'client-b',
|
|
);
|
|
});
|
|
|
|
it('detaches a late historical worktree branch response', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const branched = createDeferred<{
|
|
sessionId: string;
|
|
displayName: string;
|
|
clientId: string;
|
|
}>();
|
|
source.client.branchSession.mockReturnValueOnce(branched.promise);
|
|
const { actions } = createActionsHarness({ session: source });
|
|
|
|
const pending = actions.branchSession({
|
|
atRecordId: 'checkpoint-1',
|
|
worktree: {},
|
|
});
|
|
await actions.clearSession();
|
|
branched.resolve({
|
|
sessionId: 'session-b',
|
|
displayName: 'Worktree branch',
|
|
clientId: 'client-b',
|
|
});
|
|
|
|
await expect(pending).resolves.toMatchObject({ switchStarted: false });
|
|
await Promise.resolve();
|
|
expect(source.client.detachSession).toHaveBeenCalledWith(
|
|
'session-b',
|
|
'client-b',
|
|
);
|
|
});
|
|
|
|
it('creates from the active session client when the connection matches', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const nextSession = createMockSession('session-b');
|
|
existingSession.client.createOrAttachSession.mockResolvedValue(nextSession);
|
|
const createDetachedSession = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
createDetachedSession,
|
|
session: existingSession,
|
|
});
|
|
|
|
await expect(actions.createSession()).resolves.toBe(nextSession);
|
|
|
|
expect(existingSession.client.createOrAttachSession).toHaveBeenCalledOnce();
|
|
expect(createDetachedSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates a detached session when no active session exists', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const createDetachedSession = vi.fn(async () => nextSession);
|
|
const { actions, sessionRef, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
});
|
|
|
|
await expect(actions.createSession()).resolves.toBe(nextSession);
|
|
|
|
expect(createDetachedSession).toHaveBeenCalledOnce();
|
|
expect(sessionRef.current).toBe(nextSession);
|
|
expect(getConnection()).toMatchObject({
|
|
sessionId: 'session-b',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
});
|
|
});
|
|
|
|
it('publishes the daemon-reported workspace after detached create', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
nextSession.workspaceCwd = '/private/canonical-workspace';
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession: vi.fn(async () => nextSession),
|
|
});
|
|
|
|
await actions.createSession({ workspaceCwd: '/workspace-alias' });
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
sessionContext: {
|
|
kind: 'workspace',
|
|
cwd: '/private/canonical-workspace',
|
|
},
|
|
workspaceCwd: '/private/canonical-workspace',
|
|
});
|
|
});
|
|
|
|
it('clears standalone state after detached workspace create', async () => {
|
|
const nextSession = createMockSession('workspace-b');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'standalone' },
|
|
standaloneSession: {
|
|
projectlessOutputDirectory: '/output/old',
|
|
workingDirectory: { state: 'ready' },
|
|
},
|
|
},
|
|
createDetachedSession: vi.fn(async () => nextSession),
|
|
});
|
|
|
|
await actions.createSession({ workspaceCwd: '/workspace' });
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
});
|
|
expect(getConnection().standaloneSession).toBeUndefined();
|
|
});
|
|
|
|
it('uses the standalone create path without a workspace fallback', async () => {
|
|
const nextSession = createMockSession('standalone-b');
|
|
Object.assign(nextSession, {
|
|
session: {
|
|
sessionId: 'standalone-b',
|
|
workspaceCwd: '/private/standalone-b',
|
|
sourceType: 'standalone',
|
|
context: { kind: 'standalone' },
|
|
projectlessOutputDirectory: '/output/standalone-b',
|
|
workingDirectory: { state: 'ready' },
|
|
},
|
|
});
|
|
const createDetachedSession = vi.fn();
|
|
const createDetachedStandaloneSession = vi.fn(async () => nextSession);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'standalone' },
|
|
},
|
|
createDetachedSession,
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(
|
|
actions.createSession({
|
|
approvalMode: 'yolo',
|
|
modelServiceId: 'qwen3.8-max(USE_OPENAI)',
|
|
}),
|
|
).resolves.toBe(nextSession);
|
|
|
|
expect(createDetachedStandaloneSession).toHaveBeenCalledWith({
|
|
approvalMode: 'yolo',
|
|
modelServiceId: 'qwen3.8-max(USE_OPENAI)',
|
|
});
|
|
expect(createDetachedSession).not.toHaveBeenCalled();
|
|
expect(getConnection()).toMatchObject({
|
|
sessionContext: { kind: 'standalone' },
|
|
workspaceCwd: undefined,
|
|
standaloneSession: {
|
|
projectlessOutputDirectory: '/output/standalone-b',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('uses the default standalone context for a contextless create', async () => {
|
|
const nextSession = createMockSession('standalone-default');
|
|
Object.assign(nextSession, {
|
|
session: {
|
|
sessionId: 'standalone-default',
|
|
workspaceCwd: '/private/standalone-default',
|
|
sourceType: 'standalone',
|
|
context: { kind: 'standalone' },
|
|
workingDirectory: { state: 'ready' },
|
|
},
|
|
});
|
|
const createDetachedSession = vi.fn();
|
|
const createDetachedStandaloneSession = vi.fn(async () => nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
createDetachedStandaloneSession,
|
|
getDefaultSessionContext: () => ({ kind: 'standalone' }),
|
|
});
|
|
|
|
await expect(actions.createSession()).resolves.toBe(nextSession);
|
|
expect(createDetachedStandaloneSession).toHaveBeenCalledOnce();
|
|
expect(createDetachedSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a detached standalone result without replacing an active session', async () => {
|
|
const activeSession = createMockSession('workspace-a');
|
|
const nextSession = createMockSession('standalone-b');
|
|
const standaloneRecord = {
|
|
sessionId: 'standalone-b',
|
|
workspaceCwd: '/private/standalone-b',
|
|
sourceType: 'standalone',
|
|
context: { kind: 'standalone' as const },
|
|
workingDirectory: { state: 'ready' as const },
|
|
};
|
|
Object.assign(nextSession, { session: standaloneRecord });
|
|
const createDetachedStandaloneSession = vi.fn(async () => nextSession);
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'workspace-a',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
};
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection,
|
|
session: activeSession,
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(
|
|
actions.createSession({
|
|
sessionContext: { kind: 'standalone' },
|
|
approvalMode: 'yolo',
|
|
}),
|
|
).resolves.toBe(standaloneRecord);
|
|
expect(createDetachedStandaloneSession).toHaveBeenCalledWith({
|
|
approvalMode: 'yolo',
|
|
});
|
|
expect(activeSession.client.createOrAttachSession).not.toHaveBeenCalled();
|
|
expect(getConnection()).toEqual(connection);
|
|
});
|
|
|
|
it('clears workspace metadata before publishing a fresh standalone session', async () => {
|
|
const nextSession = createMockSession('standalone-b');
|
|
Object.assign(nextSession, {
|
|
session: {
|
|
sessionId: 'standalone-b',
|
|
workspaceCwd: '/private/standalone-b',
|
|
sourceType: 'standalone',
|
|
context: { kind: 'standalone' },
|
|
workingDirectory: { state: 'ready' },
|
|
},
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('workspace-command')],
|
|
skills: ['workspace-skill'],
|
|
models: [{ id: 'workspace-model', label: 'Workspace model' }],
|
|
providers: {
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: true,
|
|
providers: [],
|
|
},
|
|
gitBranch: 'main',
|
|
gitStatus: {} as never,
|
|
},
|
|
createDetachedStandaloneSession: vi.fn(async () => nextSession),
|
|
});
|
|
|
|
await actions.clearSession();
|
|
await actions.createSession({ sessionContext: { kind: 'standalone' } });
|
|
|
|
expect(getConnection().sessionContext).toEqual({ kind: 'standalone' });
|
|
expect(getConnection().workspaceCwd).toBeUndefined();
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().providers).toBeUndefined();
|
|
expect(getConnection().gitBranch).toBeUndefined();
|
|
expect(getConnection().gitStatus).toBeUndefined();
|
|
});
|
|
|
|
it('surfaces standalone create recovery without retrying create', async () => {
|
|
const originalError = new DaemonHttpError(
|
|
503,
|
|
{ code: 'standalone_session_creating' },
|
|
'outcome unknown',
|
|
);
|
|
const error = new DaemonStandaloneCreationOutcomeUnknownError(
|
|
'019cf000-0000-7000-8000-000000000001',
|
|
{
|
|
state: 'creating',
|
|
sessionId: '019cf000-0000-7000-8000-000000000001',
|
|
},
|
|
originalError,
|
|
);
|
|
const createDetachedStandaloneSession = vi.fn(async () => {
|
|
throw error;
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'standalone' },
|
|
},
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(actions.createSession()).rejects.toBe(error);
|
|
|
|
expect(createDetachedStandaloneSession).toHaveBeenCalledOnce();
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'error',
|
|
sessionId: '019cf000-0000-7000-8000-000000000001',
|
|
sessionContext: { kind: 'standalone' },
|
|
workspaceCwd: undefined,
|
|
standaloneSession: {
|
|
creationRecovery: {
|
|
state: 'creating',
|
|
sessionId: '019cf000-0000-7000-8000-000000000001',
|
|
},
|
|
errorCode: 'standalone_session_creating',
|
|
},
|
|
errorStatus: 503,
|
|
});
|
|
});
|
|
|
|
it('publishes standalone recovery after a workspace predecessor is cleared', async () => {
|
|
const originalError = new DaemonHttpError(
|
|
503,
|
|
{ code: 'standalone_session_creating' },
|
|
'outcome unknown',
|
|
);
|
|
const error = new DaemonStandaloneCreationOutcomeUnknownError(
|
|
'019cf000-0000-7000-8000-000000000003',
|
|
{
|
|
state: 'creating',
|
|
sessionId: '019cf000-0000-7000-8000-000000000003',
|
|
},
|
|
originalError,
|
|
);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('workspace-command')],
|
|
skills: ['workspace-skill'],
|
|
models: [{ id: 'workspace-model', label: 'Workspace model' }],
|
|
currentModel: 'workspace-model',
|
|
providers: {
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: true,
|
|
providers: [],
|
|
},
|
|
gitBranch: 'main',
|
|
gitStatus: {} as never,
|
|
},
|
|
createDetachedStandaloneSession: vi.fn(async () => {
|
|
throw error;
|
|
}),
|
|
});
|
|
|
|
await actions.clearSession();
|
|
await expect(
|
|
actions.createSession({ sessionContext: { kind: 'standalone' } }),
|
|
).rejects.toBe(error);
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'error',
|
|
sessionContext: { kind: 'standalone' },
|
|
sessionId: '019cf000-0000-7000-8000-000000000003',
|
|
workspaceCwd: undefined,
|
|
standaloneSession: {
|
|
creationRecovery: {
|
|
state: 'creating',
|
|
sessionId: '019cf000-0000-7000-8000-000000000003',
|
|
},
|
|
},
|
|
});
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().currentModel).toBeUndefined();
|
|
expect(getConnection().providers).toBeUndefined();
|
|
expect(getConnection().gitBranch).toBeUndefined();
|
|
expect(getConnection().gitStatus).toBeUndefined();
|
|
});
|
|
|
|
it('does not replace an active workspace with detached standalone recovery', async () => {
|
|
const activeSession = createMockSession('workspace-a');
|
|
const originalError = new DaemonHttpError(
|
|
503,
|
|
{ code: 'standalone_session_creating' },
|
|
'outcome unknown',
|
|
);
|
|
const error = new DaemonStandaloneCreationOutcomeUnknownError(
|
|
'019cf000-0000-7000-8000-000000000002',
|
|
{
|
|
state: 'creating',
|
|
sessionId: '019cf000-0000-7000-8000-000000000002',
|
|
},
|
|
originalError,
|
|
);
|
|
const createDetachedStandaloneSession = vi.fn(async () => {
|
|
throw error;
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'workspace-a',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
},
|
|
session: activeSession,
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(
|
|
actions.createSession({ sessionContext: { kind: 'standalone' } }),
|
|
).rejects.toBe(error);
|
|
|
|
expect(getConnection()).toEqual({
|
|
status: 'connected',
|
|
sessionId: 'workspace-a',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
});
|
|
});
|
|
|
|
it('rejects create in the Live session context', async () => {
|
|
const createDetachedSession = vi.fn();
|
|
const createDetachedStandaloneSession = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'live' },
|
|
},
|
|
createDetachedSession,
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(actions.createSession()).rejects.toThrow(
|
|
'Live session context does not support create',
|
|
);
|
|
expect(createDetachedSession).not.toHaveBeenCalled();
|
|
expect(createDetachedStandaloneSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects newSession in the Live context without changing state', async () => {
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'live-a',
|
|
sessionContext: { kind: 'live' },
|
|
displayName: 'Live A',
|
|
};
|
|
const { actions, getConnection } = createActionsHarness({ connection });
|
|
|
|
await expect(actions.newSession()).rejects.toThrow(
|
|
'Live session context does not support create',
|
|
);
|
|
expect(getConnection()).toEqual(connection);
|
|
});
|
|
|
|
it.each([
|
|
{ sourceType: 'default' },
|
|
{ worktree: { slug: 'feature' } },
|
|
{ branch: { name: 'feature' } },
|
|
])(
|
|
'rejects workspace-only create options for standalone sessions: $sourceType$worktree.slug$branch.name',
|
|
async (workspaceOnlyOption) => {
|
|
const createDetachedSession = vi.fn();
|
|
const createDetachedStandaloneSession = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'standalone' },
|
|
},
|
|
createDetachedSession,
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(actions.createSession(workspaceOnlyOption)).rejects.toThrow(
|
|
'Standalone session creation does not support sourceType, worktree, or branch options',
|
|
);
|
|
expect(createDetachedSession).not.toHaveBeenCalled();
|
|
expect(createDetachedStandaloneSession).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('rejects a per-call model for workspace creation', async () => {
|
|
const createDetachedSession = vi.fn();
|
|
const createDetachedStandaloneSession = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
},
|
|
createDetachedSession,
|
|
createDetachedStandaloneSession,
|
|
});
|
|
|
|
await expect(
|
|
actions.createSession({ modelServiceId: 'qwen3.8-max(USE_OPENAI)' }),
|
|
).rejects.toThrow(
|
|
'Per-call modelServiceId is only supported for standalone session creation',
|
|
);
|
|
expect(createDetachedSession).not.toHaveBeenCalled();
|
|
expect(createDetachedStandaloneSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not apply the workspace create watchdog to standalone create', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
const nextSession = createMockSession('standalone-b');
|
|
Object.assign(nextSession, {
|
|
session: {
|
|
sessionId: 'standalone-b',
|
|
workspaceCwd: '/private/standalone-b',
|
|
sourceType: 'standalone',
|
|
context: { kind: 'standalone' },
|
|
workingDirectory: { state: 'ready' },
|
|
},
|
|
});
|
|
const { actions } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'standalone' },
|
|
},
|
|
createDetachedStandaloneSession: vi.fn(() => deferred.promise),
|
|
});
|
|
|
|
const pending = actions.createSession();
|
|
let settled = false;
|
|
void pending.then(
|
|
() => {
|
|
settled = true;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
},
|
|
);
|
|
// Past the workspace create watchdog, which this path must not apply.
|
|
await vi.advanceTimersByTimeAsync(
|
|
resolveCreateWatchdogTimeoutMs(undefined) + 1,
|
|
);
|
|
expect(settled).toBe(false);
|
|
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
|
await expect(pending).resolves.toBe(nextSession);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
describe.each(['active', 'detached'] as const)(
|
|
'%s workspace create deadlines',
|
|
(path) => {
|
|
it('keeps the create watchdog above two SDK request budgets plus headroom', async () => {
|
|
vi.useFakeTimers();
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
try {
|
|
const { client, fetch } = createTimedCreateClient({
|
|
sessionDelayMs: 120_000,
|
|
});
|
|
const requestStarted = Date.now();
|
|
const request = client
|
|
.createOrAttachSession({})
|
|
.catch((error: unknown) => ({
|
|
error,
|
|
elapsedMs: Date.now() - requestStarted,
|
|
}));
|
|
await vi.runAllTimersAsync();
|
|
const requestResult = await request;
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
expect(requestResult).toMatchObject({
|
|
error: { name: 'TimeoutError' },
|
|
});
|
|
if (!('elapsedMs' in requestResult))
|
|
throw new Error('Expected SDK timeout');
|
|
const singleRequestBudgetMs = requestResult.elapsedMs;
|
|
|
|
const { client: preflightClient } = createTimedCreateClient({
|
|
capabilitiesDelayMs: 120_000,
|
|
});
|
|
const preflightStarted = Date.now();
|
|
const preflight = preflightClient
|
|
.capabilities()
|
|
.catch((error: unknown) => ({
|
|
error,
|
|
elapsedMs: Date.now() - preflightStarted,
|
|
}));
|
|
await vi.runAllTimersAsync();
|
|
const preflightResult = await preflight;
|
|
expect(preflightResult).toMatchObject({
|
|
error: { name: 'TimeoutError' },
|
|
});
|
|
if (!('elapsedMs' in preflightResult))
|
|
throw new Error('Expected capability preflight timeout');
|
|
// A create is sized from the daemon's advertised session budget while
|
|
// a capability preflight stays on the generic request deadline, so the
|
|
// two budgets differ; the watchdog assertion below covers their sum.
|
|
expect(singleRequestBudgetMs).toBeGreaterThan(
|
|
preflightResult.elapsedMs,
|
|
);
|
|
|
|
const { actions, existing } = createWorkspaceCreateHarness(
|
|
path,
|
|
client,
|
|
() => deferred.promise,
|
|
);
|
|
existing.client.createOrAttachSession.mockReturnValue(
|
|
deferred.promise,
|
|
);
|
|
const createStarted = Date.now();
|
|
const create = actions.createSession().catch((error: unknown) => ({
|
|
error,
|
|
elapsedMs: Date.now() - createStarted,
|
|
}));
|
|
await vi.runAllTimersAsync();
|
|
const createResult = await create;
|
|
expect(createResult).toMatchObject({
|
|
error: {
|
|
message: expect.stringContaining(
|
|
'Create session timed out after',
|
|
),
|
|
},
|
|
});
|
|
if (!('elapsedMs' in createResult))
|
|
throw new Error('Expected create timeout');
|
|
expect(createResult.elapsedMs).toBeGreaterThanOrEqual(
|
|
preflightResult.elapsedMs + singleRequestBudgetMs + 15_000,
|
|
);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
deferred.resolve(
|
|
createMockSession('session-b') as unknown as DaemonSessionClient,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('bounds a hung ACP initialization with the create watchdog', async () => {
|
|
vi.useFakeTimers();
|
|
const initialization = createDeferred<Response>();
|
|
const transport = new AcpHttpTransport(
|
|
'http://localhost',
|
|
'',
|
|
vi.fn(() => initialization.promise),
|
|
);
|
|
try {
|
|
const client = new DaemonClient({
|
|
baseUrl: 'http://localhost',
|
|
token: '',
|
|
transport,
|
|
});
|
|
const { actions, sessionRef } = createWorkspaceCreateHarness(
|
|
path,
|
|
client,
|
|
);
|
|
const watchdogTimeoutMs = resolveCreateWatchdogTimeoutMs(undefined);
|
|
const rejected = vi.fn();
|
|
const pending = actions
|
|
.createSession({ sourceType: 'default' })
|
|
.catch(rejected);
|
|
await vi.advanceTimersByTimeAsync(watchdogTimeoutMs - 1);
|
|
expect(rejected).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(rejected).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
message: `Create session timed out after ${watchdogTimeoutMs}ms`,
|
|
}),
|
|
);
|
|
await pending;
|
|
expect(sessionRef.current?.sessionId).toBe(
|
|
path === 'active' ? 'session-a' : undefined,
|
|
);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
initialization.reject(new Error('Test cleanup'));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
transport.dispose();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('sizes the watchdog from the advertised budget, not the fallback', async () => {
|
|
// The call sites pass `getConnection().capabilities`; a harness with no
|
|
// advertised budget cannot tell that argument from `undefined`.
|
|
vi.useFakeTimers();
|
|
const initialization = createDeferred<Response>();
|
|
const transport = new AcpHttpTransport(
|
|
'http://localhost',
|
|
'',
|
|
vi.fn(() => initialization.promise),
|
|
);
|
|
try {
|
|
const client = new DaemonClient({
|
|
baseUrl: 'http://localhost',
|
|
token: '',
|
|
transport,
|
|
});
|
|
const { actions } = createWorkspaceCreateHarness(
|
|
path,
|
|
client,
|
|
undefined,
|
|
advertisingRestoreBudget(90_000),
|
|
);
|
|
const rejected = vi.fn();
|
|
const pending = actions
|
|
.createSession({ sourceType: 'default' })
|
|
.catch(rejected);
|
|
await vi.advanceTimersByTimeAsync(144_999);
|
|
expect(rejected).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(rejected).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
message: 'Create session timed out after 145000ms',
|
|
}),
|
|
);
|
|
await pending;
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
initialization.reject(new Error('Test cleanup'));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
transport.dispose();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('detaches a successful result arriving after the create watchdog', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const created = createMockSession('session-b');
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
const client = new DaemonClient({
|
|
baseUrl: 'http://localhost',
|
|
token: '',
|
|
});
|
|
const { actions, sessionRef, existing } =
|
|
createWorkspaceCreateHarness(path, client, () => deferred.promise);
|
|
existing.client.createOrAttachSession.mockReturnValue(
|
|
deferred.promise,
|
|
);
|
|
const watchdogTimeoutMs = resolveCreateWatchdogTimeoutMs(undefined);
|
|
const rejected = vi.fn();
|
|
const pending = actions.createSession().catch(rejected);
|
|
await vi.advanceTimersByTimeAsync(watchdogTimeoutMs);
|
|
expect(rejected).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
message: `Create session timed out after ${watchdogTimeoutMs}ms`,
|
|
}),
|
|
);
|
|
await pending;
|
|
deferred.resolve(created as unknown as DaemonSessionClient);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
if (path === 'active') {
|
|
expect(
|
|
existing.client.detachSession,
|
|
).toHaveBeenCalledExactlyOnceWith(
|
|
created.sessionId,
|
|
created.clientId,
|
|
);
|
|
expect(created.detach).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(created.detach).toHaveBeenCalledOnce();
|
|
}
|
|
expect(sessionRef.current?.sessionId).toBe(
|
|
path === 'active' ? 'session-a' : undefined,
|
|
);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ cache: 'cold', capabilitiesDelayMs: 20_000, sessionDelayMs: 15_000 },
|
|
{
|
|
cache: 'expired',
|
|
capabilitiesDelayMs: 20_000,
|
|
sessionDelayMs: 15_000,
|
|
},
|
|
{ cache: 'warm', capabilitiesDelayMs: 20_000, sessionDelayMs: 15_000 },
|
|
{ cache: 'cold', capabilitiesDelayMs: 29_000, sessionDelayMs: 29_000 },
|
|
] as const)(
|
|
'waits for $capabilitiesDelayMs/$sessionDelayMs ms SDK requests with a $cache capability cache',
|
|
async ({ cache, capabilitiesDelayMs, sessionDelayMs }) => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { client, fetch, signals } = createTimedCreateClient({
|
|
capabilitiesDelayMs,
|
|
sessionDelayMs,
|
|
});
|
|
if (cache !== 'cold') {
|
|
const preflight = client.capabilities();
|
|
await vi.advanceTimersByTimeAsync(capabilitiesDelayMs);
|
|
await preflight;
|
|
if (cache === 'expired') {
|
|
await vi.advanceTimersByTimeAsync(60_001);
|
|
}
|
|
fetch.mockClear();
|
|
}
|
|
const detach = vi.spyOn(client, 'detachSession');
|
|
const { actions, sessionRef, existing } =
|
|
createWorkspaceCreateHarness(path, client);
|
|
const completed = vi.fn();
|
|
const pending = actions
|
|
.createSession({ sourceType: 'default' })
|
|
.then(completed);
|
|
if (cache !== 'warm') {
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
expect(completed).not.toHaveBeenCalled();
|
|
expect(signals.every((signal) => !signal.aborted)).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(
|
|
capabilitiesDelayMs + sessionDelayMs - 30_001,
|
|
);
|
|
} else {
|
|
await vi.advanceTimersByTimeAsync(sessionDelayMs);
|
|
}
|
|
await pending;
|
|
expect(completed).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({ sessionId: 'session-b' }),
|
|
);
|
|
expect(
|
|
fetch.mock.calls.map(([url]) => new URL(String(url)).pathname),
|
|
).toEqual(
|
|
cache === 'warm' ? ['/session'] : ['/capabilities', '/session'],
|
|
);
|
|
expect(detach).not.toHaveBeenCalled();
|
|
expect(existing.client.detachSession).not.toHaveBeenCalled();
|
|
if (path === 'detached') {
|
|
expect(sessionRef.current?.sessionId).toBe('session-b');
|
|
}
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(['missing capability', 'request timeout'] as const)(
|
|
'preserves SDK failure for %s',
|
|
async (failure) => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { client, fetch, signals } = createTimedCreateClient({
|
|
features: failure === 'missing capability' ? [] : undefined,
|
|
// Past the SDK create deadline, so the request times out instead
|
|
// of succeeding.
|
|
sessionDelayMs:
|
|
failure === 'request timeout' ? 120_000 : undefined,
|
|
});
|
|
const { actions } = createWorkspaceCreateHarness(path, client);
|
|
const rejected = vi.fn((error: unknown) => error);
|
|
const outcome = actions
|
|
.createSession({ sourceType: 'default' })
|
|
.catch(rejected);
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
if (failure === 'request timeout') {
|
|
// One tick short of the SDK create deadline: the advertised
|
|
// session budget plus request headroom.
|
|
const createBudgetMs =
|
|
resolveSessionRestoreTimeouts(undefined).requestTimeoutMs;
|
|
await vi.advanceTimersByTimeAsync(createBudgetMs - 1);
|
|
expect(rejected).not.toHaveBeenCalled();
|
|
expect(signals.at(-1)?.aborted).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(rejected).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({ name: 'TimeoutError' }),
|
|
);
|
|
}
|
|
expect(await outcome).toMatchObject({
|
|
name:
|
|
failure === 'request timeout'
|
|
? 'TimeoutError'
|
|
: 'DaemonCapabilityMissingError',
|
|
});
|
|
expect(fetch).toHaveBeenCalledTimes(
|
|
failure === 'request timeout' ? 2 : 1,
|
|
);
|
|
expect(signals.at(-1)?.aborted).toBe(failure === 'request timeout');
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
it('continues first-prompt preparation once after a slow workspace create', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { client } = createTimedCreateClient();
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession: vi.fn((workspaceCwd, overrides) =>
|
|
DaemonSessionClient.createOrAttach(client, {
|
|
workspaceCwd,
|
|
...overrides,
|
|
}),
|
|
),
|
|
});
|
|
const attachSession = vi.fn(async () => {});
|
|
const onSessionCreated = vi.fn();
|
|
const submitPrompt = vi.fn();
|
|
const pending = createAndAttachSessionForPrompt({
|
|
sessionActions: { ...actions, attachSession },
|
|
workspaceCwd: '/workspace',
|
|
onSessionCreated,
|
|
getCurrentSessionId: () => getConnection().sessionId,
|
|
}).then(submitPrompt);
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
expect(submitPrompt).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
await pending;
|
|
expect(onSessionCreated).toHaveBeenCalledExactlyOnceWith('session-b');
|
|
expect(attachSession).toHaveBeenCalledOnce();
|
|
expect(submitPrompt).toHaveBeenCalledOnce();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('forwards options.workspaceCwd to the detached create branch', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const createDetachedSession = vi.fn(async () => nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
});
|
|
|
|
await actions.createSession({ workspaceCwd: '/ws/secondary' });
|
|
|
|
expect(createDetachedSession).toHaveBeenCalledWith('/ws/secondary', {});
|
|
});
|
|
|
|
it('omits the workspaceCwd override on the detached branch by default', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const createDetachedSession = vi.fn(async () => nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
});
|
|
|
|
await actions.createSession();
|
|
|
|
expect(createDetachedSession).toHaveBeenCalledWith(undefined, {});
|
|
});
|
|
|
|
it('forwards options.approvalMode to the detached create branch', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const createDetachedSession = vi.fn(async () => nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
});
|
|
|
|
await actions.createSession({ approvalMode: 'yolo' });
|
|
|
|
expect(createDetachedSession).toHaveBeenCalledWith(undefined, {
|
|
approvalMode: 'yolo',
|
|
});
|
|
});
|
|
|
|
it('forwards options.sourceType to the detached create branch', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const createDetachedSession = vi.fn(async () => nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
});
|
|
|
|
await actions.createSession({ sourceType: 'default' });
|
|
|
|
expect(createDetachedSession).toHaveBeenCalledWith(undefined, {
|
|
sourceType: 'default',
|
|
});
|
|
});
|
|
|
|
it('merges options.workspaceCwd into the active session request', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const nextSession = createMockSession('session-b');
|
|
existingSession.client.createOrAttachSession.mockResolvedValue(nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: existingSession,
|
|
});
|
|
|
|
await actions.createSession({ workspaceCwd: '/ws/secondary' });
|
|
|
|
expect(existingSession.client.createOrAttachSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({ workspaceCwd: '/ws/secondary' }),
|
|
);
|
|
});
|
|
|
|
it('folds options.approvalMode into the active session request', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const nextSession = createMockSession('session-b');
|
|
existingSession.client.createOrAttachSession.mockResolvedValue(nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: existingSession,
|
|
});
|
|
|
|
await actions.createSession({ approvalMode: 'yolo' });
|
|
|
|
expect(existingSession.client.createOrAttachSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({ approvalMode: 'yolo' }),
|
|
);
|
|
});
|
|
|
|
it('folds options.sourceType into the active session request', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const nextSession = createMockSession('session-b');
|
|
existingSession.client.createOrAttachSession.mockResolvedValue(nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: existingSession,
|
|
});
|
|
|
|
await actions.createSession({ sourceType: 'default' });
|
|
|
|
expect(existingSession.client.createOrAttachSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sourceType: 'default' }),
|
|
);
|
|
});
|
|
|
|
it('does not replace navigation after a slow detached create', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const nextSession = createMockSession('session-b');
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
const manualSessionClearRef = { current: false };
|
|
const { actions, sessionRef, getConnection, replaceConnection } =
|
|
createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession: vi.fn(() => deferred.promise),
|
|
manualSessionClearRef,
|
|
});
|
|
const outcome = actions.createSession().catch((error: unknown) => error);
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
const navigatedConnection: DaemonConnectionState = {
|
|
status: 'connecting',
|
|
sessionId: 'session-c',
|
|
sessionContext: { kind: 'workspace', cwd: '/other-workspace' },
|
|
};
|
|
replaceConnection(navigatedConnection);
|
|
await vi.advanceTimersByTimeAsync(9_999);
|
|
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
|
|
|
expect(await outcome).toMatchObject({
|
|
name: 'AbortError',
|
|
message: 'Session creation interrupted',
|
|
});
|
|
expect(manualSessionClearRef.current).toBe(false);
|
|
expect(nextSession.detach).toHaveBeenCalledOnce();
|
|
expect(getConnection()).toBe(navigatedConnection);
|
|
expect(sessionRef.current).toBeUndefined();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not publish a slow detached create after session-less workspace navigation', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const nextSession = createMockSession('session-b');
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
let selectedWorkspaceCwd = '/workspace';
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
};
|
|
const publishedSessionIds: Array<string | undefined> = [];
|
|
const { actions, sessionRef, getConnection } = createActionsHarness({
|
|
connection,
|
|
createDetachedSession: vi.fn(() => deferred.promise),
|
|
onConnectionChange: (next) => publishedSessionIds.push(next.sessionId),
|
|
});
|
|
const outcome = actions
|
|
.createSession({
|
|
workspaceCwd: '/workspace',
|
|
getCurrentWorkspaceCwd: () => selectedWorkspaceCwd,
|
|
})
|
|
.catch((error: unknown) => error);
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
selectedWorkspaceCwd = '/other-workspace';
|
|
expect(getConnection()).toBe(connection);
|
|
await vi.advanceTimersByTimeAsync(25_000);
|
|
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
|
|
|
expect(await outcome).toMatchObject({ name: 'AbortError' });
|
|
expect(nextSession.detach).toHaveBeenCalledOnce();
|
|
expect(sessionRef.current).toBeUndefined();
|
|
expect(getConnection()).toBe(connection);
|
|
expect(publishedSessionIds).not.toContain('session-b');
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'equivalent paths',
|
|
target: '/workspace',
|
|
current: '/workspace///',
|
|
attached: false,
|
|
},
|
|
{
|
|
name: 'another checkout',
|
|
target: '/other-checkout',
|
|
current: '/workspace',
|
|
attached: false,
|
|
},
|
|
{
|
|
name: 'the returned session already attached',
|
|
target: '/other-checkout',
|
|
current: '/other-checkout',
|
|
attached: true,
|
|
},
|
|
])(
|
|
'accepts detached creation with $name',
|
|
async ({ target, current, attached }) => {
|
|
const nextSession = createMockSession('session-b');
|
|
nextSession.workspaceCwd = target;
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
let selectedWorkspaceCwd = '/workspace';
|
|
const { actions, sessionRef, getConnection, replaceConnection } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
},
|
|
createDetachedSession: vi.fn(() => deferred.promise),
|
|
});
|
|
const pending = actions.createSession({
|
|
workspaceCwd: target,
|
|
getCurrentWorkspaceCwd: () => selectedWorkspaceCwd,
|
|
});
|
|
selectedWorkspaceCwd = current;
|
|
if (attached) {
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: nextSession.sessionId,
|
|
sessionContext: { kind: 'workspace', cwd: target },
|
|
});
|
|
}
|
|
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
|
|
|
await expect(pending).resolves.toBe(nextSession);
|
|
expect(nextSession.detach).not.toHaveBeenCalled();
|
|
expect(sessionRef.current).toBe(nextSession);
|
|
expect(getConnection().sessionId).toBe('session-b');
|
|
},
|
|
);
|
|
|
|
it('stops first-prompt preparation before callbacks after workspace navigation', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
let selectedWorkspaceCwd = '/workspace';
|
|
const { actions, getConnection, replaceConnection } = createActionsHarness({
|
|
createDetachedSession: vi.fn(() => deferred.promise),
|
|
});
|
|
const attachSession = vi.spyOn(actions, 'attachSession');
|
|
const clearSession = vi.spyOn(actions, 'clearSession');
|
|
const onSessionCreated = vi.fn();
|
|
const onSessionAllocated = vi.fn();
|
|
const pending = createAndAttachSessionForPrompt({
|
|
sessionActions: actions,
|
|
workspaceCwd: '/workspace',
|
|
getCurrentWorkspaceCwd: () => selectedWorkspaceCwd,
|
|
getCurrentSessionId: () => getConnection().sessionId,
|
|
onSessionCreated,
|
|
onSessionAllocated,
|
|
});
|
|
selectedWorkspaceCwd = '/other-workspace';
|
|
const navigatedConnection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: 'session-c',
|
|
sessionContext: { kind: 'workspace', cwd: selectedWorkspaceCwd },
|
|
};
|
|
replaceConnection(navigatedConnection);
|
|
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
|
|
|
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
|
expect(nextSession.detach).toHaveBeenCalledOnce();
|
|
expect(onSessionCreated).not.toHaveBeenCalled();
|
|
expect(onSessionAllocated).not.toHaveBeenCalled();
|
|
expect(attachSession).not.toHaveBeenCalled();
|
|
expect(clearSession).not.toHaveBeenCalled();
|
|
expect(getConnection()).toBe(navigatedConnection);
|
|
});
|
|
|
|
it('does not restore a detached session after the session was cleared', async () => {
|
|
const nextSession = createMockSession('session-b');
|
|
const deferred = createDeferred<DaemonSessionClient>();
|
|
const manualSessionClearRef = { current: false };
|
|
const createDetachedSession = vi.fn(() => deferred.promise);
|
|
const { actions, sessionRef, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected' },
|
|
createDetachedSession,
|
|
manualSessionClearRef,
|
|
});
|
|
|
|
const createPromise = actions.createSession();
|
|
manualSessionClearRef.current = true;
|
|
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
|
|
|
await expect(createPromise).rejects.toMatchObject({
|
|
name: 'AbortError',
|
|
message: 'Session creation interrupted',
|
|
});
|
|
expect(nextSession.detach).toHaveBeenCalledOnce();
|
|
expect(sessionRef.current).toBeUndefined();
|
|
expect(getConnection()).not.toHaveProperty('sessionId');
|
|
});
|
|
|
|
it('clears the active session while a session switch is loading', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const { actions, getConnection, pendingSessionLoadRef, sessionRef } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
goalState: { v: 2, goal: null, activity: 'idle' },
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions.loadSession('session-b').catch(() => undefined);
|
|
|
|
expect(existingSession.detach).toHaveBeenCalledOnce();
|
|
expect(existingSession.cancel).not.toHaveBeenCalled();
|
|
expect(existingSession.submitPrompt).not.toHaveBeenCalled();
|
|
expect(sessionRef.current).toBeUndefined();
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'session-b',
|
|
loadingTranscript: true,
|
|
catchingUp: undefined,
|
|
});
|
|
expect(pendingSessionLoadRef.current).toMatchObject({
|
|
sessionId: 'session-b',
|
|
requestTimeoutMs: 70_000,
|
|
});
|
|
expect(getConnection().goalState).toBeUndefined();
|
|
});
|
|
|
|
it('drops workspace previews when switching to a standalone target', () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('workspace-command')],
|
|
skills: ['workspace-skill'],
|
|
models: [{ id: 'workspace-model', label: 'Workspace model' }],
|
|
providers: {
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: true,
|
|
providers: [],
|
|
},
|
|
gitBranch: 'main',
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions
|
|
.loadSession('standalone-b', {
|
|
sessionContext: { kind: 'standalone' },
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'standalone-b',
|
|
sessionContext: { kind: 'standalone' },
|
|
loadingTranscript: true,
|
|
});
|
|
expect(getConnection().workspaceCwd).toBeUndefined();
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().providers).toBeUndefined();
|
|
expect(getConnection().gitBranch).toBeUndefined();
|
|
});
|
|
|
|
it('drops stale previews when switching between standalone sessions', () => {
|
|
const existingSession = createMockSession('standalone-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'standalone-a',
|
|
sessionContext: { kind: 'standalone' },
|
|
commands: [commandInfo('standalone-command')],
|
|
skills: ['standalone-skill'],
|
|
models: [{ id: 'standalone-model', label: 'Standalone model' }],
|
|
currentModel: 'standalone-model',
|
|
currentMode: 'yolo',
|
|
contextWindow: 32_000,
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions
|
|
.loadSession('standalone-b', {
|
|
sessionContext: { kind: 'standalone' },
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'standalone-b',
|
|
sessionContext: { kind: 'standalone' },
|
|
});
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().currentModel).toBeUndefined();
|
|
expect(getConnection().currentMode).toBeUndefined();
|
|
expect(getConnection().contextWindow).toBeUndefined();
|
|
});
|
|
|
|
it('drops stale previews when switching between Live sessions', () => {
|
|
const existingSession = createMockSession('live-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'live-a',
|
|
sessionContext: { kind: 'live' },
|
|
commands: [commandInfo('live-command')],
|
|
skills: ['live-skill'],
|
|
models: [{ id: 'live-model', label: 'Live model' }],
|
|
currentModel: 'live-model',
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions
|
|
.loadSession('live-b', { sessionContext: { kind: 'live' } })
|
|
.catch(() => undefined);
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'live-b',
|
|
sessionContext: { kind: 'live' },
|
|
});
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().currentModel).toBeUndefined();
|
|
});
|
|
|
|
it('drops standalone previews when switching to a workspace target', () => {
|
|
const existingSession = createMockSession('standalone-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'standalone-a',
|
|
sessionContext: { kind: 'standalone' },
|
|
commands: [commandInfo('standalone-command')],
|
|
skills: ['standalone-skill'],
|
|
models: [{ id: 'standalone-model', label: 'Standalone model' }],
|
|
currentModel: 'standalone-model',
|
|
currentMode: 'yolo',
|
|
contextWindow: 32_000,
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions
|
|
.loadSession('workspace-b', {
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'workspace-b',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
loadingTranscript: true,
|
|
});
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().currentModel).toBeUndefined();
|
|
expect(getConnection().currentMode).toBeUndefined();
|
|
expect(getConnection().contextWindow).toBeUndefined();
|
|
});
|
|
|
|
it('drops workspace previews when switching to a Live target', () => {
|
|
const existingSession = createMockSession('workspace-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'workspace-a',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('workspace-command')],
|
|
skills: ['workspace-skill'],
|
|
models: [{ id: 'workspace-model', label: 'Workspace model' }],
|
|
currentModel: 'workspace-model',
|
|
contextWindow: 32_000,
|
|
providers: {
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: true,
|
|
providers: [],
|
|
},
|
|
gitBranch: 'main',
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions
|
|
.loadSession('live-b', { sessionContext: { kind: 'live' } })
|
|
.catch(() => undefined);
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'live-b',
|
|
sessionContext: { kind: 'live' },
|
|
});
|
|
expect(getConnection().workspaceCwd).toBeUndefined();
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().currentModel).toBeUndefined();
|
|
expect(getConnection().contextWindow).toBeUndefined();
|
|
expect(getConnection().providers).toBeUndefined();
|
|
expect(getConnection().gitBranch).toBeUndefined();
|
|
});
|
|
|
|
it('drops Live previews when switching to a workspace target', () => {
|
|
const existingSession = createMockSession('live-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'live-a',
|
|
sessionContext: { kind: 'live' },
|
|
commands: [commandInfo('live-command')],
|
|
skills: ['live-skill'],
|
|
models: [{ id: 'live-model', label: 'Live model' }],
|
|
currentModel: 'live-model',
|
|
contextWindow: 32_000,
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions
|
|
.loadSession('workspace-b', {
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'workspace-b',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
workspaceCwd: '/workspace',
|
|
});
|
|
expect(getConnection().commands).toBeUndefined();
|
|
expect(getConnection().skills).toBeUndefined();
|
|
expect(getConnection().models).toBeUndefined();
|
|
expect(getConnection().currentModel).toBeUndefined();
|
|
expect(getConnection().contextWindow).toBeUndefined();
|
|
});
|
|
|
|
it('carries the daemon-advertised restore budget into the load request', async () => {
|
|
// Live path for the whole chain: advertised capability -> connection ->
|
|
// resolveSessionRestoreTimeouts -> pending load. Dropping the capabilities
|
|
// argument at the real call site leaves every default-budget assertion
|
|
// green, so this is the only test that fails when the advertised budget
|
|
// stops reaching the SDK.
|
|
const existingSession = createMockSession('session-a');
|
|
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
capabilities: advertisingRestoreBudget(90_000),
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
void actions.loadSession('session-b').catch(() => undefined);
|
|
|
|
expect(pendingSessionLoadRef.current).toMatchObject({
|
|
sessionId: 'session-b',
|
|
requestTimeoutMs: 100_000,
|
|
});
|
|
});
|
|
|
|
it('detaches the old same-session attachment after its replacement loads', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const { actions, getConnection, pendingSessionLoadRef, sessionRef, store } =
|
|
createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: existingSession,
|
|
});
|
|
|
|
const loadPromise = actions.loadSession('session-a');
|
|
|
|
expect(existingSession.detach).not.toHaveBeenCalled();
|
|
expect(sessionRef.current).toBe(existingSession);
|
|
expect(store.reset).not.toHaveBeenCalled();
|
|
expect(getConnection()).toEqual({
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
});
|
|
|
|
const pendingLoad = pendingSessionLoadRef.current;
|
|
pendingSessionLoadRef.current = undefined;
|
|
clearTimeout(pendingLoad?.timeout);
|
|
pendingLoad?.resolve();
|
|
await loadPromise;
|
|
expect(existingSession.detach).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('clears the transcript when the same session id changes workspace', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
existingSession.workspaceCwd = '/work/a';
|
|
const { actions, getConnection, pendingSessionLoadRef, sessionRef, store } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/work/a',
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
const loadPromise = actions.loadSession('session-a', {
|
|
workspaceCwd: '/work/b',
|
|
});
|
|
|
|
expect(existingSession.detach).toHaveBeenCalledOnce();
|
|
expect(sessionRef.current).toBeUndefined();
|
|
expect(store.reset).toHaveBeenCalledOnce();
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/work/b',
|
|
loadingTranscript: true,
|
|
});
|
|
|
|
const pendingLoad = pendingSessionLoadRef.current;
|
|
pendingSessionLoadRef.current = undefined;
|
|
clearTimeout(pendingLoad?.timeout);
|
|
pendingLoad?.resolve();
|
|
await loadPromise;
|
|
});
|
|
|
|
it('keeps the old same-session attachment when its replacement fails', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const { actions, pendingSessionLoadRef, sessionRef } = createActionsHarness(
|
|
{
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: existingSession,
|
|
},
|
|
);
|
|
|
|
const loadPromise = actions.loadSession('session-a');
|
|
const pendingLoad = pendingSessionLoadRef.current;
|
|
pendingSessionLoadRef.current = undefined;
|
|
clearTimeout(pendingLoad?.timeout);
|
|
pendingLoad?.reject(new Error('load failed'));
|
|
|
|
await expect(loadPromise).rejects.toThrow('load failed');
|
|
expect(existingSession.detach).not.toHaveBeenCalled();
|
|
expect(sessionRef.current).toBe(existingSession);
|
|
});
|
|
|
|
it('does not start a session reload with an aborted signal', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const { actions, pendingSessionLoadRef, sessionRef, store } =
|
|
createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: existingSession,
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
await expect(
|
|
actions.reloadSession(controller.signal),
|
|
).rejects.toMatchObject({ name: 'AbortError' });
|
|
|
|
expect(pendingSessionLoadRef.current).toBeUndefined();
|
|
expect(sessionRef.current).toBe(existingSession);
|
|
expect(existingSession.detach).not.toHaveBeenCalled();
|
|
expect(store.reset).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the reload abort signal with the pending load', () => {
|
|
const controller = new AbortController();
|
|
const clearLiveJournalRepair = vi.fn();
|
|
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
|
clearLiveJournalRepair,
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: createMockSession('session-a'),
|
|
});
|
|
|
|
void actions
|
|
.reloadSession(controller.signal, { replaySource: 'memory' })
|
|
.catch(() => undefined);
|
|
|
|
expect(pendingSessionLoadRef.current?.signal).toBe(controller.signal);
|
|
expect(pendingSessionLoadRef.current?.replaySource).toBe('memory');
|
|
expect(clearLiveJournalRepair).not.toHaveBeenCalled();
|
|
clearTimeout(pendingSessionLoadRef.current?.timeout);
|
|
pendingSessionLoadRef.current?.reject(
|
|
new DOMException('Test cleanup', 'AbortError'),
|
|
);
|
|
pendingSessionLoadRef.current = undefined;
|
|
});
|
|
|
|
it('clears live journal repair state for a configured reload', () => {
|
|
const controller = new AbortController();
|
|
const clearLiveJournalRepair = vi.fn();
|
|
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
|
clearLiveJournalRepair,
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session: createMockSession('session-a'),
|
|
});
|
|
|
|
void actions.reloadSession(controller.signal).catch(() => undefined);
|
|
|
|
expect(clearLiveJournalRepair).toHaveBeenCalledOnce();
|
|
clearTimeout(pendingSessionLoadRef.current?.timeout);
|
|
pendingSessionLoadRef.current?.reject(
|
|
new DOMException('Test cleanup', 'AbortError'),
|
|
);
|
|
pendingSessionLoadRef.current = undefined;
|
|
});
|
|
|
|
it('keeps the active workspace when a session load omits one', () => {
|
|
const setRestoreSessionContext = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace/secondary',
|
|
},
|
|
setRestoreSessionContext,
|
|
});
|
|
|
|
void actions.loadSession('session-b').catch(() => undefined);
|
|
|
|
expect(setRestoreSessionContext).toHaveBeenCalledWith({
|
|
kind: 'workspace',
|
|
cwd: '/workspace/secondary',
|
|
});
|
|
});
|
|
|
|
it('uses the default standalone context after a failed connection', () => {
|
|
const setRestoreSessionContext = vi.fn();
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'error',
|
|
error: 'previous load failed',
|
|
sessionContext: { kind: 'workspace', cwd: '/failed-target' },
|
|
workspaceCwd: '/failed-target',
|
|
},
|
|
getDefaultSessionContext: () => ({ kind: 'standalone' }),
|
|
setRestoreSessionContext,
|
|
});
|
|
|
|
void actions.loadSession('standalone-b').catch(() => undefined);
|
|
|
|
expect(setRestoreSessionContext).toHaveBeenCalledWith({
|
|
kind: 'standalone',
|
|
});
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'standalone-b',
|
|
sessionContext: { kind: 'standalone' },
|
|
workspaceCwd: undefined,
|
|
});
|
|
});
|
|
|
|
it('does not inherit a failed load target workspace on the next switch', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
existingSession.workspaceCwd = '/work/a';
|
|
const setRestoreSessionContext = vi.fn();
|
|
const { actions, getConnection, pendingSessionLoadRef } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/work/a',
|
|
},
|
|
session: existingSession,
|
|
setRestoreSessionContext,
|
|
});
|
|
|
|
const first = actions.loadSession('session-b', {
|
|
workspaceCwd: '/work/b',
|
|
});
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'session-b',
|
|
workspaceCwd: '/work/b',
|
|
loadingTranscript: true,
|
|
});
|
|
|
|
const pendingLoad = pendingSessionLoadRef.current;
|
|
pendingSessionLoadRef.current = undefined;
|
|
clearTimeout(pendingLoad?.timeout);
|
|
pendingLoad?.reject(new Error('load failed'));
|
|
|
|
await expect(first).rejects.toThrow('load failed');
|
|
// The failed target stays visible for the error state...
|
|
expect(getConnection()).toMatchObject({
|
|
sessionId: 'session-b',
|
|
workspaceCwd: '/work/b',
|
|
error: 'load failed',
|
|
});
|
|
|
|
// ...but the next workspace-less switch must not inherit it.
|
|
void actions.loadSession('session-c');
|
|
expect(setRestoreSessionContext).toHaveBeenLastCalledWith(undefined);
|
|
});
|
|
|
|
it('does not roll back the workspace for a superseded load', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
existingSession.workspaceCwd = '/work/a';
|
|
const { actions, getConnection, pendingSessionLoadRef } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/work/a',
|
|
},
|
|
session: existingSession,
|
|
});
|
|
|
|
const first = actions.loadSession('session-b', { workspaceCwd: '/work/b' });
|
|
const second = actions.loadSession('session-c', {
|
|
workspaceCwd: '/work/c',
|
|
});
|
|
|
|
// The first load was superseded; its rejection must not roll the
|
|
// workspace back over the second load's connecting state.
|
|
await expect(first).rejects.toMatchObject({ name: 'AbortError' });
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'session-c',
|
|
workspaceCwd: '/work/c',
|
|
});
|
|
|
|
const pendingLoad = pendingSessionLoadRef.current;
|
|
pendingSessionLoadRef.current = undefined;
|
|
clearTimeout(pendingLoad?.timeout);
|
|
pendingLoad?.resolve();
|
|
await second;
|
|
});
|
|
|
|
it('forwards the workspace when resuming a session', () => {
|
|
const setRestoreSessionContext = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected', workspaceCwd: '/workspace/primary' },
|
|
setRestoreSessionContext,
|
|
});
|
|
|
|
void actions
|
|
.resumeSession('session-b', { workspaceCwd: '/workspace/secondary' })
|
|
.catch(() => undefined);
|
|
|
|
expect(setRestoreSessionContext).toHaveBeenCalledWith({
|
|
kind: 'workspace',
|
|
cwd: '/workspace/secondary',
|
|
});
|
|
});
|
|
|
|
it('clears transcript loading when a session switch fails', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const existingSession = createMockSession('session-a');
|
|
const manualSessionClearRef = { current: false };
|
|
const setRestoreSessionId = vi.fn();
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
manualSessionClearRef,
|
|
session: existingSession,
|
|
setRestoreSessionId,
|
|
});
|
|
|
|
const loadPromise = actions.loadSession('session-b');
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'connecting',
|
|
sessionId: 'session-b',
|
|
loadingTranscript: true,
|
|
});
|
|
|
|
// Split the boundary so a shorter watchdog cannot pass this test.
|
|
let settledEarly = false;
|
|
void loadPromise.catch(() => {
|
|
settledEarly = true;
|
|
});
|
|
await vi.advanceTimersByTimeAsync(74_999);
|
|
expect(settledEarly).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
await expect(loadPromise).rejects.toThrow('Session load timed out');
|
|
expect(getConnection()).toMatchObject({
|
|
status: 'disconnected',
|
|
sessionId: undefined,
|
|
loadingTranscript: undefined,
|
|
catchingUp: undefined,
|
|
});
|
|
expect(manualSessionClearRef.current).toBe(true);
|
|
expect(setRestoreSessionId).toHaveBeenLastCalledWith(undefined);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('creates a detached session when the ref and connection do not match', async () => {
|
|
const existingSession = createMockSession('session-a');
|
|
const nextSession = createMockSession('session-b');
|
|
const createDetachedSession = vi.fn(async () => nextSession);
|
|
const { actions } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-other' },
|
|
createDetachedSession,
|
|
session: existingSession,
|
|
});
|
|
|
|
await expect(actions.createSession()).resolves.toBe(nextSession);
|
|
|
|
expect(existingSession.client.createOrAttachSession).not.toHaveBeenCalled();
|
|
expect(createDetachedSession).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('starts an attach session load and bumps the attach nonce', async () => {
|
|
const session = createMockSession('session-a');
|
|
const setAttachSessionNonce = vi.fn();
|
|
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
|
session,
|
|
setAttachSessionNonce,
|
|
});
|
|
|
|
const attachPromise = actions.attachSession();
|
|
|
|
expect(pendingSessionLoadRef.current).toMatchObject({
|
|
id: 1,
|
|
sessionId: 'session-a',
|
|
mode: 'attach',
|
|
});
|
|
expect(pendingSessionLoadRef.current?.requestTimeoutMs).toBeUndefined();
|
|
expect(setAttachSessionNonce).toHaveBeenCalledOnce();
|
|
const nonceUpdater = setAttachSessionNonce.mock.calls[0]?.[0];
|
|
expect(typeof nonceUpdater).toBe('function');
|
|
expect(nonceUpdater?.(1)).toBe(2);
|
|
|
|
clearTimeout(pendingSessionLoadRef.current?.timeout);
|
|
pendingSessionLoadRef.current?.resolve();
|
|
await expect(attachPromise).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('reports attach timeouts as attach session failures', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const { actions } = createActionsHarness({
|
|
addNotice,
|
|
session,
|
|
});
|
|
|
|
const attachPromise = actions.attachSession();
|
|
vi.advanceTimersByTime(30_000);
|
|
|
|
await expect(attachPromise).rejects.toThrow('Session attach timed out');
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
code: 'daemon.attach_session.failed',
|
|
operation: 'attach_session',
|
|
}),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('rejects attachSession when no session exists', async () => {
|
|
const { actions } = createActionsHarness();
|
|
|
|
await expect(actions.attachSession()).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
});
|
|
|
|
it('keeps workflow task loading behind an explicit adapter action', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(actions.getTasks()).resolves.toMatchObject({
|
|
sessionId: 'session-a',
|
|
tasks: [],
|
|
});
|
|
await expect(actions.getWorkflowTasks()).resolves.toMatchObject({
|
|
sessionId: 'session-a',
|
|
tasks: [],
|
|
});
|
|
|
|
expect(session.tasks).toHaveBeenCalledOnce();
|
|
expect(session.workflowTasks).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('reports getTasks failures by default', async () => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValueOnce(new Error('Failed to fetch'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks()).rejects.toThrow('Failed to fetch');
|
|
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
code: 'daemon.load_tasks.failed',
|
|
message: 'Get tasks failed: Failed to fetch',
|
|
operation: 'load_tasks',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('suppresses notices for silent transient getTasks failures', async () => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValueOnce(new Error('Failed to fetch'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(
|
|
'Failed to fetch',
|
|
);
|
|
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['Request timed out', 'Network error', 'NetworkError'])(
|
|
'suppresses notices for silent %s getTasks failures',
|
|
async (message) => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValueOnce(new Error(message));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(message);
|
|
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([500, 408, 429])(
|
|
'suppresses notices for silent retryable HTTP %i getTasks failures',
|
|
async (status) => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValueOnce(
|
|
new DaemonHttpError(status, undefined, 'Retryable failure'),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toBeInstanceOf(
|
|
DaemonHttpError,
|
|
);
|
|
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('suppresses notices for silent abort getTasks failures', async () => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValueOnce(
|
|
new DOMException('Aborted', 'AbortError'),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toMatchObject({
|
|
name: 'AbortError',
|
|
});
|
|
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports silent hard HTTP getTasks failures once', async () => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValueOnce(
|
|
new DaemonHttpError(403, undefined, 'Forbidden'),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toBeInstanceOf(
|
|
DaemonHttpError,
|
|
);
|
|
|
|
expect(addNotice).toHaveBeenCalledOnce();
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
code: 'daemon.load_tasks.failed',
|
|
message: 'Get tasks failed: Forbidden',
|
|
operation: 'load_tasks',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('reports silent hard getTasks failures once', async () => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.tasks.mockRejectedValue(new Error('Malformed response'));
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(
|
|
'Malformed response',
|
|
);
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(
|
|
'Malformed response',
|
|
);
|
|
|
|
expect(addNotice).toHaveBeenCalledOnce();
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
code: 'daemon.load_tasks.failed',
|
|
message: 'Get tasks failed: Malformed response',
|
|
operation: 'load_tasks',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('resets silent hard getTasks failure dedupe when clearing the session', async () => {
|
|
const sessionA = createMockSession('session-a');
|
|
const sessionB = createMockSession('session-b');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
sessionA.tasks.mockRejectedValue(new Error('Malformed response'));
|
|
sessionB.tasks.mockRejectedValue(new Error('Malformed response'));
|
|
const { actions, sessionRef } = createActionsHarness({
|
|
addNotice,
|
|
session: sessionA,
|
|
});
|
|
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(
|
|
'Malformed response',
|
|
);
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(
|
|
'Malformed response',
|
|
);
|
|
await actions.clearSession();
|
|
sessionRef.current = sessionB as unknown as DaemonSessionClient;
|
|
await expect(actions.getTasks({ silent: true })).rejects.toThrow(
|
|
'Malformed response',
|
|
);
|
|
|
|
expect(addNotice).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('rejects getTasks silently when no session exists', async () => {
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ addNotice });
|
|
|
|
await expect(actions.getTasks()).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('starts a saved workflow through the session workflow action', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.client.sessionWorkflowTaskAction.mockResolvedValueOnce({
|
|
changed: true,
|
|
status: 'running',
|
|
taskId: 'wf_5678efab',
|
|
});
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(actions.runSavedWorkflow('deep-review')).resolves.toEqual({
|
|
started: true,
|
|
status: 'running',
|
|
taskId: 'wf_5678efab',
|
|
});
|
|
expect(session.client.sessionWorkflowTaskAction).toHaveBeenCalledWith(
|
|
'session-a',
|
|
'deep-review',
|
|
'run-saved',
|
|
'client-session-a',
|
|
);
|
|
});
|
|
|
|
it('reads a saved workflow definition and unwraps the envelope', async () => {
|
|
const session = createMockSession('session-a');
|
|
const workflow = {
|
|
v: 1 as const,
|
|
sessionId: 'session-a',
|
|
name: 'deep-review',
|
|
source: 'project' as const,
|
|
scriptPath: '/workspace/.qwen/workflows/deep-review.js',
|
|
script: 'export const meta = { name: "deep-review", description: "d" }',
|
|
meta: { name: 'deep-review', description: 'd' },
|
|
};
|
|
session.savedWorkflow.mockResolvedValueOnce({
|
|
v: 1,
|
|
sessionId: 'session-a',
|
|
name: 'deep-review',
|
|
workflow,
|
|
});
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(actions.readSavedWorkflow('deep-review')).resolves.toEqual(
|
|
workflow,
|
|
);
|
|
expect(session.savedWorkflow).toHaveBeenCalledWith('deep-review');
|
|
});
|
|
|
|
it('suppresses a stale workflow-control failure after switching sessions', async () => {
|
|
const sessionA = createMockSession('session-a');
|
|
const sessionB = createMockSession('session-b');
|
|
const pending = createDeferred<never>();
|
|
sessionA.controlWorkflowTask.mockReturnValueOnce(pending.promise);
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const { actions, sessionRef } = createActionsHarness({
|
|
addNotice,
|
|
session: sessionA,
|
|
});
|
|
|
|
const result = actions.controlWorkflowTask('wf-1', 'pause');
|
|
sessionRef.current = sessionB as unknown as DaemonSessionClient;
|
|
pending.reject(new Error('old workflow failed'));
|
|
|
|
await expect(result).rejects.toThrow('old workflow failed');
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('suppresses a stale saved-workflow failure after switching sessions', async () => {
|
|
const sessionA = createMockSession('session-a');
|
|
const sessionB = createMockSession('session-b');
|
|
const pending = createDeferred<never>();
|
|
sessionA.client.sessionWorkflowTaskAction.mockReturnValueOnce(
|
|
pending.promise,
|
|
);
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const { actions, sessionRef } = createActionsHarness({
|
|
addNotice,
|
|
session: sessionA,
|
|
});
|
|
|
|
const result = actions.runSavedWorkflow('deep-review');
|
|
sessionRef.current = sessionB as unknown as DaemonSessionClient;
|
|
pending.reject(new Error('old saved workflow failed'));
|
|
|
|
await expect(result).rejects.toThrow('old saved workflow failed');
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('aborts active prompts and rejects pending session loads when clearing', async () => {
|
|
const controller = new AbortController();
|
|
const session = createMockSession('session-a');
|
|
const pendingReject = vi.fn();
|
|
const pendingSessionLoadRef = {
|
|
current: {
|
|
id: 1,
|
|
sessionId: 'session-a',
|
|
mode: 'attach' as const,
|
|
timeout: setTimeout(() => undefined, 30_000),
|
|
resolve: vi.fn(),
|
|
reject: pendingReject,
|
|
},
|
|
};
|
|
const { actions } = createActionsHarness({
|
|
activePrompts: new Map([['session-a', { controller } as ActivePrompt]]),
|
|
pendingSessionLoadRef,
|
|
session,
|
|
});
|
|
|
|
await actions.clearSession();
|
|
|
|
expect(controller.signal.aborted).toBe(true);
|
|
expect(pendingReject).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'AbortError',
|
|
message: 'Session cleared',
|
|
}),
|
|
);
|
|
expect(pendingSessionLoadRef.current).toBeUndefined();
|
|
});
|
|
|
|
describe('continueSession', () => {
|
|
function connection(): DaemonConnectionState {
|
|
return {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/workspace',
|
|
context: {
|
|
...contextStatus('session-a'),
|
|
recovery: { kind: 'interrupted_prompt', canContinue: true },
|
|
},
|
|
};
|
|
}
|
|
|
|
it('tracks the admitted turn without appending a user message', async () => {
|
|
const session = createMockSession('session-a');
|
|
const restartEventStream = vi.fn();
|
|
const onContinuationAdmitted = vi.fn();
|
|
const {
|
|
actions,
|
|
activePromptsRef,
|
|
store,
|
|
getConnection,
|
|
setPromptStatus,
|
|
} = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
restartEventStream,
|
|
onContinuationAdmitted,
|
|
});
|
|
const pending = actions.continueSession({
|
|
confirmCancellation: 'legacy-daemon',
|
|
});
|
|
expect(getConnection().context?.recovery).toEqual({
|
|
kind: 'interrupted_prompt',
|
|
canContinue: false,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(activePromptsRef.current.get('session-a')?.promptId).toBe(
|
|
'continue-1',
|
|
);
|
|
});
|
|
const active = activePromptsRef.current.get('session-a')!;
|
|
activePromptsRef.current.delete('session-a');
|
|
active.resolve?.({ stopReason: 'end_turn' });
|
|
|
|
await expect(pending).resolves.toBeUndefined();
|
|
expect(session.continueSession).toHaveBeenCalledWith(
|
|
expect.any(AbortSignal),
|
|
{ confirmCancellation: 'legacy-daemon' },
|
|
);
|
|
expect(restartEventStream).toHaveBeenCalledWith('session-a');
|
|
expect(onContinuationAdmitted).toHaveBeenCalledExactlyOnceWith(
|
|
session,
|
|
'continue-1',
|
|
);
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
expect(session.context).not.toHaveBeenCalled();
|
|
expect(setPromptStatus).toHaveBeenLastCalledWith('idle');
|
|
});
|
|
|
|
it('consumes a terminal event that arrives before admission returns', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, activePromptsRef, settledPromptsRef } =
|
|
createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
});
|
|
session.continueSession.mockImplementationOnce(async () => {
|
|
activePromptsRef.current.delete('session-a');
|
|
settledPromptsRef.current.set(
|
|
JSON.stringify(['session-a', 'continue-1']),
|
|
{
|
|
status: 'resolved',
|
|
result: { stopReason: 'end_turn' },
|
|
},
|
|
);
|
|
return {
|
|
accepted: true,
|
|
interruption: 'interrupted_prompt',
|
|
promptId: 'continue-1',
|
|
lastEventId: 7,
|
|
eventEpoch: 'epoch-1',
|
|
};
|
|
});
|
|
|
|
await expect(actions.continueSession()).resolves.toBeUndefined();
|
|
expect(settledPromptsRef.current.size).toBe(0);
|
|
expect(activePromptsRef.current.size).toBe(0);
|
|
});
|
|
|
|
it('rejects a double click while admission is pending', async () => {
|
|
const session = createMockSession('session-a');
|
|
const admission = createDeferred<DaemonContinueSessionResult>();
|
|
session.continueSession.mockReturnValueOnce(admission.promise);
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
});
|
|
const first = actions.continueSession();
|
|
|
|
await expect(actions.continueSession()).rejects.toThrow(
|
|
'already in progress',
|
|
);
|
|
expect(session.continueSession).toHaveBeenCalledOnce();
|
|
admission.resolve({ accepted: false, interruption: 'none' });
|
|
await first;
|
|
});
|
|
|
|
it.each([
|
|
'active',
|
|
'unsupported',
|
|
'disabled',
|
|
'disconnected',
|
|
'loading',
|
|
'catching-up',
|
|
'stale-context',
|
|
])('does not submit when recovery is %s', async (state) => {
|
|
const session = createMockSession('session-a');
|
|
const current = connection();
|
|
if (state === 'unsupported') delete current.context?.recovery;
|
|
if (state === 'disabled') current.context!.recovery!.canContinue = false;
|
|
if (state === 'disconnected') current.status = 'disconnected';
|
|
if (state === 'loading') current.loadingTranscript = true;
|
|
if (state === 'catching-up') current.catchingUp = true;
|
|
if (state === 'stale-context') current.context!.sessionId = 'session-b';
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: current,
|
|
hasSessionActivePrompt: () => state === 'active',
|
|
});
|
|
|
|
await expect(actions.continueSession()).rejects.toThrow();
|
|
expect(session.continueSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('settles cancellation through the normal cancel action', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, activePromptsRef, store } = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
});
|
|
const pending = actions.continueSession();
|
|
await vi.waitFor(() => {
|
|
expect(activePromptsRef.current.get('session-a')?.promptId).toBe(
|
|
'continue-1',
|
|
);
|
|
});
|
|
|
|
await actions.cancel();
|
|
await expect(pending).resolves.toBeUndefined();
|
|
expect(session.cancel).toHaveBeenCalledOnce();
|
|
expect(activePromptsRef.current.size).toBe(0);
|
|
expect(store.dispatch).toHaveBeenCalledWith({
|
|
type: 'assistant.done',
|
|
reason: 'cancelled',
|
|
});
|
|
});
|
|
|
|
it('refreshes a definite rejection without resubmitting', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.continueSession.mockRejectedValueOnce(
|
|
new DaemonHttpError(409, {}, 'Busy'),
|
|
);
|
|
session.context.mockResolvedValueOnce({
|
|
...contextStatus('session-a'),
|
|
recovery: { kind: 'interrupted_prompt', canContinue: false },
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
});
|
|
|
|
await expect(actions.continueSession()).rejects.toThrow('Busy');
|
|
expect(session.continueSession).toHaveBeenCalledOnce();
|
|
expect(session.context).toHaveBeenCalledOnce();
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(false);
|
|
});
|
|
|
|
it('restores recovery after repeated cancellations without terminal events', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.continueSession.mockImplementation(
|
|
(signal) =>
|
|
new Promise<DaemonContinueSessionResult>((_resolve, reject) => {
|
|
signal!.addEventListener('abort', () => reject(signal!.reason), {
|
|
once: true,
|
|
});
|
|
}),
|
|
);
|
|
session.context.mockResolvedValue(connection().context!);
|
|
const { actions, activePromptsRef, getConnection, store } =
|
|
createActionsHarness({ session, connection: connection() });
|
|
|
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
const pending = actions.continueSession();
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(false);
|
|
await actions.cancel();
|
|
await pending;
|
|
await vi.waitFor(() =>
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(true),
|
|
);
|
|
expect(activePromptsRef.current.size).toBe(0);
|
|
expect(session.continueSession).toHaveBeenCalledTimes(attempt);
|
|
expect(session.cancel).toHaveBeenCalledTimes(attempt);
|
|
}
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['failed', 'owner-replaced', 'new-active', 'idle'] as const)(
|
|
'does not refresh recovery for a %s cancellation',
|
|
async (mode) => {
|
|
const session = createMockSession('session-a');
|
|
const cancelled = createDeferred<undefined>();
|
|
session.cancel.mockReturnValueOnce(cancelled.promise);
|
|
const { actions, activePromptsRef, sessionRef } = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
activePrompts: new Map(
|
|
mode === 'idle'
|
|
? []
|
|
: [['session-a', { controller: new AbortController() }]],
|
|
),
|
|
});
|
|
const pending = actions.cancel();
|
|
if (mode === 'owner-replaced') {
|
|
sessionRef.current = createMockSession(
|
|
'session-a',
|
|
) as unknown as DaemonSessionClient;
|
|
} else if (mode === 'new-active') {
|
|
activePromptsRef.current.set('session-a', {
|
|
controller: new AbortController(),
|
|
});
|
|
}
|
|
if (mode === 'failed') {
|
|
cancelled.reject(new TypeError('cancel outcome unknown'));
|
|
await expect(pending).rejects.toThrow('cancel outcome unknown');
|
|
} else {
|
|
cancelled.resolve(undefined);
|
|
await pending;
|
|
}
|
|
expect(session.context).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('does not hold cancellation open while recovery is loading', async () => {
|
|
const session = createMockSession('session-a');
|
|
const refreshed = createDeferred<DaemonSessionContextStatus>();
|
|
session.context.mockReturnValueOnce(refreshed.promise);
|
|
const { actions, activePromptsRef, getConnection, setPromptStatus } =
|
|
createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
activePrompts: new Map([
|
|
['session-a', { controller: new AbortController() }],
|
|
]),
|
|
});
|
|
await actions.cancel();
|
|
expect(session.context).toHaveBeenCalledOnce();
|
|
expect(activePromptsRef.current.size).toBe(0);
|
|
expect(setPromptStatus).toHaveBeenLastCalledWith('idle');
|
|
refreshed.resolve({
|
|
...connection().context!,
|
|
recovery: { kind: 'clean', canContinue: false },
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(getConnection().context?.recovery?.kind).toBe('clean'),
|
|
);
|
|
});
|
|
|
|
it('refreshes rejection recovery while an approval change is pending', async () => {
|
|
const session = createMockSession('session-a');
|
|
const refreshed = createDeferred<DaemonSessionContextStatus>();
|
|
const approval =
|
|
createDeferred<
|
|
Awaited<ReturnType<typeof session.client.setSessionApprovalMode>>
|
|
>();
|
|
session.client.setSessionApprovalMode.mockReturnValueOnce(
|
|
approval.promise,
|
|
);
|
|
session.continueSession.mockResolvedValueOnce({
|
|
accepted: false,
|
|
interruption: 'none',
|
|
});
|
|
session.context.mockReturnValueOnce(refreshed.promise);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
});
|
|
const changing = actions.setApprovalMode('auto-edit');
|
|
const pending = actions.continueSession();
|
|
await vi.waitFor(() => expect(session.context).toHaveBeenCalledOnce());
|
|
refreshed.resolve(connection().context!);
|
|
await pending;
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(true);
|
|
approval.resolve({
|
|
sessionId: 'session-a',
|
|
mode: 'auto-edit',
|
|
previous: 'default',
|
|
persisted: false,
|
|
});
|
|
await changing;
|
|
expect(getConnection().currentMode).toBe('auto-edit');
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(true);
|
|
});
|
|
|
|
it('keeps unknown admission disabled until server events or reload', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.continueSession.mockRejectedValueOnce(
|
|
new TypeError('fetch failed'),
|
|
);
|
|
const restartEventStream = vi.fn();
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
restartEventStream,
|
|
});
|
|
|
|
await expect(actions.continueSession()).rejects.toThrow('fetch failed');
|
|
await expect(actions.continueSession()).rejects.toThrow(
|
|
'no resumable interrupted turn',
|
|
);
|
|
expect(session.continueSession).toHaveBeenCalledOnce();
|
|
expect(session.context).not.toHaveBeenCalled();
|
|
expect(restartEventStream).not.toHaveBeenCalled();
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(false);
|
|
});
|
|
|
|
it.each(['session-a', 'session-b'])(
|
|
'does not change explicitly loaded %s when admission finishes late',
|
|
async (nextSessionId) => {
|
|
const session = createMockSession('session-a');
|
|
const admission = createDeferred<DaemonContinueSessionResult>();
|
|
session.continueSession.mockReturnValueOnce(admission.promise);
|
|
const restartEventStream = vi.fn();
|
|
const onContinuationAdmitted = vi.fn();
|
|
const {
|
|
actions,
|
|
sessionRef,
|
|
replaceConnection,
|
|
getConnection,
|
|
store,
|
|
pendingSessionLoadRef,
|
|
} = createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
restartEventStream,
|
|
onContinuationAdmitted,
|
|
});
|
|
const pending = actions.continueSession();
|
|
const reloading = actions.loadSession(nextSessionId);
|
|
sessionRef.current = createMockSession(
|
|
nextSessionId,
|
|
) as unknown as DaemonSessionClient;
|
|
const replacement = { ...connection(), sessionId: nextSessionId };
|
|
replaceConnection(replacement);
|
|
admission.resolve({
|
|
accepted: true,
|
|
interruption: 'interrupted_prompt',
|
|
promptId: 'continue-1',
|
|
lastEventId: 7,
|
|
eventEpoch: 'epoch-1',
|
|
});
|
|
|
|
await expect(pending).resolves.toBeUndefined();
|
|
expect(getConnection()).toBe(replacement);
|
|
expect(restartEventStream).not.toHaveBeenCalled();
|
|
expect(onContinuationAdmitted).not.toHaveBeenCalled();
|
|
expect(store.dispatch).not.toHaveBeenCalled();
|
|
expect(session.context).not.toHaveBeenCalled();
|
|
pendingSessionLoadRef.current!.resolve();
|
|
await reloading;
|
|
},
|
|
);
|
|
|
|
it('tracks an admission accepted after automatic same-session reattach', async () => {
|
|
const session = createMockSession('session-a');
|
|
const admission = createDeferred<DaemonContinueSessionResult>();
|
|
session.continueSession.mockReturnValueOnce(admission.promise);
|
|
const replacement = createMockSession('session-a');
|
|
const restartEventStream = vi.fn();
|
|
const { actions, sessionRef, activePromptsRef, setPromptStatus } =
|
|
createActionsHarness({
|
|
session,
|
|
connection: connection(),
|
|
restartEventStream,
|
|
});
|
|
let settled = false;
|
|
const pending = actions.continueSession().then(() => {
|
|
settled = true;
|
|
});
|
|
expect(setPromptStatus).toHaveBeenCalledWith('waiting');
|
|
sessionRef.current = replacement as unknown as DaemonSessionClient;
|
|
admission.resolve({
|
|
accepted: true,
|
|
interruption: 'interrupted_prompt',
|
|
promptId: 'continue-reattached',
|
|
lastEventId: 7,
|
|
eventEpoch: 'epoch-1',
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(activePromptsRef.current.get('session-a')?.promptId).toBe(
|
|
'continue-reattached',
|
|
),
|
|
);
|
|
expect(settled).toBe(false);
|
|
expect(restartEventStream).toHaveBeenCalledWith('session-a');
|
|
const active = activePromptsRef.current.get('session-a')!;
|
|
activePromptsRef.current.delete('session-a');
|
|
active.resolve?.({ stopReason: 'end_turn' });
|
|
await pending;
|
|
expect(setPromptStatus).toHaveBeenLastCalledWith('idle');
|
|
});
|
|
|
|
it('refreshes definite rejection through the reattached session', async () => {
|
|
const session = createMockSession('session-a');
|
|
const admission = createDeferred<DaemonContinueSessionResult>();
|
|
session.continueSession.mockReturnValueOnce(admission.promise);
|
|
const replacement = createMockSession('session-a');
|
|
replacement.context.mockResolvedValue(connection().context!);
|
|
const addNotice = vi.fn();
|
|
const { actions, sessionRef, getConnection, setPromptStatus } =
|
|
createActionsHarness({ session, connection: connection(), addNotice });
|
|
const pending = actions.continueSession();
|
|
sessionRef.current = replacement as unknown as DaemonSessionClient;
|
|
admission.reject(new DaemonHttpError(409, {}, 'Busy'));
|
|
|
|
await expect(pending).rejects.toThrow('Busy');
|
|
expect(session.context).not.toHaveBeenCalled();
|
|
expect(replacement.context).toHaveBeenCalledOnce();
|
|
expect(getConnection().context?.recovery?.canContinue).toBe(true);
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'continue_session' }),
|
|
);
|
|
expect(setPromptStatus).toHaveBeenLastCalledWith('idle');
|
|
});
|
|
});
|
|
|
|
it('restarts the event stream after prompt admission', async () => {
|
|
const restartEventStream = vi.fn();
|
|
const onAdmissionStarted = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
restartEventStream,
|
|
session,
|
|
});
|
|
|
|
const prompt = actions.sendPrompt('hello', { onAdmissionStarted });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(restartEventStream).toHaveBeenCalledWith('session-a');
|
|
});
|
|
expect(onAdmissionStarted).toHaveBeenCalledOnce();
|
|
await actions.cancel();
|
|
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
|
|
});
|
|
|
|
it('starts admission only after local prompt guards pass', async () => {
|
|
const onAdmissionStarted = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
activePrompts: new Map([
|
|
['session-a', { controller: new AbortController() } as ActivePrompt],
|
|
]),
|
|
session,
|
|
});
|
|
|
|
await expect(
|
|
actions.sendPrompt('hello', { onAdmissionStarted }),
|
|
).rejects.toThrow('A prompt is already in progress');
|
|
|
|
expect(onAdmissionStarted).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reads and controls the authoritative Goal through the session client', async () => {
|
|
const session = createMockSession('session-a');
|
|
const snapshot = {
|
|
v: 2 as const,
|
|
activity: 'idle' as const,
|
|
goal: null,
|
|
};
|
|
session.goal.mockResolvedValue({ snapshot });
|
|
session.controlGoal.mockResolvedValue({ snapshot });
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session,
|
|
});
|
|
const request = { action: 'create' as const, objective: 'ship safely' };
|
|
|
|
await expect(actions.getGoal()).resolves.toEqual({ snapshot });
|
|
await expect(actions.controlGoal(request)).resolves.toEqual({ snapshot });
|
|
|
|
expect(session.goal).toHaveBeenCalledOnce();
|
|
expect(session.controlGoal).toHaveBeenCalledWith(request);
|
|
expect(getConnection().goalState).toBe(snapshot);
|
|
});
|
|
|
|
it('does not let delayed Goal responses regress the current revision', async () => {
|
|
const session = createMockSession('session-a');
|
|
const current = {
|
|
v: 2 as const,
|
|
activity: 'idle' as const,
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 7,
|
|
objective: 'newer objective',
|
|
status: 'paused' as const,
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 3,
|
|
activeTimeMs: 4_000,
|
|
createdAt: 10,
|
|
updatedAt: 30,
|
|
},
|
|
};
|
|
const stale = {
|
|
...current,
|
|
activity: 'running' as const,
|
|
goal: { ...current.goal, revision: 6, status: 'active' as const },
|
|
};
|
|
session.goal.mockResolvedValue({ snapshot: stale });
|
|
session.controlGoal.mockResolvedValue({ snapshot: stale });
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
goalState: current,
|
|
},
|
|
session,
|
|
});
|
|
|
|
await actions.getGoal();
|
|
await actions.controlGoal({
|
|
action: 'pause',
|
|
expectedGoalId: 'goal-1',
|
|
expectedRevision: 7,
|
|
});
|
|
|
|
expect(getConnection().goalState).toBe(current);
|
|
});
|
|
|
|
it('installs an out-of-band Goal snapshot for the attached session only', () => {
|
|
const session = createMockSession('session-a');
|
|
const active: GoalSnapshotV2 = {
|
|
v: 2,
|
|
activity: 'running',
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 3,
|
|
objective: 'ship safely',
|
|
status: 'active',
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 0,
|
|
activeTimeMs: 0,
|
|
createdAt: 10,
|
|
updatedAt: 20,
|
|
},
|
|
};
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session,
|
|
});
|
|
|
|
actions.applyGoalSnapshot('session-b', active);
|
|
expect(getConnection().goalState).toBeUndefined();
|
|
|
|
actions.applyGoalSnapshot('session-a', active);
|
|
expect(getConnection().goalState).toBe(active);
|
|
|
|
// Reconciled like any other snapshot, so a stale one cannot regress it.
|
|
actions.applyGoalSnapshot('session-a', {
|
|
...active,
|
|
goal: { ...active.goal!, revision: 2 },
|
|
});
|
|
expect(getConnection().goalState).toBe(active);
|
|
});
|
|
|
|
it('does not let a stale bare-null Goal read wipe a Goal created meanwhile', async () => {
|
|
// The daemon answered the read while the session was goal-less, so the
|
|
// response carries no `clearedGoal` tombstone. Reconciling it against the
|
|
// goal created while it was in flight would clear that goal outright.
|
|
const session = createMockSession('session-a');
|
|
let resolveRead:
|
|
| ((value: { snapshot: GoalSnapshotV2 }) => void)
|
|
| undefined;
|
|
session.goal.mockReturnValue(
|
|
new Promise<{ snapshot: GoalSnapshotV2 }>((resolve) => {
|
|
resolveRead = resolve;
|
|
}),
|
|
);
|
|
const { actions, getConnection, replaceConnection } = createActionsHarness({
|
|
connection: { status: 'connected', sessionId: 'session-a' },
|
|
session,
|
|
});
|
|
|
|
const read = actions.getGoal();
|
|
const created: GoalSnapshotV2 = {
|
|
v: 2,
|
|
activity: 'running',
|
|
goal: {
|
|
goalId: 'goal-new',
|
|
revision: 1,
|
|
objective: 'ship safely',
|
|
status: 'active',
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 0,
|
|
activeTimeMs: 0,
|
|
createdAt: 10,
|
|
updatedAt: 20,
|
|
},
|
|
};
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
goalState: created,
|
|
});
|
|
resolveRead?.({ snapshot: { v: 2, goal: null, activity: 'idle' } });
|
|
await read;
|
|
|
|
expect(getConnection().goalState).toBe(created);
|
|
});
|
|
|
|
it('applies a bare-null Goal read to the Goal it observed', async () => {
|
|
// Same shape, but nothing changed while the read was in flight: an older
|
|
// daemon that clears without a tombstone must still clear the UI.
|
|
const session = createMockSession('session-a');
|
|
const active: GoalSnapshotV2 = {
|
|
v: 2,
|
|
activity: 'running',
|
|
goal: {
|
|
goalId: 'goal-1',
|
|
revision: 7,
|
|
objective: 'ship safely',
|
|
status: 'active',
|
|
evidenceCursor: { recordId: 'record-1' },
|
|
turnCount: 3,
|
|
activeTimeMs: 4_000,
|
|
createdAt: 10,
|
|
updatedAt: 30,
|
|
},
|
|
};
|
|
session.goal.mockResolvedValue({
|
|
snapshot: { v: 2, goal: null, activity: 'idle' },
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
goalState: active,
|
|
},
|
|
session,
|
|
});
|
|
|
|
await actions.getGoal();
|
|
|
|
expect(getConnection().goalState?.goal).toBeNull();
|
|
});
|
|
|
|
it('uploads prompt images and submits attachment references instead of base64', async () => {
|
|
const onAdmissionStarted = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
onAdmissionStarted,
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
|
expect.any(Blob),
|
|
'image.png',
|
|
'image/png',
|
|
undefined,
|
|
);
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{ type: 'text', text: 'look' },
|
|
{
|
|
type: 'image',
|
|
attachmentId: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
},
|
|
],
|
|
});
|
|
expect(onAdmissionStarted).toHaveBeenCalledOnce();
|
|
expect(onAdmissionStarted.mock.invocationCallOrder[0]).toBeLessThan(
|
|
session.submitPrompt.mock.invocationCallOrder[0]!,
|
|
);
|
|
});
|
|
|
|
it('does not upload attachments discarded by slash commands', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('help', 'builtin-command')],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/help', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
files: [{ name: 'notes.txt', text: 'hello', media_type: 'text/plain' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [{ type: 'text', text: '/help' }],
|
|
});
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'/help',
|
|
[],
|
|
undefined,
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('does not upload attachments for built-in command aliases', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('compress', 'builtin-command', ['summarize'])],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/summarize', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [{ type: 'text', text: '/summarize' }],
|
|
});
|
|
});
|
|
|
|
it.each(['/Compress', '/SUMMARIZE'])(
|
|
'keeps attachments for unresolved wrong-case command %s',
|
|
async (text) => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('compress', 'builtin-command', ['summarize'])],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt(text, {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
|
|
it('prefers a primary command name over another command alias', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [
|
|
commandInfo('compress', 'builtin-command', ['summarize']),
|
|
commandInfo('summarize', 'skill-dir-command'),
|
|
],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/summarize', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('recognizes built-in commands with whitespace after the slash', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('compress', 'builtin-command')],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/ compress', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not upload an unknown slash command before command metadata loads', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('price-sheet', 'skill-dir-command')],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/remember this API shape', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not upload a slash command before command metadata loads', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/remember this API shape', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
'// stack trace from prod',
|
|
'/* crash note */',
|
|
'/var/log/app.log shows the crash',
|
|
'/var\\log\\app.log shows the crash',
|
|
])('uploads attachments for non-command slash input %s', async (text) => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt(text, {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{ type: 'text', text },
|
|
{
|
|
type: 'image',
|
|
attachmentId: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('uploads attachments for unknown commands after metadata loads', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('compress', 'builtin-command')],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/unlisted update', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(['known', 'empty', 'unknown'] as const)(
|
|
'skill attachments survive %s catalog',
|
|
async (catalog) => {
|
|
const session = createMockSession('session-a');
|
|
Object.assign(session, {
|
|
supportedCommands: vi.fn(async () => ({
|
|
v: 1,
|
|
sessionId: 'session-a',
|
|
availableCommands: [
|
|
{
|
|
name: 'review',
|
|
description: 'Review',
|
|
input: null,
|
|
_meta: { source: 'skill-dir-command' },
|
|
},
|
|
],
|
|
availableSkills: ['review'],
|
|
})),
|
|
});
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands:
|
|
catalog === 'known'
|
|
? [commandInfo('review', 'skill-dir-command')]
|
|
: catalog === 'empty'
|
|
? []
|
|
: undefined,
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
await actions.submitPrompt('/review this diff', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
expect(session.supportedCommands).toHaveBeenCalledTimes(
|
|
catalog === 'known' ? 0 : 1,
|
|
);
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'appends ordinary text synchronously before %s yields',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockRejectedValueOnce(
|
|
new Error('stop after optimistic append'),
|
|
);
|
|
const { actions, store } = createActionsHarness({ session });
|
|
const submission = actions[method]('ordinary message');
|
|
const outcome = submission.catch((error: unknown) => error);
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
|
|
expect(session.supportedCommands).not.toHaveBeenCalled();
|
|
expect(await outcome).toMatchObject({
|
|
message: 'stop after optimistic append',
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'rejects %s if the session changes after command classification resolves',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
const replacement = createMockSession('session-b');
|
|
const pendingCommands =
|
|
createDeferred<ReturnType<typeof supportedCommandsStatus>>();
|
|
session.supportedCommands.mockReturnValueOnce(pendingCommands.promise);
|
|
const { actions, store, sessionRef, getConnection } =
|
|
createActionsHarness({ session });
|
|
const submission = actions[method]('/review this diff', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
const outcome = submission.catch((error: unknown) => error);
|
|
pendingCommands.resolve(supportedCommandsStatus('session-a', 'review'));
|
|
for (
|
|
let tick = 0;
|
|
tick < 20 && !getConnection().supportedCommands;
|
|
tick++
|
|
)
|
|
await Promise.resolve();
|
|
expect(getConnection().supportedCommands).toBeDefined();
|
|
sessionRef.current = replacement as unknown as DaemonSessionClient;
|
|
expect(await outcome).toMatchObject({
|
|
message: 'Session changed before prompt submission',
|
|
});
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'does not upload or submit when %s cannot classify attachments',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
session.supportedCommands.mockRejectedValueOnce(
|
|
new Error('commands unavailable'),
|
|
);
|
|
const { actions, store } = createActionsHarness({ session });
|
|
await expect(
|
|
actions[method]('/review this diff', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
}),
|
|
).rejects.toThrow('commands unavailable');
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'cancelling %s settles a pending command classification immediately',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
const pendingCommands =
|
|
createDeferred<ReturnType<typeof supportedCommandsStatus>>();
|
|
session.supportedCommands.mockReturnValueOnce(pendingCommands.promise);
|
|
const { actions } = createActionsHarness({ session });
|
|
const controller = new AbortController();
|
|
const submission = actions[method]('/review this diff', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
...(method === 'submitPrompt' ? { signal: controller.signal } : {}),
|
|
});
|
|
let settled = false;
|
|
void submission.then(
|
|
() => {
|
|
settled = true;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
},
|
|
);
|
|
// Let the submission reach the pending classification read.
|
|
for (let tick = 0; tick < 5; tick++) await Promise.resolve();
|
|
if (method === 'sendPrompt') {
|
|
await actions.cancel();
|
|
} else {
|
|
controller.abort();
|
|
}
|
|
for (let tick = 0; tick < 20; tick++) await Promise.resolve();
|
|
// The read never settles, so only the abort race can end the wait.
|
|
expect(settled).toBe(true);
|
|
const outcome = await submission.catch((error: unknown) => error);
|
|
if (method === 'sendPrompt') {
|
|
expect(outcome).toMatchObject({ stopReason: 'cancelled' });
|
|
} else {
|
|
expect(outcome).toMatchObject({ name: 'AbortError' });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(['sendPrompt', 'submitPrompt'] as const)(
|
|
'rejects %s when attachment classification belongs to a replaced session',
|
|
async (method) => {
|
|
const session = createMockSession('session-a');
|
|
const replacement = createMockSession('session-b');
|
|
const pendingCommands =
|
|
createDeferred<ReturnType<typeof supportedCommandsStatus>>();
|
|
session.supportedCommands.mockReturnValueOnce(pendingCommands.promise);
|
|
const { actions, store, sessionRef, replaceConnection, getConnection } =
|
|
createActionsHarness({ session });
|
|
const submission = actions[method]('/review this diff', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
const outcome = submission.catch((error: unknown) => error);
|
|
sessionRef.current = replacement as unknown as DaemonSessionClient;
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: 'session-b',
|
|
workspaceCwd: '/other-workspace',
|
|
commands: [commandInfo('other-command', 'skill-dir-command')],
|
|
});
|
|
pendingCommands.resolve(supportedCommandsStatus('session-a', 'review'));
|
|
expect(await outcome).toMatchObject({
|
|
message: 'Session changed before prompt submission',
|
|
});
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(replacement.submitPrompt).not.toHaveBeenCalled();
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
expect(getConnection()).toMatchObject({
|
|
sessionId: 'session-b',
|
|
workspaceCwd: '/other-workspace',
|
|
commands: [commandInfo('other-command', 'skill-dir-command')],
|
|
});
|
|
},
|
|
);
|
|
|
|
it('uploads attachments used by skill slash commands', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('price-sheet', 'skill-dir-command')],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('/price-sheet update these prices', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
|
expect.any(Blob),
|
|
'image.png',
|
|
'image/png',
|
|
undefined,
|
|
);
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{ type: 'text', text: '/price-sheet update these prices' },
|
|
{
|
|
type: 'image',
|
|
attachmentId: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
},
|
|
],
|
|
});
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'/price-sheet update these prices',
|
|
[{ data: 'AQID', mimeType: 'image/png' }],
|
|
undefined,
|
|
[],
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['skill-dir-command', true],
|
|
['builtin-command', false],
|
|
] as const)(
|
|
'classifies %s command attachments on the sendPrompt path',
|
|
async (source, shouldUpload) => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
commands: [commandInfo('price-sheet', source)],
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const prompt = actions.sendPrompt('/price-sheet update', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled());
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledTimes(
|
|
shouldUpload ? 1 : 0,
|
|
);
|
|
expect(session.submitPrompt).toHaveBeenCalledWith(
|
|
{
|
|
eventDetailMode: 'full',
|
|
prompt: shouldUpload
|
|
? [
|
|
{ type: 'text', text: '/price-sheet update' },
|
|
{
|
|
type: 'image',
|
|
attachmentId: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
},
|
|
]
|
|
: [{ type: 'text', text: '/price-sheet update' }],
|
|
},
|
|
expect.any(AbortSignal),
|
|
);
|
|
|
|
await actions.cancel();
|
|
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
|
|
},
|
|
);
|
|
|
|
it('uploads text attachments and submits attachment references', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('check', {
|
|
files: [{ name: 'notes.txt', text: 'hello', media_type: 'text/plain' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
|
expect.any(Blob),
|
|
'notes.txt',
|
|
'text/plain',
|
|
undefined,
|
|
);
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{
|
|
type: 'text',
|
|
text: 'check\n\n@attachment:///notes.txt',
|
|
},
|
|
{
|
|
type: 'resource',
|
|
attachmentId: 'notes.txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
},
|
|
],
|
|
});
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'check',
|
|
[],
|
|
undefined,
|
|
[
|
|
{
|
|
name: 'notes.txt',
|
|
mimeType: 'text/plain',
|
|
text: 'hello',
|
|
attachmentId: 'notes.txt',
|
|
},
|
|
],
|
|
);
|
|
expect(session.uploadAttachment.mock.invocationCallOrder[0]).toBeLessThan(
|
|
store.appendLocalUserMessage.mock.invocationCallOrder[0]!,
|
|
);
|
|
});
|
|
|
|
it('uploads arbitrary file bytes without text decoding', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
const data = new Blob([Uint8Array.from([0, 255, 1])], {
|
|
type: 'application/pdf',
|
|
});
|
|
|
|
await actions.submitPrompt('check', {
|
|
files: [{ name: 'report.pdf', data, media_type: 'application/pdf' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
|
data,
|
|
'report.pdf',
|
|
'application/pdf',
|
|
undefined,
|
|
);
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{
|
|
type: 'text',
|
|
text: 'check\n\n@attachment:///report.pdf',
|
|
},
|
|
{
|
|
type: 'resource',
|
|
attachmentId: 'report.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 3,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('uses the matching uploaded reference for image-typed files', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment
|
|
.mockResolvedValueOnce({
|
|
type: 'image',
|
|
attachmentId: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
type: 'image',
|
|
attachmentId: 'diagram (1).png',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
});
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('check', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
files: [
|
|
{
|
|
name: 'diagram.png',
|
|
data: new Blob([Uint8Array.of(1, 2, 3)], { type: 'image/png' }),
|
|
media_type: 'image/png',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{
|
|
type: 'text',
|
|
text: 'check\n\n@attachment:///diagram%20(1).png',
|
|
},
|
|
expect.objectContaining({ attachmentId: 'image.png' }),
|
|
expect.objectContaining({ attachmentId: 'diagram (1).png' }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('uses the daemon-deduplicated attachment name in the prompt token', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockResolvedValueOnce({
|
|
type: 'resource',
|
|
attachmentId: 'notes (1).txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
});
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('check', {
|
|
files: [{ name: 'notes.txt', text: 'hello', media_type: 'text/plain' }],
|
|
});
|
|
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{
|
|
type: 'text',
|
|
text: 'check\n\n@attachment:///notes%20(1).txt',
|
|
},
|
|
{
|
|
type: 'resource',
|
|
attachmentId: 'notes (1).txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('keeps images without a concrete mime type inline instead of uploading', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/*' }],
|
|
});
|
|
|
|
// The attachment route matches concrete image types only; uploading a literal
|
|
// image/* Content-Type 400s, so such images must stay inline (untyped,
|
|
// matching the legacy shape).
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{ type: 'text', text: 'look' },
|
|
{ type: 'image', data: 'AQID' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('does not mark admission started when attachment upload fails', async () => {
|
|
const onAdmissionStarted = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockRejectedValueOnce(new Error('upload failed'));
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
onAdmissionStarted,
|
|
}),
|
|
).rejects.toThrow('upload failed');
|
|
|
|
expect(onAdmissionStarted).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not inline or admit an image after a negotiated chunk upload returns 404', async () => {
|
|
const session = createMockSession('session-a');
|
|
const failure = new DaemonAttachmentUploadError(
|
|
new DaemonHttpError(
|
|
404,
|
|
{ code: 'attachment_upload_not_found' },
|
|
'Upload expired',
|
|
),
|
|
);
|
|
session.uploadAttachment.mockRejectedValueOnce(failure);
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments', 'session_attachment_chunk_upload'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
}),
|
|
).rejects.toBe(failure);
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('falls back to inline image data when the attachment route is unavailable', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockRejectedValueOnce(
|
|
new DaemonHttpError(404, undefined, 'Not found'),
|
|
);
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{ type: 'text', text: 'look' },
|
|
{ type: 'image', data: 'AQID', mimeType: 'image/png' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('does not submit empty files when attachment upload is unsupported', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: [],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('check', {
|
|
files: [
|
|
{
|
|
name: 'report.pdf',
|
|
data: new Blob(['pdf']),
|
|
media_type: 'application/pdf',
|
|
},
|
|
],
|
|
}),
|
|
).rejects.toThrow('File attachment upload is not supported');
|
|
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not submit empty files when the attachment route is unavailable', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockRejectedValueOnce(
|
|
new DaemonHttpError(404, undefined, 'Not found'),
|
|
);
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('check', {
|
|
files: [
|
|
{
|
|
name: 'report.pdf',
|
|
data: new Blob(['pdf']),
|
|
media_type: 'application/pdf',
|
|
},
|
|
],
|
|
}),
|
|
).rejects.toThrow('Not found');
|
|
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes successful attachment uploads when another upload fails', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment
|
|
.mockResolvedValueOnce({
|
|
type: 'resource',
|
|
attachmentId: 'first.txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
})
|
|
.mockRejectedValueOnce(new Error('second upload failed'));
|
|
const first = new Blob(['first']);
|
|
const second = new Blob(['second']);
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
files: [
|
|
{ name: 'first.txt', data: first, media_type: 'text/plain' },
|
|
{ name: 'second.txt', data: second, media_type: 'text/plain' },
|
|
],
|
|
}),
|
|
).rejects.toThrow('second upload failed');
|
|
|
|
expect(session.removeAttachment).toHaveBeenCalledWith('first.txt');
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'look',
|
|
[],
|
|
undefined,
|
|
[
|
|
{
|
|
name: 'first.txt',
|
|
mimeType: 'text/plain',
|
|
data: first,
|
|
},
|
|
{
|
|
name: 'second.txt',
|
|
mimeType: 'text/plain',
|
|
data: second,
|
|
},
|
|
],
|
|
);
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes uploaded attachments when prompt admission is rejected', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockRejectedValueOnce(
|
|
new DaemonPendingPromptLimitError('session-a', 20, 20),
|
|
);
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.sendPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
}),
|
|
).rejects.toThrow('Pending prompts full');
|
|
|
|
expect(session.removeAttachment).toHaveBeenCalledWith('image.png');
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
|
|
'look',
|
|
[{ data: 'AQID', mimeType: 'image/png' }],
|
|
undefined,
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('publishes standalone working-directory admission failures', async () => {
|
|
const session = createMockSession('standalone-a');
|
|
session.submitPrompt.mockRejectedValueOnce(
|
|
new DaemonHttpError(
|
|
409,
|
|
{ code: 'working_directory_missing' },
|
|
'working directory missing',
|
|
),
|
|
);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'standalone-a',
|
|
sessionContext: { kind: 'standalone' },
|
|
standaloneSession: { workingDirectory: { state: 'ready' } },
|
|
},
|
|
});
|
|
|
|
await expect(actions.sendPrompt('look')).rejects.toThrow(
|
|
'working directory missing',
|
|
);
|
|
|
|
expect(getConnection().standaloneSession).toEqual({
|
|
workingDirectory: { state: 'ready' },
|
|
errorCode: 'working_directory_missing',
|
|
});
|
|
});
|
|
|
|
it('publishes standalone working-directory shell failures', async () => {
|
|
const session = createMockSession('standalone-shell');
|
|
session.shellCommand.mockRejectedValueOnce(
|
|
new DaemonHttpError(
|
|
409,
|
|
{ code: 'working_directory_compromised' },
|
|
'working directory compromised',
|
|
),
|
|
);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'standalone-shell',
|
|
sessionContext: { kind: 'standalone' },
|
|
standaloneSession: { workingDirectory: { state: 'ready' } },
|
|
},
|
|
});
|
|
|
|
await expect(actions.sendShellCommand('pwd')).rejects.toThrow(
|
|
'working directory compromised',
|
|
);
|
|
|
|
expect(getConnection().standaloneSession).toEqual({
|
|
workingDirectory: { state: 'ready' },
|
|
errorCode: 'working_directory_compromised',
|
|
});
|
|
});
|
|
|
|
it('does not publish workspace prompt admission failures as standalone state', async () => {
|
|
const session = createMockSession('workspace-a');
|
|
session.submitPrompt.mockRejectedValueOnce(
|
|
new DaemonHttpError(
|
|
409,
|
|
{ code: 'working_directory_compromised' },
|
|
'working directory compromised',
|
|
),
|
|
);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'workspace-a',
|
|
workspaceCwd: '/workspace',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
},
|
|
});
|
|
|
|
await expect(actions.submitPrompt('look')).rejects.toThrow(
|
|
'working directory compromised',
|
|
);
|
|
|
|
expect(getConnection().standaloneSession).toBeUndefined();
|
|
});
|
|
|
|
it('keeps uploaded attachments when prompt admission is uncertain', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
}),
|
|
).rejects.toThrow('fetch failed');
|
|
|
|
expect(session.removeAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes uploaded attachments when cancelled before prompt admission', async () => {
|
|
const upload = createDeferred<{
|
|
type: 'image';
|
|
attachmentId: string;
|
|
mimeType: string;
|
|
size: number;
|
|
}>();
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockReturnValueOnce(upload.promise);
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const prompt = actions.sendPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
await vi.waitFor(() => expect(session.uploadAttachment).toHaveBeenCalled());
|
|
await actions.cancel();
|
|
upload.resolve({
|
|
type: 'image',
|
|
attachmentId: 'media-1',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
});
|
|
|
|
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
|
|
expect(session.removeAttachment).toHaveBeenCalledWith('media-1');
|
|
expect(store.appendLocalUserMessage).toHaveBeenCalled();
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes uploaded attachments when an admitted pending prompt is removed', async () => {
|
|
const controller = new AbortController();
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockImplementationOnce(async () => {
|
|
controller.abort();
|
|
return { promptId: 'prompt-1' };
|
|
});
|
|
session.removePendingPrompt.mockResolvedValueOnce({ removed: true });
|
|
const onPromptAdmitted = vi.fn();
|
|
const onPromptRemoved = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
onPromptAdmitted,
|
|
onPromptRemoved,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
signal: controller.signal,
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
}),
|
|
).resolves.toEqual({ promptId: 'prompt-1', removedAfterAbort: true });
|
|
|
|
expect(session.removeAttachment).toHaveBeenCalledWith('image.png');
|
|
expect(onPromptAdmitted).toHaveBeenCalledWith(session, {
|
|
promptId: 'prompt-1',
|
|
label: 'look',
|
|
});
|
|
expect(onPromptRemoved).toHaveBeenCalledWith(session, 'prompt-1');
|
|
});
|
|
|
|
it('does not admit a queued prompt cancelled while its upload response was in flight', async () => {
|
|
const upload = createDeferred<{
|
|
type: 'image';
|
|
attachmentId: string;
|
|
mimeType: string;
|
|
size: number;
|
|
}>();
|
|
const controller = new AbortController();
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockReturnValueOnce(upload.promise);
|
|
const { actions, store } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
const onAdmissionStarted = vi.fn();
|
|
const result = actions.submitPrompt('', {
|
|
signal: controller.signal,
|
|
optimisticUserMessage: false,
|
|
onAdmissionStarted,
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
const rejected = result.catch((error: unknown) => error);
|
|
await vi.waitFor(() => expect(session.uploadAttachment).toHaveBeenCalled());
|
|
controller.abort();
|
|
upload.resolve({
|
|
type: 'image',
|
|
attachmentId: 'cancelled-upload',
|
|
mimeType: 'image/png',
|
|
size: 3,
|
|
});
|
|
expect(await rejected).toMatchObject({ name: 'AbortError' });
|
|
expect(session.removeAttachment).toHaveBeenCalledWith('cancelled-upload');
|
|
expect(session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(onAdmissionStarted).not.toHaveBeenCalled();
|
|
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps uploaded attachments when the admitted prompt already started', async () => {
|
|
const controller = new AbortController();
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockImplementationOnce(async () => {
|
|
controller.abort();
|
|
return { promptId: 'prompt-1' };
|
|
});
|
|
session.removePendingPrompt.mockResolvedValueOnce({ removed: false });
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
v: 1,
|
|
mode: 'http-bridge',
|
|
features: ['session_attachments'],
|
|
modelServices: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
signal: controller.signal,
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
}),
|
|
).rejects.toMatchObject({ name: 'AbortError' });
|
|
|
|
expect(session.removeAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps prompt images inline for an older daemon', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await actions.submitPrompt('look', {
|
|
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
|
});
|
|
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
expect(session.submitPrompt).toHaveBeenCalledWith({
|
|
eventDetailMode: 'full',
|
|
prompt: [
|
|
{ type: 'text', text: 'look' },
|
|
{
|
|
type: 'image',
|
|
data: 'AQID',
|
|
mimeType: 'image/png',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('removes an orphaned upload from its original session after a switch', async () => {
|
|
const session = createMockSession('session-current', 'client-current');
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.removeAttachment('media-old', { sessionId: 'session-old' }),
|
|
).resolves.toBe(true);
|
|
|
|
expect(session.removeAttachment).not.toHaveBeenCalled();
|
|
expect(session.client.removeSessionAttachment).toHaveBeenCalledWith(
|
|
'session-old',
|
|
'media-old',
|
|
);
|
|
});
|
|
|
|
it('removes an orphaned upload after the active session is cleared', async () => {
|
|
const session = createMockSession('session-old', 'client-old');
|
|
const { actions, sessionRef } = createActionsHarness({ session });
|
|
await actions.uploadAttachment({ data: 'AQID', mimeType: 'image/png' });
|
|
sessionRef.current = undefined;
|
|
|
|
await expect(
|
|
actions.removeAttachment('media-old', { sessionId: 'session-old' }),
|
|
).resolves.toBe(true);
|
|
|
|
expect(session.client.removeSessionAttachment).toHaveBeenCalledWith(
|
|
'session-old',
|
|
'media-old',
|
|
{ clientId: 'client-old' },
|
|
);
|
|
});
|
|
|
|
it('uses the target session client id when removing an old attachment', async () => {
|
|
vi.stubGlobal('window', {
|
|
sessionStorage: {
|
|
getItem: vi.fn(() => 'client-old'),
|
|
},
|
|
});
|
|
try {
|
|
const session = createMockSession('session-current', 'client-current');
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await actions.removeAttachment('media-old', {
|
|
sessionId: 'session-old',
|
|
});
|
|
|
|
expect(session.client.removeSessionAttachment).toHaveBeenCalledWith(
|
|
'session-old',
|
|
'media-old',
|
|
{ clientId: 'client-old' },
|
|
);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('retries cross-session attachment removal without the client id when it is stale', async () => {
|
|
// Detach unregisters the persisted client id on the daemon; the cleanup
|
|
// must degrade to a no-clientId removal instead of orphaning the media.
|
|
vi.stubGlobal('window', {
|
|
sessionStorage: {
|
|
getItem: vi.fn(() => 'client-old'),
|
|
},
|
|
});
|
|
try {
|
|
const session = createMockSession('session-current', 'client-current');
|
|
session.client.removeSessionAttachment = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(
|
|
new DaemonHttpError(
|
|
400,
|
|
{ code: 'invalid_client_id' },
|
|
'invalid client id',
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(true);
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.removeAttachment('media-old', { sessionId: 'session-old' }),
|
|
).resolves.toBe(true);
|
|
|
|
expect(session.client.removeSessionAttachment).toHaveBeenCalledTimes(2);
|
|
expect(session.client.removeSessionAttachment).toHaveBeenNthCalledWith(
|
|
1,
|
|
'session-old',
|
|
'media-old',
|
|
{ clientId: 'client-old' },
|
|
);
|
|
expect(session.client.removeSessionAttachment).toHaveBeenNthCalledWith(
|
|
2,
|
|
'session-old',
|
|
'media-old',
|
|
);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('does not retry unrelated cross-session attachment removal errors', async () => {
|
|
vi.stubGlobal('window', {
|
|
sessionStorage: {
|
|
getItem: vi.fn(() => 'client-old'),
|
|
},
|
|
});
|
|
try {
|
|
const session = createMockSession('session-current', 'client-current');
|
|
const error = new DaemonHttpError(
|
|
400,
|
|
{ code: 'invalid_attachment_id' },
|
|
'invalid attachment id',
|
|
);
|
|
session.client.removeSessionAttachment = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(error);
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.removeAttachment('media-old', { sessionId: 'session-old' }),
|
|
).rejects.toBe(error);
|
|
expect(session.client.removeSessionAttachment).toHaveBeenCalledOnce();
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('lists attachments through the active session client', async () => {
|
|
const session = createMockSession('session-current', 'client-current');
|
|
session.listAttachments = vi.fn(async () => [
|
|
{
|
|
type: 'resource',
|
|
attachmentId: 'notes.txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
},
|
|
]);
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(actions.listAttachments()).resolves.toEqual([
|
|
{
|
|
type: 'resource',
|
|
attachmentId: 'notes.txt',
|
|
mimeType: 'text/plain',
|
|
size: 5,
|
|
},
|
|
]);
|
|
expect(session.listAttachments).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects listing attachments without a notice when no session exists', async () => {
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ addNotice });
|
|
|
|
await expect(actions.listAttachments()).rejects.toThrow(
|
|
'Daemon session is not connected',
|
|
);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normalizes image MIME parameters when naming an uploaded attachment', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await actions.uploadAttachment({
|
|
data: 'AQID',
|
|
mimeType: 'image/jpeg; charset=binary',
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
|
expect.any(Blob),
|
|
'image.jpeg',
|
|
'image/jpeg',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('uploads a file attachment with its original name and MIME type', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions } = createActionsHarness({ session });
|
|
const data = new Blob(['hello'], { type: 'text/plain' });
|
|
|
|
await actions.uploadAttachment({
|
|
name: 'notes.txt',
|
|
data,
|
|
mimeType: 'text/plain',
|
|
});
|
|
|
|
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
|
data,
|
|
'notes.txt',
|
|
'text/plain',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('does not upload an attachment after the active session changes', async () => {
|
|
const session = createMockSession('session-b');
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.uploadAttachment(
|
|
{
|
|
name: 'notes.txt',
|
|
data: new Blob(['hello']),
|
|
mimeType: 'text/plain',
|
|
},
|
|
{ sessionId: 'session-a' },
|
|
),
|
|
).rejects.toThrow('Attachment session changed');
|
|
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not restart the event stream when the admitted prompt is stale', async () => {
|
|
const restartEventStream = vi.fn();
|
|
const session = createMockSession('session-a');
|
|
const accepted = createDeferred<{ promptId: string }>();
|
|
session.submitPrompt.mockReturnValueOnce(accepted.promise);
|
|
const { actions, activePromptsRef } = createActionsHarness({
|
|
restartEventStream,
|
|
session,
|
|
});
|
|
|
|
const prompt = actions.sendPrompt('hello');
|
|
activePromptsRef.current.clear();
|
|
accepted.resolve({ promptId: 'prompt-1' });
|
|
|
|
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
|
|
expect(restartEventStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rethrows a stale branch point error without a generic notice', async () => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
session.client.branchSession.mockRejectedValueOnce(
|
|
new DaemonHttpError(409, { code: 'branch_point_invalid' }, 'Conflict'),
|
|
);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.branchSession({ atRecordId: 'a1' }),
|
|
).rejects.toMatchObject({ _alreadyDispatched: true });
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['branch_worktree_activation_failed', 0],
|
|
['branch_worktree_outcome_unknown', 1],
|
|
['worktree_create_failed', 1],
|
|
] as const)('handles %s with %i generic notices', async (code, count) => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const error = new DaemonHttpError(500, { code }, 'Worktree failed');
|
|
session.client.branchSession.mockRejectedValueOnce(error);
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(
|
|
actions.branchSession({ atRecordId: 'a1', worktree: {} }),
|
|
).rejects.toBe(error);
|
|
expect(error).toMatchObject({ _alreadyDispatched: true });
|
|
expect(addNotice).toHaveBeenCalledTimes(count);
|
|
if (count) {
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operation: 'branch_session' }),
|
|
);
|
|
}
|
|
});
|
|
|
|
it('lets the SDK own the branch deadline instead of adding a 30s action timeout', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const session = createMockSession('session-a');
|
|
const branchResult = createDeferred<{
|
|
sessionId: string;
|
|
displayName: string;
|
|
}>();
|
|
session.client.branchSession.mockReturnValueOnce(branchResult.promise);
|
|
const addNotice = vi.fn((notice) => notice);
|
|
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
|
addNotice,
|
|
session,
|
|
});
|
|
|
|
let settled = false;
|
|
const branch = actions
|
|
.branchSession({ atRecordId: 'checkpoint-1' })
|
|
.finally(() => {
|
|
settled = true;
|
|
});
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
expect(settled).toBe(false);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
|
|
branchResult.resolve({
|
|
sessionId: 'session-b',
|
|
displayName: 'Historical branch',
|
|
});
|
|
await expect(branch).resolves.toEqual({
|
|
sessionId: 'session-b',
|
|
displayName: 'Historical branch',
|
|
switchStarted: true,
|
|
});
|
|
if (pendingSessionLoadRef.current) {
|
|
clearTimeout(pendingSessionLoadRef.current.timeout);
|
|
pendingSessionLoadRef.current.resolve();
|
|
pendingSessionLoadRef.current = undefined;
|
|
}
|
|
} finally {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not let a late branch result supersede newer navigation', async () => {
|
|
const session = createMockSession('session-a');
|
|
const branchResult = createDeferred<{
|
|
sessionId: string;
|
|
displayName: string;
|
|
}>();
|
|
session.client.branchSession.mockReturnValueOnce(branchResult.promise);
|
|
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
|
session,
|
|
});
|
|
|
|
const branch = actions.branchSession({ atRecordId: 'checkpoint-1' });
|
|
const newerLoad = actions.loadSession('session-b');
|
|
expect(pendingSessionLoadRef.current?.sessionId).toBe('session-b');
|
|
|
|
branchResult.resolve({
|
|
sessionId: 'session-c',
|
|
displayName: 'Historical branch',
|
|
});
|
|
await expect(branch).resolves.toEqual({
|
|
sessionId: 'session-c',
|
|
displayName: 'Historical branch',
|
|
switchStarted: false,
|
|
});
|
|
expect(pendingSessionLoadRef.current?.sessionId).toBe('session-b');
|
|
|
|
if (pendingSessionLoadRef.current) {
|
|
clearTimeout(pendingSessionLoadRef.current.timeout);
|
|
pendingSessionLoadRef.current.resolve();
|
|
pendingSessionLoadRef.current = undefined;
|
|
}
|
|
await expect(newerLoad).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('preserves ambiguous stable-id admission failures for reconciliation', async () => {
|
|
const onAdmissionStarted = vi.fn();
|
|
const session = {
|
|
...createMockSession('session-a'),
|
|
enqueueMidTurnMessage: vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('response lost')),
|
|
};
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.enqueueMidTurnMessage('follow up', {
|
|
messageId: 'stable-id',
|
|
onAdmissionStarted,
|
|
}),
|
|
).rejects.toThrow('response lost');
|
|
expect(onAdmissionStarted).toHaveBeenCalledOnce();
|
|
// The stable id must reach the session client verbatim: the daemon's
|
|
// messageId-keyed idempotency and the reconciliation rings never match
|
|
// if this hop drops the option.
|
|
expect(session.enqueueMidTurnMessage).toHaveBeenCalledWith('follow up', {
|
|
eventDetailMode: 'full',
|
|
messageId: 'stable-id',
|
|
});
|
|
});
|
|
|
|
it('forwards the daemon idle rejection reason across the actions hop', async () => {
|
|
const session = {
|
|
...createMockSession('session-a'),
|
|
enqueueMidTurnMessage: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ accepted: false, reason: 'session_idle' }),
|
|
};
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
// The hook decides whether to resubmit on this field alone, and a
|
|
// narrowing at this hop type-checks because the field is optional — it
|
|
// would silently restore the race the reason exists to end.
|
|
await expect(
|
|
actions.enqueueMidTurnMessage('follow up', { messageId: 'stable-id' }),
|
|
).resolves.toEqual({ accepted: false, reason: 'session_idle' });
|
|
});
|
|
|
|
it('does not mark a stable-id admission started without a session', async () => {
|
|
const onAdmissionStarted = vi.fn();
|
|
const { actions } = createActionsHarness();
|
|
|
|
await expect(
|
|
actions.enqueueMidTurnMessage('follow up', {
|
|
messageId: 'stable-id',
|
|
onAdmissionStarted,
|
|
}),
|
|
).resolves.toEqual({ accepted: false });
|
|
expect(onAdmissionStarted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps legacy mid-turn admission failures best-effort', async () => {
|
|
const session = {
|
|
...createMockSession('session-a'),
|
|
enqueueMidTurnMessage: vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('daemon unavailable')),
|
|
};
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(actions.enqueueMidTurnMessage('follow up')).resolves.toEqual({
|
|
accepted: false,
|
|
});
|
|
});
|
|
|
|
it('resolves undefined when getMidTurnMessages fails instead of throwing', async () => {
|
|
// Snapshot failure is unknown state. The caller must not infer that it is
|
|
// safe to resend.
|
|
const session = {
|
|
...createMockSession('session-a'),
|
|
getMidTurnMessages: vi.fn().mockRejectedValue(new Error('daemon 500')),
|
|
};
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({ addNotice, session });
|
|
|
|
await expect(actions.getMidTurnMessages()).resolves.toBeUndefined();
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('asks the current session to send its queued mid-turn messages now', async () => {
|
|
const session = {
|
|
...createMockSession('session-a'),
|
|
sendMidTurnMessagesNow: vi.fn(async () => ({ requested: true })),
|
|
};
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.sendMidTurnMessagesNow({ sessionId: 'session-a' }),
|
|
).resolves.toEqual({ requested: true });
|
|
expect(session.sendMidTurnMessagesNow).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('sends a restored row now against its own session', async () => {
|
|
const base = createMockSession('session-a', 'client-a');
|
|
const sendMidTurnMessagesNow = vi.fn(async () => ({ requested: false }));
|
|
const session = {
|
|
...base,
|
|
client: { ...base.client, sendMidTurnMessagesNow },
|
|
sendMidTurnMessagesNow: vi.fn(),
|
|
};
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await expect(
|
|
actions.sendMidTurnMessagesNow({ sessionId: 'session-b' }),
|
|
).resolves.toEqual({ requested: false });
|
|
expect(sendMidTurnMessagesNow).toHaveBeenCalledWith('session-b', {
|
|
clientId: 'client-a',
|
|
});
|
|
expect(session.sendMidTurnMessagesNow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the restored session persisted client id to send its row now', async () => {
|
|
vi.stubGlobal('window', {
|
|
sessionStorage: { getItem: vi.fn(() => 'client-b') },
|
|
});
|
|
try {
|
|
const base = createMockSession('session-a', 'client-a');
|
|
const sendMidTurnMessagesNow = vi.fn(async () => ({ requested: true }));
|
|
const session = {
|
|
...base,
|
|
client: { ...base.client, sendMidTurnMessagesNow },
|
|
};
|
|
const { actions } = createActionsHarness({ session });
|
|
|
|
await actions.sendMidTurnMessagesNow({ sessionId: 'session-b' });
|
|
|
|
expect(sendMidTurnMessagesNow).toHaveBeenCalledWith('session-b', {
|
|
clientId: 'client-b',
|
|
});
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('resolves undefined from getMidTurnMessages when no session exists', async () => {
|
|
const { actions } = createActionsHarness();
|
|
|
|
await expect(actions.getMidTurnMessages()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('does not apply a late model update to a replacement attachment', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const target = createMockSession('session-a', 'client-b');
|
|
const result = { applied: true };
|
|
const deferred = createDeferred<typeof result>();
|
|
source.setModel.mockReturnValueOnce(deferred.promise);
|
|
const { actions, getConnection, replaceConnection, sessionRef } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: source.sessionId,
|
|
clientId: source.clientId,
|
|
currentModel: 'source-model',
|
|
},
|
|
session: source,
|
|
});
|
|
|
|
const pending = actions.setModel('late-source-model');
|
|
sessionRef.current = target as unknown as DaemonSessionClient;
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: target.sessionId,
|
|
clientId: target.clientId,
|
|
currentModel: 'target-model',
|
|
});
|
|
deferred.resolve(result);
|
|
|
|
await expect(pending).resolves.toBe(result);
|
|
expect(getConnection()).toMatchObject({
|
|
clientId: 'client-b',
|
|
currentModel: 'target-model',
|
|
});
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'applies confirmed reasoning with a provider preview=%s',
|
|
async (withProviders) => {
|
|
const session = createMockSession('session-a');
|
|
session.setConfigOption.mockResolvedValueOnce({
|
|
configOptions: reasoningConfigOptions('medium'),
|
|
persisted: true,
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentModel: 'qwen3.8-max',
|
|
...(withProviders
|
|
? { providers: workspaceProvidersStatus('low') }
|
|
: {}),
|
|
},
|
|
session,
|
|
});
|
|
await expect(
|
|
actions.setReasoningEffort('medium', { persist: true }),
|
|
).resolves.toBeUndefined();
|
|
expect(session.setConfigOption).toHaveBeenCalledWith(
|
|
'reasoning_effort',
|
|
'medium',
|
|
{ persist: true },
|
|
);
|
|
expect(getConnection().reasoning).toEqual({
|
|
enabled: true,
|
|
effort: 'medium',
|
|
efforts: ['low', 'medium', 'xhigh'],
|
|
});
|
|
if (withProviders) {
|
|
await actions.clearSession();
|
|
expect(getConnection().sessionId).toBeUndefined();
|
|
expect(getConnection().models?.[0]?.reasoningPreview).toMatchObject({
|
|
enabled: true,
|
|
effort: 'medium',
|
|
efforts: ['low', 'medium', 'xhigh'],
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['ready', false],
|
|
['ready', true],
|
|
['unsupported', false],
|
|
['unsupported', true],
|
|
] as const)(
|
|
'applies confirmed reasoning before its own %s config reconciliation (deferred=%s)',
|
|
async (workflowModeStatus, deferConnectionUpdates) => {
|
|
const session = createMockSession('session-a');
|
|
const readback = createDeferred<DaemonSessionContextStatus>();
|
|
session.context.mockReturnValueOnce(readback.promise);
|
|
session.setConfigOption.mockResolvedValueOnce({
|
|
configOptions: reasoningConfigOptions('medium'),
|
|
persisted: true,
|
|
});
|
|
const { actions, getConnection, flushConnectionUpdates } =
|
|
createActionsHarness({
|
|
deferConnectionUpdates,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentModel: 'qwen3.8-max',
|
|
providers: workspaceProvidersStatus('low'),
|
|
workflowModeStatus,
|
|
},
|
|
session,
|
|
});
|
|
|
|
await actions.setReasoningEffort('medium', { persist: true });
|
|
|
|
expect(session.context).toHaveBeenCalledTimes(1);
|
|
flushConnectionUpdates();
|
|
expect(getConnection().reasoning).toMatchObject({
|
|
enabled: true,
|
|
effort: 'medium',
|
|
});
|
|
readback.reject(new Error('readback offline'));
|
|
await vi.waitFor(() => {
|
|
flushConnectionUpdates();
|
|
expect(getConnection().workflowModeStatus).toBe('unknown');
|
|
});
|
|
await actions.clearSession();
|
|
flushConnectionUpdates();
|
|
expect(getConnection().models?.[0]?.reasoningPreview).toMatchObject({
|
|
enabled: true,
|
|
effort: 'medium',
|
|
});
|
|
},
|
|
);
|
|
|
|
it('drops the connection session context through the real clearSession action', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
sessionContext: { kind: 'live' },
|
|
},
|
|
session,
|
|
});
|
|
|
|
await actions.clearSession({ dropSessionContext: true });
|
|
|
|
expect(getConnection().sessionContext).toBeUndefined();
|
|
expect(getConnection().sessionId).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the connection session context when clearSession drops only the session', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
sessionContext: { kind: 'live' },
|
|
},
|
|
session,
|
|
});
|
|
|
|
await actions.clearSession();
|
|
|
|
expect(getConnection().sessionId).toBeUndefined();
|
|
expect(getConnection().sessionContext).toEqual({ kind: 'live' });
|
|
});
|
|
|
|
it('captures and marks a clear before waiting for persisted reasoning', async () => {
|
|
const session = createMockSession('session-a');
|
|
const replacement = createMockSession('session-b');
|
|
const manualSessionClearRef = { current: false };
|
|
const persisted = createDeferred<{
|
|
configOptions: ReturnType<typeof reasoningConfigOptions>;
|
|
persisted: boolean;
|
|
}>();
|
|
session.setConfigOption.mockReturnValueOnce(persisted.promise);
|
|
const {
|
|
actions,
|
|
activePromptsRef,
|
|
getConnection,
|
|
replaceConnection,
|
|
sessionRef,
|
|
store,
|
|
} = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentModel: 'qwen3.8-max',
|
|
providers: workspaceProvidersStatus('low'),
|
|
},
|
|
session,
|
|
manualSessionClearRef,
|
|
});
|
|
|
|
const update = actions.setReasoningEffort('medium', { persist: true });
|
|
const clear = actions.clearSession();
|
|
await Promise.resolve();
|
|
|
|
expect(manualSessionClearRef.current).toBe(true);
|
|
expect(session.detach).not.toHaveBeenCalled();
|
|
sessionRef.current = replacement as unknown as DaemonSessionClient;
|
|
const replacementConnection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
sessionId: replacement.sessionId,
|
|
clientId: replacement.clientId,
|
|
currentModel: 'qwen3.8-max',
|
|
};
|
|
replaceConnection(replacementConnection);
|
|
const controller = new AbortController();
|
|
activePromptsRef.current.set('replacement-prompt', { controller });
|
|
persisted.resolve({
|
|
configOptions: reasoningConfigOptions('medium'),
|
|
persisted: true,
|
|
});
|
|
await update;
|
|
await clear;
|
|
|
|
expect(session.detach).toHaveBeenCalledOnce();
|
|
expect(replacement.detach).not.toHaveBeenCalled();
|
|
expect(sessionRef.current).toBe(replacement);
|
|
expect(getConnection()).toBe(replacementConnection);
|
|
expect(store.reset).not.toHaveBeenCalled();
|
|
expect(controller.signal.aborted).toBe(false);
|
|
expect(activePromptsRef.current.size).toBe(1);
|
|
});
|
|
|
|
it.each(['xhigh', 'none'])(
|
|
'accepts a confirmed default reset to %s without inventing a Default option',
|
|
async (defaultValue) => {
|
|
const session = createMockSession('session-a');
|
|
session.setConfigOption.mockResolvedValueOnce({
|
|
configOptions: reasoningConfigOptions(defaultValue),
|
|
persisted: true,
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentModel: 'qwen3.8-max',
|
|
providers: workspaceProvidersStatus('none'),
|
|
},
|
|
session,
|
|
});
|
|
|
|
await expect(
|
|
actions.setReasoningEffort('default', { persist: true }),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(session.setConfigOption).toHaveBeenCalledWith(
|
|
'reasoning_effort',
|
|
'default',
|
|
{ persist: true },
|
|
);
|
|
expect(getConnection().reasoning).toMatchObject({
|
|
enabled: defaultValue !== 'none',
|
|
effort: defaultValue === 'none' ? 'default' : defaultValue,
|
|
});
|
|
await actions.clearSession();
|
|
expect(getConnection().models?.[0]?.reasoningPreview?.enabled).toBe(
|
|
defaultValue !== 'none',
|
|
);
|
|
},
|
|
);
|
|
|
|
it('rejects a reasoning effort when live config options do not confirm it', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.setConfigOption.mockResolvedValueOnce({
|
|
configOptions: [],
|
|
persisted: false,
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentModel: 'qwen3.8-max',
|
|
},
|
|
session,
|
|
});
|
|
|
|
await expect(actions.setReasoningEffort('medium')).rejects.toThrow(
|
|
'Daemon did not confirm reasoning effort "medium"',
|
|
);
|
|
|
|
expect(getConnection().reasoning).toBeUndefined();
|
|
});
|
|
|
|
it('does not update reasoning when persistence is not confirmed', async () => {
|
|
const session = createMockSession('session-a');
|
|
const rejectedPersistence = createDeferred<{
|
|
configOptions: ReturnType<typeof reasoningConfigOptions>;
|
|
persisted: boolean;
|
|
}>();
|
|
session.setConfigOption.mockReturnValueOnce(rejectedPersistence.promise);
|
|
const { actions, getConnection } = createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentModel: 'qwen3.8-max',
|
|
providers: workspaceProvidersStatus('low'),
|
|
},
|
|
session,
|
|
});
|
|
|
|
const update = actions.setReasoningEffort('medium', { persist: true });
|
|
const clear = actions.clearSession();
|
|
rejectedPersistence.resolve({
|
|
configOptions: reasoningConfigOptions('medium'),
|
|
persisted: false,
|
|
});
|
|
|
|
await expect(update).rejects.toThrow(
|
|
'Daemon did not confirm reasoning effort "medium"',
|
|
);
|
|
await expect(clear).resolves.toBeUndefined();
|
|
|
|
expect(getConnection().reasoning).toBeUndefined();
|
|
expect(getConnection().models?.[0]?.reasoningPreview?.effort).toBe('low');
|
|
});
|
|
|
|
it('preserves both Plan fields when context omits modes and clears policy on a confirmed exit', async () => {
|
|
const session = createMockSession('session-a');
|
|
const { actions, getConnection } = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
currentMode: 'plan',
|
|
planExecutionMode: 'yolo',
|
|
},
|
|
});
|
|
session.context.mockResolvedValueOnce({
|
|
...contextStatus('session-a'),
|
|
state: {},
|
|
});
|
|
await actions.getContext();
|
|
expect(getConnection()).toMatchObject({
|
|
currentMode: 'plan',
|
|
planExecutionMode: 'yolo',
|
|
});
|
|
session.context.mockResolvedValueOnce({
|
|
...contextStatus('session-a'),
|
|
state: { modes: { currentModeId: 'default' } },
|
|
});
|
|
await actions.getContext();
|
|
expect(getConnection().currentMode).toBe('default');
|
|
expect(getConnection().planExecutionMode).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the selected Plan permission when an older context read finishes later', async () => {
|
|
const session = createMockSession('session-a');
|
|
const staleContext = createDeferred<ReturnType<typeof contextStatus>>();
|
|
session.context.mockReturnValueOnce(staleContext.promise);
|
|
session.client.setSessionApprovalMode.mockResolvedValueOnce({
|
|
sessionId: 'session-a',
|
|
mode: 'plan',
|
|
planExecutionMode: 'yolo',
|
|
previous: 'default',
|
|
persisted: false,
|
|
});
|
|
const { actions, getConnection } = createActionsHarness({ session });
|
|
const pendingContext = actions.getContext();
|
|
await actions.setApprovalMode('yolo', { planMode: true });
|
|
expect(session.client.setSessionApprovalMode).toHaveBeenCalledWith(
|
|
'session-a',
|
|
'yolo',
|
|
expect.objectContaining({ planMode: true }),
|
|
);
|
|
staleContext.resolve({
|
|
...contextStatus('session-a'),
|
|
state: { modes: { currentModeId: 'default' } },
|
|
});
|
|
await pendingContext;
|
|
expect(getConnection()).toMatchObject({
|
|
currentMode: 'plan',
|
|
planExecutionMode: 'yolo',
|
|
});
|
|
session.client.setSessionApprovalMode.mockResolvedValueOnce({
|
|
sessionId: 'session-a',
|
|
mode: 'yolo',
|
|
previous: 'plan',
|
|
persisted: false,
|
|
});
|
|
await actions.setApprovalMode('yolo', { planMode: false });
|
|
expect(getConnection().currentMode).toBe('yolo');
|
|
expect(getConnection().planExecutionMode).toBeUndefined();
|
|
});
|
|
|
|
it('does not apply a late approval mode to a replacement attachment', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const target = createMockSession('session-a', 'client-b');
|
|
const result = {
|
|
sessionId: 'session-a',
|
|
mode: 'yolo',
|
|
previous: 'default',
|
|
persisted: false,
|
|
};
|
|
const deferred = createDeferred<typeof result>();
|
|
source.client.setSessionApprovalMode.mockReturnValueOnce(deferred.promise);
|
|
const { actions, getConnection, replaceConnection, sessionRef } =
|
|
createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: source.sessionId,
|
|
clientId: source.clientId,
|
|
currentMode: 'default',
|
|
},
|
|
session: source,
|
|
});
|
|
|
|
const pending = actions.setApprovalMode('yolo');
|
|
sessionRef.current = target as unknown as DaemonSessionClient;
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: target.sessionId,
|
|
clientId: target.clientId,
|
|
currentMode: 'plan',
|
|
});
|
|
deferred.resolve(result);
|
|
|
|
await expect(pending).resolves.toBe(result);
|
|
expect(getConnection()).toMatchObject({
|
|
clientId: 'client-b',
|
|
currentMode: 'plan',
|
|
});
|
|
});
|
|
|
|
it('does not apply late commands to a replacement attachment', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const target = createMockSession('session-a', 'client-b');
|
|
const status = supportedCommandsStatus('session-a', 'source-command');
|
|
const deferred = createDeferred<typeof status>();
|
|
source.supportedCommands.mockReturnValueOnce(deferred.promise);
|
|
const targetStatus = supportedCommandsStatus('session-a', 'target-command');
|
|
const { actions, getConnection, replaceConnection, sessionRef } =
|
|
createActionsHarness({ session: source });
|
|
|
|
const pending = actions.refreshCommands();
|
|
sessionRef.current = target as unknown as DaemonSessionClient;
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: target.sessionId,
|
|
clientId: target.clientId,
|
|
commands: [commandInfo('target-command')],
|
|
skills: ['target-skill'],
|
|
supportedCommands: targetStatus,
|
|
});
|
|
deferred.resolve(status);
|
|
|
|
await expect(pending).resolves.toBeUndefined();
|
|
expect(getConnection()).toMatchObject({
|
|
clientId: 'client-b',
|
|
commands: [commandInfo('target-command')],
|
|
skills: ['target-skill'],
|
|
supportedCommands: targetStatus,
|
|
});
|
|
});
|
|
|
|
it('does not apply late context to a replacement attachment', async () => {
|
|
const source = createMockSession('session-a', 'client-a');
|
|
const target = createMockSession('session-a', 'client-b');
|
|
const context = {
|
|
...contextStatus('session-a'),
|
|
state: {
|
|
models: { currentModelId: 'source-model' },
|
|
modes: { currentModeId: 'source-mode' },
|
|
},
|
|
};
|
|
const deferred = createDeferred<typeof context>();
|
|
source.context.mockReturnValueOnce(deferred.promise);
|
|
const targetContext = contextStatus('session-a');
|
|
const { actions, getConnection, replaceConnection, sessionRef } =
|
|
createActionsHarness({ session: source });
|
|
|
|
const pending = actions.getContext();
|
|
sessionRef.current = target as unknown as DaemonSessionClient;
|
|
replaceConnection({
|
|
status: 'connected',
|
|
sessionId: target.sessionId,
|
|
clientId: target.clientId,
|
|
context: targetContext,
|
|
currentModel: 'target-model',
|
|
currentMode: 'target-mode',
|
|
});
|
|
deferred.resolve(context);
|
|
|
|
await expect(pending).resolves.toBe(context);
|
|
expect(getConnection()).toMatchObject({
|
|
clientId: 'client-b',
|
|
context: targetContext,
|
|
currentModel: 'target-model',
|
|
currentMode: 'target-mode',
|
|
});
|
|
});
|
|
});
|
|
|
|
function createActionsHarness(
|
|
opts: {
|
|
activePrompts?: Map<string, ActivePrompt>;
|
|
addNotice?: ReturnType<typeof vi.fn>;
|
|
clearLiveJournalRepair?: ReturnType<typeof vi.fn>;
|
|
connection?: DaemonConnectionState;
|
|
deferConnectionUpdates?: boolean;
|
|
onConnectionChange?: (connection: DaemonConnectionState) => void;
|
|
createDetachedSession?: ReturnType<typeof vi.fn>;
|
|
createDetachedStandaloneSession?: ReturnType<typeof vi.fn>;
|
|
daemonActivePromptRef?: {
|
|
current: DaemonActivePromptState | undefined;
|
|
};
|
|
flushTranscript?: ReturnType<typeof vi.fn>;
|
|
getSnapshot?: () => { activeAssistantBlockId: string | undefined };
|
|
hasSessionActivePrompt?: () => boolean;
|
|
manualSessionClearRef?: { current: boolean };
|
|
onPromptAdmitted?: ReturnType<typeof vi.fn>;
|
|
onContinuationAdmitted?: ReturnType<typeof vi.fn>;
|
|
onPromptRemoved?: ReturnType<typeof vi.fn>;
|
|
onGeneratedUiInvalidated?: ReturnType<typeof vi.fn>;
|
|
passiveAssistantDoneTimerRef?: {
|
|
current: ReturnType<typeof setTimeout> | undefined;
|
|
};
|
|
pendingSessionLoadRef?: { current: PendingSessionLoad | undefined };
|
|
settleRestoredActivePrompt?: ReturnType<typeof vi.fn>;
|
|
restartEventStream?: ReturnType<typeof vi.fn>;
|
|
session?: ReturnType<typeof createMockSession>;
|
|
setAttachSessionNonce?: ReturnType<typeof vi.fn>;
|
|
setRestoreSessionId?: ReturnType<typeof vi.fn>;
|
|
setRestoreSessionContext?: ReturnType<typeof vi.fn>;
|
|
getDefaultSessionContext?: () => DaemonProductSessionContext | undefined;
|
|
getClient?: () => DaemonClient | undefined;
|
|
getEventDetailMode?: () => 'full' | 'summary';
|
|
} = {},
|
|
) {
|
|
let connection: DaemonConnectionState = opts.connection ?? {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
};
|
|
const connectionUpdates: Array<() => void> = [];
|
|
const replaceConnection = (next: DaemonConnectionState) => {
|
|
connection = next;
|
|
};
|
|
const sessionRef = {
|
|
current: opts.session as unknown as DaemonSessionClient | undefined,
|
|
};
|
|
const activePromptsRef = {
|
|
current: opts.activePrompts ?? new Map<string, ActivePrompt>(),
|
|
};
|
|
const settledPromptsRef = { current: new Map<string, SettledPrompt>() };
|
|
const pendingSessionLoadRef =
|
|
opts.pendingSessionLoadRef ??
|
|
({ current: undefined } as {
|
|
current: PendingSessionLoad | undefined;
|
|
});
|
|
const setPromptStatus = vi.fn();
|
|
const settleRestoredActivePrompt =
|
|
opts.settleRestoredActivePrompt ?? vi.fn(() => false);
|
|
const passiveAssistantDoneTimerRef =
|
|
opts.passiveAssistantDoneTimerRef ??
|
|
({ current: undefined } as {
|
|
current: ReturnType<typeof setTimeout> | undefined;
|
|
});
|
|
const store = {
|
|
reset: vi.fn(),
|
|
appendLocalUserMessage: vi.fn(),
|
|
dispatch: vi.fn(),
|
|
getSnapshot: vi.fn(
|
|
opts.getSnapshot ??
|
|
(() => ({ blocks: [], activeAssistantBlockId: undefined })),
|
|
),
|
|
};
|
|
const sessionConfigGeneration: SessionConfigSyncMap = new WeakMap();
|
|
const actions = createDaemonSessionActions({
|
|
store: store as never,
|
|
sessionRef,
|
|
activePromptsRef,
|
|
settledPromptsRef,
|
|
pendingSessionLoadRef,
|
|
pendingSessionLoadIdRef: { current: 0 },
|
|
sessionConfigGeneration,
|
|
sessionRecoveryGeneration: new WeakMap(),
|
|
heartbeatSupportedRef: { current: false },
|
|
manualSessionClearRef: opts.manualSessionClearRef ?? { current: false },
|
|
skipNextCleanupDetachSessionRef: { current: undefined },
|
|
passiveAssistantDoneTimerRef,
|
|
daemonActivePromptRef: opts.daemonActivePromptRef ?? { current: undefined },
|
|
flushTranscript: opts.flushTranscript ?? vi.fn(),
|
|
settleRestoredActivePrompt,
|
|
getCreateSessionRequest: () => ({ workspaceCwd: '/workspace' }),
|
|
createDetachedSession: (opts.createDetachedSession ??
|
|
vi.fn(
|
|
async () =>
|
|
createMockSession(
|
|
'detached-session',
|
|
) as unknown as DaemonSessionClient,
|
|
)) as () => Promise<DaemonSessionClient>,
|
|
createDetachedStandaloneSession: (opts.createDetachedStandaloneSession ??
|
|
vi.fn(
|
|
async () =>
|
|
createMockSession(
|
|
'detached-standalone-session',
|
|
) as unknown as DaemonSessionClient,
|
|
)) as () => Promise<DaemonSessionClient>,
|
|
getDefaultSessionContext:
|
|
opts.getDefaultSessionContext ?? (() => undefined),
|
|
getClient: opts.getClient,
|
|
getConnection: () => connection,
|
|
getEventDetailMode: opts.getEventDetailMode ?? (() => 'full'),
|
|
hasSessionActivePrompt: opts.hasSessionActivePrompt ?? (() => false),
|
|
resetCurrentSessionActivePrompt: vi.fn(),
|
|
restartEventStream: opts.restartEventStream ?? vi.fn(),
|
|
addNotice: opts.addNotice ?? vi.fn(),
|
|
clearLiveJournalRepair: opts.clearLiveJournalRepair,
|
|
onPromptAdmitted: opts.onPromptAdmitted,
|
|
onContinuationAdmitted: opts.onContinuationAdmitted,
|
|
onPromptRemoved: opts.onPromptRemoved,
|
|
onGeneratedUiInvalidated: opts.onGeneratedUiInvalidated,
|
|
setConnection: (update) => {
|
|
const apply = () => {
|
|
connection = typeof update === 'function' ? update(connection) : update;
|
|
opts.onConnectionChange?.(connection);
|
|
};
|
|
if (opts.deferConnectionUpdates) connectionUpdates.push(apply);
|
|
else apply();
|
|
},
|
|
setPromptStatus,
|
|
setRestoreSessionId: opts.setRestoreSessionId ?? vi.fn(),
|
|
setRestoreSessionContext: opts.setRestoreSessionContext ?? vi.fn(),
|
|
setRestoreMode: vi.fn(),
|
|
setRestoreSessionNonce: vi.fn(),
|
|
setAttachSessionNonce: opts.setAttachSessionNonce ?? vi.fn(),
|
|
setNewSessionNonce: vi.fn(),
|
|
});
|
|
return {
|
|
actions,
|
|
flushConnectionUpdates: () => {
|
|
for (const apply of connectionUpdates.splice(0)) apply();
|
|
},
|
|
sessionConfigGeneration,
|
|
activePromptsRef,
|
|
settledPromptsRef,
|
|
getConnection: () => connection,
|
|
passiveAssistantDoneTimerRef,
|
|
pendingSessionLoadRef,
|
|
replaceConnection,
|
|
sessionRef,
|
|
settleRestoredActivePrompt,
|
|
setPromptStatus,
|
|
store,
|
|
};
|
|
}
|
|
|
|
function createWorkspaceCreateHarness(
|
|
path: 'active' | 'detached',
|
|
client: DaemonClient,
|
|
createDetachedSession: Parameters<
|
|
typeof createDaemonSessionActions
|
|
>[0]['createDetachedSession'] = (workspaceCwd, overrides) =>
|
|
DaemonSessionClient.createOrAttach(client, { workspaceCwd, ...overrides }),
|
|
capabilities?: DaemonCapabilities,
|
|
) {
|
|
const existing = createMockSession('session-a');
|
|
existing.client.createOrAttachSession.mockImplementation((request) =>
|
|
client.createOrAttachSession(request),
|
|
);
|
|
return {
|
|
existing,
|
|
...createActionsHarness({
|
|
connection: {
|
|
status: 'connected',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
...(path === 'active' ? { sessionId: 'session-a' } : {}),
|
|
...(capabilities ? { capabilities } : {}),
|
|
},
|
|
...(path === 'active' ? { session: existing } : {}),
|
|
createDetachedSession: vi.fn(createDetachedSession),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createMockSession(
|
|
sessionId: string,
|
|
clientId = `client-${sessionId}`,
|
|
) {
|
|
return {
|
|
sessionId,
|
|
workspaceCwd: '/workspace',
|
|
clientId,
|
|
client: {
|
|
workspaceByCwd: vi.fn(() => ({
|
|
reloadModelProviders: vi.fn(async () => ({
|
|
status: 'applied' as string,
|
|
})),
|
|
workspaceProviders: vi.fn(async () => workspaceProvidersStatus('low')),
|
|
})),
|
|
createOrAttachSession: vi.fn(),
|
|
branchSession: vi.fn(),
|
|
detachSession: vi.fn(async () => undefined),
|
|
setSessionApprovalMode: vi.fn(async () => ({
|
|
sessionId,
|
|
mode: 'default',
|
|
previous: 'default',
|
|
persisted: false,
|
|
})),
|
|
listWorkspaceSessions: vi.fn(),
|
|
listStandaloneSessions: vi.fn(),
|
|
closeSession: vi.fn(),
|
|
sessionWorkflowTaskAction: vi.fn(),
|
|
removeSessionAttachment: vi.fn(async () => true),
|
|
upsertSessionSource: vi.fn(async () => ({ revision: 1 })),
|
|
},
|
|
savedWorkflow: vi.fn(),
|
|
cancel: vi.fn(async () => undefined),
|
|
btw: vi.fn(async () => ({ sessionId, answer: 'Legacy answer' })),
|
|
btwStream: vi.fn(
|
|
async function* (): AsyncGenerator<DaemonSessionBtwStreamEvent> {
|
|
yield {
|
|
v: 1,
|
|
type: 'done',
|
|
requestId: 'btw-request',
|
|
answer: 'Side answer',
|
|
} as const;
|
|
},
|
|
),
|
|
context: vi.fn(async () => contextStatus(sessionId)),
|
|
detach: vi.fn(async () => undefined),
|
|
setModel: vi.fn(async () => ({})),
|
|
setConfigOption: vi.fn(async (_configId: string, value: string) => ({
|
|
configOptions: reasoningConfigOptions(value),
|
|
persisted: false,
|
|
})),
|
|
uploadAttachment: vi.fn(
|
|
async (data: Blob, name: string, mimeType: string) => ({
|
|
type: mimeType.startsWith('image/')
|
|
? ('image' as const)
|
|
: ('resource' as const),
|
|
attachmentId: name,
|
|
mimeType,
|
|
size: data.size,
|
|
}),
|
|
),
|
|
readAttachment: vi.fn(async () => ({
|
|
data: 'aGVsbG8=',
|
|
mimeType: 'text/plain',
|
|
})),
|
|
listAttachments: vi.fn(async () => []),
|
|
removeAttachment: vi.fn(async () => true),
|
|
removePendingPrompt: vi.fn(async () => ({ removed: true })),
|
|
shellCommand: vi.fn(async () => ({ promptId: 'shell-prompt-1' })),
|
|
submitPrompt: vi.fn(async () => ({ promptId: 'prompt-1' })),
|
|
continueSession: vi.fn(
|
|
async (_signal?: AbortSignal): Promise<DaemonContinueSessionResult> => ({
|
|
accepted: true,
|
|
interruption: 'interrupted_prompt',
|
|
promptId: 'continue-1',
|
|
lastEventId: 7,
|
|
eventEpoch: 'epoch-1',
|
|
}),
|
|
),
|
|
supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)),
|
|
stats: vi.fn(),
|
|
contextUsage: vi.fn(),
|
|
getRewindSnapshots: vi.fn(),
|
|
rewind: vi.fn(),
|
|
tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })),
|
|
workflowTasks: vi.fn(async () => ({
|
|
v: 1 as const,
|
|
sessionId,
|
|
tasks: [],
|
|
})),
|
|
controlWorkflowTask: vi.fn(),
|
|
goal: vi.fn(),
|
|
controlGoal: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function reasoningConfigOptions(currentValue: string) {
|
|
return [
|
|
{
|
|
id: 'reasoning_effort',
|
|
currentValue,
|
|
options: [
|
|
{ value: 'none' },
|
|
{ value: 'low' },
|
|
{ value: 'medium' },
|
|
{ value: 'xhigh' },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
function workspaceProvidersStatus(
|
|
currentValue: string,
|
|
): NonNullable<DaemonConnectionState['providers']> {
|
|
return {
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: true,
|
|
current: { modelId: 'qwen3.8-max' },
|
|
providers: [
|
|
{
|
|
kind: 'model_provider',
|
|
status: 'ok',
|
|
authType: 'qwen-oauth',
|
|
current: true,
|
|
models: [
|
|
{
|
|
modelId: 'qwen3.8-max',
|
|
baseModelId: 'qwen3.8-max',
|
|
name: 'Qwen 3.8 Max',
|
|
isCurrent: true,
|
|
isRuntime: false,
|
|
configOptions: reasoningConfigOptions(currentValue),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function commandInfo(name: string, source?: string, altNames?: string[]) {
|
|
const raw = commandRaw(name);
|
|
return {
|
|
name,
|
|
description: '',
|
|
...(source ? { source } : {}),
|
|
...(altNames ? { altNames } : {}),
|
|
raw,
|
|
};
|
|
}
|
|
|
|
function commandRaw(name: string) {
|
|
return {
|
|
name,
|
|
description: '',
|
|
input: null,
|
|
};
|
|
}
|
|
|
|
function supportedCommandsStatus(sessionId: string, ...names: string[]) {
|
|
return {
|
|
v: 1 as const,
|
|
sessionId,
|
|
availableCommands: names.map(commandRaw),
|
|
availableSkills: [],
|
|
};
|
|
}
|
|
|
|
function contextStatus(sessionId: string): DaemonSessionContextStatus {
|
|
return {
|
|
v: 1 as const,
|
|
sessionId,
|
|
workspaceCwd: '/workspace',
|
|
state: {},
|
|
};
|
|
}
|
|
|
|
describe('accepted attachment sources', () => {
|
|
const file = {
|
|
name: 'original.txt',
|
|
mimeType: 'text/plain',
|
|
data: new Blob(['reference']),
|
|
};
|
|
const connection = {
|
|
status: 'connected' as const,
|
|
workspaceCwd: '/workspace',
|
|
capabilities: {
|
|
features: ['session_attachments', 'session_sources'],
|
|
} as DaemonCapabilities,
|
|
};
|
|
it('starts metadata registration only after admission without blocking admission or changing the prompt', async () => {
|
|
const session = createMockSession('session-a');
|
|
let resolve!: () => void;
|
|
session.client.upsertSessionSource.mockReturnValueOnce(
|
|
new Promise((done) => {
|
|
resolve = () => done({ revision: 1 });
|
|
}),
|
|
);
|
|
const { actions } = createActionsHarness({ session, connection });
|
|
await actions.submitPrompt('look', { files: [file] });
|
|
expect(session.client.upsertSessionSource).toHaveBeenCalledWith(
|
|
'session-a',
|
|
{
|
|
title: 'original.txt',
|
|
locator: { type: 'attachment', attachmentId: 'original.txt' },
|
|
},
|
|
'client-session-a',
|
|
);
|
|
expect(session.submitPrompt.mock.invocationCallOrder[0]).toBeLessThan(
|
|
session.client.upsertSessionSource.mock.invocationCallOrder[0]!,
|
|
);
|
|
expect(JSON.stringify(session.submitPrompt.mock.calls)).not.toContain(
|
|
'sources',
|
|
);
|
|
resolve();
|
|
});
|
|
it('retries failed metadata without uploading or submitting the prompt again', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.client.upsertSessionSource.mockRejectedValueOnce(
|
|
new Error('offline'),
|
|
);
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection,
|
|
addNotice,
|
|
});
|
|
await actions.submitPrompt('look', { files: [file] });
|
|
await vi.waitFor(() => expect(addNotice).toHaveBeenCalled());
|
|
const retry = addNotice.mock.calls.find(
|
|
([notice]) => notice.sourceRetry,
|
|
)?.[0].sourceRetry;
|
|
expect(retry).toBeTypeOf('function');
|
|
await retry();
|
|
expect(session.client.upsertSessionSource).toHaveBeenCalledTimes(2);
|
|
expect(session.submitPrompt).toHaveBeenCalledOnce();
|
|
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
|
});
|
|
it('registers nothing when prompt admission is rejected', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.submitPrompt.mockRejectedValueOnce(
|
|
new DaemonHttpError(400, { code: 'invalid_prompt' }, 'rejected'),
|
|
);
|
|
const { actions } = createActionsHarness({ session, connection });
|
|
await expect(
|
|
actions.submitPrompt('look', { files: [file] }),
|
|
).rejects.toThrow('rejected');
|
|
expect(session.client.upsertSessionSource).not.toHaveBeenCalled();
|
|
expect(session.removeAttachment).toHaveBeenCalledOnce();
|
|
});
|
|
it('does not register attachments removed after queued admission is cancelled', async () => {
|
|
const session = createMockSession('session-a');
|
|
const controller = new AbortController();
|
|
session.submitPrompt.mockImplementationOnce(async () => {
|
|
controller.abort();
|
|
return { promptId: 'prompt-1' };
|
|
});
|
|
const { actions } = createActionsHarness({ session, connection });
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
files: [file],
|
|
signal: controller.signal,
|
|
}),
|
|
).resolves.toEqual({ promptId: 'prompt-1', removedAfterAbort: true });
|
|
expect(session.removeAttachment).toHaveBeenCalledWith('original.txt');
|
|
expect(session.client.upsertSessionSource).not.toHaveBeenCalled();
|
|
});
|
|
it.each(['already running', 'cleanup failed'])(
|
|
'keeps accepted attachment registration when cancelled admission is %s',
|
|
async (outcome) => {
|
|
const session = createMockSession('session-a');
|
|
const controller = new AbortController();
|
|
session.submitPrompt.mockImplementationOnce(async () => {
|
|
controller.abort();
|
|
return { promptId: 'prompt-1' };
|
|
});
|
|
if (outcome === 'already running') {
|
|
session.removePendingPrompt.mockResolvedValueOnce({ removed: false });
|
|
} else {
|
|
session.removePendingPrompt.mockRejectedValueOnce(new Error('offline'));
|
|
}
|
|
const { actions } = createActionsHarness({ session, connection });
|
|
await expect(
|
|
actions.submitPrompt('look', {
|
|
files: [file],
|
|
signal: controller.signal,
|
|
}),
|
|
).rejects.toMatchObject({ name: 'AbortError' });
|
|
expect(session.removeAttachment).not.toHaveBeenCalled();
|
|
expect(session.client.upsertSessionSource).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
it.each([
|
|
'source_limit_reached',
|
|
'invalid_source',
|
|
'source_attachment_not_found',
|
|
])('does not offer retries for terminal metadata error %s', async (code) => {
|
|
const session = createMockSession('session-a');
|
|
session.client.upsertSessionSource.mockRejectedValue(
|
|
new DaemonHttpError(409, { code }, code),
|
|
);
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection,
|
|
addNotice,
|
|
});
|
|
await actions.submitPrompt('look', { files: [file] });
|
|
await actions.submitPrompt('look again', { files: [file] });
|
|
await Promise.resolve();
|
|
expect(session.client.upsertSessionSource).toHaveBeenCalledTimes(2);
|
|
expect(addNotice.mock.calls.some(([notice]) => notice.sourceRetry)).toBe(
|
|
false,
|
|
);
|
|
expect(session.removeAttachment).not.toHaveBeenCalled();
|
|
});
|
|
it('uses original file titles alongside the uploaded image identity', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.uploadAttachment.mockImplementation(
|
|
async (data, name, mimeType) => ({
|
|
type: mimeType.startsWith('image/') ? 'image' : 'resource',
|
|
attachmentId: `id-${name}`,
|
|
mimeType,
|
|
size: data.size,
|
|
}),
|
|
);
|
|
const { actions } = createActionsHarness({ session, connection });
|
|
await actions.submitPrompt('look', {
|
|
images: [{ data: btoa('image'), mimeType: 'image/png' }],
|
|
files: [
|
|
{ ...file, name: 'alpha.txt' },
|
|
{ ...file, name: 'beta.txt' },
|
|
],
|
|
});
|
|
const calls = session.client.upsertSessionSource.mock
|
|
.calls as unknown as Array<
|
|
[string, { title: string; locator: { attachmentId: string } }]
|
|
>;
|
|
expect(calls.map(([, input]) => input.title)).toEqual([
|
|
calls[0]?.[1].locator.attachmentId,
|
|
'alpha.txt',
|
|
'beta.txt',
|
|
]);
|
|
});
|
|
it('suppresses the failed registration notice after switching owners', async () => {
|
|
const session = createMockSession('session-a');
|
|
let reject!: (error: Error) => void;
|
|
session.client.upsertSessionSource.mockReturnValueOnce(
|
|
new Promise((_, fail) => {
|
|
reject = fail;
|
|
}),
|
|
);
|
|
const addNotice = vi.fn();
|
|
const { actions, sessionRef } = createActionsHarness({
|
|
session,
|
|
connection,
|
|
addNotice,
|
|
});
|
|
await actions.submitPrompt('look', { files: [file] });
|
|
sessionRef.current = undefined;
|
|
reject(new Error('offline'));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('clearing selected stopped session removes stale stop and recovery flags', () => {
|
|
const next = getConnectionAfterSessionClear(
|
|
{
|
|
status: 'disconnected',
|
|
sessionId: 'session-1',
|
|
workspaceCwd: '/workspace',
|
|
runtimeStopped: true,
|
|
runtimeStopPersistenceUnconfirmed: true,
|
|
capacityRecovery: {
|
|
sessionId: 'session-1',
|
|
mode: 'load',
|
|
error: new Error('full'),
|
|
},
|
|
},
|
|
'session-1',
|
|
);
|
|
expect(next.sessionId).toBeUndefined();
|
|
expect(next.runtimeStopped).not.toBe(true);
|
|
expect(next.runtimeStopPersistenceUnconfirmed).not.toBe(true);
|
|
expect(next.capacityRecovery).toBeUndefined();
|
|
});
|
|
|
|
function createTimedCreateClient({
|
|
features = ['session_source_metadata'],
|
|
capabilitiesDelayMs = 20_000,
|
|
sessionDelayMs = 15_000,
|
|
}: {
|
|
features?: string[];
|
|
capabilitiesDelayMs?: number;
|
|
sessionDelayMs?: number;
|
|
} = {}) {
|
|
const signals: AbortSignal[] = [];
|
|
const fetch = vi.fn<typeof globalThis.fetch>((url, init) => {
|
|
const capabilities = new URL(String(url)).pathname === '/capabilities';
|
|
const signal = init?.signal;
|
|
if (!signal) throw new Error('Expected SDK request cancellation signal');
|
|
signals.push(signal);
|
|
return new Promise<Response>((resolve, reject) => {
|
|
const abort = () => {
|
|
clearTimeout(timer);
|
|
reject(signal.reason);
|
|
};
|
|
const timer = setTimeout(
|
|
() => {
|
|
signal.removeEventListener('abort', abort);
|
|
resolve(
|
|
Response.json(
|
|
capabilities
|
|
? { features }
|
|
: {
|
|
sessionId: 'session-b',
|
|
clientId: 'client-b',
|
|
workspaceCwd: '/workspace',
|
|
},
|
|
),
|
|
);
|
|
},
|
|
capabilities ? capabilitiesDelayMs : sessionDelayMs,
|
|
);
|
|
signal.addEventListener('abort', abort, { once: true });
|
|
});
|
|
});
|
|
return {
|
|
client: new DaemonClient({ baseUrl: 'http://localhost', token: '', fetch }),
|
|
fetch,
|
|
signals,
|
|
};
|
|
}
|
|
|
|
describe('model catalog refresh', () => {
|
|
it.each([false, true])(
|
|
'preserves an unlisted subscription selection after logout (live: %s)',
|
|
async (live) => {
|
|
const session = createMockSession('hidden-subscription');
|
|
const workspace = session.client.workspaceByCwd();
|
|
session.client.workspaceByCwd.mockReturnValue(workspace);
|
|
const modelId = 'qwen3.8-max(qwen-subscription)';
|
|
workspace.workspaceProviders.mockResolvedValue({
|
|
v: 1,
|
|
initialized: true,
|
|
workspaceCwd: '/workspace',
|
|
current: { authType: 'qwen-subscription', modelId },
|
|
providers: [],
|
|
});
|
|
const context = contextStatus('hidden-subscription');
|
|
context.state.models = { currentModelId: modelId, availableModels: [] };
|
|
session.context.mockResolvedValue(context);
|
|
const h = createActionsHarness({
|
|
...(live ? { session } : {}),
|
|
getClient: () => session.client as unknown as DaemonClient,
|
|
connection: {
|
|
status: 'connected',
|
|
...(live ? { sessionId: 'hidden-subscription' } : {}),
|
|
workspaceCwd: '/workspace',
|
|
currentModel: modelId,
|
|
contextWindow: 128000,
|
|
models: [{ id: modelId, authType: 'qwen-subscription' }],
|
|
},
|
|
});
|
|
await expect(
|
|
h.actions.refreshModels(undefined, { reloadProviders: false }),
|
|
).resolves.toBeUndefined();
|
|
expect(h.getConnection().currentModel).toBe(modelId);
|
|
expect(h.getConnection().contextWindow).toBe(128000);
|
|
expect(h.getConnection().models).toEqual([]);
|
|
expect(session.setModel).not.toHaveBeenCalled();
|
|
expect(workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('reads a recovered live model without reloading providers', async () => {
|
|
const h = setup();
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
context: {
|
|
...h.context,
|
|
state: { _meta: { authType: 'qwen-subscription' } },
|
|
},
|
|
});
|
|
h.context.state._meta = { authType: 'openai' };
|
|
h.context.state.models = {
|
|
currentModelId: 'bailian',
|
|
availableModels: [
|
|
{ modelId: 'bailian', name: 'Bailian', _meta: { contextLimit: 8192 } },
|
|
],
|
|
};
|
|
await h.actions.refreshModels(undefined, { reloadProviders: false });
|
|
expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
expect(h.getConnection().currentModel).toBe('bailian');
|
|
expect(h.getConnection().contextWindow).toBe(8192);
|
|
expect(h.getConnection().context?.state._meta?.['authType']).toBe('openai');
|
|
});
|
|
|
|
it('does not overwrite a model notification received during readback', async () => {
|
|
const h = setup();
|
|
const gate = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockImplementation(() => gate.promise);
|
|
const refresh = h.actions.refreshModels(undefined, {
|
|
reloadProviders: false,
|
|
});
|
|
await vi.waitFor(() => expect(h.session.context).toHaveBeenCalled());
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
currentModel: 'newer-model',
|
|
models: [{ id: 'newer-model' }],
|
|
});
|
|
gate.resolve(h.context);
|
|
await refresh;
|
|
expect(h.getConnection().currentModel).toBe('newer-model');
|
|
expect(h.getConnection().models).toEqual([{ id: 'newer-model' }]);
|
|
expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('preserves a non-subscription live model when the workspace signs out', async () => {
|
|
const session = createMockSession('other-provider');
|
|
const workspace = session.client.workspaceByCwd();
|
|
session.client.workspaceByCwd.mockReturnValue(workspace);
|
|
workspace.workspaceProviders.mockResolvedValue({
|
|
v: 1,
|
|
initialized: true,
|
|
workspaceCwd: '/workspace',
|
|
providers: [],
|
|
current: { modelId: 'coder-model' },
|
|
});
|
|
const context = contextStatus('other-provider');
|
|
context.state.models = {
|
|
currentModelId: 'manual-bailian',
|
|
availableModels: [
|
|
{
|
|
modelId: 'manual-bailian',
|
|
name: 'Bailian',
|
|
_meta: { authType: 'openai' },
|
|
},
|
|
],
|
|
};
|
|
session.context.mockResolvedValue(context);
|
|
const h = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'other-provider',
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'manual-bailian',
|
|
providers: {
|
|
current: { authType: 'qwen-subscription', modelId: 'qwen3.8-max' },
|
|
},
|
|
models: [{ id: 'manual-bailian', authType: 'openai', isRuntime: true }],
|
|
},
|
|
});
|
|
await h.actions.refreshModels();
|
|
expect(h.getConnection().currentModel).toBe('manual-bailian');
|
|
await h.actions.refreshModels(undefined, { reloadProviders: false });
|
|
expect(h.getConnection().currentModel).toBe('manual-bailian');
|
|
});
|
|
|
|
function setup() {
|
|
const session = createMockSession('session-a');
|
|
const workspace = session.client.workspaceByCwd();
|
|
session.client.workspaceByCwd.mockReturnValue(workspace);
|
|
const context = contextStatus('session-a');
|
|
context.state.models = {
|
|
currentModelId: 'new-default',
|
|
availableModels: [
|
|
{ modelId: 'old-model', name: 'Old' },
|
|
{ modelId: 'new-model', name: 'New' },
|
|
],
|
|
};
|
|
session.context.mockResolvedValue(context);
|
|
const h = createActionsHarness({
|
|
session,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: 'session-a',
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'old-model',
|
|
models: [{ id: 'old-model' }],
|
|
},
|
|
});
|
|
return { ...h, session, workspace, context };
|
|
}
|
|
|
|
it.each([false, true])(
|
|
'clears a signed-out subscription selection without another provider (live session: %s)',
|
|
async (live) => {
|
|
const session = createMockSession('unused');
|
|
const workspace = session.client.workspaceByCwd();
|
|
workspace.reloadModelProviders.mockResolvedValue({
|
|
status: live ? 'applied' : 'deferred',
|
|
});
|
|
workspace.workspaceProviders.mockResolvedValue({
|
|
v: 1,
|
|
initialized: true,
|
|
workspaceCwd: '/workspace',
|
|
providers: [],
|
|
current: { modelId: 'coder-model' },
|
|
});
|
|
session.client.workspaceByCwd.mockReturnValue(workspace);
|
|
const context = contextStatus('unused');
|
|
delete context.state.models;
|
|
session.context.mockResolvedValue(context);
|
|
const h = createActionsHarness({
|
|
...(live ? { session } : {}),
|
|
getClient: () => session.client as unknown as DaemonClient,
|
|
connection: {
|
|
status: 'connected',
|
|
...(live ? { sessionId: 'unused' } : {}),
|
|
workspaceCwd: '/workspace',
|
|
currentModel: 'old',
|
|
providers: {
|
|
current: { authType: 'qwen-subscription', modelId: 'old' },
|
|
},
|
|
models: [{ id: 'old', label: 'Qwen', authType: 'qwen-subscription' }],
|
|
},
|
|
});
|
|
await expect(
|
|
h.actions.refreshModels(undefined, { reloadProviders: false }),
|
|
).resolves.toBeUndefined();
|
|
expect(h.getConnection().currentModel).toBeUndefined();
|
|
expect(h.getConnection().models).toEqual([]);
|
|
expect(h.getConnection().sessionId).toBe(live ? 'unused' : undefined);
|
|
expect(workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['openai', 'qwen-subscription'] as const)(
|
|
'keeps a removed subscription selection and publishes the new catalog for reselection (workspace: %s)',
|
|
async (authType) => {
|
|
const h = setup();
|
|
const providers = workspaceProvidersStatus('high');
|
|
providers.current = { authType, modelId: 'old-model' };
|
|
h.workspace.workspaceProviders.mockResolvedValue(providers);
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
models: [
|
|
{ id: 'old-model', label: 'Old', authType: 'qwen-subscription' },
|
|
],
|
|
});
|
|
h.context.state.models!.availableModels = [
|
|
{ modelId: 'new-model', name: 'New' },
|
|
];
|
|
await expect(h.actions.refreshModels()).rejects.toThrow(
|
|
'Run /model to choose another model',
|
|
);
|
|
expect(h.getConnection().currentModel).toBe('old-model');
|
|
expect(h.getConnection().models?.map((model) => model.id)).toEqual([
|
|
'new-model',
|
|
]);
|
|
expect(h.session.setModel).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
'refreshes with an absent or void callback (callback=%s)',
|
|
async (withCallback) => {
|
|
const h = setup();
|
|
const callback = vi.fn(async () => undefined);
|
|
await h.actions.refreshModels(withCallback ? callback : undefined);
|
|
if (withCallback) {
|
|
expect(callback).toHaveBeenCalledWith({
|
|
workspaceCwd: '/workspace',
|
|
sessionId: 'session-a',
|
|
});
|
|
expect(callback.mock.invocationCallOrder[0]).toBeLessThan(
|
|
h.workspace.reloadModelProviders.mock.invocationCallOrder[0]!,
|
|
);
|
|
}
|
|
expect(h.session.client.workspaceByCwd).toHaveBeenCalledWith(
|
|
'/workspace',
|
|
);
|
|
expect(h.workspace.reloadModelProviders).toHaveBeenCalledWith({
|
|
clientId: 'client-session-a',
|
|
});
|
|
expect(
|
|
h.workspace.reloadModelProviders.mock.invocationCallOrder[0],
|
|
).toBeLessThan(h.session.context.mock.invocationCallOrder[0]!);
|
|
expect(h.getConnection().models?.map((model) => model.id)).toEqual([
|
|
'old-model',
|
|
'new-model',
|
|
]);
|
|
expect(h.getConnection().currentModel).toBe('old-model');
|
|
expect(h.session.setModel).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('applies refresh across an equivalent same-session context replacement', async () => {
|
|
const h = setup();
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
});
|
|
const gate = createDeferred<void>();
|
|
h.workspace.reloadModelProviders.mockImplementation(async () => {
|
|
await gate.promise;
|
|
return { status: 'applied' };
|
|
});
|
|
const refresh = h.actions.refreshModels();
|
|
await vi.waitFor(() =>
|
|
expect(h.workspace.reloadModelProviders).toHaveBeenCalled(),
|
|
);
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
});
|
|
gate.resolve();
|
|
await refresh;
|
|
expect(h.getConnection().models?.map((model) => model.id)).toEqual([
|
|
'old-model',
|
|
'new-model',
|
|
]);
|
|
});
|
|
|
|
it('retains the refreshed provider catalog when the session is cleared', async () => {
|
|
const h = setup();
|
|
const providers = workspaceProvidersStatus('high');
|
|
providers.providers[0].models = [
|
|
{
|
|
...providers.providers[0].models[0],
|
|
modelId: 'old-model',
|
|
baseModelId: 'old-model',
|
|
},
|
|
{
|
|
...providers.providers[0].models[0],
|
|
modelId: 'new-model',
|
|
baseModelId: 'new-model',
|
|
},
|
|
];
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
providers: workspaceProvidersStatus('high'),
|
|
});
|
|
h.workspace.workspaceProviders.mockResolvedValue(providers);
|
|
await h.actions.refreshModels();
|
|
expect(
|
|
getConnectionAfterSessionClear(
|
|
h.getConnection(),
|
|
'session-a',
|
|
).models?.map((model) => model.id),
|
|
).toEqual(['old-model', 'new-model']);
|
|
});
|
|
|
|
it('updates the context window for the retained model rather than the daemon default', async () => {
|
|
const h = setup();
|
|
h.replaceConnection({ ...h.getConnection(), contextWindow: 1000 });
|
|
h.context.state.models = {
|
|
currentModelId: 'new-default',
|
|
availableModels: [
|
|
{ modelId: 'old-model', contextLimit: 2000 },
|
|
{ modelId: 'new-default', contextLimit: 8000 },
|
|
],
|
|
};
|
|
await h.actions.refreshModels();
|
|
expect(h.getConnection().currentModel).toBe('old-model');
|
|
expect(h.getConnection().contextWindow).toBe(2000);
|
|
});
|
|
|
|
it('preserves the welcome catalog when provider readback reports a failure', async () => {
|
|
const session = createMockSession('unused');
|
|
const workspace = session.client.workspaceByCwd();
|
|
workspace.reloadModelProviders.mockResolvedValue({ status: 'deferred' });
|
|
session.client.workspaceByCwd.mockReturnValue(workspace);
|
|
const providers = workspaceProvidersStatus('high');
|
|
const models = [{ id: 'retained-model' }];
|
|
const h = createActionsHarness({
|
|
getClient: () => session.client as unknown as DaemonClient,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
sessionContext: { kind: 'workspace', cwd: '/workspace' },
|
|
models,
|
|
providers,
|
|
},
|
|
});
|
|
workspace.workspaceProviders.mockResolvedValue({
|
|
v: 1,
|
|
workspaceCwd: '/workspace',
|
|
initialized: false,
|
|
providers: [],
|
|
errors: [
|
|
{
|
|
kind: 'providers',
|
|
status: 'error',
|
|
error: 'bad model_providers block',
|
|
},
|
|
],
|
|
});
|
|
await expect(h.actions.refreshModels()).rejects.toThrow(
|
|
'bad model_providers block',
|
|
);
|
|
expect(h.getConnection().models).toBe(models);
|
|
expect(h.getConnection().providers).toBe(providers);
|
|
});
|
|
|
|
it('awaits the host and stops on host failure without losing models', async () => {
|
|
const h = setup();
|
|
const host = createDeferred<void>();
|
|
const refresh = h.actions.refreshModels(() => host.promise);
|
|
expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
host.reject(new Error('catalog unavailable'));
|
|
await expect(refresh).rejects.toThrow('catalog unavailable');
|
|
expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
expect(h.getConnection().models).toEqual([{ id: 'old-model' }]);
|
|
});
|
|
|
|
it.each(['failed', 'deferred'])(
|
|
'preserves old models after a %s live reload',
|
|
async (status) => {
|
|
const h = setup();
|
|
h.workspace.reloadModelProviders.mockResolvedValue({ status });
|
|
await expect(h.actions.refreshModels()).rejects.toThrow(
|
|
'Unable to reload',
|
|
);
|
|
expect(h.session.context).not.toHaveBeenCalled();
|
|
expect(h.getConnection().models).toEqual([{ id: 'old-model' }]);
|
|
},
|
|
);
|
|
|
|
it.each(['host', 'reload', 'read'])(
|
|
'discards refresh after navigation during %s',
|
|
async (stage) => {
|
|
const h = setup();
|
|
const gate = createDeferred<void>();
|
|
if (stage === 'reload')
|
|
h.workspace.reloadModelProviders.mockImplementation(async () => {
|
|
await gate.promise;
|
|
return { status: 'applied' };
|
|
});
|
|
if (stage === 'read')
|
|
h.session.context.mockImplementation(async () => {
|
|
await gate.promise;
|
|
return contextStatus('session-a');
|
|
});
|
|
const refresh = h.actions.refreshModels(
|
|
stage === 'host' ? () => gate.promise : undefined,
|
|
);
|
|
if (stage !== 'host')
|
|
await vi.waitFor(() =>
|
|
expect(h.workspace.reloadModelProviders).toHaveBeenCalled(),
|
|
);
|
|
if (stage === 'read')
|
|
await vi.waitFor(() => expect(h.session.context).toHaveBeenCalled());
|
|
h.sessionRef.current = createMockSession(
|
|
'session-b',
|
|
) as unknown as DaemonSessionClient;
|
|
h.replaceConnection({
|
|
status: 'connected',
|
|
sessionId: 'session-b',
|
|
workspaceCwd: '/other',
|
|
models: [{ id: 'other-model' }],
|
|
});
|
|
gate.resolve();
|
|
await refresh;
|
|
expect(h.getConnection().models).toEqual([{ id: 'other-model' }]);
|
|
if (stage === 'host')
|
|
expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('discards readback when connection ownership changes before session refs', async () => {
|
|
const h = setup();
|
|
const read = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockReturnValue(read.promise);
|
|
const refresh = h.actions.refreshModels();
|
|
await vi.waitFor(() => expect(h.session.context).toHaveBeenCalled());
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
sessionId: 'session-b',
|
|
models: [{ id: 'other-model' }],
|
|
});
|
|
read.resolve(h.context);
|
|
await refresh;
|
|
expect(h.getConnection().models).toEqual([{ id: 'other-model' }]);
|
|
});
|
|
|
|
it('retains a model selected while the read is in flight', async () => {
|
|
const h = setup();
|
|
const read = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockReturnValue(read.promise);
|
|
const refresh = h.actions.refreshModels();
|
|
await vi.waitFor(() => expect(h.session.context).toHaveBeenCalled());
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
currentModel: 'concurrent-model',
|
|
});
|
|
read.resolve(h.context);
|
|
await refresh;
|
|
expect(h.getConnection().currentModel).toBe('concurrent-model');
|
|
});
|
|
|
|
it('retains the old list if readback fails', async () => {
|
|
const h = setup();
|
|
h.session.context.mockRejectedValue(new Error('read failed'));
|
|
await expect(h.actions.refreshModels()).rejects.toThrow('read failed');
|
|
expect(h.getConnection().models).toEqual([{ id: 'old-model' }]);
|
|
});
|
|
|
|
it('reloads the admission workspace even when context reports a worktree subdirectory', async () => {
|
|
const h = setup();
|
|
const context = h.context;
|
|
context.workspaceCwd = '/workspace/.qwen/worktrees/branch/subdir';
|
|
h.session.context.mockResolvedValue(context);
|
|
await h.actions.refreshModels();
|
|
expect(h.session.client.workspaceByCwd).toHaveBeenLastCalledWith(
|
|
'/workspace',
|
|
);
|
|
});
|
|
|
|
it('refreshes a deferred welcome workspace without creating a session', async () => {
|
|
const session = createMockSession('unused');
|
|
const workspace = session.client.workspaceByCwd();
|
|
workspace.reloadModelProviders.mockResolvedValue({ status: 'deferred' });
|
|
session.client.workspaceByCwd.mockReturnValue(workspace);
|
|
const client = session.client as unknown as DaemonClient;
|
|
const h = createActionsHarness({
|
|
getClient: () => client,
|
|
connection: {
|
|
status: 'connected',
|
|
workspaceCwd: '/secondary',
|
|
currentModel: 'keep-selected',
|
|
sessionContext: { kind: 'workspace', cwd: '/secondary' },
|
|
},
|
|
});
|
|
await h.actions.refreshModels();
|
|
expect(session.client.workspaceByCwd).toHaveBeenLastCalledWith(
|
|
'/secondary',
|
|
);
|
|
expect(workspace.workspaceProviders).toHaveBeenCalledOnce();
|
|
expect(h.getConnection().models?.length).toBeGreaterThan(0);
|
|
expect(h.getConnection().providers).toBeDefined();
|
|
expect(h.getConnection().currentModel).toBe('keep-selected');
|
|
expect(session.client.createOrAttachSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['standalone', 'live'] as const)(
|
|
'does not reload %s through a workspace fallback',
|
|
async (kind) => {
|
|
const h = setup();
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
sessionContext: { kind } as DaemonProductSessionContext,
|
|
});
|
|
await expect(h.actions.refreshModels()).rejects.toThrow(
|
|
'connected workspace',
|
|
);
|
|
expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('streaming side questions', () => {
|
|
const connection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
capabilities: { features: ['session_btw_stream'] } as DaemonCapabilities,
|
|
};
|
|
|
|
it.each([undefined, [], ['session_btw']])(
|
|
'uses legacy JSON when streaming is not advertised: %j',
|
|
async (features) => {
|
|
const session = createMockSession('session-a');
|
|
const legacyConnection: DaemonConnectionState = {
|
|
status: 'connected',
|
|
workspaceCwd: '/workspace',
|
|
...(features === undefined
|
|
? {}
|
|
: { capabilities: { features } as DaemonCapabilities }),
|
|
};
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection: legacyConnection,
|
|
});
|
|
const signal = new AbortController().signal;
|
|
const events = [];
|
|
for await (const event of actions.btwSessionStream('Question', {
|
|
signal,
|
|
}))
|
|
events.push(event);
|
|
expect(events).toEqual([{ type: 'done', answer: 'Legacy answer' }]);
|
|
expect(session.btw).toHaveBeenCalledWith('Question', { signal });
|
|
expect(session.btwStream).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('does not retry JSON after an advertised stream fails', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.btwStream.mockImplementationOnce(async function* () {
|
|
yield await Promise.reject(new Error('Stream not found'));
|
|
});
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection,
|
|
addNotice,
|
|
});
|
|
await expect(
|
|
actions.btwSessionStream('Question').next(),
|
|
).rejects.toMatchObject({ message: 'Stream not found' });
|
|
expect(session.btwStream).toHaveBeenCalledOnce();
|
|
expect(session.btw).not.toHaveBeenCalled();
|
|
expect(addNotice).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps streaming beyond the ordinary 30-second action deadline', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const session = createMockSession('session-a');
|
|
const finish = createDeferred<void>();
|
|
session.btwStream.mockImplementationOnce(async function* () {
|
|
await finish.promise;
|
|
yield {
|
|
v: 1,
|
|
type: 'done',
|
|
requestId: 'btw-request',
|
|
answer: 'Slow answer',
|
|
} as const;
|
|
});
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
addNotice,
|
|
connection,
|
|
});
|
|
const stream = actions.btwSessionStream('Question');
|
|
const answer = stream.next();
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
finish.resolve();
|
|
expect((await answer).value).toMatchObject({
|
|
type: 'done',
|
|
answer: 'Slow answer',
|
|
});
|
|
await stream.next();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it.each(['btw_timeout', 'btw_cancelled', 'btw_failed'])(
|
|
'preserves terminal error code %s in the notice',
|
|
async (code) => {
|
|
const session = createMockSession('session-a');
|
|
const addNotice = vi.fn();
|
|
session.btwStream.mockImplementationOnce(async function* () {
|
|
yield {
|
|
v: 1,
|
|
type: 'error',
|
|
requestId: 'btw-request',
|
|
code,
|
|
message: 'Stream failed',
|
|
} as const;
|
|
});
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
addNotice,
|
|
connection,
|
|
});
|
|
await expect(
|
|
actions.btwSessionStream('Question').next(),
|
|
).rejects.toMatchObject({ message: 'Stream failed' });
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: 'btw_session',
|
|
code,
|
|
message: 'Side question failed: Stream failed',
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
it.each([
|
|
[410, 'btw_expired'],
|
|
[409, 'btw_request_conflict'],
|
|
[429, 'btw_limit'],
|
|
[501, 'btw_stream_not_supported'],
|
|
] as const)(
|
|
'preserves HTTP %i error code %s in the notice',
|
|
async (status, code) => {
|
|
const session = createMockSession('session-a');
|
|
const error = new DaemonHttpError(status, { code }, 'Server explanation');
|
|
session.btwStream.mockImplementationOnce(async function* () {
|
|
yield await Promise.reject(error);
|
|
});
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection,
|
|
addNotice,
|
|
});
|
|
await expect(actions.btwSessionStream('Question').next()).rejects.toBe(
|
|
error,
|
|
);
|
|
expect(addNotice).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: 'btw_session',
|
|
code,
|
|
message: 'Side question failed: Server explanation',
|
|
}),
|
|
);
|
|
expect(session.btw).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['legacy action', 'stream fallback'] as const)(
|
|
'keeps the 30-second deadline for %s',
|
|
async (path) => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const session = createMockSession('session-a');
|
|
session.btw.mockImplementationOnce(() => new Promise(() => {}));
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
addNotice,
|
|
connection: {
|
|
...connection,
|
|
capabilities: { features: [] } as DaemonCapabilities,
|
|
},
|
|
});
|
|
const result =
|
|
path === 'legacy action'
|
|
? actions.btwSession('Question')
|
|
: actions.btwSessionStream('Question').next();
|
|
const failed = result.catch((error: unknown) => error);
|
|
await vi.advanceTimersByTimeAsync(29_999);
|
|
expect(addNotice).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(await failed).toMatchObject({
|
|
message: 'Side question timed out after 30000ms',
|
|
});
|
|
expect(addNotice).toHaveBeenCalledOnce();
|
|
expect(session.btwStream).not.toHaveBeenCalled();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it('reports a stream that ends without a terminal snapshot', async () => {
|
|
const session = createMockSession('session-a');
|
|
session.btwStream.mockImplementationOnce(async function* () {
|
|
yield {
|
|
v: 1,
|
|
type: 'answer',
|
|
requestId: 'request',
|
|
answer: 'Partial',
|
|
} as const;
|
|
});
|
|
const addNotice = vi.fn();
|
|
const { actions } = createActionsHarness({
|
|
session,
|
|
connection,
|
|
addNotice,
|
|
});
|
|
const stream = actions.btwSessionStream('Question');
|
|
expect((await stream.next()).value).toEqual({
|
|
type: 'answer',
|
|
answer: 'Partial',
|
|
});
|
|
await expect(stream.next()).rejects.toThrow('ended before completion');
|
|
expect(addNotice).toHaveBeenCalledOnce();
|
|
expect(session.btw).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Workflow mode actions', () => {
|
|
function configOptions(requested: boolean): unknown[] {
|
|
return [
|
|
{
|
|
id: 'workflow_mode',
|
|
type: 'select',
|
|
currentValue: requested ? 'on' : 'off',
|
|
options: [{ value: 'off' }, { value: 'on' }],
|
|
_meta: {
|
|
'qwen.workflowMode': { available: true, effective: requested },
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function fixture() {
|
|
const session = createMockSession('workflow-session');
|
|
const snapshot = (requested: boolean) => ({
|
|
...contextStatus(session.sessionId),
|
|
state: { configOptions: configOptions(requested) },
|
|
});
|
|
session.context.mockResolvedValue(snapshot(false));
|
|
const addNotice = vi.fn();
|
|
const harness = createActionsHarness({
|
|
session,
|
|
addNotice,
|
|
connection: {
|
|
status: 'connected',
|
|
sessionId: session.sessionId,
|
|
clientId: session.clientId,
|
|
workflowModeStatus: 'ready',
|
|
workflowMode: { requested: false, available: true, effective: false },
|
|
context: snapshot(false),
|
|
},
|
|
});
|
|
const push = (requested: boolean) => {
|
|
observeSessionConfig(
|
|
harness.sessionConfigGeneration,
|
|
session as unknown as DaemonSessionClient,
|
|
);
|
|
harness.replaceConnection(
|
|
applyConfigOptions(harness.getConnection(), configOptions(requested)),
|
|
);
|
|
};
|
|
return { ...harness, session, snapshot, push, addNotice };
|
|
}
|
|
|
|
it.each(['missing array', 'newer opposite value'] as const)(
|
|
'reconciles a successful ACK with %s without resending the write',
|
|
async (shape) => {
|
|
const h = fixture();
|
|
const readback = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockReturnValue(readback.promise);
|
|
h.session.setConfigOption.mockResolvedValue({
|
|
...(shape === 'missing array'
|
|
? {}
|
|
: { configOptions: configOptions(false) }),
|
|
persisted: true,
|
|
} as Awaited<ReturnType<typeof h.session.setConfigOption>>);
|
|
await h.actions.setWorkflowMode(true);
|
|
expect(h.getConnection()).toMatchObject({
|
|
workflowModeStatus: 'syncing',
|
|
workflowMode: { requested: false },
|
|
workflowModePending: false,
|
|
});
|
|
expect(h.session.setConfigOption).toHaveBeenCalledExactlyOnceWith(
|
|
'workflow_mode',
|
|
'on',
|
|
);
|
|
expect(h.session.context).toHaveBeenCalledTimes(1);
|
|
readback.resolve(h.snapshot(true));
|
|
await vi.waitFor(() =>
|
|
expect(h.getConnection()).toMatchObject({
|
|
workflowModeStatus: 'ready',
|
|
workflowMode: { requested: true },
|
|
}),
|
|
);
|
|
expect(h.addNotice).not.toHaveBeenCalled();
|
|
expect(h.session.submitPrompt).not.toHaveBeenCalled();
|
|
expect(h.session.cancel).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['resolve', 'reject'] as const)(
|
|
'keeps a newer push when a mode ACK later %s',
|
|
async (outcome) => {
|
|
const h = fixture();
|
|
const ack =
|
|
createDeferred<Awaited<ReturnType<typeof h.session.setConfigOption>>>();
|
|
h.session.setConfigOption.mockReturnValue(ack.promise);
|
|
const pending = h.actions.setWorkflowMode(true);
|
|
const observed = pending.catch(() => undefined);
|
|
await expect(h.actions.setWorkflowMode(true)).rejects.toThrow(
|
|
'not available',
|
|
);
|
|
h.push(true);
|
|
h.push(false);
|
|
if (outcome === 'resolve')
|
|
ack.resolve({ configOptions: configOptions(true), persisted: true });
|
|
else ack.reject(new Error('late transport error'));
|
|
await observed;
|
|
await vi.waitFor(() =>
|
|
expect(h.getConnection().workflowModeStatus).toBe('ready'),
|
|
);
|
|
expect(h.getConnection().workflowMode?.requested).toBe(false);
|
|
expect(h.getConnection().context?.state.configOptions).toEqual(
|
|
configOptions(false),
|
|
);
|
|
expect(h.session.setConfigOption).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it('rejects a pre-push context and a GET completed after a newer GET', async () => {
|
|
const h = fixture();
|
|
const old = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockReturnValueOnce(old.promise);
|
|
const pending = h.actions.getContext();
|
|
h.push(true);
|
|
old.resolve(h.snapshot(false));
|
|
await pending;
|
|
expect(h.getConnection().workflowMode?.requested).toBe(true);
|
|
const first = createDeferred<DaemonSessionContextStatus>();
|
|
const second = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockReturnValueOnce(second.promise);
|
|
const a = h.actions.getContext();
|
|
const b = h.actions.getContext();
|
|
second.resolve(h.snapshot(false));
|
|
await b;
|
|
first.resolve(h.snapshot(true));
|
|
await a;
|
|
expect(h.getConnection().workflowMode?.requested).toBe(false);
|
|
});
|
|
|
|
it('does not read overlapping unfinished writes as idle and reads once after settlement', async () => {
|
|
const h = fixture();
|
|
const mode =
|
|
createDeferred<Awaited<ReturnType<typeof h.session.setConfigOption>>>();
|
|
const approval =
|
|
createDeferred<
|
|
Awaited<ReturnType<typeof h.session.client.setSessionApprovalMode>>
|
|
>();
|
|
h.session.setConfigOption.mockReturnValue(mode.promise);
|
|
h.session.client.setSessionApprovalMode.mockReturnValue(approval.promise);
|
|
h.session.context.mockResolvedValue(h.snapshot(true));
|
|
const a = h.actions.setWorkflowMode(true);
|
|
const b = h.actions.setApprovalMode('auto-edit');
|
|
await h.actions.getContext();
|
|
expect(h.getConnection().workflowMode?.requested).toBe(false);
|
|
mode.resolve({ configOptions: configOptions(true), persisted: true });
|
|
await a;
|
|
expect(h.session.context).toHaveBeenCalledTimes(1);
|
|
approval.resolve({
|
|
sessionId: h.session.sessionId,
|
|
mode: 'auto-edit',
|
|
previous: 'default',
|
|
persisted: false,
|
|
});
|
|
await b;
|
|
await vi.waitFor(() =>
|
|
expect(h.getConnection().workflowMode?.requested).toBe(true),
|
|
);
|
|
expect(h.session.context).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps a newer mode while reasoning returns an obsolete whole config array', async () => {
|
|
const h = fixture();
|
|
const ack =
|
|
createDeferred<Awaited<ReturnType<typeof h.session.setConfigOption>>>();
|
|
h.session.setConfigOption.mockReturnValue(ack.promise);
|
|
const options = [
|
|
...configOptions(true),
|
|
...reasoningConfigOptions('medium'),
|
|
];
|
|
h.session.context.mockResolvedValue({
|
|
...h.snapshot(true),
|
|
state: { configOptions: options },
|
|
});
|
|
const pending = h.actions.setReasoningEffort('medium');
|
|
h.push(true);
|
|
ack.resolve({
|
|
configOptions: [
|
|
...configOptions(false),
|
|
...reasoningConfigOptions('medium'),
|
|
],
|
|
persisted: false,
|
|
});
|
|
await pending;
|
|
await vi.waitFor(() =>
|
|
expect(h.getConnection().reasoning?.effort).toBe('medium'),
|
|
);
|
|
expect(h.getConnection().workflowMode?.requested).toBe(true);
|
|
expect(h.getConnection().context?.state.configOptions).toEqual(options);
|
|
});
|
|
|
|
it('releases a timed-out write, reconciles once, and ignores its late ACK', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const h = fixture();
|
|
const ack =
|
|
createDeferred<Awaited<ReturnType<typeof h.session.setConfigOption>>>();
|
|
h.session.setConfigOption.mockReturnValue(ack.promise);
|
|
h.session.context.mockRejectedValue(new Error('read offline'));
|
|
const pending = h.actions.setWorkflowMode(true);
|
|
const result = pending.catch((error: Error) => error);
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(await result).toBeInstanceOf(Error);
|
|
expect(h.getConnection()).toMatchObject({
|
|
workflowModePending: false,
|
|
workflowModeStatus: 'unknown',
|
|
workflowMode: { requested: false },
|
|
});
|
|
expect(
|
|
h.sessionConfigGeneration.get(
|
|
h.session as unknown as DaemonSessionClient,
|
|
)?.pendingWrites,
|
|
).toBe(0);
|
|
expect(h.session.context).toHaveBeenCalledTimes(1);
|
|
await expect(h.actions.setWorkflowMode(true)).rejects.toThrow(
|
|
'not available',
|
|
);
|
|
ack.resolve({ configOptions: configOptions(true), persisted: true });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(h.getConnection().workflowMode?.requested).toBe(false);
|
|
expect(h.session.setConfigOption).toHaveBeenCalledTimes(1);
|
|
expect(h.session.context).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('bounds an unanswered reconciliation and keeps the last confirmed mode', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const h = fixture();
|
|
const read = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockReturnValue(read.promise);
|
|
h.session.setConfigOption.mockResolvedValue({
|
|
configOptions: configOptions(true),
|
|
persisted: true,
|
|
});
|
|
await h.actions.setWorkflowMode(true);
|
|
expect(h.getConnection().workflowModeStatus).toBe('syncing');
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(h.getConnection()).toMatchObject({
|
|
workflowModeStatus: 'unknown',
|
|
workflowModePending: false,
|
|
workflowMode: { requested: false },
|
|
});
|
|
expect(h.session.context).toHaveBeenCalledTimes(1);
|
|
read.resolve(h.snapshot(true));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(h.getConnection().workflowMode?.requested).toBe(false);
|
|
expect(h.session.context).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('finishes mode reconciliation when cancellation also refreshes recovery', async () => {
|
|
const h = fixture();
|
|
const recovery = { kind: 'clean' as const, canContinue: false };
|
|
h.replaceConnection({
|
|
...h.getConnection(),
|
|
context: { ...h.snapshot(false), recovery },
|
|
});
|
|
const read = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context
|
|
.mockReturnValueOnce(read.promise)
|
|
.mockResolvedValue({ ...h.snapshot(true), recovery });
|
|
h.session.setConfigOption.mockResolvedValue({ persisted: true } as Awaited<
|
|
ReturnType<typeof h.session.setConfigOption>
|
|
>);
|
|
await h.actions.setWorkflowMode(true);
|
|
expect(h.getConnection().workflowModeStatus).toBe('syncing');
|
|
h.activePromptsRef.current.set(h.session.sessionId, {
|
|
controller: new AbortController(),
|
|
});
|
|
await h.actions.cancel();
|
|
expect(h.session.context).toHaveBeenCalledTimes(2);
|
|
read.resolve(h.snapshot(true));
|
|
await vi.waitFor(() =>
|
|
expect(h.getConnection()).toMatchObject({
|
|
workflowModeStatus: 'ready',
|
|
workflowMode: { requested: true },
|
|
workflowModePending: false,
|
|
context: { recovery },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each(['model write', 'model read', 'explicit read'] as const)(
|
|
'settles mode synchronization when a newer %s fails',
|
|
async (failure) => {
|
|
const h = fixture();
|
|
const old = createDeferred<DaemonSessionContextStatus>();
|
|
h.session.context.mockReturnValueOnce(old.promise);
|
|
h.session.setConfigOption.mockResolvedValue({
|
|
persisted: true,
|
|
} as Awaited<ReturnType<typeof h.session.setConfigOption>>);
|
|
await h.actions.setWorkflowMode(true);
|
|
expect(h.getConnection().workflowModeStatus).toBe('syncing');
|
|
if (failure === 'model write') {
|
|
h.session.setModel.mockRejectedValue(new Error('model rejected'));
|
|
h.session.context.mockResolvedValue(h.snapshot(true));
|
|
await expect(h.actions.setModel('new-model')).rejects.toThrow(
|
|
'model rejected',
|
|
);
|
|
} else {
|
|
h.session.context.mockRejectedValue(new Error('read offline'));
|
|
if (failure === 'model read') await h.actions.setModel('new-model');
|
|
else
|
|
await expect(h.actions.getContext()).rejects.toThrow('read offline');
|
|
}
|
|
old.resolve(h.snapshot(false));
|
|
await vi.waitFor(() =>
|
|
expect(h.getConnection().workflowModeStatus).toBe(
|
|
failure === 'model write' ? 'ready' : 'unknown',
|
|
),
|
|
);
|
|
expect(h.getConnection().workflowModePending).toBe(false);
|
|
expect(h.session.context).toHaveBeenCalledTimes(2);
|
|
expect(h.session.setConfigOption).toHaveBeenCalledTimes(1);
|
|
if (failure === 'model write')
|
|
expect(h.getConnection().workflowMode?.requested).toBe(true);
|
|
},
|
|
);
|
|
|
|
it.each(['new client', 'new stream'] as const)(
|
|
'keeps same-id replacement state and notices after %s',
|
|
async (replacement) => {
|
|
const h = fixture();
|
|
const ack =
|
|
createDeferred<Awaited<ReturnType<typeof h.session.setConfigOption>>>();
|
|
h.session.setConfigOption.mockReturnValue(ack.promise);
|
|
const pending = h.actions.setWorkflowMode(true).catch(() => undefined);
|
|
if (replacement === 'new client') {
|
|
h.sessionRef.current = createMockSession(
|
|
h.session.sessionId,
|
|
'replacement',
|
|
) as unknown as DaemonSessionClient;
|
|
} else
|
|
resetSessionConfigSync(
|
|
h.sessionConfigGeneration,
|
|
h.session as unknown as DaemonSessionClient,
|
|
);
|
|
const next = {
|
|
...h.getConnection(),
|
|
workflowModePending: true,
|
|
workflowModeStatus: 'ready' as const,
|
|
workflowMode: { requested: true, available: true, effective: true },
|
|
};
|
|
h.replaceConnection(next);
|
|
ack.reject(new Error('old owner failure'));
|
|
await pending;
|
|
expect(h.getConnection()).toBe(next);
|
|
expect(h.addNotice).not.toHaveBeenCalled();
|
|
expect(h.session.context).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|