fix(web-shell): defer session creation until first prompt (#6066)

* fix(web-shell): defer session creation until first prompt

* fix(web-shell): stabilize deferred session attach

* docs(web-shell): document optional session change callback

* test(web-shell): cover deferred session setup failures

* fix(webui): preserve concurrent session load on clear

* fix(web-shell): keep session prep helper in utils

* fix(web-shell): harden deferred session lifecycle

* fix(web-shell): guard deferred session races

* fix(web-shell): simplify controlled session selection

* fix(web-shell): tighten empty session edge cases

* fix(web-shell): tighten deferred session lifecycle

* test(webui): align session action mocks with daemon types

* fix(web-shell): notify cleared session ids

* fix(webui): guard session cleanup races

* fix(web-shell): preserve blocked local commands

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
ytahdn 2026-07-01 22:04:37 +08:00 committed by GitHub
parent 487fb45510
commit 1467ed3100
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2328 additions and 450 deletions

View file

@ -174,32 +174,33 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
* and risking transcript wipes if reconnect later attaches a different
* session and hits the sessionId-change `store.reset()` branch.
*
* 404/410 (session-not-found) normally keep the reconnect-then-recreate
* behavior, unless the caller opts into leaving missing sessions disconnected.
* 404/410 (session-not-found) leave the requested session disconnected instead
* of silently creating a replacement empty session.
*/
const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]);
const UNHANDLED_SESSION = Symbol('unhandled session');
export function DaemonSessionProvider({
baseUrl,
token,
workspaceCwd,
initialSessionId,
clientId,
createSessionRequest,
maxQueued = 1024,
maxBlocks = DEFAULT_MAX_BLOCKS,
suppressOwnUserEcho = true,
includeRawEvent = false,
autoConnect = true,
autoReconnect = true,
missingSessionBehavior = 'create',
reconnectDelayMs = 1_000,
maxReconnectDelayMs = 10_000,
heartbeatIntervalMs = 30_000,
heartbeatFailureThreshold = 3,
loadWarnings,
children,
}: DaemonSessionProviderProps) {
export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const {
baseUrl,
token,
workspaceCwd,
sessionId,
clientId,
createSessionRequest,
maxQueued = 1024,
maxBlocks = DEFAULT_MAX_BLOCKS,
suppressOwnUserEcho = true,
includeRawEvent = false,
autoConnect = true,
autoReconnect = true,
reconnectDelayMs = 1_000,
maxReconnectDelayMs = 10_000,
heartbeatIntervalMs = 30_000,
heartbeatFailureThreshold = 3,
loadWarnings,
children,
} = props;
const workspace = useOptionalDaemonWorkspace();
const resolvedBaseUrl = baseUrl ?? workspace?.baseUrl;
const resolvedToken = token ?? workspace?.token;
@ -210,8 +211,19 @@ export function DaemonSessionProvider({
workspaceCapabilitiesRef.current = workspace?.capabilities;
const workspaceGetCapabilitiesRef = useRef(workspace?.getCapabilities);
workspaceGetCapabilitiesRef.current = workspace?.getCapabilities;
const initialRestoreSessionIdRef = useRef(sessionId);
const initialRestoreSessionId = initialRestoreSessionIdRef.current;
// Captured once at mount: if the host did not provide an initial session,
// keep the provider empty until the first prompt creates one. Later
// sessionId prop changes are handled by the controlled-session effect below.
const shouldDeferInitialSessionCreation =
initialRestoreSessionId === undefined;
const resolvedWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
resolvedWorkspaceCwdRef.current = resolvedWorkspaceCwd;
const activeWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
if (resolvedWorkspaceCwd) {
activeWorkspaceCwdRef.current = resolvedWorkspaceCwd;
}
const store = useMemo(
() => createDaemonTranscriptStore({ maxBlocks }),
@ -229,6 +241,10 @@ export function DaemonSessionProvider({
ReturnType<typeof setTimeout> | undefined
>(undefined);
const heartbeatSupportedRef = useRef(false);
const manualSessionClearRef = useRef(false);
const skipNextCleanupDetachSessionIdRef = useRef<string | undefined>(
undefined,
);
const settledRestoredActivePromptSessionsRef = useRef<
WeakSet<DaemonSessionClient>
>(new WeakSet());
@ -248,14 +264,18 @@ export function DaemonSessionProvider({
createSessionRequestRef.current = createSessionRequest;
const [promptStatus, setPromptStatus] = useState<DaemonPromptStatus>('idle');
const [restoreSessionId, setRestoreSessionId] = useState<string | undefined>(
initialSessionId,
initialRestoreSessionId,
);
const [restoreMode, setRestoreMode] = useState<'load' | 'resume'>('load');
const [restoreSessionNonce, setRestoreSessionNonce] = useState(0);
const [attachSessionNonce, setAttachSessionNonce] = useState(0);
const [newSessionNonce, setNewSessionNonce] = useState(0);
const [connection, setConnection] = useState<DaemonConnectionState>({
status: autoConnect ? 'connecting' : 'idle',
...(initialRestoreSessionId ? { sessionId: initialRestoreSessionId } : {}),
});
const connectionRef = useRef(connection);
connectionRef.current = connection;
const noticeIdRef = useRef(0);
const [notices, setNotices] = useState<DaemonSessionNotice[]>([]);
const addNotice = useCallback<AddDaemonSessionNotice>((input) => {
@ -284,6 +304,14 @@ export function DaemonSessionProvider({
const [workspaceEventSignals, setWorkspaceEventSignals] =
useState<DaemonWorkspaceEventSignals>(INITIAL_WORKSPACE_EVENT_SIGNALS);
const hasCurrentSessionActivePromptRef = useRef<() => boolean>(() => false);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!autoConnect) return undefined;
@ -345,11 +373,26 @@ export function DaemonSessionProvider({
let isSameSessionReconnect = false;
let shouldInjectReplaySnapshot = false;
let needsStoreReset = false;
let attachedExistingSession = false;
// Only populated when this attempt (re)loads the session: a reused
// session object carries the snapshot from its original load, whose
// usage may be older than the in-memory count.
let replayTokenUsage: DaemonConnectionState['tokenUsage'];
let replayTokenCount: number | undefined;
if (!session) {
const existingSession = sessionRef.current;
if (
existingSession &&
!restoreSessionId &&
!reconnectSessionId &&
!shouldCreateFreshSession
) {
session = existingSession;
reconnectSessionId = existingSession.sessionId;
lastSessionIdRef.current = existingSession.sessionId;
attachedExistingSession = true;
}
}
if (!session) {
setConnection((current) => ({
...current,
@ -370,6 +413,41 @@ export function DaemonSessionProvider({
caps.features.includes('client_heartbeat');
const effectWorkspaceCwd =
resolvedWorkspaceCwdRef.current ?? caps.workspaceCwd;
activeWorkspaceCwdRef.current = effectWorkspaceCwd;
if (
(shouldDeferInitialSessionCreation ||
manualSessionClearRef.current) &&
!restoreSessionId &&
!reconnectSessionId &&
!shouldCreateFreshSession
) {
const providerResult = await Promise.allSettled([
client.workspaceProviders(),
]);
if (providerResult[0].status === 'rejected') {
console.warn(
'[DaemonSessionProvider] workspaceProviders failed in deferred connect:',
providerResult[0].reason,
);
}
const providers =
providerResult[0].status === 'fulfilled'
? providerResult[0].value
: undefined;
const providerModelStatus = mapProviderStatus(providers);
setConnection((current) => ({
...current,
status: 'connected',
workspaceCwd: effectWorkspaceCwd,
models: providerModelStatus.models,
currentModel: providerModelStatus.currentModel,
currentMode: providerModelStatus.currentMode,
contextWindow: providerModelStatus.contextWindow,
providers,
capabilities: caps,
}));
return;
}
const restoreMethod =
restoreSessionId && restoreMode === 'resume'
? DaemonSessionClient.resume
@ -510,32 +588,40 @@ export function DaemonSessionProvider({
hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt;
setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle');
const canReuseSessionMetadata =
attachedExistingSession &&
connectionRef.current.commands !== undefined &&
connectionRef.current.skills !== undefined &&
connectionRef.current.supportedCommands !== undefined &&
connectionRef.current.context !== undefined;
const [providerResult, commandResult, contextResult] =
await Promise.allSettled([
client.workspaceProviders(),
activeSession.supportedCommands(),
activeSession.context(),
]);
canReuseSessionMetadata
? [undefined, undefined, undefined]
: await Promise.allSettled([
client.workspaceProviders(),
activeSession.supportedCommands(),
activeSession.context(),
]);
const providers =
providerResult.status === 'fulfilled'
providerResult?.status === 'fulfilled'
? providerResult.value
: undefined;
const supportedCommands =
commandResult.status === 'fulfilled'
commandResult?.status === 'fulfilled'
? commandResult.value
: undefined;
const context =
contextResult.status === 'fulfilled'
contextResult?.status === 'fulfilled'
? contextResult.value
: undefined;
const loadWarningTexts = [
providerResult.status === 'rejected'
providerResult?.status === 'rejected'
? loadWarningsRef.current?.models
: undefined,
commandResult.status === 'rejected'
commandResult?.status === 'rejected'
? loadWarningsRef.current?.commands
: undefined,
contextResult.status === 'rejected'
contextResult?.status === 'rejected'
? loadWarningsRef.current?.context
: undefined,
].filter((warning): warning is string => Boolean(warning));
@ -560,7 +646,8 @@ export function DaemonSessionProvider({
?.contextWindow ??
providerContextWindow;
const { commands, skills } = mapSupportedCommands(supportedCommands);
const currentMode = getCurrentMode(context);
const currentMode =
getCurrentMode(context) ?? providerModelStatus.currentMode;
setConnection((current) => ({
status: 'connected',
@ -571,11 +658,11 @@ export function DaemonSessionProvider({
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
commands,
skills,
models: sessionModels,
currentModel: sessionCurrentModel,
currentMode,
commands: commands.length > 0 ? commands : current.commands,
skills: skills.length > 0 ? skills : current.skills,
models: sessionModels.length > 0 ? sessionModels : current.models,
currentModel: sessionCurrentModel ?? current.currentModel,
currentMode: currentMode ?? current.currentMode,
displayName:
getSessionDisplayName(activeSession.state) ??
(current.sessionId === activeSession.sessionId
@ -600,11 +687,11 @@ export function DaemonSessionProvider({
: current.sessionId === activeSession.sessionId
? (current.tokenCount ?? 0)
: 0,
contextWindow: sessionContextWindow,
providers,
supportedCommands,
context,
capabilities,
contextWindow: sessionContextWindow ?? current.contextWindow,
providers: providers ?? current.providers,
supportedCommands: supportedCommands ?? current.supportedCommands,
context: context ?? current.context,
capabilities: capabilities ?? current.capabilities,
catchingUp:
isSameSessionReconnect ||
activeSession.lastEventId != null ||
@ -718,9 +805,14 @@ export function DaemonSessionProvider({
if (pendingLoadToResolve) {
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoadToResolve.timeout);
if (
skipNextCleanupDetachSessionIdRef.current ===
activeSession.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
pendingLoadToResolve.resolve();
}
let sawEvent = false;
let resyncRequested = false;
const requestEpochResetReload = () => {
@ -755,6 +847,9 @@ export function DaemonSessionProvider({
signal: abort.signal,
maxQueued,
})) {
if (sessionRef.current?.sessionId !== activeSession.sessionId) {
break;
}
if (!sawEvent) {
sawEvent = true;
reconnectAttempt = 0;
@ -1023,6 +1118,12 @@ export function DaemonSessionProvider({
}));
return;
}
if (manualSessionClearRef.current) {
session = undefined;
sessionRef.current = undefined;
hasCurrentSessionActivePromptRef.current = () => false;
return;
}
if (!disposed && !abort.signal.aborted && !resyncRequested) {
// Keep the session handle after a normal SSE close so the next
// subscription can resume from DaemonSessionClient.lastEventId.
@ -1055,10 +1156,6 @@ export function DaemonSessionProvider({
const failedSessionId = session?.sessionId;
const isAuthFailure = isAuthFailureHttpError(error);
const isTerminal = isTerminalSessionHttpError(error);
const shouldDisconnectMissingSession =
isTerminal &&
!isAuthFailure &&
missingSessionBehavior === 'disconnect';
if (failedSessionId && (isAuthFailure || isTerminal)) {
const active = activePromptsRef.current.get(failedSessionId);
active?.controller.abort();
@ -1077,6 +1174,12 @@ export function DaemonSessionProvider({
(pendingLoad.sessionId === restoreSessionId ||
pendingLoad.sessionId === reconnectSessionId)
) {
if (
skipNextCleanupDetachSessionIdRef.current ===
pendingLoad.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoad.timeout);
pendingLoad.reject(error);
@ -1091,20 +1194,14 @@ export function DaemonSessionProvider({
setConnection({ status: 'error', error: message });
return;
}
if (shouldDisconnectMissingSession) {
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: message,
catchingUp: undefined,
}));
return;
}
reconnectSessionId = undefined;
if (restoreSessionId) {
setRestoreSessionId(undefined);
}
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: message,
catchingUp: undefined,
}));
return;
} else {
// Retriable error (network failure, timeout, etc.) — preserve
// the session so the next iteration skips the full load() and
@ -1168,14 +1265,20 @@ export function DaemonSessionProvider({
hasCurrentSessionActivePromptRef.current = () => false;
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (pendingSessionLoadRef.current) {
const keepSessionForNextEffect =
session?.sessionId === skipNextCleanupDetachSessionIdRef.current;
const isUnmounting = !mountedRef.current;
if (
pendingSessionLoadRef.current &&
(!keepSessionForNextEffect || isUnmounting)
) {
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('Session load interrupted by cleanup', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
if (session?.clientId) {
if ((!keepSessionForNextEffect || isUnmounting) && session?.clientId) {
void detachDaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
@ -1185,12 +1288,13 @@ export function DaemonSessionProvider({
console.warn('[DaemonSessionProvider] detach failed:', err),
);
}
sessionRef.current = undefined;
if (!keepSessionForNextEffect || isUnmounting) {
sessionRef.current = undefined;
}
};
}, [
autoConnect,
autoReconnect,
missingSessionBehavior,
resolvedBaseUrl,
resolvedToken,
workspaceCwd,
@ -1201,8 +1305,10 @@ export function DaemonSessionProvider({
restoreSessionId,
restoreMode,
restoreSessionNonce,
attachSessionNonce,
newSessionNonce,
clientId,
shouldDeferInitialSessionCreation,
clearNotices,
addNotice,
]);
@ -1210,6 +1316,7 @@ export function DaemonSessionProvider({
useEffect(() => {
if (
!heartbeatSupportedRef.current ||
connection.status !== 'connected' ||
heartbeatIntervalMs <= 0 ||
heartbeatFailureThreshold <= 0 ||
!connection.sessionId
@ -1251,7 +1358,12 @@ export function DaemonSessionProvider({
disposed = true;
clearInterval(timer);
};
}, [connection.sessionId, heartbeatFailureThreshold, heartbeatIntervalMs]);
}, [
connection.sessionId,
connection.status,
heartbeatFailureThreshold,
heartbeatIntervalMs,
]);
const actions = useMemo<DaemonSessionActions>(
() =>
@ -1263,6 +1375,8 @@ export function DaemonSessionProvider({
pendingSessionLoadRef,
pendingSessionLoadIdRef,
heartbeatSupportedRef,
manualSessionClearRef,
skipNextCleanupDetachSessionIdRef,
passiveAssistantDoneTimerRef,
hasSessionActivePrompt: () =>
hasCurrentSessionActivePromptRef.current(),
@ -1273,18 +1387,69 @@ export function DaemonSessionProvider({
...createSessionRequestRef.current,
sessionScope: 'thread',
workspaceCwd:
resolvedWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
}),
createDetachedSession: () => {
const client =
workspaceClientRef.current ??
new DaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
});
const request = {
...createSessionRequestRef.current,
sessionScope: 'thread' as const,
workspaceCwd:
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
};
const requestClientId = clientId
? clientIdRef.current
: getStableClientId(undefined);
return DaemonSessionClient.createOrAttach(
client,
request,
requestClientId,
);
},
getConnection: () => connectionRef.current,
addNotice,
setConnection,
setPromptStatus,
setRestoreSessionId,
setRestoreMode,
setRestoreSessionNonce,
setAttachSessionNonce,
setNewSessionNonce,
}),
[addNotice, store],
[addNotice, clientId, resolvedBaseUrl, resolvedToken, store],
);
const lastHandledSessionIdRef = useRef<
string | undefined | typeof UNHANDLED_SESSION
>(UNHANDLED_SESSION);
useEffect(() => {
if (lastHandledSessionIdRef.current === sessionId) return;
lastHandledSessionIdRef.current = sessionId;
const currentSessionId = connectionRef.current.sessionId;
if (sessionId === currentSessionId) return;
const request = sessionId
? actions.loadSession(sessionId, { deferTranscriptReset: true })
: currentSessionId
? actions.clearSession()
: undefined;
if (!request) return;
void request.catch((error: unknown) => {
console.warn(
'[DaemonSessionProvider] controlled session transition failed:',
error,
);
});
}, [actions, sessionId]);
return (
<DaemonStoreContext.Provider value={store}>
<DaemonConnectionContext.Provider value={connection}>

View file

@ -0,0 +1,380 @@
import { describe, expect, it, vi } from 'vitest';
import type { DaemonSessionClient } from '@qwen-code/sdk/daemon';
import {
createDaemonSessionActions,
getConnectionAfterSessionClear,
} from './actions';
import type {
ActivePrompt,
DaemonConnectionState,
PendingSessionLoad,
SettledPrompt,
} from './types';
describe('getConnectionAfterSessionClear', () => {
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',
tokenCount: 42,
commands: [commandInfo('old-command')],
skills: ['old-skill'],
supportedCommands: supportedCommandsStatus('session-a'),
context: contextStatus('session-a'),
catchingUp: true,
error: 'old error',
} as DaemonConnectionState,
'session-a',
);
expect(next).toMatchObject({
status: 'connected',
workspaceCwd: '/workspace',
catchingUp: undefined,
error: undefined,
});
expect(next).not.toHaveProperty('sessionId');
expect(next).not.toHaveProperty('clientId');
expect(next).not.toHaveProperty('displayName');
expect(next).not.toHaveProperty('tokenCount');
expect(next).not.toHaveProperty('commands');
expect(next).not.toHaveProperty('skills');
expect(next).not.toHaveProperty('supportedCommands');
expect(next).not.toHaveProperty('context');
});
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'),
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'),
catchingUp: undefined,
error: undefined,
});
});
});
describe('createDaemonSessionActions', () => {
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' });
});
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('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(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('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();
});
});
function createActionsHarness(
opts: {
activePrompts?: Map<string, ActivePrompt>;
addNotice?: ReturnType<typeof vi.fn>;
connection?: DaemonConnectionState;
createDetachedSession?: ReturnType<typeof vi.fn>;
manualSessionClearRef?: { current: boolean };
pendingSessionLoadRef?: { current: PendingSessionLoad | undefined };
session?: ReturnType<typeof createMockSession>;
setAttachSessionNonce?: ReturnType<typeof vi.fn>;
} = {},
) {
let connection: DaemonConnectionState = opts.connection ?? {
status: 'connected',
workspaceCwd: '/workspace',
};
const sessionRef = {
current: opts.session as unknown as DaemonSessionClient | undefined,
};
const activePromptsRef = {
current: opts.activePrompts ?? new Map<string, ActivePrompt>(),
};
const pendingSessionLoadRef =
opts.pendingSessionLoadRef ??
({ current: undefined } as {
current: PendingSessionLoad | undefined;
});
const actions = createDaemonSessionActions({
store: {
reset: vi.fn(),
appendLocalUserMessage: vi.fn(),
dispatch: vi.fn(),
} as never,
sessionRef,
activePromptsRef,
settledPromptsRef: { current: new Map<string, SettledPrompt>() },
pendingSessionLoadRef,
pendingSessionLoadIdRef: { current: 0 },
heartbeatSupportedRef: { current: false },
manualSessionClearRef: opts.manualSessionClearRef ?? { current: false },
skipNextCleanupDetachSessionIdRef: { current: undefined },
passiveAssistantDoneTimerRef: { current: undefined },
getCreateSessionRequest: () => ({ workspaceCwd: '/workspace' }),
createDetachedSession: (opts.createDetachedSession ??
vi.fn(
async () =>
createMockSession(
'detached-session',
) as unknown as DaemonSessionClient,
)) as () => Promise<DaemonSessionClient>,
getConnection: () => connection,
hasSessionActivePrompt: () => false,
resetCurrentSessionActivePrompt: vi.fn(),
addNotice: opts.addNotice ?? vi.fn(),
setConnection: (update) => {
connection = typeof update === 'function' ? update(connection) : update;
},
setPromptStatus: vi.fn(),
setRestoreSessionId: vi.fn(),
setRestoreMode: vi.fn(),
setRestoreSessionNonce: vi.fn(),
setAttachSessionNonce: opts.setAttachSessionNonce ?? vi.fn(),
setNewSessionNonce: vi.fn(),
});
return {
actions,
getConnection: () => connection,
pendingSessionLoadRef,
sessionRef,
};
}
function createMockSession(sessionId: string) {
return {
sessionId,
workspaceCwd: '/workspace',
clientId: `client-${sessionId}`,
client: {
createOrAttachSession: vi.fn(),
setSessionApprovalMode: vi.fn(),
listWorkspaceSessions: vi.fn(),
closeSession: vi.fn(),
},
detach: vi.fn(async () => undefined),
};
}
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) {
const raw = commandRaw(name);
return {
name,
description: '',
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) {
return {
v: 1 as const,
sessionId,
workspaceCwd: '/workspace',
state: {},
};
}

View file

@ -53,8 +53,12 @@ export interface CreateDaemonSessionActionsArgs {
pendingSessionLoadRef: RefBox<PendingSessionLoad | undefined>;
pendingSessionLoadIdRef: RefBox<number>;
heartbeatSupportedRef: RefBox<boolean>;
manualSessionClearRef: RefBox<boolean>;
skipNextCleanupDetachSessionIdRef: RefBox<string | undefined>;
passiveAssistantDoneTimerRef: TimerRef;
getCreateSessionRequest: () => CreateSessionRequest;
createDetachedSession: () => Promise<DaemonSessionClient>;
getConnection: () => DaemonConnectionState;
hasSessionActivePrompt: () => boolean;
resetCurrentSessionActivePrompt: () => void;
addNotice: AddDaemonSessionNotice;
@ -63,9 +67,34 @@ export interface CreateDaemonSessionActionsArgs {
setRestoreSessionId: Dispatch<SetStateAction<string | undefined>>;
setRestoreMode: Dispatch<SetStateAction<'load' | 'resume'>>;
setRestoreSessionNonce: Dispatch<SetStateAction<number>>;
setAttachSessionNonce: Dispatch<SetStateAction<number>>;
setNewSessionNonce: Dispatch<SetStateAction<number>>;
}
export function getConnectionAfterSessionClear(
current: DaemonConnectionState,
clearedSessionId: string | undefined,
): DaemonConnectionState {
const next = { ...current };
if (!clearedSessionId || current.sessionId === clearedSessionId) {
delete next.sessionId;
delete next.clientId;
delete next.displayName;
delete next.tokenUsage;
delete next.tokenCount;
delete next.commands;
delete next.skills;
delete next.supportedCommands;
delete next.context;
}
return {
...next,
status: 'connected',
catchingUp: undefined,
error: undefined,
};
}
export function createDaemonSessionActions({
store,
sessionRef,
@ -74,8 +103,12 @@ export function createDaemonSessionActions({
pendingSessionLoadRef,
pendingSessionLoadIdRef,
heartbeatSupportedRef,
manualSessionClearRef,
skipNextCleanupDetachSessionIdRef,
passiveAssistantDoneTimerRef,
getCreateSessionRequest,
createDetachedSession,
getConnection,
hasSessionActivePrompt,
resetCurrentSessionActivePrompt,
addNotice,
@ -84,12 +117,37 @@ export function createDaemonSessionActions({
setRestoreSessionId,
setRestoreMode,
setRestoreSessionNonce,
setAttachSessionNonce,
setNewSessionNonce,
}: CreateDaemonSessionActionsArgs): DaemonSessionActions {
function startSessionSwitch(
function clearActiveSessionState() {
for (const [, active] of activePromptsRef.current) {
active.controller.abort();
}
activePromptsRef.current.clear();
settledPromptsRef.current.clear();
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (pendingSessionLoadRef.current) {
if (
skipNextCleanupDetachSessionIdRef.current ===
pendingSessionLoadRef.current.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('Session cleared', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
store.reset();
setRestoreSessionId(undefined);
}
function startPendingSessionLoad(
sessionId: string,
mode: 'load' | 'resume',
opts?: SessionSwitchOptions,
mode: PendingSessionLoad['mode'],
): Promise<void> {
const loadId = pendingSessionLoadIdRef.current + 1;
pendingSessionLoadIdRef.current = loadId;
@ -111,7 +169,7 @@ export function createDaemonSessionActions({
addNotice,
`${capitalize(mode)} session failed`,
new Error(`Session ${mode} timed out`),
mode === 'load' ? 'load_session' : 'resume_session',
getSessionLoadNoticeOperation(mode),
),
);
}
@ -125,6 +183,16 @@ export function createDaemonSessionActions({
reject,
};
});
return loadPromise;
}
function startSessionSwitch(
sessionId: string,
mode: 'load' | 'resume',
opts?: SessionSwitchOptions,
): Promise<void> {
manualSessionClearRef.current = false;
const loadPromise = startPendingSessionLoad(sessionId, mode);
const currentSessionId = sessionRef.current?.sessionId;
const activePrompt = currentSessionId
? activePromptsRef.current.get(currentSessionId)
@ -489,37 +557,96 @@ export function createDaemonSessionActions({
},
async createSession() {
try {
manualSessionClearRef.current = false;
const session = sessionRef.current;
const activeSession =
session && getConnection().sessionId === session.sessionId
? session
: undefined;
if (activeSession) {
const nextSession = await withActionTimeout(
activeSession.client.createOrAttachSession(
getCreateSessionRequest(),
),
'Create session timed out',
);
persistStableClientId(nextSession.clientId, nextSession.sessionId);
return nextSession;
}
const nextSession = await withActionTimeout(
createDetachedSession(),
'Create session timed out',
);
if (manualSessionClearRef.current) {
try {
await withActionTimeout(
nextSession.detach(),
'Detach cleared session timed out',
);
} catch (error) {
console.warn(
'[DaemonSessionActions] detach after interrupted create failed:',
error,
);
}
throw new DOMException('Session creation interrupted', 'AbortError');
}
persistStableClientId(nextSession.clientId, nextSession.sessionId);
sessionRef.current = nextSession;
skipNextCleanupDetachSessionIdRef.current = nextSession.sessionId;
setConnection((current) => ({
...current,
status: 'connected',
sessionId: nextSession.sessionId,
...(nextSession.clientId ? { clientId: nextSession.clientId } : {}),
workspaceCwd: nextSession.workspaceCwd,
error: undefined,
}));
return nextSession;
} catch (error) {
throw dispatchActionError(
addNotice,
'Create session failed',
error,
'create_session',
);
}
},
async attachSession() {
const session = requireSessionForAction(
addNotice,
sessionRef.current,
'Create session failed',
'create_session',
'Attach session failed',
'attach_session',
);
const nextSession = await withActionTimeout(
session.client.createOrAttachSession(getCreateSessionRequest()),
'Create session timed out',
const loadPromise = startPendingSessionLoad(session.sessionId, 'attach');
setAttachSessionNonce((nonce) => nonce + 1);
return loadPromise;
},
async clearSession() {
const session = sessionRef.current;
manualSessionClearRef.current = true;
clearActiveSessionState();
sessionRef.current = undefined;
setConnection((current) =>
getConnectionAfterSessionClear(current, session?.sessionId),
);
persistStableClientId(nextSession.clientId, nextSession.sessionId);
return nextSession;
if (session) {
try {
await withActionTimeout(session.detach(), 'Clear session timed out');
} catch (error) {
console.warn('[DaemonSessionActions] detach on clear failed:', error);
}
}
},
async newSession() {
for (const [, active] of activePromptsRef.current) {
active.controller.abort();
}
activePromptsRef.current.clear();
settledPromptsRef.current.clear();
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (pendingSessionLoadRef.current) {
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('New session requested', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
store.reset();
setRestoreSessionId(undefined);
manualSessionClearRef.current = false;
clearActiveSessionState();
setNewSessionNonce((nonce) => nonce + 1);
},
@ -1183,3 +1310,11 @@ function markNoticeDispatched(error: Error): Error {
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function getSessionLoadNoticeOperation(
mode: PendingSessionLoad['mode'],
): DaemonNoticeOperation {
if (mode === 'resume') return 'resume_session';
if (mode === 'attach') return 'attach_session';
return 'load_session';
}

View file

@ -25,12 +25,14 @@ export function mapProviderStatus(
): {
models: DaemonModelInfo[];
currentModel?: string;
currentMode?: string;
contextWindow?: number;
} {
if (!status) return { models: [] };
const seen = new Set<string>();
const models: DaemonModelInfo[] = [];
let currentModel = preferredCurrentModel ?? status.current?.modelId;
const currentMode = status.approvalMode;
let contextWindow: number | undefined;
for (const provider of status.providers) {
@ -68,7 +70,7 @@ export function mapProviderStatus(
}
}
return { models, currentModel, contextWindow };
return { models, currentModel, currentMode, contextWindow };
}
export function mapSessionContextModels(

View file

@ -92,8 +92,8 @@ export interface DaemonSessionProviderProps {
token?: string;
/** Workspace cwd used when creating, loading, or resuming daemon sessions. */
workspaceCwd?: string;
/** Session id to load on mount instead of creating or attaching automatically. */
initialSessionId?: string;
/** Session id to load. Undefined keeps the page empty until a prompt creates one. */
sessionId?: string;
/** Stable client identity to reuse for session-scoped daemon requests. */
clientId?: string;
/** Extra create-session options, excluding workspaceCwd which is owned by the provider. */
@ -110,8 +110,6 @@ export interface DaemonSessionProviderProps {
autoConnect?: boolean;
/** Reconnect automatically after recoverable daemon/session failures. */
autoReconnect?: boolean;
/** Behavior when the active session is missing (404/410). Defaults to create. */
missingSessionBehavior?: 'create' | 'disconnect';
/** Initial reconnect delay in milliseconds. */
reconnectDelayMs?: number;
/** Maximum reconnect delay in milliseconds after backoff. */
@ -152,6 +150,7 @@ export type DaemonNoticeOperation =
| 'set_approval_mode'
| 'submit_permission'
| 'cancel_prompt'
| 'attach_session'
| 'load_session'
| 'resume_session'
| 'create_session'
@ -311,7 +310,13 @@ export interface DaemonSessionActions {
}): Promise<DaemonSessionSummary[]>;
loadSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
resumeSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
/**
* Create a daemon session and update local session state. Callers that need
* transcript/event streaming must follow with `attachSession()`.
*/
createSession(): Promise<DaemonSession>;
attachSession(): Promise<void>;
clearSession(): Promise<void>;
newSession(): Promise<void>;
releaseSession(sessionId: string): Promise<void>;
closeSession(): Promise<void>;
@ -413,7 +418,7 @@ export type SettledPrompt =
export interface PendingSessionLoad {
id: number;
sessionId: string;
mode: 'load' | 'resume';
mode: 'load' | 'resume' | 'attach';
timeout: ReturnType<typeof setTimeout>;
resolve: () => void;
reject: (error: unknown) => void;