Files
qwen-code/packages/web-shell/client/daemon/session/actions.test.ts
T

8976 lines
304 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();
},
);
it('releases through the workspace client when no session is connected', async () => {
const closeSession = vi.fn().mockResolvedValue(undefined);
const onGeneratedUiInvalidated = vi.fn();
const h = createActionsHarness({
getClient: () => ({ closeSession }) as unknown as DaemonClient,
onGeneratedUiInvalidated,
});
await h.actions.releaseSession('background');
expect(closeSession).toHaveBeenCalledWith('background');
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('local prompt timing before the authoritative echo', () => {
it.each(['completed', 'cancelled'] as const)(
'clears its local clock after an admitted turn is %s',
async (outcome) => {
const session = createMockSession('session-a');
const h = createActionsHarness({ session });
const result = h.actions.sendPrompt('new question', {
optimisticUserMessage: false,
});
await vi.waitFor(() =>
expect(h.activePromptsRef.current.get('session-a')?.resolve).toBeTypeOf(
'function',
),
);
expect(h.getConnection().localPrompt?.sessionId).toBe('session-a');
const active = h.activePromptsRef.current.get('session-a')!;
if (outcome === 'completed') active.resolve?.({ stopReason: 'end_turn' });
else active.controller.abort();
await result;
expect(h.getConnection().localPrompt).toBeUndefined();
expect(h.store.appendLocalUserMessage).not.toHaveBeenCalled();
},
);
it.each([false, true])(
'clears only its own clock after rejection (newer owner: %s)',
async (newerOwner) => {
const session = createMockSession('session-a');
const admission = createDeferred<{ promptId: string }>();
session.submitPrompt.mockReturnValueOnce(admission.promise);
const h = createActionsHarness({ session });
const before = Date.now();
const result = h.actions.sendPrompt('new question', {
optimisticUserMessage: false,
});
const rejected = result.catch((error: unknown) => error);
await vi.waitFor(() =>
expect(session.submitPrompt).toHaveBeenCalledOnce(),
);
const local = h.getConnection().localPrompt;
expect(local?.sessionId).toBe('session-a');
expect(local?.startedAt).toBeGreaterThanOrEqual(before);
expect(h.store.appendLocalUserMessage).not.toHaveBeenCalled();
const replacement = {
sessionId: 'session-b',
startedAt: Date.now() + 1000,
};
if (newerOwner)
h.replaceConnection({ ...h.getConnection(), localPrompt: replacement });
admission.reject(new DaemonHttpError(413, {}, 'Rejected'));
await rejected;
expect(h.getConnection().localPrompt).toEqual(
newerOwner ? replacement : undefined,
);
},
);
});
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('marks a workspace session-limit create failure with its daemon code', async () => {
const addNotice = vi.fn();
const createDetachedSession = vi.fn(async () => {
throw new DaemonHttpError(
503,
{
error: 'Session limit reached (64)',
code: 'session_limit_exceeded',
limit: 64,
scope: 'workspace',
},
'Session limit reached (64)',
);
});
const { actions } = createActionsHarness({
connection: {
status: 'connected',
sessionContext: { kind: 'workspace', cwd: '/workspace' },
workspaceCwd: '/workspace',
},
addNotice,
createDetachedSession,
});
await expect(actions.createSession()).rejects.toBeInstanceOf(
DaemonHttpError,
);
expect(createDetachedSession).toHaveBeenCalledOnce();
expect(addNotice).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'create_session',
code: 'session_limit_exceeded',
}),
);
});
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();
},
);
});