kimi-code/apps/kimi-web/test/workspace-state.test.ts
2026-07-14 06:15:25 +08:00

1396 lines
51 KiB
TypeScript

import { computed, ref, type Ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppApprovalRequest, AppQuestionRequest, AppSession, AppTask } from '../src/api/types';
import { DaemonApiError } from '../src/api/errors';
import { createInitialState } from '../src/api/daemon/eventReducer';
import { mergeWorkspaces } from '../src/lib/mergeWorkspaces';
import { loadWorkspaceNameOverrides, saveWorkspaceNameOverrides } from '../src/lib/storage';
import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState';
import type { ExtendedState } from '../src/composables/useKimiWebClient';
import { clearTrace, traceKeyEvent } from '../src/debug/trace';
const apiMock = vi.hoisted(() => ({
abortPrompt: vi.fn(),
abortSession: vi.fn(),
addWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
createSession: vi.fn(),
exportSession: vi.fn(),
updateSession: vi.fn(),
submitPrompt: vi.fn(),
respondQuestion: vi.fn(),
respondApproval: vi.fn(),
dismissQuestion: vi.fn(),
cancelTask: vi.fn(),
getAuth: vi.fn(),
getConfig: vi.fn(),
getFsHome: vi.fn(),
getHealth: vi.fn(),
getMeta: vi.fn(),
listSessions: vi.fn(),
listWorkspaces: vi.fn(),
}));
vi.mock('../src/api', () => ({
getKimiWebApi: () => apiMock,
}));
function createSession(): AppSession {
return {
id: 'sess_1',
title: 'Session',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: 'running',
archived: false,
currentPromptId: 'prompt_live',
cwd: '/workspace',
model: 'kimi-code',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalCostUsd: 0,
contextTokens: 0,
contextLimit: 0,
turnCount: 0,
},
messageCount: 0,
lastSeq: 0,
};
}
function createState(): ExtendedState {
return {
...createInitialState(),
sessions: [createSession()],
activeSessionId: 'sess_1',
connected: true,
serverVersion: '',
dangerousBypassAuth: false,
backend: 'v1',
workspaceName: 'kimi-web',
connection: 'connected',
permission: 'manual',
thinking: 'high',
planModeBySession: {},
swarmModeBySession: {},
goalModeBySession: {},
loading: false,
sessionLoading: false,
queuedBySession: {},
gitStatusBySession: {},
promptIdBySession: { sess_1: 'prompt_stale' },
sendingBySession: {},
unreadBySession: {},
authReady: true,
defaultModel: null,
managedProviderStatus: null,
workspaces: [],
activeWorkspaceId: null,
fsHome: null,
recentRoots: [],
hiddenWorkspaceRoots: [],
availableOpenInApps: [],
config: null,
sideChatMessagesByAgent: {},
sideChatSendingByAgent: {},
sideChatUserMessageIdsBySession: {},
messagesLoadingMoreBySession: {},
messagesHasMoreBySession: {},
messagesLoadMoreErrorBySession: {},
};
}
function createDeps(): UseWorkspaceStateDeps {
return {
taskPoller: {},
sideChat: {},
modelProvider: {},
pushOperationFailure: vi.fn(),
activity: computed(() => 'running'),
inFlightPromptSessions: new Set(),
sessionsKnownEmpty: new Set(),
setSessions: vi.fn(),
updateSession: vi.fn(),
upsertSessionFront: vi.fn(),
appendSession: vi.fn(),
forgetSession: vi.fn(),
setActiveSessionId: vi.fn(),
updateSessionMessages: vi.fn(),
nextOptimisticMsgId: () => 'msg_opt_1',
getEventConn: () => null,
syncSessionFromSnapshot: vi.fn(),
subscribeToSessionEvents: vi.fn(),
hasLoadedMessages: vi.fn(),
refreshSessionStatus: vi.fn(),
refreshSessionGoal: vi.fn(),
persistSessionProfile: vi.fn(),
mergedWorkspaces: computed(() => []),
workspacesView: computed(() => []),
status: computed(() => ({})),
workspaceIdForSession: vi.fn(),
savePermissionToStorage: vi.fn(),
savePlanModeToStorage: vi.fn(),
saveSwarmModeToStorage: vi.fn(),
saveGoalModeToStorage: vi.fn(),
draftModes: { planMode: false, swarmMode: false, goalMode: false },
saveUnread: vi.fn(),
saveActiveWorkspaceToStorage: vi.fn(),
saveHiddenWorkspacesToStorage: vi.fn(),
goalErrorMessage: vi.fn(),
basename: (path: string) => path.split('/').at(-1) ?? path,
resetFastMoon: vi.fn(),
initialized: ref(true),
selectedDiffPath: ref(null),
fileDiffLines: ref([]),
fileDiffLoading: ref(false),
} as unknown as UseWorkspaceStateDeps;
}
function createMemoryStorage(): Storage {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear() {
data.clear();
},
getItem(key: string) {
return data.get(key) ?? null;
},
key(index: number) {
return Array.from(data.keys()).at(index) ?? null;
},
removeItem(key: string) {
data.delete(key);
},
setItem(key: string, value: string) {
data.set(key, String(value));
},
};
}
function installStorage(storage: Storage): void {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
}
function workspace(id: string, root: string, name: string) {
return { id, root, name, isGitRepo: false, sessionCount: 0 };
}
function questionRequest(questionId: string): AppQuestionRequest {
return {
questionId,
sessionId: 'sess_1',
questions: [
{
id: 'q1',
question: 'Pick one',
options: [{ id: 'a', label: 'A' }],
},
],
createdAt: '2026-01-01T00:00:00.000Z',
};
}
function approvalRequest(approvalId: string): AppApprovalRequest {
return {
approvalId,
sessionId: 'sess_1',
toolCallId: 'tc_1',
toolName: 'bash',
action: 'shell',
display: null,
expiresAt: '2099-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
};
}
function task(id: string, status: AppTask['status'] = 'running'): AppTask {
return {
id,
sessionId: 'sess_1',
kind: 'bash',
description: 'run',
status,
createdAt: '2026-01-01T00:00:00.000Z',
};
}
describe('useWorkspaceState — abortCurrentPrompt', () => {
beforeEach(() => {
apiMock.abortPrompt.mockReset();
apiMock.abortSession.mockReset();
});
it('falls back to session abort when the cached prompt id is already completed', async () => {
apiMock.abortPrompt.mockResolvedValue({ aborted: false });
apiMock.abortSession.mockResolvedValue({ aborted: true });
const state = createState();
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'prompt_stale');
expect(apiMock.abortSession).toHaveBeenCalledWith('sess_1');
expect(state.promptIdBySession).toEqual({});
});
it('does not fall back when prompt abort succeeds', async () => {
apiMock.abortPrompt.mockResolvedValue({ aborted: true });
const workspace = useWorkspaceState(createState(), createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'prompt_stale');
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
it('uses a server-v2 msg prompt id recovered from session state', async () => {
apiMock.abortPrompt.mockResolvedValue({ aborted: true });
const state = createState();
state.promptIdBySession = {};
state.sessions = [{ ...state.sessions[0]!, currentPromptId: 'msg_live' }];
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'msg_live');
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
it('does not send synthetic projector prompt ids to per-prompt abort', async () => {
apiMock.abortSession.mockResolvedValue({ aborted: true });
const state = createState();
state.promptIdBySession = {};
state.sessions = [{ ...state.sessions[0]!, currentPromptId: 'pr_synthetic' }];
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).not.toHaveBeenCalled();
expect(apiMock.abortSession).toHaveBeenCalledWith('sess_1');
});
});
describe('useWorkspaceState — exportSession', () => {
let anchor: {
href: string;
download: string;
click: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
};
let append: ReturnType<typeof vi.fn>;
let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
beforeEach(() => {
apiMock.exportSession.mockReset();
clearTrace();
anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
append = vi.fn();
createObjectURL = vi.fn(() => 'blob:session-export');
revokeObjectURL = vi.fn();
vi.stubGlobal('document', {
createElement: vi.fn(() => anchor),
body: { append },
});
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
});
afterEach(() => {
clearTrace();
vi.unstubAllGlobals();
});
it('downloads the returned ZIP and reclaims its temporary browser resources', async () => {
const secret = 'PROMPT_TEXT_MUST_NOT_ENTER_EXPORT_REQUEST';
const metadata = {
sessionId: 'sess_1',
contentCount: 1,
mediaCount: 0,
text: secret,
};
traceKeyEvent('prompt:start', metadata);
const blob = new Blob(['zip']);
apiMock.exportSession.mockResolvedValue({ blob, fileName: 'sess_1.zip' });
const workspace = useWorkspaceState(createState(), createDeps());
await workspace.exportSession();
const webLog = apiMock.exportSession.mock.calls[0]?.[1] as string;
expect(webLog).toContain('prompt:start');
expect(webLog).toContain('contentCount');
expect(webLog).not.toContain(secret);
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(anchor).toMatchObject({ href: 'blob:session-export', download: 'sess_1.zip' });
expect(append).toHaveBeenCalledWith(anchor);
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.remove).toHaveBeenCalledOnce();
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
});
it('keeps one request targeted at the session selected when export started', async () => {
let resolveExport!: (value: { blob: Blob; fileName: string }) => void;
apiMock.exportSession.mockReturnValue(
new Promise((resolve) => {
resolveExport = resolve;
}),
);
const state = createState();
const workspace = useWorkspaceState(state, createDeps());
const first = workspace.exportSession();
state.activeSessionId = 'sess_2';
const second = workspace.exportSession();
resolveExport({ blob: new Blob(['zip']), fileName: 'sess_1.zip' });
await Promise.all([first, second]);
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
expect(apiMock.exportSession).toHaveBeenCalledTimes(1);
expect(apiMock.exportSession).toHaveBeenCalledWith('sess_1', expect.any(String));
});
it('reclaims the object URL when the browser rejects the download click', async () => {
apiMock.exportSession.mockResolvedValue({ blob: new Blob(['zip']), fileName: 'sess_1.zip' });
anchor.click.mockImplementation(() => {
throw new Error('download blocked');
});
const deps = createDeps();
const workspace = useWorkspaceState(createState(), deps);
await workspace.exportSession();
expect(anchor.remove).toHaveBeenCalledOnce();
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
expect(deps.pushOperationFailure).toHaveBeenCalledWith(
'exportSession',
expect.any(Error),
{ sessionId: 'sess_1' },
);
});
it('surfaces an error instead of silently exporting without an active session', async () => {
const state = createState();
state.activeSessionId = undefined;
const deps = createDeps();
const workspace = useWorkspaceState(state, deps);
await workspace.exportSession();
expect(apiMock.exportSession).not.toHaveBeenCalled();
expect(deps.pushOperationFailure).toHaveBeenCalledWith(
'exportSession',
expect.any(Error),
expect.objectContaining({ message: expect.any(String) }),
);
});
});
describe('mergeWorkspaces', () => {
it('collapses registered workspaces that share a root, keeping the first entry and its sessions', () => {
const result = mergeWorkspaces({
workspaces: [
// Server orders by last_opened_at desc, so the most recently opened
// (typically the canonical re-add) comes first.
{ id: 'wd_current', root: '/agent/GEO', name: 'GEO', isGitRepo: false, sessionCount: 0 },
{ id: 'wd_legacy', root: '/agent/GEO', name: 'GEO', isGitRepo: false, sessionCount: 0 },
],
// A session whose daemon workspace_id points at the dropped (legacy) entry.
sessions: [{ id: 's1', cwd: '/agent/GEO', workspaceId: 'wd_legacy' }],
hiddenWorkspaceRoots: [],
activeRoot: undefined,
activeBranch: null,
sessionsHasMoreByWorkspace: { wd_current: false },
});
expect(result).toHaveLength(1);
expect(result[0]?.root).toBe('/agent/GEO');
// Keeps the first (most recent) entry, matching the sidebar's first-match
// session assignment so the rendered workspace is the one sessions land under.
expect(result[0]?.id).toBe('wd_current');
expect(result[0]?.sessionCount).toBe(1);
});
it('keeps distinct roots separate and appends derived cwds after real ones', () => {
const result = mergeWorkspaces({
workspaces: [
{ id: 'wd_a', root: '/agent/A', name: 'A', isGitRepo: false, sessionCount: 1 },
],
sessions: [
{ id: 's1', cwd: '/agent/A', workspaceId: 'wd_a' },
{ id: 's2', cwd: '/agent/B', workspaceId: 'wd_b' },
],
hiddenWorkspaceRoots: [],
activeRoot: undefined,
activeBranch: null,
sessionsHasMoreByWorkspace: {},
});
expect(result.map((w) => w.root)).toEqual(['/agent/A', '/agent/B']);
expect(result.find((w) => w.root === '/agent/B')?.id).toBe('wd_b');
});
it('hides workspaces whose root the user removed', () => {
const result = mergeWorkspaces({
workspaces: [
{ id: 'wd_a', root: '/agent/A', name: 'A', isGitRepo: false, sessionCount: 1 },
],
sessions: [{ id: 's1', cwd: '/agent/A', workspaceId: 'wd_a' }],
hiddenWorkspaceRoots: ['/agent/A'],
activeRoot: undefined,
activeBranch: null,
sessionsHasMoreByWorkspace: {},
});
expect(result.map((w) => w.root)).not.toContain('/agent/A');
});
});
describe('useWorkspaceState — renameWorkspace', () => {
beforeEach(() => {
apiMock.updateWorkspace.mockReset();
installStorage(createMemoryStorage());
});
afterEach(() => {
installStorage(createMemoryStorage());
});
it('renames via the daemon and applies the name locally', async () => {
apiMock.updateWorkspace.mockResolvedValue({});
const state = createState();
state.workspaces = [workspace('wd_1', '/abs/path', 'Old')];
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.renameWorkspace('wd_1', 'New');
expect(apiMock.updateWorkspace).toHaveBeenCalledWith('wd_1', { name: 'New' });
expect(state.workspaces[0]?.name).toBe('New');
expect(loadWorkspaceNameOverrides()).toEqual({});
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('falls back to a local override when the daemon reports not found', async () => {
apiMock.updateWorkspace.mockRejectedValue(
new DaemonApiError({ code: 40410, msg: 'workspace not found', requestId: 'r' }),
);
const state = createState();
state.workspaces = [workspace('wd_1', '/abs/path', 'Old')];
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.renameWorkspace('wd_1', 'New');
expect(state.workspaces[0]?.name).toBe('New');
expect(loadWorkspaceNameOverrides()).toEqual({ '/abs/path': 'New' });
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('surfaces daemon errors other than not-found', async () => {
apiMock.updateWorkspace.mockRejectedValue(
new DaemonApiError({ code: 50000, msg: 'boom', requestId: 'r' }),
);
const state = createState();
state.workspaces = [workspace('wd_1', '/abs/path', 'Old')];
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.renameWorkspace('wd_1', 'New');
expect(state.workspaces[0]?.name).toBe('Old');
expect(loadWorkspaceNameOverrides()).toEqual({});
expect(deps.pushOperationFailure).toHaveBeenCalled();
});
it('keeps a saved name override when a workspace is upserted (derived → registered)', () => {
// Simulates: user renamed a derived workspace, then the daemon registers
// the root (e.g. on first chat) and returns the default basename.
saveWorkspaceNameOverrides({ '/abs/path': 'Renamed' });
const state = createState();
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
ws.upsertWorkspacePreserveOrder(workspace('wd_1', '/abs/path', 'path'));
expect(state.workspaces[0]?.name).toBe('Renamed');
});
});
describe('useWorkspaceState — addWorkspaceByPath', () => {
beforeEach(() => {
apiMock.addWorkspace.mockReset();
});
it('registers the workspace with the daemon and selects it', async () => {
const registered = {
id: 'wd_abc',
root: '/abs/path',
name: 'path',
isGitRepo: false,
sessionCount: 0,
};
apiMock.addWorkspace.mockResolvedValue(registered);
const state = createState();
const deps = createDeps();
const workspace = useWorkspaceState(state, deps);
const ok = await workspace.addWorkspaceByPath(' /abs/path ');
expect(ok).toBe(true);
expect(apiMock.addWorkspace).toHaveBeenCalledWith({ root: '/abs/path' });
expect(state.workspaces).toContainEqual(registered);
expect(state.activeWorkspaceId).toBe('wd_abc');
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('returns false and adds no local workspace on failure', async () => {
const err = new Error('path not found');
apiMock.addWorkspace.mockRejectedValue(err);
const state = createState();
const deps = createDeps();
const workspace = useWorkspaceState(state, deps);
const ok = await workspace.addWorkspaceByPath('/abs/missing');
expect(ok).toBe(false);
// The caller (the picker) is responsible for surfacing the failure inline.
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
expect(state.workspaces).toEqual([]);
expect(state.activeWorkspaceId).toBeNull();
});
});
describe('useWorkspaceState — respondQuestion', () => {
const response = { answers: {}, method: 'click' as const };
beforeEach(() => {
apiMock.respondQuestion.mockReset();
});
it('removes the question locally and stays silent when already resolved (40902)', async () => {
apiMock.respondQuestion.mockRejectedValue(
new DaemonApiError({ code: 40902, msg: 'question q_1 already resolved', requestId: 'r' }),
);
const state = createState();
state.questionsBySession = { sess_1: [questionRequest('q_1')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.respondQuestion('q_1', response);
expect(apiMock.respondQuestion).toHaveBeenCalledOnce();
// Already resolved is the desired end state, so the card is dropped locally
// without surfacing a duplicate error to the user.
expect(state.questionsBySession['sess_1']).toEqual([]);
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('surfaces genuine errors and keeps the question for retry', async () => {
apiMock.respondQuestion.mockRejectedValue(
new DaemonApiError({ code: 50001, msg: 'boom', requestId: 'r' }),
);
const state = createState();
state.questionsBySession = { sess_1: [questionRequest('q_1')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.respondQuestion('q_1', response);
expect(state.questionsBySession['sess_1']).toHaveLength(1);
expect(deps.pushOperationFailure).toHaveBeenCalledOnce();
});
it('drops a duplicate submit while the first respond is still in flight', async () => {
let resolveRespond!: (value: { resolved: true; resolvedAt: string }) => void;
apiMock.respondQuestion.mockReturnValue(
new Promise<{ resolved: true; resolvedAt: string }>((r) => {
resolveRespond = r;
}),
);
const state = createState();
state.questionsBySession = { sess_1: [questionRequest('q_1')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
const first = ws.respondQuestion('q_1', response);
// Second click while the first request is still in flight must be a no-op.
await ws.respondQuestion('q_1', response);
expect(apiMock.respondQuestion).toHaveBeenCalledOnce();
// Resolve the first request and ensure the question is removed.
resolveRespond({ resolved: true, resolvedAt: '2026-01-01T00:00:00.000Z' });
await first;
expect(state.questionsBySession['sess_1']).toEqual([]);
});
});
describe('useWorkspaceState — respondApproval', () => {
beforeEach(() => {
apiMock.respondApproval.mockReset();
});
it('removes the approval locally and stays silent when already resolved (40902)', async () => {
apiMock.respondApproval.mockRejectedValue(
new DaemonApiError({ code: 40902, msg: 'approval a_1 already resolved', requestId: 'r' }),
);
const state = createState();
state.approvalsBySession = { sess_1: [approvalRequest('a_1')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.respondApproval('a_1', { decision: 'approved' });
expect(apiMock.respondApproval).toHaveBeenCalledOnce();
expect(state.approvalsBySession['sess_1']).toEqual([]);
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
});
describe('useWorkspaceState — cancelTask', () => {
beforeEach(() => {
apiMock.cancelTask.mockReset();
});
it('stays silent and does not force-cancel when the task already finished (40904)', async () => {
apiMock.cancelTask.mockRejectedValue(
new DaemonApiError({ code: 40904, msg: 'task t_1 already finished', requestId: 'r' }),
);
const state = createState();
state.tasksBySession = { sess_1: [task('t_1', 'running')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.cancelTask('t_1');
expect(apiMock.cancelTask).toHaveBeenCalledOnce();
// Benign idempotent conflict — no error, and we do NOT lie about the
// status (the task finished; it was not cancelled).
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
expect(state.tasksBySession['sess_1']?.[0]?.status).toBe('running');
});
it('marks the task cancelled on success', async () => {
apiMock.cancelTask.mockResolvedValue({ cancelled: true });
const state = createState();
state.tasksBySession = { sess_1: [task('t_1', 'running')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
await ws.cancelTask('t_1');
expect(state.tasksBySession['sess_1']?.[0]?.status).toBe('cancelled');
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('drops a duplicate cancel while the first is still in flight', async () => {
let resolveCancel!: (value: { cancelled: true }) => void;
apiMock.cancelTask.mockReturnValue(
new Promise<{ cancelled: true }>((r) => {
resolveCancel = r;
}),
);
const state = createState();
state.tasksBySession = { sess_1: [task('t_1', 'running')] };
const deps = createDeps();
const ws = useWorkspaceState(state, deps);
const first = ws.cancelTask('t_1');
await ws.cancelTask('t_1');
expect(apiMock.cancelTask).toHaveBeenCalledOnce();
resolveCancel({ cancelled: true });
await first;
});
});
describe('useWorkspaceState — startSessionAndActivateSkill', () => {
const registered = { id: 'wd_1', root: '/abs/path', name: 'A', isGitRepo: false, sessionCount: 0 };
const newSession = { ...createSession(), id: 'sess_new', workspaceId: 'wd_1', cwd: '/abs/path' };
beforeEach(() => {
apiMock.addWorkspace.mockReset();
apiMock.createSession.mockReset();
apiMock.addWorkspace.mockResolvedValue(registered);
apiMock.createSession.mockResolvedValue(newSession);
});
function skillDeps(activateSkill: ReturnType<typeof vi.fn>): UseWorkspaceStateDeps {
return {
...createDeps(),
taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'],
modelProvider: {
draftModel: ref(null),
skillsBySession: ref({}),
loadSkillsForSession: vi.fn(),
activateSkill,
} as unknown as UseWorkspaceStateDeps['modelProvider'],
mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]),
};
}
it('creates a session, then activates the skill on the new session id', async () => {
const activateSkill = vi.fn().mockResolvedValue(undefined);
const deps = skillDeps(activateSkill);
const ws = useWorkspaceState(createState(), deps);
await ws.startSessionAndActivateSkill('wd_1', 'pre-changelog');
expect(apiMock.createSession).toHaveBeenCalledOnce();
// The activation targets the freshly created session, so a concurrent
// session switch can't redirect it.
expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new');
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('passes through skill args', async () => {
const activateSkill = vi.fn().mockResolvedValue(undefined);
const deps = skillDeps(activateSkill);
const ws = useWorkspaceState(createState(), deps);
await ws.startSessionAndActivateSkill('wd_1', 'write-goal', 'ship it');
expect(activateSkill).toHaveBeenCalledWith('write-goal', 'ship it', 'sess_new');
});
it('awaits the profile POST before activating, so draft controls apply first', async () => {
// Skill activation only carries `args`, so the daemon never sees the per-
// prompt controls (plan/swarm plus permission and thinking) the user set on
// the draft. We persist them to the new session's profile and must WAIT for
// it; otherwise :activate can race ahead of applyAgentState and the first
// skill turn runs at daemon defaults while the UI shows otherwise.
let resolveProfile!: () => void;
const profileGate = new Promise<void>((r) => {
resolveProfile = r;
});
const activateSkill = vi.fn().mockResolvedValue(undefined);
const persistSessionProfile = vi.fn().mockReturnValue(profileGate);
const deps = {
...skillDeps(activateSkill),
persistSessionProfile,
draftModes: { planMode: true, swarmMode: true, goalMode: false },
};
const state = createState();
state.permission = 'auto';
state.thinking = 'high';
const ws = useWorkspaceState(state, deps);
const pending = ws.startSessionAndActivateSkill('wd_1', 'pre-changelog');
// Yield a macrotask so createDraftSession's chain (which awaits selectSession
// before persisting the profile) progresses to the in-flight /profile POST.
// Activation must NOT have started while /profile is still pending.
await new Promise((r) => setTimeout(r, 0));
expect(persistSessionProfile).toHaveBeenCalledWith(
{ model: undefined, planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' },
'sess_new',
);
expect(activateSkill).not.toHaveBeenCalled();
resolveProfile();
await pending;
expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new');
});
it('coerces a stale thinking level against the new session model before persisting', async () => {
// Regression for: rawState.thinking can be stale relative to the new
// session's model (e.g. 'max' carried over from an effort model). Persisting
// the raw value would make the first skill turn run at a level the UI
// wouldn't send for this model; we must coerce it like the first-prompt
// path does.
const activateSkill2 = vi.fn().mockResolvedValue(undefined);
const persistSessionProfile2 = vi.fn().mockResolvedValue(undefined);
const state2 = createState();
state2.thinking = 'max';
const deps2: UseWorkspaceStateDeps = {
...skillDeps(activateSkill2),
persistSessionProfile: persistSessionProfile2,
// upsertSessionFront must actually land the new session in rawState.sessions
// so startSessionAndActivateSkill can read its model.
upsertSessionFront: vi.fn((s) => {
state2.sessions = [s, ...state2.sessions.filter((x) => x.id !== s.id)];
}),
draftModes: { planMode: true, swarmMode: false, goalMode: false },
};
// 'kimi-code' declares efforts ['low','medium','high']; 'max' isn't in the
// list so coercion picks the default (middle) level → 'medium'.
(deps2.modelProvider as unknown as { models: unknown }).models = ref([
{
id: 'kimi-code',
model: 'kimi-code',
provider: 'kimi',
displayName: 'kimi-code',
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'high'],
},
]);
const ws2 = useWorkspaceState(state2, deps2);
await ws2.startSessionAndActivateSkill('wd_1', 'pre-changelog');
// Effort model default level = middle of supportEfforts: 'medium'.
// Confirms the raw carry-over 'max' was coerced, not persisted verbatim.
expect(persistSessionProfile2).toHaveBeenCalledWith(
expect.objectContaining({ thinking: 'medium' }),
'sess_new',
);
expect(activateSkill2).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new');
});
it('is a no-op for an unknown workspace', async () => {
const activateSkill = vi.fn().mockResolvedValue(undefined);
const deps = skillDeps(activateSkill);
const ws = useWorkspaceState(createState(), deps);
await ws.startSessionAndActivateSkill('wd_missing', 'pre-changelog');
expect(apiMock.createSession).not.toHaveBeenCalled();
expect(activateSkill).not.toHaveBeenCalled();
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
});
describe('useWorkspaceState — createGoal from an empty composer', () => {
const registered = { id: 'wd_1', root: '/abs/path', name: 'A', isGitRepo: false, sessionCount: 0 };
const newSession = { ...createSession(), id: 'sess_new', workspaceId: 'wd_1', cwd: '/abs/path' };
beforeEach(() => {
apiMock.addWorkspace.mockReset();
apiMock.createSession.mockReset();
apiMock.updateSession.mockReset();
apiMock.submitPrompt.mockReset();
apiMock.addWorkspace.mockResolvedValue(registered);
apiMock.createSession.mockResolvedValue(newSession);
apiMock.updateSession.mockResolvedValue({});
apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_goal' });
});
function emptyComposerState() {
const state = createState();
state.activeSessionId = null;
state.activeWorkspaceId = 'wd_1';
state.workspaces = [workspace('wd_1', '/abs/path', 'A')];
state.permission = 'auto'; // skip the interactive goal-start confirmation
return state;
}
function goalDeps(): UseWorkspaceStateDeps {
return {
...createDeps(),
taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'],
modelProvider: {
draftModel: ref(null),
skillsBySession: ref({}),
loadSkillsForSession: vi.fn(),
} as unknown as UseWorkspaceStateDeps['modelProvider'],
// Something the goal can land in + what's visible in the sidebar.
mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]),
workspacesView: computed(() => [workspace('wd_1', '/abs/path', 'A')]),
} as unknown as UseWorkspaceStateDeps;
}
it('creates a session, sets the goal profile, and submits the objective', async () => {
const state = emptyComposerState(); // rawState.activeWorkspaceId = 'wd_1'
const deps = goalDeps();
const ws = useWorkspaceState(state, deps);
await ws.createGoal('improve test coverage');
expect(apiMock.createSession).toHaveBeenCalledOnce();
// Profile is updated on the new session: that's what marks the prompt as a goal.
expect(apiMock.updateSession).toHaveBeenCalledWith('sess_new', { goalObjective: 'improve test coverage' });
// And the objective is sent as the first user prompt on the new session.
expect(apiMock.submitPrompt).toHaveBeenCalledWith(
'sess_new',
expect.objectContaining({
content: [{ type: 'text', text: 'improve test coverage' }],
}),
);
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('falls back to the first visible workspace when raw activeWorkspaceId is unset', async () => {
// Regression for a real empty-workspace boot: load() never writes
// rawState.activeWorkspaceId when there are no sessions, so the raw read is
// null, but the sidebar still shows a usable workspace via the computed
// fallback. First-session goals must work there too.
const state = emptyComposerState();
state.activeWorkspaceId = null;
const ws = useWorkspaceState(state, goalDeps());
await ws.createGoal('improve test coverage');
expect(apiMock.createSession).toHaveBeenCalledOnce();
expect(apiMock.updateSession).toHaveBeenCalledWith('sess_new', { goalObjective: 'improve test coverage' });
expect(apiMock.submitPrompt).toHaveBeenCalledOnce();
});
it('queues the objective when the active session is running (no queue bypass)', async () => {
// Regression: creating a goal against an already-active session must honor
// sendPrompt's queue guard, not bypass straight to submitPromptInternal.
// Otherwise a /goal message sent while another turn is running races with
// the active turn instead of being locally queued like normal sends.
const state = createState();
state.activeSessionId = 'sess_1';
state.permission = 'auto'; // skip the interactive goal-start confirmation
const ws = useWorkspaceState(state, createDeps());
await ws.createGoal('improve test coverage');
// Didn't create a session: we targeted the existing one.
expect(apiMock.createSession).not.toHaveBeenCalled();
expect(apiMock.updateSession).toHaveBeenCalledWith('sess_1', { goalObjective: 'improve test coverage' });
// And because the session is running (createDeps' default activity is
// 'running'), sendPrompt queues rather than posting immediately.
expect(apiMock.submitPrompt).not.toHaveBeenCalled();
expect(state.queuedBySession['sess_1']).toEqual([
{ text: 'improve test coverage', attachments: undefined },
]);
});
it('is a no-op when there is no active session and no usable workspace', async () => {
const state = emptyComposerState();
state.activeWorkspaceId = null;
const deps: UseWorkspaceStateDeps = {
...createDeps(),
mergedWorkspaces: computed(() => []),
workspacesView: computed(() => []),
};
const ws = useWorkspaceState(state, deps);
await ws.createGoal('improve test coverage');
expect(apiMock.createSession).not.toHaveBeenCalled();
expect(apiMock.updateSession).not.toHaveBeenCalled();
expect(apiMock.submitPrompt).not.toHaveBeenCalled();
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('ignores empty/whitespace objectives', async () => {
const state = emptyComposerState();
const ws = useWorkspaceState(state, goalDeps());
await ws.createGoal(' ');
expect(apiMock.createSession).not.toHaveBeenCalled();
expect(apiMock.updateSession).not.toHaveBeenCalled();
});
it('clears staged goal mode so the objective prompt is submitted once', async () => {
// Regression for: empty composer with bare `/goal` staged (draftModes.goalMode),
// then `/goal <objective>`. createDraftSession copies draftModes.goalMode into
// goalModeBySession[sid]. If we don't clear it after the explicit
// updateSession(goalObjective), submitPromptInternal re-POSTs a goalObjective,
// the daemon rejects it (existing goal), and the objective prompt never sends.
const state = emptyComposerState();
const deps: UseWorkspaceStateDeps = {
...goalDeps(),
draftModes: { planMode: false, swarmMode: false, goalMode: true },
};
const ws = useWorkspaceState(state, deps);
await ws.createGoal('improve test coverage');
// The explicit goal objective went through...
expect(apiMock.updateSession).toHaveBeenCalledWith('sess_new', { goalObjective: 'improve test coverage' });
// ...and the objective prompt itself was submitted exactly once as a user prompt.
expect(apiMock.submitPrompt).toHaveBeenCalledTimes(1);
expect(apiMock.submitPrompt).toHaveBeenCalledWith(
'sess_new',
expect.objectContaining({
content: [{ type: 'text', text: 'improve test coverage' }],
}),
);
// goal mode flag was consumed by the explicit goal.
expect(state.goalModeBySession['sess_new']).toBe(false);
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('surfaces session-creation failures instead of leaking an unhandled rejection', async () => {
// App.vue invokes createGoal fire-and-forget, so a rejection from
// createDraftSession must be caught and reported via pushOperationFailure —
// mirroring the other draft-session paths (skill / BTW / first prompt).
const state = emptyComposerState();
const deps = goalDeps();
const ws = useWorkspaceState(state, deps);
const err = new Error('snapshot failed');
apiMock.createSession.mockRejectedValue(err);
await expect(ws.createGoal('improve test coverage')).resolves.toBeUndefined();
expect(deps.pushOperationFailure).toHaveBeenCalledWith('createGoal', err);
expect(apiMock.updateSession).not.toHaveBeenCalled();
expect(apiMock.submitPrompt).not.toHaveBeenCalled();
});
});
describe('useWorkspaceState — startSessionAndOpenSideChat', () => {
const registered = { id: 'wd_1', root: '/abs/path', name: 'A', isGitRepo: false, sessionCount: 0 };
const newSession = { ...createSession(), id: 'sess_new', workspaceId: 'wd_1', cwd: '/abs/path' };
beforeEach(() => {
apiMock.addWorkspace.mockReset();
apiMock.createSession.mockReset();
apiMock.addWorkspace.mockResolvedValue(registered);
apiMock.createSession.mockResolvedValue(newSession);
});
function sideChatDeps(openSideChatOn: ReturnType<typeof vi.fn>): UseWorkspaceStateDeps {
return {
...createDeps(),
taskPoller: { loadTasksForSession: vi.fn() } as unknown as UseWorkspaceStateDeps['taskPoller'],
sideChat: { openSideChatOn } as unknown as UseWorkspaceStateDeps['sideChat'],
modelProvider: {
draftModel: ref(null),
skillsBySession: ref({}),
loadSkillsForSession: vi.fn(),
} as unknown as UseWorkspaceStateDeps['modelProvider'],
mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]),
};
}
it('creates a session, then opens BTW on the new session id with the question', async () => {
const openSideChatOn = vi.fn().mockResolvedValue(undefined);
const deps = sideChatDeps(openSideChatOn);
const ws = useWorkspaceState(createState(), deps);
await ws.startSessionAndOpenSideChat('wd_1', 'what changed?');
expect(apiMock.createSession).toHaveBeenCalledOnce();
// The BTW sub-agent is opened on the freshly created session, so a
// concurrent session switch can't redirect it.
expect(openSideChatOn).toHaveBeenCalledWith('sess_new', 'what changed?');
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
it('works without an initial question (bare /btw)', async () => {
const openSideChatOn = vi.fn().mockResolvedValue(undefined);
const deps = sideChatDeps(openSideChatOn);
const ws = useWorkspaceState(createState(), deps);
await ws.startSessionAndOpenSideChat('wd_1');
expect(openSideChatOn).toHaveBeenCalledWith('sess_new', undefined);
});
it('is a no-op for an unknown workspace', async () => {
const openSideChatOn = vi.fn().mockResolvedValue(undefined);
const deps = sideChatDeps(openSideChatOn);
const ws = useWorkspaceState(createState(), deps);
await ws.startSessionAndOpenSideChat('wd_missing', 'what changed?');
expect(apiMock.createSession).not.toHaveBeenCalled();
expect(openSideChatOn).not.toHaveBeenCalled();
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
});
describe('useWorkspaceState — first-load auth gate', () => {
beforeEach(() => {
apiMock.getAuth.mockReset();
apiMock.getHealth.mockReset().mockResolvedValue({ ok: true });
apiMock.getMeta.mockReset().mockResolvedValue({
serverVersion: '0.0.0',
openInApps: [],
dangerousBypassAuth: false,
backend: 'v1',
});
apiMock.getConfig.mockReset().mockResolvedValue({});
apiMock.listWorkspaces.mockReset().mockResolvedValue([]);
apiMock.getFsHome.mockReset().mockResolvedValue({ home: '', recentRoots: [] });
apiMock.listSessions.mockReset().mockResolvedValue({ items: [], hasMore: false });
});
function createLoadDeps(
initialized: Ref<boolean>,
connectIssue: Ref<string | null>,
): UseWorkspaceStateDeps {
return {
...createDeps(),
modelProvider: { loadModels: vi.fn().mockResolvedValue(undefined) },
initialized,
connectIssue,
} as unknown as UseWorkspaceStateDeps;
}
it('keeps the splash up and retries /auth when the first check fails transiently', async () => {
vi.useFakeTimers();
try {
const initialized = ref(false);
const connectIssue = ref<string | null>(null);
const state = createState();
state.authReady = false;
apiMock.getAuth
.mockRejectedValueOnce(new Error('connection refused'))
.mockRejectedValueOnce(new Error('connection refused'))
.mockResolvedValue({ ready: true, defaultModel: 'kimi-code', managedProvider: null });
const ws = useWorkspaceState(state, createLoadDeps(initialized, connectIssue));
const pending = ws.load();
await vi.advanceTimersByTimeAsync(0);
// First /auth failed: NOT treated as "not signed in" — no initialization.
// The first failure stays silent so a single blip flashes no error.
expect(initialized.value).toBe(false);
expect(apiMock.getAuth).toHaveBeenCalledTimes(1);
expect(connectIssue.value).toBeNull();
// From the 2nd failed attempt the reason is surfaced for the splash.
await vi.advanceTimersByTimeAsync(2000);
expect(apiMock.getAuth).toHaveBeenCalledTimes(2);
expect(initialized.value).toBe(false);
expect(connectIssue.value).toBe('connection refused');
// The retry re-checks /auth; once it answers, load completes.
await vi.advanceTimersByTimeAsync(2000);
await pending;
expect(apiMock.getAuth).toHaveBeenCalledTimes(3);
expect(initialized.value).toBe(true);
expect(state.authReady).toBe(true);
expect(connectIssue.value).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('initializes normally (into the login gate) when /auth answers ready:false', async () => {
const initialized = ref(false);
const state = createState();
state.authReady = false;
apiMock.getAuth.mockResolvedValue({ ready: false, defaultModel: null, managedProvider: null });
const ws = useWorkspaceState(state, createLoadDeps(initialized, ref(null)));
await ws.load();
// A definitive "not ready" answer behaves exactly as before: initialize and
// let the auth gate show /login.
expect(apiMock.getAuth).toHaveBeenCalledTimes(1);
expect(initialized.value).toBe(true);
expect(state.authReady).toBe(false);
});
it.each([40101, 401])(
'stops without retrying when /auth rejects with %i (server token required)',
async (code) => {
vi.useFakeTimers();
try {
const initialized = ref(false);
const state = createState();
state.authReady = false;
apiMock.getAuth.mockRejectedValue(
new DaemonApiError({ code, msg: 'Unauthorized', requestId: 'req_1' }),
);
const ws = useWorkspaceState(state, createLoadDeps(initialized, ref(null)));
await ws.load();
expect(apiMock.getAuth).toHaveBeenCalledTimes(1);
expect(initialized.value).toBe(false);
// No retry loop is running — recovery belongs to the ServerAuthDialog,
// which reloads the page once the user enters the token.
await vi.advanceTimersByTimeAsync(10_000);
expect(apiMock.getAuth).toHaveBeenCalledTimes(1);
expect(initialized.value).toBe(false);
} finally {
vi.useRealTimers();
}
},
);
});
// /meta re-read on every WS (re)connect — keeps version / backend truthful
// across backend restarts and dev-proxy backend switches.
describe('useWorkspaceState — refreshServerMeta', () => {
beforeEach(() => {
apiMock.getMeta.mockReset();
});
it('applies the meta payload including the v2 backend marker', async () => {
apiMock.getMeta.mockResolvedValue({
serverVersion: '9.9.9',
openInApps: ['finder'],
dangerousBypassAuth: true,
backend: 'v2',
});
const state = createState();
const ws = useWorkspaceState(state, createDeps());
await ws.refreshServerMeta();
expect(state.serverVersion).toBe('9.9.9');
expect(state.availableOpenInApps).toEqual(['finder']);
expect(state.dangerousBypassAuth).toBe(true);
expect(state.backend).toBe('v2');
});
it('keeps the previous meta when /meta fails', async () => {
apiMock.getMeta.mockRejectedValue(new Error('connection refused'));
const state = createState();
state.backend = 'v2';
const ws = useWorkspaceState(state, createDeps());
await ws.refreshServerMeta();
expect(state.backend).toBe('v2');
expect(state.serverVersion).toBe('');
});
});
// Regression coverage for wake/reconnect snapshot recovery.
describe('useWorkspaceState — snapshot prompt recovery', () => {
function promptDeps(overrides: Partial<UseWorkspaceStateDeps> = {}): UseWorkspaceStateDeps {
return {
...createDeps(),
modelProvider: { models: ref([]) } as unknown as UseWorkspaceStateDeps['modelProvider'],
...overrides,
};
}
beforeEach(() => {
apiMock.submitPrompt.mockReset();
apiMock.submitPrompt.mockResolvedValue({ promptId: 'prompt_new' });
});
it('clears a finished prompt from a terminal snapshot so the next send is immediate', async () => {
const state = createState();
const inFlight = new Set(['sess_1']);
state.sendingBySession = { sess_1: true };
const ws = useWorkspaceState(
state,
promptDeps({ inFlightPromptSessions: inFlight, activity: computed(() => 'idle') }),
);
ws.handleSessionSnapshot('sess_1', { inFlightTurn: null, status: 'idle' });
expect(inFlight.has('sess_1')).toBe(false);
expect(state.sendingBySession.sess_1).toBe(false);
expect(state.promptIdBySession.sess_1).toBeUndefined();
await ws.sendPrompt('next');
expect(apiMock.submitPrompt).toHaveBeenCalledOnce();
expect(state.queuedBySession.sess_1).toBeUndefined();
});
it('keeps a genuinely running prompt in flight and queues the next send', async () => {
const state = createState();
const inFlight = new Set(['sess_1']);
state.sendingBySession = { sess_1: true };
const ws = useWorkspaceState(state, promptDeps({ inFlightPromptSessions: inFlight }));
ws.handleSessionSnapshot('sess_1', {
inFlightTurn: { turnId: 1, assistantText: '', thinkingText: '', runningTools: [] },
status: 'running',
});
await ws.sendPrompt('next');
expect(inFlight.has('sess_1')).toBe(true);
expect(state.sendingBySession.sess_1).toBe(true);
expect(apiMock.submitPrompt).not.toHaveBeenCalled();
expect(state.queuedBySession.sess_1).toEqual([{ text: 'next', attachments: undefined }]);
});
it('rejects a snapshot when a new local prompt started during the request', async () => {
const state = createState();
const inFlight = new Set<string>();
const ws = useWorkspaceState(state, promptDeps({ inFlightPromptSessions: inFlight }));
const atRequest = ws.localTurnStartState('sess_1');
await ws.submitPromptInternal('sess_1', 'fresh prompt');
expect(ws.isLocalTurnSnapshotCurrent('sess_1', atRequest)).toBe(false);
expect(inFlight.has('sess_1')).toBe(true);
expect(state.sendingBySession.sess_1).toBe(true);
});
it('rejects a snapshot requested while the local submit is still pending', async () => {
let resolveSubmit!: (value: { promptId: string }) => void;
apiMock.submitPrompt.mockImplementation(
() =>
new Promise<{ promptId: string }>((resolve) => {
resolveSubmit = resolve;
}),
);
const ws = useWorkspaceState(createState(), promptDeps());
const pendingSubmit = ws.submitPromptInternal('sess_1', 'fresh prompt');
const atRequest = ws.localTurnStartState('sess_1');
const retrySnapshot = vi.fn();
expect(atRequest.pending).toBe(true);
expect(ws.isLocalTurnSnapshotCurrent('sess_1', atRequest)).toBe(false);
ws.afterLocalTurnStartsSettle('sess_1', retrySnapshot);
expect(retrySnapshot).not.toHaveBeenCalled();
resolveSubmit({ promptId: 'prompt_new' });
await pendingSubmit;
expect(ws.localTurnStartState('sess_1').pending).toBe(false);
expect(retrySnapshot).toHaveBeenCalledOnce();
});
});
// Regression: a search-triggered full session-list reload must not clobber the
// live usage (context ring) with the list endpoint's all-zero placeholder.
describe('useWorkspaceState — loadAllSessions usage preservation', () => {
beforeEach(() => {
apiMock.listSessions.mockReset();
});
function liveUsage() {
return {
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalCostUsd: 0,
contextTokens: 28772,
contextLimit: 1048576,
turnCount: 3,
};
}
it('keeps the cached live usage when the reloaded row carries the placeholder', async () => {
const state = createState();
state.sessions = [{ ...createSession(), usage: liveUsage() }];
apiMock.listSessions.mockResolvedValue({
items: [{ ...createSession(), title: 'Fresh from server' }],
hasMore: false,
});
const setSessions = vi.fn();
const ws = useWorkspaceState(state, { ...createDeps(), setSessions });
await ws.loadAllSessions();
expect(setSessions).toHaveBeenCalledOnce();
const next = setSessions.mock.calls[0][0];
expect(next[0].title).toBe('Fresh from server');
expect(next[0].usage).toEqual(liveUsage());
});
it('takes the server row as-is when there is no live usage to preserve', async () => {
const state = createState();
apiMock.listSessions.mockResolvedValue({ items: [createSession()], hasMore: false });
const setSessions = vi.fn();
const ws = useWorkspaceState(state, { ...createDeps(), setSessions });
await ws.loadAllSessions();
const next = setSessions.mock.calls[0][0];
expect(next[0].usage.contextTokens).toBe(0);
});
});