From ebcff7948e0cb81b051595621385f2b59d8072e6 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 04:04:11 +0800 Subject: [PATCH 1/2] fix(web): surface session list load failures --- .changeset/fix-web-session-load-errors.md | 5 + .../composables/client/useWorkspaceState.ts | 79 ++++++-- apps/kimi-web/test/workspace-state.test.ts | 182 ++++++++++++++++++ 3 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-web-session-load-errors.md diff --git a/.changeset/fix-web-session-load-errors.md b/.changeset/fix-web-session-load-errors.md new file mode 100644 index 0000000000..9e3148f310 --- /dev/null +++ b/.changeset/fix-web-session-load-errors.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show session list loading failures without discarding sessions that are still available. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 175e6da387..08118364e0 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -588,8 +588,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } /** Fetch the first page of sessions for every known workspace concurrently. - * Returns the merged, recency-sorted list and seeds per-workspace hasMore. */ - async function loadInitialSessionsByWorkspace(): Promise { + * Returns the merged, recency-sorted list and seeds per-workspace hasMore. + * When every workspace request fails, returns undefined so the caller keeps + * the previously loaded sessions instead of committing a false empty list. */ + async function loadInitialSessionsByWorkspace(): Promise { const workspaces = rawState.workspaces; if (workspaces.length === 0) { // /workspaces may be unavailable or empty on older / partially-failing @@ -606,23 +608,66 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta rawState.sessionsFullyLoaded = true; return fallback; } - const pages = await Promise.all( - workspaces.map((w) => - loadInitialSessionsForWorkspace(w.id).catch((err) => { - console.warn('[kimi-web] initial session load failed for workspace', w.id, err); - return { - workspaceId: w.id, - page: { items: [] as AppSession[], hasMore: false }, - }; - }), - ), + const results = await Promise.allSettled( + workspaces.map((w) => loadInitialSessionsForWorkspace(w.id)), ); const loaded: AppSession[] = []; + const loadedIds = new Set(); + const successfulPages = new Map(); + const failedWorkspaceIds = new Set(); + let firstError: unknown; + for (let index = 0; index < results.length; index++) { + const result = results[index]!; + if (result.status === 'fulfilled') { + successfulPages.set(result.value.workspaceId, result.value.page); + for (const session of result.value.page.items) { + if (loadedIds.has(session.id)) continue; + loaded.push(session); + loadedIds.add(session.id); + } + continue; + } + if (failedWorkspaceIds.size === 0) firstError = result.reason; + failedWorkspaceIds.add(workspaces[index]!.id); + } + + // One failed workspace must not erase another workspace's successful page, + // nor the failed workspace's last usable rows. If every request failed, + // leave both sessions and pagination state untouched for a natural retry. + if (successfulPages.size === 0) { + pushOperationFailure('load', firstError); + return undefined; + } + const failedWorkspaceRoots = new Set( + workspaces + .filter((workspace) => failedWorkspaceIds.has(workspace.id)) + .map((workspace) => workspace.root), + ); + for (const session of rawState.sessions) { + const belongsToFailedWorkspace = + session.workspaceId !== undefined + ? failedWorkspaceIds.has(session.workspaceId) + : failedWorkspaceRoots.has(session.cwd) || + failedWorkspaceIds.has(workspaceIdForSession(session)); + if (!belongsToFailedWorkspace || loadedIds.has(session.id)) continue; + loaded.push(session); + loadedIds.add(session.id); + } + const hasMore: Record = {}; const cursors: Record = {}; const counts: Record = {}; - for (const { workspaceId, page } of pages) { - loaded.push(...page.items); + for (const { id: workspaceId } of workspaces) { + const page = successfulPages.get(workspaceId); + if (page === undefined) { + const previousHasMore = rawState.sessionsHasMoreByWorkspace[workspaceId]; + const previousCursor = rawState.sessionsCursorByWorkspace[workspaceId]; + const previousCount = rawState.sessionsInitialCountByWorkspace[workspaceId]; + if (previousHasMore !== undefined) hasMore[workspaceId] = previousHasMore; + if (previousCursor !== undefined) cursors[workspaceId] = previousCursor; + if (previousCount !== undefined) counts[workspaceId] = previousCount; + continue; + } // Trust the server's hasMore — the per-workspace session_count is only a // (possibly stale) label total, not an authority on whether more pages exist. hasMore[workspaceId] = page.hasMore; @@ -646,6 +691,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // Keep rawState.sessions newest-first for readers that pick sessions[0] // (e.g. auto-selecting the most recent session on first load). loaded.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + if (failedWorkspaceIds.size > 0) pushOperationFailure('load', firstError); return loaded; } @@ -764,8 +810,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // the old full global walk: the sidebar now truncates by loading, not by // hiding already-fetched rows. await loadWorkspaces(); - const sessions = await loadInitialSessionsByWorkspace(); - setSessionsPreservingLiveUsage(sessions); + const loadedSessions = await loadInitialSessionsByWorkspace(); + const sessions = loadedSessions ?? rawState.sessions; + if (loadedSessions !== undefined) setSessionsPreservingLiveUsage(loadedSessions); // First load: pick the workspace of the most-recent session, unless the // user already has a persisted active workspace that still exists. diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index ef96f5967f..1dd07a1553 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1,3 +1,8 @@ +// Scenario: workspace/session actions exposed by useWorkspaceState. +// Responsibilities: observable state and error reporting across load, paging, and user actions. +// Wiring: the composable is real; daemon requests and unrelated facade collaborators are stubbed. +// Run: pnpm --filter @moonshot-ai/kimi-web exec vitest run test/workspace-state.test.ts + 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'; @@ -89,6 +94,11 @@ function createState(): ExtendedState { managedProviderStatus: null, workspaces: [], activeWorkspaceId: null, + sessionsHasMoreByWorkspace: {}, + sessionsLoadingMoreByWorkspace: {}, + sessionsCursorByWorkspace: {}, + sessionsInitialCountByWorkspace: {}, + sessionsFullyLoaded: false, fsHome: null, recentRoots: [], hiddenWorkspaceRoots: [], @@ -1211,6 +1221,178 @@ describe('useWorkspaceState — first-load auth gate', () => { ); }); +describe('useWorkspaceState — session list loading', () => { + beforeEach(() => { + apiMock.getAuth.mockReset().mockResolvedValue({ + ready: true, + defaultModel: 'kimi-code', + managedProvider: null, + }); + 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(); + }); + + function createSessionLoadRig(sessions: AppSession[]) { + const state = createState(); + state.sessions = sessions; + state.activeSessionId = sessions[0]?.id ?? null; + const deps = { + ...createDeps(), + modelProvider: { loadModels: vi.fn().mockResolvedValue(undefined) }, + initialized: ref(false), + connectIssue: ref(null), + setSessions: vi.fn((next: AppSession[]) => { + state.sessions = next; + }), + workspaceIdForSession: vi.fn( + (session: { workspaceId?: string; cwd: string }) => + state.workspaces.find((item) => item.root === session.cwd)?.id ?? + session.workspaceId ?? + session.cwd, + ), + } as unknown as UseWorkspaceStateDeps; + return { state, deps, workspaceState: useWorkspaceState(state, deps) }; + } + + it('reports one load failure when the no-workspace session fallback rejects', async () => { + const error = new Error('session index unavailable'); + apiMock.listSessions.mockRejectedValue(error); + const { deps, workspaceState } = createSessionLoadRig([]); + + await workspaceState.load(); + + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); + }); + + it('keeps failed workspace sessions while replacing a successful shared-root workspace', async () => { + const error = new Error('legacy workspace unavailable'); + const cached = { + ...createSession(), + id: 'sess_cached', + title: 'Cached legacy', + workspaceId: 'wd_legacy', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + const fresh = { + ...createSession(), + id: 'sess_fresh', + title: 'Fresh current', + workspaceId: 'wd_current', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + const staleCurrent = { + ...createSession(), + id: 'sess_stale', + title: 'Stale current', + workspaceId: 'wd_current', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + apiMock.listWorkspaces.mockResolvedValue([ + workspace('wd_current', '/workspace', 'Workspace'), + workspace('wd_legacy', '/workspace', 'Workspace'), + ]); + apiMock.listSessions.mockImplementation( + async ({ workspaceId }: { workspaceId?: string }) => { + if (workspaceId === 'wd_current') return { items: [fresh], hasMore: false }; + throw error; + }, + ); + const { state, deps, workspaceState } = createSessionLoadRig([cached, staleCurrent]); + + await workspaceState.load(); + + expect(state.sessions.map((session) => session.id)).toEqual(['sess_fresh', 'sess_cached']); + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); + }); + + it('preserves cached sessions when every workspace initial page rejects', async () => { + const firstError = new Error('workspace A unavailable'); + const cachedA = { + ...createSession(), + id: 'sess_a', + cwd: '/workspace-a', + workspaceId: 'wd_a', + }; + const cachedB = { + ...createSession(), + id: 'sess_b', + cwd: '/workspace-b', + workspaceId: 'wd_b', + }; + apiMock.listWorkspaces.mockResolvedValue([ + workspace('wd_a', '/workspace-a', 'A'), + workspace('wd_b', '/workspace-b', 'B'), + ]); + apiMock.listSessions.mockImplementation( + async ({ workspaceId }: { workspaceId?: string }) => { + if (workspaceId === 'wd_a') throw firstError; + throw new Error('workspace B unavailable'); + }, + ); + const { state, deps, workspaceState } = createSessionLoadRig([cachedA, cachedB]); + + await workspaceState.load(); + + expect(state.sessions.map((session) => session.id)).toEqual(['sess_a', 'sess_b']); + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', firstError); + }); + + it('loads workspace sessions when a retry follows an initial failure', async () => { + const cached = { + ...createSession(), + title: 'Cached', + workspaceId: 'wd_1', + }; + const recovered = { ...cached, title: 'Recovered' }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_1', '/workspace', 'Workspace')]); + apiMock.listSessions + .mockRejectedValueOnce(new Error('session index unavailable')) + .mockResolvedValue({ items: [recovered], hasMore: false }); + const { state, workspaceState } = createSessionLoadRig([cached]); + + await workspaceState.load(); + await workspaceState.load(); + + expect(state.sessions.map((session) => session.title)).toEqual(['Recovered']); + }); + + it('loads the next workspace page when a retry follows a rejection', async () => { + const loaded = { ...createSession(), workspaceId: 'wd_1' }; + const older = { + ...createSession(), + id: 'sess_older', + workspaceId: 'wd_1', + updatedAt: '2025-12-31T00:00:00.000Z', + }; + const { state, deps, workspaceState } = createSessionLoadRig([loaded]); + state.workspaces = [workspace('wd_1', '/workspace', 'Workspace')]; + state.sessionsHasMoreByWorkspace = { wd_1: true }; + state.sessionsCursorByWorkspace = { wd_1: 'sess_1' }; + state.sessionsLoadingMoreByWorkspace = { wd_1: false }; + apiMock.listSessions + .mockRejectedValueOnce(new Error('next page unavailable')) + .mockResolvedValue({ items: [older], hasMore: false }); + + await workspaceState.loadMoreSessions('wd_1'); + await workspaceState.loadMoreSessions('wd_1'); + + expect(state.sessions.map((session) => session.id)).toEqual(['sess_1', 'sess_older']); + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + }); +}); + // /meta re-read on every WS (re)connect — keeps version / backend truthful // across backend restarts and dev-proxy backend switches. describe('useWorkspaceState — refreshServerMeta', () => { From edfdc2b9d28470be864ec63f23586de09d226952 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 04:25:57 +0800 Subject: [PATCH 2/2] fix(web): preserve partial session pages --- .../composables/client/useWorkspaceState.ts | 88 +++++++++++---- apps/kimi-web/test/workspace-state.test.ts | 103 ++++++++++++++++++ 2 files changed, 169 insertions(+), 22 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 08118364e0..fa9891bf22 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -490,22 +490,34 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** Drain every page of sessions, newest first. A single global walk (instead of * per-workspace) so sessions whose cwd is not a registered workspace root are - * still reachable after a refresh. */ - async function listAllSessionsGlobal(): Promise { + * still reachable after a refresh. A later-page failure returns the pages + * already fetched plus the error; only a first-page failure rejects. */ + async function listAllSessionsGlobal(): Promise<{ + sessions: AppSession[]; + error?: unknown; + }> { const api = getKimiWebApi(); const items: AppSession[] = []; let beforeId: string | undefined; + let continuationError: unknown; for (;;) { - const page = await api.listSessions({ - pageSize: SESSION_PAGE_SIZE, - beforeId, - excludeEmpty: true, - }); + let page: { items: AppSession[]; hasMore: boolean }; + try { + page = await api.listSessions({ + pageSize: SESSION_PAGE_SIZE, + beforeId, + excludeEmpty: true, + }); + } catch (error) { + if (items.length === 0) throw error; + continuationError = error; + break; + } items.push(...page.items); if (!page.hasMore || page.items.length === 0) break; beforeId = page.items[page.items.length - 1]!.id; } - return items; + return { sessions: items, error: continuationError }; } /** @@ -528,6 +540,20 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta ); } + /** Keep fresh rows authoritative while retaining cached rows a partial list + * request never reached. */ + function mergePartialSessionsWithCached(sessions: AppSession[]): AppSession[] { + const merged = [...sessions]; + const loadedIds = new Set(merged.map((session) => session.id)); + for (const session of rawState.sessions) { + if (loadedIds.has(session.id)) continue; + merged.push(session); + loadedIds.add(session.id); + } + merged.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + return merged; + } + /** Load the initial page of sessions for one workspace, then keep fetching * older pages while the oldest loaded session is still within * SESSIONS_RECENT_WINDOW_MS. Every page (including continuations) uses the @@ -536,7 +562,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta * keeping only up to the first session that falls outside the window. */ async function loadInitialSessionsForWorkspace( workspaceId: string, - ): Promise<{ workspaceId: string; page: { items: AppSession[]; hasMore: boolean } }> { + ): Promise<{ + workspaceId: string; + page: { items: AppSession[]; hasMore: boolean }; + error?: unknown; + }> { const api = getKimiWebApi(); const items: AppSession[] = []; const now = Date.now(); @@ -544,6 +574,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta let beforeId: string | undefined; let hasMore = false; let isFirstPage = true; + let continuationError: unknown; for (;;) { let page: { items: AppSession[]; hasMore: boolean }; try { @@ -555,9 +586,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta }); } catch (error) { // A failed continuation page must not discard sessions already loaded - // from earlier pages; only a page-1 failure propagates (the caller then - // falls back to an empty page for that workspace). + // from earlier pages; only a page-1 failure rejects the workspace load. if (isFirstPage) throw error; + continuationError = error; + hasMore = true; break; } hasMore = page.hasMore; @@ -584,7 +616,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (!page.hasMore || oldestBeyondWindow) break; beforeId = oldest.id; } - return { workspaceId, page: { items, hasMore } }; + return { workspaceId, page: { items, hasMore }, error: continuationError }; } /** Fetch the first page of sessions for every known workspace concurrently. @@ -598,15 +630,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // daemons while /sessions still works. Fall back to the legacy global // walk so history still shows and mergedWorkspaces can derive workspaces // from session cwds, instead of rendering a blank sidebar. - const fallback = await listAllSessionsGlobal().catch((err) => { - console.warn('[kimi-web] global session fallback load failed', err); - return [] as AppSession[]; - }); + const fallback = await listAllSessionsGlobal(); + const sessions = + fallback.error === undefined + ? fallback.sessions + : mergePartialSessionsWithCached(fallback.sessions); rawState.sessionsHasMoreByWorkspace = {}; rawState.sessionsCursorByWorkspace = {}; rawState.sessionsInitialCountByWorkspace = {}; - rawState.sessionsFullyLoaded = true; - return fallback; + rawState.sessionsFullyLoaded = fallback.error === undefined; + if (fallback.error !== undefined) pushOperationFailure('load', fallback.error); + return sessions; } const results = await Promise.allSettled( workspaces.map((w) => loadInitialSessionsForWorkspace(w.id)), @@ -620,6 +654,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = results[index]!; if (result.status === 'fulfilled') { successfulPages.set(result.value.workspaceId, result.value.page); + if (result.value.error !== undefined) { + if (failedWorkspaceIds.size === 0) firstError = result.value.error; + failedWorkspaceIds.add(result.value.workspaceId); + } for (const session of result.value.page.items) { if (loadedIds.has(session.id)) continue; loaded.push(session); @@ -643,9 +681,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta .filter((workspace) => failedWorkspaceIds.has(workspace.id)) .map((workspace) => workspace.root), ); + const registeredWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id)); for (const session of rawState.sessions) { const belongsToFailedWorkspace = - session.workspaceId !== undefined + session.workspaceId !== undefined && registeredWorkspaceIds.has(session.workspaceId) ? failedWorkspaceIds.has(session.workspaceId) : failedWorkspaceRoots.has(session.cwd) || failedWorkspaceIds.has(workspaceIdForSession(session)); @@ -745,13 +784,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta * first search; a no-op once the full list is loaded. */ async function loadAllSessions(): Promise { if (rawState.sessionsFullyLoaded) return; - const sessions = await listAllSessionsGlobal().catch((err) => { + const result = await listAllSessionsGlobal().catch((err) => { console.warn('[kimi-web] loadAllSessions failed; search covers only loaded sessions', err); return null; }); - if (sessions === null) return; + if (result === null) return; + const sessions = + result.error === undefined + ? result.sessions + : mergePartialSessionsWithCached(result.sessions); setSessionsPreservingLiveUsage(sessions); - rawState.sessionsFullyLoaded = true; + rawState.sessionsFullyLoaded = result.error === undefined; + if (result.error !== undefined) return; const cleared: Record = {}; for (const w of rawState.workspaces) cleared[w.id] = false; rawState.sessionsHasMoreByWorkspace = cleared; diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 1dd07a1553..bc882d0bac 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1316,6 +1316,109 @@ describe('useWorkspaceState — session list loading', () => { expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); }); + it('keeps root-matched sessions when their stored workspace id is no longer registered', async () => { + const error = new Error('current workspace unavailable'); + const cached = { + ...createSession(), + id: 'sess_cached', + title: 'Cached old workspace id', + workspaceId: 'wd_removed', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + const fresh = { + ...createSession(), + id: 'sess_fresh', + title: 'Fresh other workspace', + cwd: '/other-workspace', + workspaceId: 'wd_other', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + apiMock.listWorkspaces.mockResolvedValue([ + workspace('wd_current', '/workspace', 'Workspace'), + workspace('wd_other', '/other-workspace', 'Other'), + ]); + apiMock.listSessions.mockImplementation( + async ({ workspaceId }: { workspaceId?: string }) => { + if (workspaceId === 'wd_current') throw error; + return { items: [fresh], hasMore: false }; + }, + ); + const { state, deps, workspaceState } = createSessionLoadRig([cached]); + + await workspaceState.load(); + + expect(state.sessions.map((session) => session.id)).toEqual(['sess_fresh', 'sess_cached']); + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); + }); + + it('loads the next page when a retry follows an automatic continuation failure', async () => { + const error = new Error('automatic continuation unavailable'); + const cached = { + ...createSession(), + title: 'Cached first page', + workspaceId: 'wd_1', + updatedAt: '2099-01-01T00:00:00.000Z', + }; + const fresh = { ...cached, title: 'Fresh first page' }; + const older = { + ...createSession(), + id: 'sess_older', + workspaceId: 'wd_1', + updatedAt: '2025-12-31T00:00:00.000Z', + }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_1', '/workspace', 'Workspace')]); + apiMock.listSessions + .mockResolvedValueOnce({ items: [fresh], hasMore: true }) + .mockRejectedValueOnce(error) + .mockResolvedValue({ items: [older], hasMore: false }); + const { state, deps, workspaceState } = createSessionLoadRig([cached]); + + await workspaceState.load(); + + expect(state.sessions.map((session) => session.title)).toEqual(['Fresh first page']); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); + + await workspaceState.loadMoreSessions('wd_1'); + + expect(state.sessions.map((session) => session.id)).toEqual(['sess_1', 'sess_older']); + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + }); + + it('recovers the global session list when a retry follows a second-page failure', async () => { + const error = new Error('global continuation unavailable'); + const cached = { ...createSession(), title: 'Cached first page' }; + const fresh = { + ...cached, + title: 'Fresh first page', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + const older = { + ...createSession(), + id: 'sess_older', + updatedAt: '2025-12-31T00:00:00.000Z', + }; + const cachedOlder = { ...older, title: 'Cached older page' }; + apiMock.listSessions + .mockResolvedValueOnce({ items: [fresh], hasMore: true }) + .mockRejectedValueOnce(error) + .mockResolvedValue({ items: [fresh, older], hasMore: false }); + const { state, deps, workspaceState } = createSessionLoadRig([cached, cachedOlder]); + + await workspaceState.load(); + + expect(state.sessions.map((session) => session.title)).toEqual([ + 'Fresh first page', + 'Cached older page', + ]); + expect(deps.pushOperationFailure).toHaveBeenCalledOnce(); + expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); + + await workspaceState.load(); + + expect(state.sessions.map((session) => session.id)).toEqual(['sess_1', 'sess_older']); + }); + it('preserves cached sessions when every workspace initial page rejects', async () => { const firstError = new Error('workspace A unavailable'); const cachedA = {