From 7355a035a941a07f7e6f98f49e95bca4ec9382f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 02:47:10 +0200 Subject: [PATCH 1/5] feat(session-ingest): paginate authorized session history --- apps/web/package.json | 1 + .../web/src/lib/session-ingest-client.test.ts | 176 ++++++++++ apps/web/src/lib/session-ingest-client.ts | 95 ++++++ .../routers/cli-sessions-v2-router.test.ts | 321 ++++++++++++++++++ .../web/src/routers/cli-sessions-v2-router.ts | 86 +++++ .../src/rpc-contract.ts | 54 +++ pnpm-lock.yaml | 3 + .../session-ingest/src/routes/api.test.ts | 186 ++++++++++ services/session-ingest/src/routes/api.ts | 120 ++++++- .../src/session-ingest-rpc.test.ts | 280 ++++++++++++++- .../session-ingest/src/session-ingest-rpc.ts | 65 ++++ 11 files changed, 1384 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 57249a59c3..ee13b1a2bd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,7 @@ "@kilocode/kiloclaw-secret-catalog": "workspace:*", "@kilocode/mcp-gateway": "workspace:*", "@kilocode/organization-entitlement": "workspace:*", + "@kilocode/session-ingest-contracts": "workspace:*", "@kilocode/worker-utils": "workspace:*", "@linear/sdk": "76.0.0", "@lottiefiles/dotlottie-react": "0.17.15", diff --git a/apps/web/src/lib/session-ingest-client.test.ts b/apps/web/src/lib/session-ingest-client.test.ts index 1ac6df2fe3..7e0fcab42f 100644 --- a/apps/web/src/lib/session-ingest-client.test.ts +++ b/apps/web/src/lib/session-ingest-client.test.ts @@ -4,6 +4,7 @@ import type { SessionSnapshot } from './session-ingest-client'; import { fetchSessionSnapshot, fetchSessionMessages, + fetchSessionMessagesPage, deleteSession, shareSession, } from './session-ingest-client'; @@ -440,3 +441,178 @@ describe('fetchSessionMessages', () => { expect(result).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// fetchSessionMessagesPage (paginated authorized history) +// --------------------------------------------------------------------------- + +describe('fetchSessionMessagesPage', () => { + beforeEach(() => { + mockFetch.mockReset(); + mockGenerateInternalServiceToken.mockReset().mockReturnValue('mock-jwt-token'); + }); + + const validSessionId = 'ses_12345678901234567890123456'; + const storedMessage = { + info: { + id: 'msg_user_01', + sessionID: validSessionId, + role: 'user', + time: { created: 1761000000100 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' }, + }, + parts: [ + { + id: 'prt_user_01', + sessionID: validSessionId, + messageID: 'msg_user_01', + type: 'text', + text: 'hello', + }, + ], + }; + + it('returns the bounded page and the opaque next cursor', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + success: true, + kiloSessionId: validSessionId, + history: { + messages: [storedMessage], + nextCursor: 'opaque-cursor', + omittedItemCount: 0, + }, + }), + }); + + const result = await fetchSessionMessagesPage(validSessionId, 'user_123', { + limit: 50, + }); + expect(result).toEqual({ + kiloSessionId: validSessionId, + history: { + messages: [storedMessage], + nextCursor: 'opaque-cursor', + omittedItemCount: 0, + }, + }); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe( + `https://ingest.test.example.com/api/session/${validSessionId}/messages?limit=50` + ); + expect(init.headers.Authorization).toBe('Bearer mock-jwt-token'); + }); + + it('forwards a continuation cursor in the query string', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + success: true, + kiloSessionId: validSessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }), + }); + + await fetchSessionMessagesPage(validSessionId, 'user_123', { + limit: 25, + before: 'opaque-cursor', + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe( + `https://ingest.test.example.com/api/session/${validSessionId}/messages?limit=25&before=opaque-cursor` + ); + }); + + it('preserves retryable_failure, too_large, and invalid_data outcomes from the worker', async () => { + for (const history of [ + { kind: 'retryable_failure', phase: 'page_parts' }, + { kind: 'too_large', maximumBytes: 8 * 1024 * 1024, phase: 'message_scan' }, + { kind: 'invalid_data' }, + ]) { + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ success: true, kiloSessionId: validSessionId, history }), + }); + const result = await fetchSessionMessagesPage(validSessionId, 'user_123', { limit: 10 }); + expect(result).toEqual({ kiloSessionId: validSessionId, history }); + } + }); + + it('returns null history for an empty page and treats missing history as null', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + success: true, + kiloSessionId: validSessionId, + history: null, + }), + }); + await expect( + fetchSessionMessagesPage(validSessionId, 'user_123', { limit: 50 }) + ).resolves.toEqual({ kiloSessionId: validSessionId, history: null }); + }); + + it('returns null when the worker reports session_not_found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve({ success: false, error: 'session_not_found' }), + }); + + await expect( + fetchSessionMessagesPage(validSessionId, 'user_123', { limit: 50 }) + ).resolves.toBeNull(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('throws and calls captureException on non-404 worker errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve('boom'), + }); + + await expect( + fetchSessionMessagesPage(validSessionId, 'user_123', { limit: 50 }) + ).rejects.toThrow(/Session ingest messages page failed/); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { source: 'session-ingest-client', endpoint: 'messagesPage' }, + }) + ); + }); + + it('encodes the session ID and uses the configured worker URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + success: true, + kiloSessionId: validSessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }), + }); + + await fetchSessionMessagesPage('ses_with spaces', 'user_123', { limit: 50 }); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe( + 'https://ingest.test.example.com/api/session/ses_with%20spaces/messages?limit=50' + ); + }); +}); diff --git a/apps/web/src/lib/session-ingest-client.ts b/apps/web/src/lib/session-ingest-client.ts index 4da96aca32..344f5658fc 100644 --- a/apps/web/src/lib/session-ingest-client.ts +++ b/apps/web/src/lib/session-ingest-client.ts @@ -5,6 +5,10 @@ import { z } from 'zod'; import { SESSION_INGEST_WORKER_URL } from '@/lib/config.server'; import { generateInternalServiceToken } from '@/lib/tokens'; import type { User } from '@kilocode/db/schema'; +import { + kiloSdkMessageHistorySchema, + type KiloSdkMessageHistory, +} from '@kilocode/session-ingest-contracts'; // --------------------------------------------------------------------------- // Zod schema (mirrors cloudflare-session-ingest SharedSessionSnapshotSchema) @@ -109,6 +113,97 @@ export async function fetchSessionMessages( return snapshot?.messages ?? null; } +// --------------------------------------------------------------------------- +// Paginated authorized session-message history +// --------------------------------------------------------------------------- + +const SessionMessagesPageResponseSchema = z.object({ + success: z.literal(true), + kiloSessionId: z.string().min(1), + history: kiloSdkMessageHistorySchema.nullable(), +}); + +export type SessionMessagesPageOptions = { + /** Bounded by the worker's shared maximum (100). Mobile default is 50. */ + limit?: number; + /** Opaque cursor returned by a previous page; requires a positive limit. */ + before?: string; +}; + +export type SessionMessagesPageResult = { + kiloSessionId: string; + history: KiloSdkMessageHistory | null; +}; + +/** + * Fetch a bounded page of persisted SDK messages for any Kilo session the + * user owns. Mirrors the access-checked RPC the worker exposes via service + * binding; returns `null` for sessions the user cannot read so the tRPC + * router can surface a stable `NOT_FOUND`. Typed failure outcomes + * (`retryable_failure`, `too_large`, `invalid_data`) are passed through + * verbatim so the caller can distinguish retryable from non-retryable + * failures without inferring retry semantics client-side. + */ +export async function fetchSessionMessagesPage( + sessionId: string, + userId: string, + options: SessionMessagesPageOptions +): Promise { + if (!SESSION_INGEST_WORKER_URL) { + throw new Error('SESSION_INGEST_WORKER_URL is not configured'); + } + + const params = new URLSearchParams(); + if (options.limit !== undefined) { + params.set('limit', String(options.limit)); + } + if (options.before !== undefined) { + params.set('before', options.before); + } + + const query = params.toString(); + const url = `${SESSION_INGEST_WORKER_URL}/api/session/${encodeURIComponent(sessionId)}/messages${ + query ? `?${query}` : '' + }`; + + const token = generateInternalServiceToken(userId); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + const error = new Error( + `Session ingest messages page failed: ${response.status} ${response.statusText}${ + errorText ? ` - ${errorText}` : '' + }` + ); + captureException(error, { + tags: { source: 'session-ingest-client', endpoint: 'messagesPage' }, + extra: { sessionId, status: response.status }, + }); + throw error; + } + + const parsed = SessionMessagesPageResponseSchema.safeParse(await response.json()); + if (!parsed.success) { + const error = new Error( + `Session ingest messages page returned an unexpected response: ${parsed.error.message}` + ); + captureException(error, { + tags: { source: 'session-ingest-client', endpoint: 'messagesPage' }, + extra: { sessionId, issues: parsed.error.issues }, + }); + throw error; + } + + return { kiloSessionId: parsed.data.kiloSessionId, history: parsed.data.history }; +} + // --------------------------------------------------------------------------- // Share // --------------------------------------------------------------------------- diff --git a/apps/web/src/routers/cli-sessions-v2-router.test.ts b/apps/web/src/routers/cli-sessions-v2-router.test.ts index 01c8ef2cbf..2d4a0d6b68 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.test.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.test.ts @@ -13,7 +13,9 @@ import { import { eq, and } from 'drizzle-orm'; import type { User, Organization } from '@kilocode/db/schema'; import * as githubAdapter from '@/lib/integrations/platforms/github/adapter'; +import { TRPCError } from '@trpc/server'; import { parseGitHubOwnerRepo } from '@/routers/cli-sessions-v2-router'; +import type { fetchSessionMessagesPage as FetchSessionMessagesPageType } from '@/lib/session-ingest-client'; jest.mock('@/lib/config.server', () => { const actual: Record = jest.requireActual('@/lib/config.server'); @@ -36,6 +38,17 @@ jest.mock('@/lib/integrations/platforms/github/adapter', () => { }; }); +// Same trick for the paginated session-message client. The web router only +// calls `fetchSessionMessagesPage`; the rest of the module keeps its real +// implementation via `requireActual`. +jest.mock('@/lib/session-ingest-client', () => { + const actual: Record = jest.requireActual('@/lib/session-ingest-client'); + return { + ...actual, + fetchSessionMessagesPage: jest.fn(), + }; +}); + const mockedFetchPullRequestForBranch = githubAdapter.fetchPullRequestForBranch as jest.MockedFunction< typeof githubAdapter.fetchPullRequestForBranch @@ -137,6 +150,314 @@ describe('cli-sessions-v2-router', () => { }); }); + describe('getSessionMessagesPage', () => { + const sessionId = 'ses_messages_page_test_1234'; + let fetchSessionMessagesPage: jest.MockedFunction; + + beforeEach(async () => { + const { fetchSessionMessagesPage: imported } = jest.requireMock( + '@/lib/session-ingest-client' + ) as { + fetchSessionMessagesPage: jest.MockedFunction; + }; + fetchSessionMessagesPage = imported; + fetchSessionMessagesPage.mockReset(); + await db.insert(cli_sessions_v2).values({ + session_id: sessionId, + kilo_user_id: regularUser.id, + created_on_platform: 'cloud-agent', + }); + }); + + afterEach(async () => { + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, sessionId)); + }); + + it('returns the bounded page and the opaque next cursor for an owned session', async () => { + const history = { + messages: [ + { + info: { + id: 'msg_user_01', + sessionID: sessionId, + role: 'user' as const, + time: { created: 1761000000100 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' }, + }, + parts: [ + { + id: 'prt_user_01', + sessionID: sessionId, + messageID: 'msg_user_01', + type: 'text' as const, + text: 'hello', + }, + ], + }, + ], + nextCursor: 'opaque-cursor', + omittedItemCount: 0, + }; + fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 50, + }); + + expect(result).toEqual({ kiloSessionId: sessionId, history }); + expect(fetchSessionMessagesPage).toHaveBeenCalledWith(sessionId, regularUser.id, { + limit: 50, + }); + }); + + it('forwards the continuation cursor to the client', async () => { + fetchSessionMessagesPage.mockResolvedValueOnce({ + kiloSessionId: sessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }); + + const caller = await createCallerForUser(regularUser.id); + const validCursor = btoa(JSON.stringify({ id: 'msg_user_01', time: 1761000000100 })).replace( + /=+$/, + '' + ); + await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 25, + cursor: validCursor, + }); + + expect(fetchSessionMessagesPage).toHaveBeenCalledWith(sessionId, regularUser.id, { + limit: 25, + before: validCursor, + }); + }); + + it('defaults an omitted limit to 50 before calling the client (bounded request)', async () => { + fetchSessionMessagesPage.mockResolvedValueOnce({ + kiloSessionId: sessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }); + + const caller = await createCallerForUser(regularUser.id); + await caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId }); + + // The tRPC input schema must fill in the shared default before the + // client is called so the worker's bounded reader always sees a + // positive limit and never falls back to the legacy unbounded scan. + expect(fetchSessionMessagesPage).toHaveBeenCalledWith(sessionId, regularUser.id, { + limit: 50, + }); + }); + + it('defaults an omitted limit to 50 and forwards a cursor that would otherwise be cursor-only', async () => { + fetchSessionMessagesPage.mockResolvedValueOnce({ + kiloSessionId: sessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }); + + const caller = await createCallerForUser(regularUser.id); + const validCursor = btoa(JSON.stringify({ id: 'msg_user_01', time: 1761000000100 })).replace( + /=+$/, + '' + ); + await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + cursor: validCursor, + }); + + expect(fetchSessionMessagesPage).toHaveBeenCalledWith(sessionId, regularUser.id, { + limit: 50, + before: validCursor, + }); + }); + + it('preserves retryable_failure so the UI can offer Retry', async () => { + const history = { kind: 'retryable_failure' as const, phase: 'page_parts' as const }; + fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 10, + }); + + expect(result).toEqual({ kiloSessionId: sessionId, history }); + }); + + it('preserves too_large and invalid_data as non-retryable outcomes', async () => { + for (const history of [ + { + kind: 'too_large' as const, + maximumBytes: 8 * 1024 * 1024, + phase: 'message_scan' as const, + }, + { kind: 'invalid_data' as const }, + ]) { + fetchSessionMessagesPage.mockReset(); + fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 10, + }); + expect(result).toEqual({ kiloSessionId: sessionId, history }); + } + }); + + it('returns an empty page for a valid session without persisted messages', async () => { + fetchSessionMessagesPage.mockResolvedValueOnce({ + kiloSessionId: sessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 50, + }); + + expect(result).toEqual({ + kiloSessionId: sessionId, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }); + }); + + it('rejects a session owned by another user before calling the client', async () => { + const caller = await createCallerForUser(otherUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 50 }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + + it('rejects a session the user no longer has organization access to', async () => { + const orgSessionId = 'ses_messages_page_org_test_12345'; + await db.insert(organization_memberships).values({ + organization_id: testOrganization.id, + kilo_user_id: regularUser.id, + role: 'member', + }); + await db.insert(cli_sessions_v2).values({ + session_id: orgSessionId, + kilo_user_id: regularUser.id, + organization_id: testOrganization.id, + created_on_platform: 'cloud-agent', + }); + + try { + // Simulate losing membership without re-creating it in afterEach. + await db + .delete(organization_memberships) + .where( + and( + eq(organization_memberships.organization_id, testOrganization.id), + eq(organization_memberships.kilo_user_id, regularUser.id) + ) + ); + + const caller = await createCallerForUser(regularUser.id); + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: orgSessionId, limit: 50 }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + } finally { + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, orgSessionId)); + } + }); + + it('rejects a positive limit above the shared maximum before calling the client', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 101 }) + ).rejects.toThrow(); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + + it('rejects limit=0 (the generic endpoint is always bounded) before calling the client', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 0 }) + ).rejects.toThrow(); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + + it('rejects limit=0 even when a cursor is supplied', async () => { + const caller = await createCallerForUser(regularUser.id); + const validCursor = btoa(JSON.stringify({ id: 'msg_user_01', time: 1761000000100 })).replace( + /=+$/, + '' + ); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 0, + cursor: validCursor, + }) + ).rejects.toThrow(); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + + it('maps a client throw to a stable INTERNAL_SERVER_ERROR (no client retry inference)', async () => { + const clientError = new Error( + 'Session ingest messages page failed: 500 Internal Server Error' + ); + fetchSessionMessagesPage.mockRejectedValueOnce(clientError); + + const caller = await createCallerForUser(regularUser.id); + const rejection = await caller.cliSessionsV2 + .getSessionMessagesPage({ session_id: sessionId, limit: 50 }) + .catch(err => err); + // The router must surface a stable, tRPC-shaped error so the mobile + // client can map it without inferring retry semantics from the worker + // message text. Assert a TRPCError with a stable message so we don't + // depend on Sentry's tRPC middleware's default fallback. + expect(rejection).toBeInstanceOf(TRPCError); + expect(rejection).toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch session messages page', + }); + }); + + it('rejects a continuation cursor without a positive limit', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, cursor: 'bad' }) + ).rejects.toThrow(); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + + it('rejects a malformed continuation cursor before calling the client', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 10, + cursor: 'not-a-real-cursor', + }) + ).rejects.toThrow(); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + + it('rejects an invalid session id before calling the client', async () => { + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: 'not-a-session', limit: 50 }) + ).rejects.toThrow(); + expect(fetchSessionMessagesPage).not.toHaveBeenCalled(); + }); + }); + describe('shareForWebhookTrigger', () => { let triggerId: string; let profileId: string; diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index b25e870ec5..f53c338306 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -28,10 +28,16 @@ import { createCloudAgentNextClient } from '@/lib/cloud-agent-next/cloud-agent-c import { generateApiToken, generateInternalServiceToken } from '@/lib/tokens'; import { fetchSessionSnapshot, + fetchSessionMessagesPage, deleteSession as deleteSessionIngest, shareSession as shareSessionIngest, } from '@/lib/session-ingest-client'; import { SESSION_INGEST_WORKER_URL } from '@/lib/config.server'; +import { + DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, + MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, + validateKiloSdkMessagesCursor, +} from '@kilocode/session-ingest-contracts'; import { baseGetSessionNextOutputSchema } from './cloud-agent-next-schemas'; import { KNOWN_PLATFORMS } from '@/routers/cli-sessions-router'; import { verifyWebhookTriggerAccess } from '@/lib/webhook-trigger-ownership'; @@ -270,6 +276,35 @@ function projectAssociatedPr( const sessionIdField = z.string().min(1); const cloudAgentSessionIdField = z.string().min(1).max(255); +/** + * Input for the paginated `getSessionMessagesPage` endpoint. Mirrors the + * worker-bound contract in `@kilocode/session-ingest-contracts` so tRPC-level + * input validation matches the worker's access-checked RPC and HTTP route. + * The `limit` default is applied here so the procedure body always sees a + * positive page size and the client receives a bounded request. + */ +const GetSessionMessagesPageInputSchema = z + .object({ + session_id: sessionIdField, + limit: z + .number() + .int() + .positive() + .max(MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE) + .default(DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE), + cursor: z.string().min(1).optional(), + }) + .superRefine((params, ctx) => { + if (params.cursor === undefined) return; + if (!validateKiloSdkMessagesCursor(params.cursor)) { + ctx.addIssue({ + code: 'custom', + path: ['cursor'], + message: 'cursor is not a valid message cursor', + }); + } + }); + /** * Verify the user owns the session and still has access to its organization. */ @@ -712,6 +747,57 @@ export const cliSessionsV2Router = createTRPCRouter({ } }), + /** + * Paginated access-checked session-message history for any V2 Kilo session + * the user owns. Mobile uses the default page size of 50; callers may pass a + * `cursor` returned by a previous page to walk older history one bounded + * page at a time. Typed failure outcomes (`retryable_failure`, `too_large`, + * `invalid_data`) are returned verbatim so the UI can distinguish + * retryable from non-retryable failures without inferring retry semantics. + */ + getSessionMessagesPage: baseProcedure + .input(GetSessionMessagesPageInputSchema) + .query(async ({ ctx, input }) => { + await getSessionWithAccessCheck(input.session_id, ctx); + + let result; + try { + result = await fetchSessionMessagesPage(input.session_id, ctx.user.id, { + limit: input.limit, + ...(input.cursor !== undefined ? { before: input.cursor } : {}), + }); + } catch (error) { + // Match the existing `getSessionMessages` error contract: surface a + // stable INTERNAL_SERVER_ERROR so the mobile client can map the + // outcome without inferring retry semantics from the worker's + // text. The client already calls `captureException`; we do not + // double-capture here. + console.error( + `Failed to fetch session messages page for session ${input.session_id}:`, + error instanceof Error ? error.message : error + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch session messages page', + cause: error, + }); + } + + // The worker returns `null` only for sessions the user cannot read; the + // router's own access check above already enforces this, so the only + // remaining `null` is an unexpected worker state. Surface it as a + // NOT_FOUND rather than letting the tRPC caller see `null` in the middle + // of a valid access-checked path. + if (result === null) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Session not found', + }); + } + + return result; + }), + /** * Get a session by session_id with runtime state from the Durable Object. * diff --git a/packages/session-ingest-contracts/src/rpc-contract.ts b/packages/session-ingest-contracts/src/rpc-contract.ts index 95bb454f06..e0206f1830 100644 --- a/packages/session-ingest-contracts/src/rpc-contract.ts +++ b/packages/session-ingest-contracts/src/rpc-contract.ts @@ -130,6 +130,15 @@ export type GetCloudAgentRootSessionSnapshotResult = CloudAgentRootSessionSnapsh export const MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE = 100; +/** + * Default page size for the generic `getSessionMessages` endpoint. Mobile + * clients walk history one bounded page at a time without specifying a limit + * most of the time; this default is applied at every layer (contract, HTTP, + * tRPC) BEFORE the request reaches the DO so the bounded reader never falls + * back to the legacy unbounded scan. + */ +export const DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE = 50; + export const listCloudAgentRootSessionsSchema = z.object({ kiloUserId: z.string().min(1), limit: z.number().int().min(1).max(100).optional().default(100), @@ -634,6 +643,50 @@ export type CloudAgentRootSessionMessages = { }; export type GetCloudAgentRootSessionMessagesResult = CloudAgentRootSessionMessages | null; +/** + * Generic authorized paginated history request used by `cliSessionsV2` for any + * Kilo session (root cloud-agent, child, or remote CLI) the user owns and + * still has organization access to. Reuses the existing opaque cursor and + * bounded DO reader so the new method is byte-identical to + * `getCloudAgentRootSessionMessages` aside from the access-check boundary. + * + * `limit` is bounded by `MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE` and must be + * a positive integer; the schema applies `DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE` + * (50) when omitted so the request is always bounded before reaching the DO. + * Unlike the legacy `getCloudAgentRootSessionMessagesSchema`, this generic + * endpoint is always bounded, so `limit: 0` is rejected at the schema level. + * `before` requires a positive `limit`; the default guarantees this unless + * the caller explicitly supplies a non-positive `limit`. + */ +export const getSessionMessagesSchema = z + .object({ + kiloUserId: z.string().min(1), + kiloSessionId: sessionIdSchema, + limit: z + .number() + .int() + .positive() + .max(MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE) + .default(DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE), + before: z.string().min(1).optional(), + }) + .superRefine((params, ctx) => { + if (params.before === undefined) return; + if (!validateKiloSdkMessagesCursor(params.before)) { + ctx.addIssue({ + code: 'custom', + path: ['before'], + message: 'before is not a valid message cursor', + }); + } + }); +export type GetSessionMessagesParams = z.input; +export type AuthorizedSessionMessages = { + kiloSessionId: string; + history: KiloSdkMessageHistory | null; +}; +export type GetSessionMessagesResult = AuthorizedSessionMessages | null; + export type SessionIngestRpcMethods = { createSessionForCloudAgent: (params: CreateSessionForCloudAgentParams) => Promise; deleteSessionForCloudAgent: (params: DeleteSessionForCloudAgentParams) => Promise; @@ -649,4 +702,5 @@ export type SessionIngestRpcMethods = { getCloudAgentRootSessionMessages: ( params: GetCloudAgentRootSessionMessagesParams ) => Promise; + getSessionMessages: (params: GetSessionMessagesParams) => Promise; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ab8de0f67..bf302f8402 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -670,6 +670,9 @@ importers: '@kilocode/organization-entitlement': specifier: workspace:* version: link:../../packages/organization-entitlement + '@kilocode/session-ingest-contracts': + specifier: workspace:* + version: link:../../packages/session-ingest-contracts '@kilocode/worker-utils': specifier: workspace:* version: link:../../packages/worker-utils diff --git a/services/session-ingest/src/routes/api.test.ts b/services/session-ingest/src/routes/api.test.ts index 4aa4480fe2..e52f8983cb 100644 --- a/services/session-ingest/src/routes/api.test.ts +++ b/services/session-ingest/src/routes/api.test.ts @@ -121,6 +121,7 @@ function makeDbFakes() { const selectResult = vi.fn<() => Promise>(async () => []); const select = { from: vi.fn(() => select), + leftJoin: vi.fn(() => select), where: vi.fn((_condition: unknown) => select), limit: vi.fn(() => select), then: vi.fn((resolve: (v: unknown) => unknown) => resolve(selectResult())), @@ -1024,6 +1025,191 @@ describe('api routes', () => { expect(ingestStub.getAllStream).toHaveBeenCalled(); }); + it('GET /session/:sessionId/messages returns 400 for invalid sessionId', async () => { + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/not-a-session/messages', { method: 'GET' }), + makeTestEnv() + ); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ success: false, error: 'Invalid sessionId' }); + }); + + it('GET /session/:sessionId/messages returns 400 for an invalid limit', async () => { + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages?limit=999', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ success: false, error: 'Invalid limit' }); + }); + + it('GET /session/:sessionId/messages returns 400 for limit=0 (the generic endpoint is always bounded)', async () => { + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages?limit=0', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ success: false, error: 'Invalid limit' }); + }); + + it('GET /session/:sessionId/messages returns 400 for limit=0 even when a cursor is supplied', async () => { + const app = makeApiApp(); + const res = await app.fetch( + new Request( + 'http://local/session/ses_12345678901234567890123456/messages?limit=0&before=eyJpZCI6Im1zZ191c2VyXzAxIiwidGltZSI6MTc2MTAwMDAwMDEwMH0', + { method: 'GET' } + ), + makeTestEnv() + ); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ success: false, error: 'Invalid limit' }); + }); + + it('GET /session/:sessionId/messages returns 400 when before is supplied without a positive limit', async () => { + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages?before=not-valid', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ success: false, error: 'Invalid paging input' }); + }); + + it('GET /session/:sessionId/messages returns 404 when the user does not own the session', async () => { + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + fns.selectResult.mockResolvedValueOnce([]); + + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages?limit=50', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ success: false, error: 'session_not_found' }); + expect(getSessionIngestDO).not.toHaveBeenCalled(); + }); + + it('GET /session/:sessionId/messages returns the bounded page for an owned session', async () => { + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + fns.selectResult.mockResolvedValueOnce([{ session_id: 'ses_12345678901234567890123456' }]); + + const sdkStoredMessage = { + info: { + id: 'msg_user_01', + sessionID: 'ses_12345678901234567890123456', + role: 'user', + time: { created: 1761000000100 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' }, + }, + parts: [ + { + id: 'prt_user_01', + sessionID: 'ses_12345678901234567890123456', + messageID: 'msg_user_01', + type: 'text', + text: 'hello', + }, + ], + }; + const readKiloSdkMessages = vi.fn(async () => ({ + messages: [sdkStoredMessage], + nextCursor: 'opaque-cursor', + omittedItemCount: 0, + })); + vi.mocked(getSessionIngestDO).mockReturnValue({ readKiloSdkMessages } as never); + + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages?limit=50', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + success: true, + kiloSessionId: 'ses_12345678901234567890123456', + history: { + messages: [sdkStoredMessage], + nextCursor: 'opaque-cursor', + omittedItemCount: 0, + }, + }); + expect(readKiloSdkMessages).toHaveBeenCalledWith({ limit: 50, before: undefined }); + }); + + it('GET /session/:sessionId/messages defaults an omitted limit to the shared page size', async () => { + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + fns.selectResult.mockResolvedValueOnce([{ session_id: 'ses_12345678901234567890123456' }]); + + const readKiloSdkMessages = vi.fn(async () => ({ + messages: [], + nextCursor: null, + omittedItemCount: 0, + })); + vi.mocked(getSessionIngestDO).mockReturnValue({ readKiloSdkMessages } as never); + + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(200); + expect(readKiloSdkMessages).toHaveBeenCalledWith({ + limit: 50, + before: undefined, + }); + }); + + it('GET /session/:sessionId/messages preserves durable retryable / too_large / invalid_data outcomes', async () => { + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + fns.selectResult.mockResolvedValue([{ session_id: 'ses_12345678901234567890123456' }]); + + const app = makeApiApp(); + + for (const history of [ + { kind: 'retryable_failure', phase: 'page_parts' }, + { kind: 'too_large', maximumBytes: 8 * 1024 * 1024, phase: 'message_scan' }, + { kind: 'invalid_data' }, + ]) { + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(async () => history), + } as never); + const res = await app.fetch( + new Request('http://local/session/ses_12345678901234567890123456/messages?limit=10', { + method: 'GET', + }), + makeTestEnv() + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + success: true, + kiloSessionId: 'ses_12345678901234567890123456', + history, + }); + } + }); + it('DELETE /session/:sessionId revokes cache, clears DO, and deletes descendants child-first', async () => { const parentSessionId = 'ses_12345678901234567890123456'; const childSessionId = 'ses_abcdefghijklmnopqrstuvwxyz'; diff --git a/services/session-ingest/src/routes/api.ts b/services/session-ingest/src/routes/api.ts index fb0472364c..2ebc8a759d 100644 --- a/services/session-ingest/src/routes/api.ts +++ b/services/session-ingest/src/routes/api.ts @@ -1,8 +1,14 @@ import { Hono, type Context } from 'hono'; import { z } from 'zod'; -import { sql, eq, and, inArray, isNull } from 'drizzle-orm'; +import { sql, eq, and, inArray, isNull, or, isNotNull } from 'drizzle-orm'; import { getWorkerDb } from '@kilocode/db/client'; -import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { cli_sessions_v2, organization_memberships } from '@kilocode/db/schema'; +import { + DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, + getSessionMessagesSchema, + MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, + persistedKiloSdkMessageHistorySchema, +} from '@kilocode/session-ingest-contracts'; import type { Env } from '../env'; import { zodJsonValidator, withDORetry } from '@kilocode/worker-utils'; @@ -296,6 +302,116 @@ api.get('/session/:sessionId/export', async c => { }); }); +/** + * Paginated session-message history for any Kilo session the user owns. + * Reuses the same `(owner, current organization membership)` check shape as + * `SessionIngestRPC.findOwnedAccessibleSession` and delegates the bounded + * read to `SessionIngestDO.readKiloSdkMessages`. Mobile uses the default + * page size of 50; the existing max of 100 is honored for callers that + * request more. + */ +api.get('/session/:sessionId/messages', async c => { + const rawSessionId = c.req.param('sessionId'); + const parsed = sessionIdSchema.safeParse(rawSessionId); + if (!parsed.success) { + return c.json({ success: false, error: 'Invalid sessionId', issues: parsed.error.issues }, 400); + } + + const limitParam = c.req.query('limit'); + const beforeParam = c.req.query('before'); + // Apply the shared default at the HTTP layer too so the value passed to + // the contract schema (and onward to the DO) is always defined. The + // contract schema's `.default(...)` is the source of truth; this is a + // symmetry shortcut that also keeps the JSON response and any logging + // explicit about the page size. + const limitParsed = + limitParam === undefined + ? { success: true as const, data: DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE } + : z.coerce + .number() + .int() + .positive() + .max(MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE) + .safeParse(limitParam); + if (!limitParsed.success) { + return c.json({ success: false, error: 'Invalid limit' }, 400); + } + const beforeParsed = + beforeParam === undefined + ? { success: true as const, data: undefined } + : z.string().min(1).max(1024).safeParse(beforeParam); + if (!beforeParsed.success) { + return c.json({ success: false, error: 'Invalid before' }, 400); + } + + const kiloUserId = c.get('user_id'); + const inputParse = getSessionMessagesSchema.safeParse({ + kiloUserId, + kiloSessionId: parsed.data, + limit: limitParsed.data, + before: beforeParsed.data, + }); + if (!inputParse.success) { + return c.json( + { success: false, error: 'Invalid paging input', issues: inputParse.error.issues }, + 400 + ); + } + + const db = getWorkerDb(c.env.HYPERDRIVE.connectionString); + // Mirrors `SessionIngestRPC.findOwnedAccessibleSession`. The join is + // duplicated here (instead of going through the RPC) because importing + // `SessionIngestRPC` from `routes/api.ts` would create a cycle + // `api.ts -> session-ingest-rpc.ts -> app.ts -> api.ts` and break the + // node-env vitest suite that loads this module. Keep the two queries + // in sync if the access shape ever changes. + const authorized = await db + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .leftJoin( + organization_memberships, + and( + eq(organization_memberships.organization_id, cli_sessions_v2.organization_id), + eq(organization_memberships.kilo_user_id, kiloUserId) + ) + ) + .where( + and( + eq(cli_sessions_v2.session_id, inputParse.data.kiloSessionId), + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + or(isNull(cli_sessions_v2.organization_id), isNotNull(organization_memberships.id)) + ) + ) + .limit(1); + if (authorized.length === 0) { + return c.json({ success: false, error: 'session_not_found' }, 404); + } + + const rawHistory = await withDORetry, unknown>( + () => + getSessionIngestDO(c.env, { + kiloUserId, + sessionId: inputParse.data.kiloSessionId, + }), + stub => + stub.readKiloSdkMessages({ + limit: inputParse.data.limit, + before: inputParse.data.before, + }), + 'SessionIngestDO.readKiloSdkMessages' + ); + const history = persistedKiloSdkMessageHistorySchema.nullable().safeParse(rawHistory); + + return c.json( + { + success: true, + kiloSessionId: inputParse.data.kiloSessionId, + history: history.success ? history.data : { kind: 'invalid_data' }, + }, + 200 + ); +}); + api.post('/session/:sessionId/share', async c => { const rawSessionId = c.req.param('sessionId'); const parsed = sessionIdSchema.safeParse(rawSessionId); diff --git a/services/session-ingest/src/session-ingest-rpc.test.ts b/services/session-ingest/src/session-ingest-rpc.test.ts index 0a2c549544..97b4e4622a 100644 --- a/services/session-ingest/src/session-ingest-rpc.test.ts +++ b/services/session-ingest/src/session-ingest-rpc.test.ts @@ -43,6 +43,7 @@ import { getWorkerDb } from '@kilocode/db/client'; import { cli_sessions_v2, organization_memberships } from '@kilocode/db/schema'; import { decodeKiloSdkMessagesCursor, + DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, encodeKiloSdkMessagesCursor, MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, messageIdSchema, @@ -84,7 +85,7 @@ const sdkStoredMessageFixture = { info: sdkUserMessageFixture, parts: [sdkTextPa type MappingRow = { kiloSessionId?: string; - cloudAgentSessionId: string | null; + cloudAgentSessionId?: string | null; title?: string | null; createdAt?: string; updatedAt?: string; @@ -922,3 +923,280 @@ describe('SessionIngestRPC.listCloudAgentRootSessions', () => { expect(db.select).not.toHaveBeenCalled(); }); }); + +describe('SessionIngestRPC.getSessionMessages (authorized generic history)', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns the bounded latest page for an owned Kilo session with the default limit', async () => { + const { db } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + const readKiloSdkMessages = vi.fn(async () => ({ + messages: [sdkStoredMessageFixture], + nextCursor: 'eyJpZCI6Im1zZ191c2VyXzAxIiwidGltZSI6MTc2MTAwMDAwMDEwMH0', + omittedItemCount: 0, + })); + vi.mocked(getSessionIngestDO).mockReturnValue({ readKiloSdkMessages } as never); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ kiloUserId: 'usr_owner', kiloSessionId: sdkSessionInfoFixture.id }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { + messages: [sdkStoredMessageFixture], + nextCursor: 'eyJpZCI6Im1zZ191c2VyXzAxIiwidGltZSI6MTc2MTAwMDAwMDEwMH0', + omittedItemCount: 0, + }, + }); + expect(readKiloSdkMessages).toHaveBeenCalledWith({ + limit: DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, + before: undefined, + }); + }); + + it('defaults omitted limit to the shared page size and pairs it with a continuation cursor', async () => { + const { db } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + const readKiloSdkMessages = vi.fn(async () => ({ + messages: [sdkStoredMessageFixture], + nextCursor: null, + omittedItemCount: 0, + })); + vi.mocked(getSessionIngestDO).mockReturnValue({ readKiloSdkMessages } as never); + const rpc = makeRpc(db); + const cursor = encodeKiloSdkMessagesCursor({ id: 'msg_user_01', time: 1761000000100 }); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + before: cursor, + }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { + messages: [sdkStoredMessageFixture], + nextCursor: null, + omittedItemCount: 0, + }, + }); + expect(readKiloSdkMessages).toHaveBeenCalledWith({ + limit: DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, + before: cursor, + }); + }); + + it('forwards an explicit limit and a decoded cursor to the DO bounded reader', async () => { + const { db } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + const readKiloSdkMessages = vi.fn(async () => ({ + messages: [sdkStoredMessageFixture], + nextCursor: null, + omittedItemCount: 1, + })); + vi.mocked(getSessionIngestDO).mockReturnValue({ readKiloSdkMessages } as never); + const rpc = makeRpc(db); + const cursor = encodeKiloSdkMessagesCursor({ id: 'msg_user_01', time: 1761000000100 }); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: 50, + before: cursor, + }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { + messages: [sdkStoredMessageFixture], + nextCursor: null, + omittedItemCount: 1, + }, + }); + expect(readKiloSdkMessages).toHaveBeenCalledWith({ limit: 50, before: cursor }); + }); + + it('returns null when the session is not owned by the requesting user', async () => { + const { db } = makeDbFakes([]); + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(), + } as never); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + }) + ).resolves.toBeNull(); + expect(getSessionIngestDO).not.toHaveBeenCalled(); + }); + + it('returns null when the org-scoped session has lost its organization membership', async () => { + const { db } = makeDbFakes([]); + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(), + } as never); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + }) + ).resolves.toBeNull(); + expect(getSessionIngestDO).not.toHaveBeenCalled(); + }); + + it('returns an empty page for a valid session with no persisted messages', async () => { + const { db } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(async () => ({ + messages: [], + nextCursor: null, + omittedItemCount: 0, + })), + } as never); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { messages: [], nextCursor: null, omittedItemCount: 0 }, + }); + }); + + it('preserves the durable retryable_failure outcome for bounded requests', async () => { + const { db } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(async () => ({ + kind: 'retryable_failure', + phase: 'message_scan', + })), + } as never); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: 10, + }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { kind: 'retryable_failure', phase: 'message_scan' }, + }); + }); + + it('preserves the durable too_large and invalid_data outcomes for bounded requests', async () => { + const { db: dbTooLarge } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(async () => ({ + kind: 'too_large', + maximumBytes: 8 * 1024 * 1024, + phase: 'page_parts', + })), + } as never); + const rpc = makeRpc(dbTooLarge); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: 10, + }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { + kind: 'too_large', + maximumBytes: 8 * 1024 * 1024, + phase: 'page_parts', + }, + }); + + const { db: dbInvalid } = makeDbFakes([{ kiloSessionId: sdkSessionInfoFixture.id }]); + vi.mocked(getSessionIngestDO).mockReturnValue({ + readKiloSdkMessages: vi.fn(async () => ({ kind: 'invalid_data' })), + } as never); + const invalidRpc = makeRpc(dbInvalid); + + await expect( + invalidRpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + }) + ).resolves.toEqual({ + kiloSessionId: sdkSessionInfoFixture.id, + history: { kind: 'invalid_data' }, + }); + }); + + it('rejects invalid Kilo session IDs, missing limits with cursors, and unknown cursors', async () => { + const { db } = makeDbFakes([]); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ kiloUserId: 'usr_owner', kiloSessionId: 'not-a-session' }) + ).rejects.toThrow(); + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + before: 'not-valid', + }) + ).rejects.toThrow(); + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: 0, + before: 'not-valid', + }) + ).rejects.toThrow(); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('rejects positive limits above the shared maximum before authorizing the request', async () => { + const { db } = makeDbFakes([]); + const rpc = makeRpc(db); + + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE + 1, + }) + ).rejects.toThrow(); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('rejects limit=0 (with or without a cursor) before authorizing the request', async () => { + const { db } = makeDbFakes([]); + const rpc = makeRpc(db); + + // limit=0 alone — the generic endpoint must always be bounded. + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: 0, + }) + ).rejects.toThrow(); + expect(db.select).not.toHaveBeenCalled(); + + // limit=0 with a cursor — same rejection, no DB or DO access. + await expect( + rpc.getSessionMessages({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + limit: 0, + before: 'eyJpZCI6Im1zZ191c2VyXzAxIiwidGltZSI6MTc2MTAwMDAwMDEwMH0', + }) + ).rejects.toThrow(); + expect(db.select).not.toHaveBeenCalled(); + expect(getSessionIngestDO).not.toHaveBeenCalled(); + }); +}); diff --git a/services/session-ingest/src/session-ingest-rpc.ts b/services/session-ingest/src/session-ingest-rpc.ts index 34252535a7..54f2d6e12a 100644 --- a/services/session-ingest/src/session-ingest-rpc.ts +++ b/services/session-ingest/src/session-ingest-rpc.ts @@ -6,6 +6,7 @@ import { createSessionForCloudAgentSchema, deleteSessionForCloudAgentSchema, getCloudAgentRootSessionMessagesSchema, + getSessionMessagesSchema, kiloSdkSessionSnapshotOutcomeSchema, listCloudAgentRootSessionsSchema, persistedKiloSdkMessageHistorySchema, @@ -18,6 +19,8 @@ import { type GetCloudAgentRootSessionMessagesResult, type GetCloudAgentRootSessionSnapshotParams, type GetCloudAgentRootSessionSnapshotResult, + type GetSessionMessagesParams, + type GetSessionMessagesResult, type ListCloudAgentRootSessionsParams, type ResolveCloudAgentRootSessionForKiloSessionParams, type ResolveCloudAgentRootSessionForKiloSessionResult, @@ -192,6 +195,42 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn }; } + /** + * Generic authorized paginated session history usable by `cliSessionsV2` for + * any Kilo session the user owns (root cloud-agent, child, or remote CLI). + * Mirrors `getCloudAgentRootSessionMessages` but enforces the same + * `(owner, current organization membership)` boundary as the web router's + * `getSessionWithAccessCheck` so the DO reader is only reached for sessions + * the caller is allowed to read. + * + * Returns `null` for any access failure (missing owner row, lost org + * membership) so the caller can surface `NOT_FOUND` without leaking which + * side of the check failed. + */ + async getSessionMessages(params: GetSessionMessagesParams): Promise { + const parsed = getSessionMessagesSchema.parse(params); + const authorized = await this.findOwnedAccessibleSession(parsed); + if (!authorized) { + return null; + } + + const rawHistory = await withDORetry, unknown>( + () => + getSessionIngestDO(this.env, { + kiloUserId: parsed.kiloUserId, + sessionId: parsed.kiloSessionId, + }), + stub => stub.readKiloSdkMessages({ limit: parsed.limit, before: parsed.before }), + 'SessionIngestDO.readKiloSdkMessages' + ); + const parsedHistory = persistedKiloSdkMessageHistorySchema.nullable().safeParse(rawHistory); + + return { + kiloSessionId: parsed.kiloSessionId, + history: parsedHistory.success ? parsedHistory.data : { kind: 'invalid_data' }, + }; + } + async listCloudAgentRootSessions( params: ListCloudAgentRootSessionsParams ): Promise { @@ -284,6 +323,32 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn return cloudAgentSessionId ? { cloudAgentSessionId } : null; } + /** + * Confirms the user owns the session and still has access to its + * organization. Unlike `findOwnedRootCloudAgentMapping`, this does not + * require a Cloud Agent mapping — it accepts any Kilo session kind so + * remote CLI sessions and child sessions are also readable. + */ + private async findOwnedAccessibleSession(params: { + kiloUserId: string; + kiloSessionId: string; + }): Promise<{ kiloSessionId: string } | null> { + const db = getWorkerDb(this.env.HYPERDRIVE.connectionString); + const rows = await db + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .leftJoin(organization_memberships, organizationMembershipJoinCondition(params.kiloUserId)) + .where( + and( + eq(cli_sessions_v2.session_id, params.kiloSessionId), + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + personalOrAccessibleOrganizationCondition() + ) + ) + .limit(1); + return rows[0] ? { kiloSessionId: rows[0].sessionId } : null; + } + /** * RPC method: delete a cli_sessions_v2 record for a cloud-agent-next session. * Called via service binding from cloud-agent-next for rollback when DO prepare() fails. From 01ce2e4658babc4590bb010fa40a9b4b8f08cc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 13:16:59 +0200 Subject: [PATCH 2/5] feat(agent-sessions): paginate session message history --- .../agents/mobile-session-manager.test.ts | 1 + .../agents/mobile-session-manager.ts | 2 + .../mobile-session-page-adapter.test.ts | 218 +++++++++ .../agents/mobile-session-page-adapter.ts | 55 +++ .../cli-historical-transport.test.ts | 298 +++++++++++- .../cli-historical-transport.ts | 86 +++- .../cli-live-transport.test.ts | 316 ++++++++++++- .../lib/cloud-agent-sdk/cli-live-transport.ts | 130 +++-- .../cloud-agent-transport.test.ts | 221 +++++++++ .../cloud-agent-sdk/cloud-agent-transport.ts | 128 ++++- apps/web/src/lib/cloud-agent-sdk/index.ts | 6 + .../cloud-agent-sdk/session-manager.test.ts | 444 +++++++++++++++++- .../lib/cloud-agent-sdk/session-manager.ts | 179 +++++++ apps/web/src/lib/cloud-agent-sdk/session.ts | 22 + apps/web/src/lib/cloud-agent-sdk/types.ts | 46 ++ 15 files changed, 2088 insertions(+), 64 deletions(-) create mode 100644 apps/mobile/src/components/agents/mobile-session-page-adapter.test.ts create mode 100644 apps/mobile/src/components/agents/mobile-session-page-adapter.ts diff --git a/apps/mobile/src/components/agents/mobile-session-manager.test.ts b/apps/mobile/src/components/agents/mobile-session-manager.test.ts index f965db1444..07563c5892 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.test.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.test.ts @@ -95,6 +95,7 @@ type CapturedSessionManagerConfig = { lifecycleHooks?: unknown; fetchSession: (kiloSessionId: string) => Promise<{ associatedPr: unknown }>; fetchSnapshot: (kiloSessionId: string) => Promise<{ info: unknown; messages: unknown[] }>; + fetchSnapshotPage: (kiloSessionId: string, options: { cursor?: string }) => Promise; prepare: (input: { prompt: string; mode: string; diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 3fd13a8ee2..772c631c4c 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -17,6 +17,7 @@ import { formatSafeCloudAgentFailureDiagnostic, withCloudAgentDiagnostics, } from '@/components/agents/mobile-session-diagnostics'; +import { fetchMobileSessionSnapshotPage } from '@/components/agents/mobile-session-page-adapter'; import { trpcClient } from '@/lib/trpc'; import { API_BASE_URL, CLOUD_AGENT_WS_URL, WEB_BASE_URL } from '@/lib/config'; import { AUTH_TOKEN_KEY } from '@/lib/storage-keys'; @@ -130,6 +131,7 @@ export function createMobileAgentSessionManager({ messages: messagesResult.messages as SessionSnapshot['messages'], }; }, + fetchSnapshotPage: fetchMobileSessionSnapshotPage, api: { send: async input => { await withCloudAgentDiagnostics('send', organizationId, async () => { diff --git a/apps/mobile/src/components/agents/mobile-session-page-adapter.test.ts b/apps/mobile/src/components/agents/mobile-session-page-adapter.test.ts new file mode 100644 index 0000000000..298a310406 --- /dev/null +++ b/apps/mobile/src/components/agents/mobile-session-page-adapter.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type KiloSdkMessageHistory, + type KiloSdkMessageHistoryPage, + type KiloSdkStoredMessage, + type KiloSessionId, + type SessionSnapshotPageOutcome, +} from 'cloud-agent-sdk'; + +const mocks = vi.hoisted(() => ({ + getSessionMessagesPageQuery: vi.fn(), +})); + +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + cliSessionsV2: { + getSessionMessagesPage: { query: mocks.getSessionMessagesPageQuery }, + }, + }, +})); + +function kiloSessionId(id: string): KiloSessionId { + return id as KiloSessionId; +} + +function storedMessage( + overrides: { + id?: string; + sessionID?: string; + created?: number; + text?: string; + } = {} +): KiloSdkStoredMessage { + const id = overrides.id ?? 'msg_user_01'; + const sessionID = overrides.sessionID ?? 'ses_123'; + const created = overrides.created ?? 1_761_000_000_100; + const text = overrides.text ?? 'hello'; + + return { + info: { + id, + sessionID, + role: 'user', + time: { created }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' }, + }, + parts: [ + { + id: `${id}-text`, + sessionID, + messageID: id, + type: 'text', + text, + }, + ], + }; +} + +function historyPage( + overrides: Partial = {} +): KiloSdkMessageHistoryPage { + return { + messages: [], + nextCursor: null, + omittedItemCount: 0, + ...overrides, + }; +} + +async function importAdapter(): Promise<{ + fetchMobileSessionSnapshotPage: ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } + ) => Promise; +}> { + const adapter = await import('@/components/agents/mobile-session-page-adapter'); + return adapter; +} + +describe('fetchMobileSessionSnapshotPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps a successful shared history page to SessionSnapshotPageOutcome success', async () => { + const message = storedMessage(); + const page = historyPage({ + messages: [message], + nextCursor: 'opaque-cursor', + omittedItemCount: 2, + }); + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history: page, + }); + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + const result = await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {}); + + expect(result).toEqual({ + kind: 'success', + info: { id: 'ses_123' }, + messages: page.messages, + nextCursor: 'opaque-cursor', + omittedItemCount: 2, + }); + // No `cursor` was supplied, so the tRPC layer must see only `session_id` + // and let its input schema default the bounded page size to 50. + expect(mocks.getSessionMessagesPageQuery).toHaveBeenCalledWith({ session_id: 'ses_123' }); + }); + + it('forwards the continuation cursor to the tRPC layer as `cursor`', async () => { + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history: historyPage({ nextCursor: null }), + }); + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), { cursor: 'opaque-cursor' }); + + expect(mocks.getSessionMessagesPageQuery).toHaveBeenCalledWith({ + session_id: 'ses_123', + cursor: 'opaque-cursor', + }); + }); + + it('omits the cursor from the tRPC request when the manager has no continuation', async () => { + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history: historyPage({ nextCursor: null }), + }); + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), { cursor: '' }); + + // An empty cursor must not be forwarded; the tRPC schema would reject + // a non-positive length string anyway, and the manager only sets + // `nextCursor: null` once the history is fully read. + expect(mocks.getSessionMessagesPageQuery).toHaveBeenCalledWith({ session_id: 'ses_123' }); + }); + + it('preserves a null history result so the manager can mark it terminal', async () => { + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history: null, + }); + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + await expect(fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {})).resolves.toBeNull(); + }); + + it('passes typed retryable_failure through verbatim so the UI can offer Retry', async () => { + const history: KiloSdkMessageHistory = { + kind: 'retryable_failure', + phase: 'page_parts', + }; + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history, + }); + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + await expect(fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {})).resolves.toEqual({ + kind: 'retryable_failure', + phase: 'page_parts', + }); + }); + + it('passes typed too_large and invalid_data failures through verbatim', async () => { + const histories: KiloSdkMessageHistory[] = [ + { + kind: 'too_large', + maximumBytes: 8 * 1024 * 1024, + phase: 'message_scan', + }, + { kind: 'invalid_data' }, + ]; + + // Queue every response up-front so the mock implementation is fully + // deterministic regardless of which adapter call hits the mock first. + mocks.getSessionMessagesPageQuery.mockReset(); + for (const history of histories) { + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history, + }); + } + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + // Two independent adapter calls; the mock queue delivers the matching + // history to each. `Promise.all` runs them in parallel and the assertion + // checks the collected results in call order. + const results = await Promise.all([ + fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {}), + fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {}), + ]); + + expect(results).toEqual(histories); + }); + + it('lets the tRPC input schema default the limit to 50 on the initial read', async () => { + // The mobile adapter never sets `limit` itself so the shared contract + // default of 50 always applies. This test pins that contract: any + // future change here must keep the request bounded by default. + mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({ + kiloSessionId: 'ses_123', + history: historyPage({ nextCursor: null }), + }); + + const { fetchMobileSessionSnapshotPage } = await importAdapter(); + await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {}); + + const call = mocks.getSessionMessagesPageQuery.mock.calls[0]?.[0] as Record; + expect(call).toBeDefined(); + expect(call.limit).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/components/agents/mobile-session-page-adapter.ts b/apps/mobile/src/components/agents/mobile-session-page-adapter.ts new file mode 100644 index 0000000000..7509a8baea --- /dev/null +++ b/apps/mobile/src/components/agents/mobile-session-page-adapter.ts @@ -0,0 +1,55 @@ +import { + type KiloSdkMessageHistory, + type KiloSdkMessageHistoryPage, + type KiloSessionId, + type SessionSnapshotPage, + type SessionSnapshotPageOutcome, +} from 'cloud-agent-sdk'; +import { trpcClient } from '@/lib/trpc'; + +/** + * Type guard to narrow KiloSdkMessageHistory to the page variant. + * The shared union is discriminated by the presence of the `messages` array. + */ +function isHistoryPage(history: KiloSdkMessageHistory): history is KiloSdkMessageHistoryPage { + return 'messages' in history && Array.isArray(history.messages); +} + +/** + * Fetch a bounded page of session messages for the mobile client. + * + * Maps the shared `KiloSdkMessageHistory` union to the SDK's + * `SessionSnapshotPageOutcome`: + * - Page variant → success outcome with messages, cursor, and omitted count + * - Failure variants → passed through verbatim (retryable, too_large, invalid_data) + * - Null history → null (access not found) + * + * The adapter does not re-validate the tRPC response; it trusts the shared + * contract and uses structural narrowing to distinguish page from failure. + */ +export async function fetchMobileSessionSnapshotPage( + kiloSessionId: KiloSessionId, + options: { cursor?: string } +): Promise { + const result = await trpcClient.cliSessionsV2.getSessionMessagesPage.query({ + session_id: kiloSessionId, + ...(options.cursor ? { cursor: options.cursor } : {}), + }); + + const history = result.history as KiloSdkMessageHistory | null; + if (history === null) { + return null; + } + + if (isHistoryPage(history)) { + return { + kind: 'success', + info: { id: result.kiloSessionId }, + messages: history.messages as SessionSnapshotPage['messages'], + nextCursor: history.nextCursor, + omittedItemCount: history.omittedItemCount, + }; + } + + return history; +} diff --git a/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.test.ts b/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.test.ts index 274ff6fca9..8d016c6f6a 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.test.ts @@ -3,7 +3,12 @@ * error handling, and lifecycle generation tracking. */ import type { ChatEvent, ServiceEvent } from './normalizer'; -import type { KiloSessionId, SessionSnapshot } from './types'; +import type { + KiloSessionId, + SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, +} from './types'; import { createCliHistoricalTransport } from './cli-historical-transport'; import { kiloId, makeSnapshot, stubUserMessage, stubTextPart } from './test-helpers'; @@ -197,3 +202,294 @@ describe('CliHistoricalTransport', () => { transport.destroy(); }); }); + +// --------------------------------------------------------------------------- +// Page-seam: transport uses fetchSnapshotPage when provided so the manager can +// record the cursor and the user can load older pages later. Legacy +// `fetchSnapshot` is preserved for callers that haven't migrated. +// --------------------------------------------------------------------------- + +type FetchPage = ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } +) => Promise; + +type CreatePageTransportOptions = { + fetchSnapshotPage: FetchPage; + fetchSnapshot?: (kiloSessionId: KiloSessionId) => Promise; + onInitialPageLoaded?: (page: SessionSnapshotPage) => void; + onError?: (message: string) => void; +}; + +function createPageTransport(options: CreatePageTransportOptions) { + const chatEvents: ChatEvent[] = []; + const serviceEvents: ServiceEvent[] = []; + const fetchSnapshotPage = jest.fn(options.fetchSnapshotPage); + const onInitialPageLoaded = jest.fn(options.onInitialPageLoaded ?? (() => undefined)); + const onError = jest.fn(options.onError ?? (() => undefined)); + + const factory = createCliHistoricalTransport({ + kiloSessionId: kiloId('kilo-ses-1'), + fetchSnapshotPage, + ...(options.fetchSnapshot ? { fetchSnapshot: options.fetchSnapshot } : {}), + onInitialPageLoaded, + onError, + }); + + const transport = factory({ + onChatEvent: event => chatEvents.push(event), + onServiceEvent: event => serviceEvents.push(event), + }); + + return { transport, chatEvents, serviceEvents, fetchSnapshotPage, onInitialPageLoaded, onError }; +} + +function makeSuccessPage( + options: { + kiloSessionId?: string; + messages?: SessionSnapshot['messages']; + nextCursor?: string | null; + omittedItemCount?: number; + } = {} +): Extract { + return { + kind: 'success', + info: { id: options.kiloSessionId ?? 'kilo-ses-1' }, + messages: options.messages ?? [], + nextCursor: options.nextCursor ?? null, + omittedItemCount: options.omittedItemCount ?? 0, + }; +} + +describe('CliHistoricalTransport page-seam', () => { + it('replays a successful page and reports it via onInitialPageLoaded', async () => { + const page = makeSuccessPage({ + kiloSessionId: 'kilo-ses-1', + nextCursor: 'cursor-A', + omittedItemCount: 3, + messages: [ + { + info: stubUserMessage({ id: 'msg-1', sessionID: 'kilo-ses-1' }), + parts: [ + stubTextPart({ + id: 'part-1', + sessionID: 'kilo-ses-1', + messageID: 'msg-1', + text: 'hello', + }), + ], + }, + ], + }); + const { transport, fetchSnapshotPage, onInitialPageLoaded, chatEvents, serviceEvents } = + createPageTransport({ fetchSnapshotPage: async () => page }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fetchSnapshotPage).toHaveBeenCalledWith('kilo-ses-1', {}); + expect(onInitialPageLoaded).toHaveBeenCalledWith(page); + expect(chatEvents).toHaveLength(2); + expect(serviceEvents).toEqual([ + expect.objectContaining({ type: 'session.created', info: page.info }), + { type: 'stopped', reason: 'complete' }, + ]); + transport.destroy(); + }); + + it('replays a successful page with no cursor and zero omitted items', async () => { + const page = makeSuccessPage({ nextCursor: null, omittedItemCount: 0 }); + const { transport, onInitialPageLoaded, serviceEvents } = createPageTransport({ + fetchSnapshotPage: async () => page, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onInitialPageLoaded).toHaveBeenCalledWith(page); + expect(serviceEvents).toEqual([ + expect.objectContaining({ type: 'session.created' }), + { type: 'stopped', reason: 'complete' }, + ]); + transport.destroy(); + }); + + it('surfaces retryable_failure as a recoverable error message and stops with error', async () => { + const { transport, onError, serviceEvents, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => ({ kind: 'retryable_failure' }), + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Session history temporarily unavailable'); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(serviceEvents).toEqual([{ type: 'stopped', reason: 'error' }]); + transport.destroy(); + }); + + it('surfaces too_large as a non-retryable terminal message and stops with error', async () => { + const { transport, onError, serviceEvents, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => ({ kind: 'too_large' }), + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Session history too large to load'); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(serviceEvents).toEqual([{ type: 'stopped', reason: 'error' }]); + transport.destroy(); + }); + + it('surfaces invalid_data as a non-retryable terminal message and stops with error', async () => { + const { transport, onError, serviceEvents } = createPageTransport({ + fetchSnapshotPage: async () => ({ kind: 'invalid_data' }), + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Session history is unavailable'); + expect(serviceEvents).toEqual([{ type: 'stopped', reason: 'error' }]); + transport.destroy(); + }); + + it('treats a null page result as session not found and stops with error', async () => { + const { transport, onError, serviceEvents, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => null, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Session not found'); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(serviceEvents).toEqual([{ type: 'stopped', reason: 'error' }]); + transport.destroy(); + }); + + it('surfaces a thrown fetch error via onError and stops with error', async () => { + const { transport, onError, serviceEvents } = createPageTransport({ + fetchSnapshotPage: async () => { + throw new Error('Network failure'); + }, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Network failure'); + expect(serviceEvents).toEqual([{ type: 'stopped', reason: 'error' }]); + transport.destroy(); + }); + + it('disconnect cancels a pending initial page fetch', async () => { + let resolvePage: ((page: SessionSnapshotPageOutcome) => void) | undefined; + const { transport, chatEvents, serviceEvents, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: () => + new Promise(resolve => { + resolvePage = resolve; + }), + }); + + transport.connect(); + transport.disconnect(); + + resolvePage?.(makeSuccessPage({ nextCursor: 'cursor-A' })); + await Promise.resolve(); + await Promise.resolve(); + + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(chatEvents).toHaveLength(0); + expect(serviceEvents).toHaveLength(0); + transport.destroy(); + }); + + it('destroy cancels a pending initial page fetch', async () => { + let resolvePage: ((page: SessionSnapshotPageOutcome) => void) | undefined; + const { transport, chatEvents, serviceEvents, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: () => + new Promise(resolve => { + resolvePage = resolve; + }), + }); + + transport.connect(); + transport.destroy(); + + resolvePage?.(makeSuccessPage({ nextCursor: 'cursor-A' })); + await Promise.resolve(); + await Promise.resolve(); + + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(chatEvents).toHaveLength(0); + expect(serviceEvents).toHaveLength(0); + }); + + it('prefers fetchSnapshotPage over legacy fetchSnapshot when both are provided', async () => { + const page = makeSuccessPage({ nextCursor: 'cursor-A' }); + const fetchSnapshot = jest.fn(() => + Promise.reject(new Error('legacy fetchSnapshot must not be called')) + ); + const { transport, fetchSnapshotPage, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => page, + fetchSnapshot, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fetchSnapshotPage).toHaveBeenCalledTimes(1); + expect(fetchSnapshot).not.toHaveBeenCalled(); + expect(onInitialPageLoaded).toHaveBeenCalledWith(page); + transport.destroy(); + }); + + it('falls back to legacy fetchSnapshot when fetchSnapshotPage is omitted', async () => { + const snapshot = makeSnapshot({ id: SES_ID }, [ + { + info: stubUserMessage({ id: 'msg-1', sessionID: SES_ID }), + parts: [stubTextPart({ id: 'part-1', sessionID: SES_ID, messageID: 'msg-1', text: 'hi' })], + }, + ]); + const onInitialPageLoaded = jest.fn(); + const onError = jest.fn(); + const fetchSnapshot = jest.fn(() => Promise.resolve(snapshot)); + const chatEvents: ChatEvent[] = []; + const serviceEvents: ServiceEvent[] = []; + + const factory = createCliHistoricalTransport({ + kiloSessionId: kiloId('kilo-ses-1'), + fetchSnapshot, + onInitialPageLoaded, + onError, + }); + const transport = factory({ + onChatEvent: event => chatEvents.push(event), + onServiceEvent: event => serviceEvents.push(event), + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fetchSnapshot).toHaveBeenCalledWith('kilo-ses-1'); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(chatEvents).toHaveLength(2); + expect(serviceEvents).toEqual([ + expect.objectContaining({ type: 'session.created' }), + { type: 'stopped', reason: 'complete' }, + ]); + transport.destroy(); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.ts b/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.ts index 574d10ef88..1fcb862239 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cli-historical-transport.ts @@ -3,12 +3,35 @@ * it as events through the TransportSink. Allows viewing historical CLI sessions * using the same ChatProcessor + ServiceState pipeline used for live sessions. */ -import type { KiloSessionId, SessionSnapshot } from './types'; +import type { + KiloSessionId, + SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, +} from './types'; import type { TransportFactory, TransportSink } from './transport'; type CliHistoricalTransportConfig = { kiloSessionId: KiloSessionId; - fetchSnapshot: (kiloSessionId: KiloSessionId) => Promise; + /** + * Legacy full-snapshot fetch. Kept for callers that haven't migrated to + * the paginated endpoint yet. `fetchSnapshotPage` takes precedence when + * both are provided. + */ + fetchSnapshot?: (kiloSessionId: KiloSessionId) => Promise; + /** + * Page-aware initial snapshot fetch. When provided, the historical + * transport uses it for the initial bounded read (newest 50) so the + * user can load older pages later via the manager's `loadOlderMessages`. + * The transport fires `onInitialPageLoaded` so the manager can record + * the cursor. + */ + fetchSnapshotPage?: ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } + ) => Promise; + /** Called after a successful initial bounded page read. */ + onInitialPageLoaded?: (page: SessionSnapshotPage) => void; onError?: (message: string) => void; }; @@ -16,10 +39,10 @@ function createCliHistoricalTransport(config: CliHistoricalTransportConfig): Tra return (sink: TransportSink) => { let generation = 0; - function replaySnapshot(snapshot: SessionSnapshot): void { - sink.onServiceEvent({ type: 'session.created', info: snapshot.info }); + function replayPage(page: SessionSnapshotPage): void { + sink.onServiceEvent({ type: 'session.created', info: page.info }); - for (const msg of snapshot.messages) { + for (const msg of page.messages) { sink.onChatEvent({ type: 'message.updated', info: msg.info }); for (const part of msg.parts) { @@ -30,11 +53,64 @@ function createCliHistoricalTransport(config: CliHistoricalTransportConfig): Tra sink.onServiceEvent({ type: 'stopped', reason: 'complete' }); } + function replaySnapshot(snapshot: SessionSnapshot): void { + replayPage({ + info: snapshot.info, + messages: snapshot.messages, + nextCursor: null, + omittedItemCount: 0, + }); + } + return { connect() { generation += 1; const expectedGeneration = generation; + if (config.fetchSnapshotPage) { + void config.fetchSnapshotPage(config.kiloSessionId, {}).then( + page => { + if (expectedGeneration !== generation) return; + if (page === null) { + const message = 'Session not found'; + config.onError?.(message); + sink.onServiceEvent({ type: 'stopped', reason: 'error' }); + return; + } + if (page.kind === 'success') { + config.onInitialPageLoaded?.(page); + replayPage(page); + return; + } + // Typed failure: surface via onError and mark the session as + // stopped with `error` so the UI doesn't appear to load + // forever. + const message = + page.kind === 'retryable_failure' + ? 'Session history temporarily unavailable' + : page.kind === 'too_large' + ? 'Session history too large to load' + : 'Session history is unavailable'; + config.onError?.(message); + sink.onServiceEvent({ type: 'stopped', reason: 'error' }); + }, + (error: unknown) => { + if (expectedGeneration !== generation) return; + const message = error instanceof Error ? error.message : 'Failed to fetch snapshot'; + config.onError?.(message); + sink.onServiceEvent({ type: 'stopped', reason: 'error' }); + } + ); + return; + } + + if (!config.fetchSnapshot) { + const message = 'fetchSnapshot is not configured'; + config.onError?.(message); + sink.onServiceEvent({ type: 'stopped', reason: 'error' }); + return; + } + void config.fetchSnapshot(config.kiloSessionId).then( snapshot => { if (expectedGeneration !== generation) return; diff --git a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts index 93b87feee2..03262174b3 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts @@ -11,7 +11,7 @@ import { type UserWebConnection, type UserWebSystemEvent, } from './user-web-connection'; -import type { KiloSessionId, SessionSnapshot } from './types'; +import type { KiloSessionId, SessionSnapshot, SessionSnapshotPageOutcome } from './types'; import { kiloId, makeSnapshot, stubTextPart, stubUserMessage } from './test-helpers'; const KILO_SESSION_ID = kiloId('kilo-ses-1'); @@ -1218,3 +1218,317 @@ describe('CliLiveTransport unified user web connection', () => { transport.destroy(); }); }); + +// --------------------------------------------------------------------------- +// Page-seam + reconnect: transport uses fetchSnapshotPage for the initial +// bounded read AND for reconnect/delayed resync replays, but only the initial +// read fires onInitialPageLoaded. A reconnect must never reset the user's +// already-advanced older-pages cursor back to the latest 50. +// --------------------------------------------------------------------------- + +type FetchPage = ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } +) => Promise; + +type CreatePageTransportOptions = { + fetchSnapshotPage: FetchPage; + fetchSnapshot?: (kiloSessionId: KiloSessionId) => Promise; + onInitialPageLoaded?: (page: { + info: { id: string }; + messages: Array<{ info: { id: string; sessionID: string }; parts: unknown[] }>; + nextCursor: string | null; + omittedItemCount: number; + }) => void; + onError?: (message: string) => void; +}; + +function createPageTransport(options: CreatePageTransportOptions) { + const chatEvents: ChatEvent[] = []; + const serviceEvents: ServiceEvent[] = []; + let replayCompleteCount = 0; + const userWebConnection = createConnection(); + const fetchSnapshotPage = jest.fn(options.fetchSnapshotPage); + const onInitialPageLoaded = jest.fn(options.onInitialPageLoaded ?? (() => undefined)); + const onError = jest.fn(options.onError ?? (() => undefined)); + + const transport = createCliLiveTransport({ + kiloSessionId: KILO_SESSION_ID, + userWebConnection, + ...(options.fetchSnapshot ? { fetchSnapshot: options.fetchSnapshot } : {}), + fetchSnapshotPage, + onInitialPageLoaded, + onError, + })({ + onChatEvent: event => chatEvents.push(event), + onServiceEvent: event => serviceEvents.push(event), + onReplayComplete: () => { + replayCompleteCount += 1; + }, + }); + + return { + transport, + userWebConnection, + chatEvents, + serviceEvents, + fetchSnapshotPage, + onInitialPageLoaded, + onError, + getReplayCompleteCount: () => replayCompleteCount, + }; +} + +function makeLivePage( + options: { + nextCursor?: string | null; + omittedItemCount?: number; + id?: string; + } = {} +): Extract { + return { + kind: 'success', + info: { id: options.id ?? KILO_SESSION_ID }, + messages: [], + nextCursor: options.nextCursor ?? null, + omittedItemCount: options.omittedItemCount ?? 0, + }; +} + +describe('CliLiveTransport page-seam + reconnect', () => { + it('uses fetchSnapshotPage for the initial bounded read and reports it via onInitialPageLoaded', async () => { + const page = makeLivePage({ nextCursor: 'cursor-A', omittedItemCount: 2 }); + const { transport, fetchSnapshotPage, onInitialPageLoaded, serviceEvents } = + createPageTransport({ + fetchSnapshotPage: async () => page, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fetchSnapshotPage).toHaveBeenCalledWith(KILO_SESSION_ID, {}); + expect(onInitialPageLoaded).toHaveBeenCalledWith(page); + // session.created from the page must have flowed through. + expect(serviceEvents.filter(event => event.type === 'session.created')).toHaveLength(1); + transport.destroy(); + }); + + it('does not call onInitialPageLoaded again on a reconnect replay (cursor preserved)', async () => { + const initial = makeLivePage({ nextCursor: 'cursor-A', omittedItemCount: 0 }); + const reconnect = makeLivePage({ nextCursor: 'cursor-B', omittedItemCount: 0 }); + const fetchSnapshotPage = jest + .fn, [KiloSessionId, { cursor?: string }]>() + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(reconnect); + const onInitialPageLoaded = jest.fn(); + const { + transport, + userWebConnection, + fetchSnapshotPage: _fetch, + } = createPageTransport({ + fetchSnapshotPage, + onInitialPageLoaded, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + expect(onInitialPageLoaded).toHaveBeenCalledTimes(1); + expect(onInitialPageLoaded).toHaveBeenLastCalledWith(initial); + + // Reconnect replay: the transport re-fetches the page (so the manager's + // already-advanced older-pages cursor is not overwritten), but it must + // NOT fire onInitialPageLoaded again — that hook is the manager's signal + // to advance `loadOlderGeneration`, which would reset the cursor. + userWebConnection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(_fetch.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(onInitialPageLoaded).toHaveBeenCalledTimes(1); + transport.destroy(); + }); + + it('does not call onInitialPageLoaded on the delayed resync replay', async () => { + jest.useFakeTimers(); + try { + const initial = makeLivePage({ nextCursor: 'cursor-A' }); + const fetchSnapshotPage = jest + .fn, [KiloSessionId, { cursor?: string }]>() + .mockResolvedValueOnce(initial) + .mockResolvedValue(makeLivePage({ nextCursor: 'cursor-B' })); + const onInitialPageLoaded = jest.fn(); + const { transport, userWebConnection } = createPageTransport({ + fetchSnapshotPage, + onInitialPageLoaded, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + expect(onInitialPageLoaded).toHaveBeenCalledTimes(1); + + userWebConnection.emitReconnect(); + jest.advanceTimersByTime(5000); + await Promise.resolve(); + await Promise.resolve(); + + // Still only the initial page fired the hook; reconnect + resync + // replays never reset the user's older-pages cursor. + expect(onInitialPageLoaded).toHaveBeenCalledTimes(1); + transport.destroy(); + } finally { + jest.useRealTimers(); + } + }); + + it('surfaces retryable_failure on the initial read via onError but stays subscribed', async () => { + const onError = jest.fn(); + const { transport, userWebConnection, onInitialPageLoaded, serviceEvents } = + createPageTransport({ + fetchSnapshotPage: async () => ({ kind: 'retryable_failure' }), + onError, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Session history temporarily unavailable'); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + // No session.created was emitted because no successful page replayed. + expect(serviceEvents.filter(event => event.type === 'session.created')).toHaveLength(0); + expect(userWebConnection.subscribeToCliSession).toHaveBeenCalledWith(KILO_SESSION_ID); + transport.destroy(); + }); + + it('surfaces too_large and invalid_data typed failures via onError', async () => { + for (const failure of [ + { kind: 'too_large' as const, expected: 'Session history too large to load' }, + { kind: 'invalid_data' as const, expected: 'Session history is unavailable' }, + ]) { + const onError = jest.fn(); + const { transport, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => failure, + onError, + }); + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + expect(onError).toHaveBeenCalledWith(failure.expected); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + transport.destroy(); + } + }); + + it('treats a null page result as session not found via onError', async () => { + const onError = jest.fn(); + const { transport, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => null, + onError, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith('Session not found'); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('prefers fetchSnapshotPage over legacy fetchSnapshot when both are provided', async () => { + const page = makeLivePage({ nextCursor: 'cursor-A' }); + const fetchSnapshot = jest.fn(() => + Promise.reject(new Error('legacy fetchSnapshot must not be called')) + ); + const { transport, fetchSnapshotPage, onInitialPageLoaded } = createPageTransport({ + fetchSnapshotPage: async () => page, + fetchSnapshot, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fetchSnapshotPage).toHaveBeenCalledTimes(1); + expect(fetchSnapshot).not.toHaveBeenCalled(); + expect(onInitialPageLoaded).toHaveBeenCalledWith(page); + transport.destroy(); + }); + + it('drains buffered live events only after a successful reconnect page', async () => { + let resolveReconnectPage: ((page: SessionSnapshotPageOutcome | null) => void) | undefined; + const initial = makeLivePage({ nextCursor: 'cursor-A' }); + const fetchSnapshotPage = jest + .fn, [KiloSessionId, { cursor?: string }]>() + .mockResolvedValueOnce(initial) + .mockReturnValueOnce( + new Promise(resolve => { + resolveReconnectPage = resolve; + }) + ); + const { transport, userWebConnection, chatEvents } = createPageTransport({ + fetchSnapshotPage, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + // After the initial bounded read, the buffer is drained; emitting now + // passes the event straight through. + emitMessageUpdated(userWebConnection); + await Promise.resolve(); + expect(chatEvents).toHaveLength(1); + + // Trigger a reconnect, then emit a new live event while the page is + // still in flight. It must be buffered, not emitted ahead of the page. + userWebConnection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(chatEvents).toHaveLength(1); + + emitMessageUpdated(userWebConnection); + await Promise.resolve(); + expect(chatEvents).toHaveLength(1); + + // Resolving the reconnect page must drain the buffered event AFTER the + // page replay so we never show a newer message over an older snapshot. + resolveReconnectPage?.(makeLivePage({ nextCursor: 'cursor-B' })); + await Promise.resolve(); + await Promise.resolve(); + expect(chatEvents).toHaveLength(2); + transport.destroy(); + }); + + it('swallows a rejected reconnect page without leaving events stranded', async () => { + const initial = makeLivePage({ nextCursor: 'cursor-A' }); + const fetchSnapshotPage = jest + .fn, [KiloSessionId, { cursor?: string }]>() + .mockResolvedValueOnce(initial) + .mockRejectedValueOnce(new Error('reconnect refetch failed')); + const onError = jest.fn(); + const { transport, userWebConnection, chatEvents, getReplayCompleteCount } = + createPageTransport({ + fetchSnapshotPage, + onError, + }); + + transport.connect(); + await Promise.resolve(); + await Promise.resolve(); + + emitMessageUpdated(userWebConnection); + userWebConnection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + + // Initial reconnect errors must not surface to the manager — the live + // stream carries on. The buffered event must still be drained. + expect(onError).not.toHaveBeenCalled(); + expect(chatEvents).toHaveLength(1); + expect(getReplayCompleteCount()).toBeGreaterThanOrEqual(2); + transport.destroy(); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts index ce96139c10..85b54edb1e 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts @@ -11,7 +11,12 @@ import { } from './schemas'; import type { RemoteModelState } from './remote-model-catalog'; import type { TransportFactory, TransportSendInput, TransportSink } from './transport'; -import type { KiloSessionId, SessionSnapshot } from './types'; +import type { + KiloSessionId, + SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, +} from './types'; import { UserWebCommandError, type UserWebCliEvent, @@ -22,6 +27,23 @@ type CliLiveTransportConfig = { kiloSessionId: KiloSessionId; userWebConnection: UserWebConnection; fetchSnapshot?: (kiloSessionId: KiloSessionId) => Promise; + /** + * Page-aware root snapshot fetch. When provided, every `replayCurrentSnapshot` + * (initial bounded read AND reconnect immediate + delayed resync) uses it + * so the transport never overwrites older messages already loaded via + * `loadOlderMessages`. The initial read additionally fires + * `onInitialPageLoaded` so the manager can record the cursor. + */ + fetchSnapshotPage?: ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } + ) => Promise; + /** + * Called after a successful initial bounded page read. Reconnect and + * delayed-resync replays do NOT call this so the user's already-advanced + * older-messages cursor is never reset to the latest 50. + */ + onInitialPageLoaded?: (page: SessionSnapshotPage) => void; onError?: (message: string) => void; onRemoteModelStateChange?: (state: RemoteModelState) => void; onCapabilityChange?: () => void; @@ -172,10 +194,10 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor }); } - function replaySnapshot(snapshot: SessionSnapshot): void { - sink.onServiceEvent({ type: 'session.created', info: snapshot.info }); + function replayPage(page: SessionSnapshotPage): void { + sink.onServiceEvent({ type: 'session.created', info: page.info }); - for (const msg of snapshot.messages) { + for (const msg of page.messages) { sink.onChatEvent({ type: 'message.updated', info: msg.info }); for (const part of msg.parts) { @@ -381,12 +403,17 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor const replayCurrentSnapshot = (reportError: boolean): void => { snapshotReplayGeneration += 1; const expectedSnapshotReplayGeneration = snapshotReplayGeneration; + // The very first call (from `connect()`) is the initial bounded + // read and fires `onInitialPageLoaded`; reconnect and delayed + // resync calls deliberately do not, so the manager's already- + // advanced older-messages cursor is never reset on reconnect. + const isInitial = reportError; if (bufferedCliEvents !== null) { bufferedEventsFromSupersededSnapshot.push(...bufferedCliEvents); } bufferedCliEvents = []; - if (!config.fetchSnapshot) { + if (!config.fetchSnapshot && !config.fetchSnapshotPage) { bufferedCliEvents = [ ...bufferedEventsFromSupersededSnapshot, ...(bufferedCliEvents ?? []), @@ -396,37 +423,70 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor return; } - void config.fetchSnapshot(config.kiloSessionId).then( - snapshot => { - if ( - expectedGeneration !== generation || - expectedSnapshotReplayGeneration !== snapshotReplayGeneration - ) { - return; - } - bufferedEventsFromSupersededSnapshot = []; - replaySnapshot(snapshot); - drainBufferedCliEvents(); - }, - (error: unknown) => { - if ( - expectedGeneration !== generation || - expectedSnapshotReplayGeneration !== snapshotReplayGeneration - ) { - return; - } - if (reportError) { - const message = error instanceof Error ? error.message : 'Failed to fetch snapshot'; - config.onError?.(message); - } - bufferedCliEvents = [ - ...bufferedEventsFromSupersededSnapshot, - ...(bufferedCliEvents ?? []), - ]; - bufferedEventsFromSupersededSnapshot = []; - drainBufferedCliEvents(); + const onPage = (page: SessionSnapshotPage): void => { + if ( + expectedGeneration !== generation || + expectedSnapshotReplayGeneration !== snapshotReplayGeneration + ) { + return; } - ); + if (isInitial) { + config.onInitialPageLoaded?.(page); + } + bufferedEventsFromSupersededSnapshot = []; + replayPage(page); + drainBufferedCliEvents(); + }; + const onError = (error: unknown): void => { + if ( + expectedGeneration !== generation || + expectedSnapshotReplayGeneration !== snapshotReplayGeneration + ) { + return; + } + if (reportError) { + const message = error instanceof Error ? error.message : 'Failed to fetch snapshot'; + config.onError?.(message); + } + bufferedCliEvents = [ + ...bufferedEventsFromSupersededSnapshot, + ...(bufferedCliEvents ?? []), + ]; + bufferedEventsFromSupersededSnapshot = []; + drainBufferedCliEvents(); + }; + + if (config.fetchSnapshotPage) { + void config.fetchSnapshotPage(config.kiloSessionId, {}).then(page => { + if (page && page.kind === 'success') { + onPage(page); + } else { + onError( + new Error( + page === null + ? 'Session not found' + : page.kind === 'retryable_failure' + ? 'Session history temporarily unavailable' + : page.kind === 'too_large' + ? 'Session history too large to load' + : 'Session history is unavailable' + ) + ); + } + }, onError); + return; + } + + if (config.fetchSnapshot) { + void config.fetchSnapshot(config.kiloSessionId).then(snapshot => { + onPage({ + info: snapshot.info, + messages: snapshot.messages, + nextCursor: null, + omittedItemCount: 0, + }); + }, onError); + } }; replayCurrentSnapshot(true); diff --git a/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.test.ts b/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.test.ts index c384150e03..85aa373e43 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.test.ts @@ -6,6 +6,7 @@ import type { CloudAgentEvent } from '@/lib/cloud-agent-next/event-types'; import { createEventHelpers } from './__fixtures__/helpers'; import type { ChatEvent, ServiceEvent } from './normalizer'; import { createCloudAgentTransport } from './cloud-agent-transport'; +import type { SessionSnapshotPageOutcome } from './types'; import { kiloId, cloudAgentId, makeSnapshot } from './test-helpers'; // --------------------------------------------------------------------------- @@ -699,3 +700,223 @@ describe('CloudAgentTransport snapshot refetch on reconnect', () => { transport.destroy(); }); }); + +describe('CloudAgentTransport page-seam', () => { + function createTransportWithPageFetch( + page: SessionSnapshotPageOutcome | null, + onError?: (message: string) => void + ) { + const chatEvents: ChatEvent[] = []; + const serviceEvents: ServiceEvent[] = []; + const fetchSnapshotPage = jest.fn(() => Promise.resolve(page)); + const onInitialPageLoaded = jest.fn(); + + const factory = createCloudAgentTransport({ + sessionId: cloudAgentId('ses-1'), + kiloSessionId: kiloId('ses-1'), + api: createMockApi(), + getTicket: () => 'test-ticket', + fetchSnapshot: () => Promise.reject(new Error('legacy fetchSnapshot should not be called')), + fetchSnapshotPage, + onInitialPageLoaded, + websocketBaseUrl: 'ws://localhost:9999', + onError, + }); + + const transport = factory({ + onChatEvent: event => chatEvents.push(event), + onServiceEvent: event => serviceEvents.push(event), + }); + + return { transport, chatEvents, serviceEvents, fetchSnapshotPage, onInitialPageLoaded }; + } + + it('uses fetchSnapshotPage for the initial bounded read and reports the page via onInitialPageLoaded', async () => { + const page = { + kind: 'success' as const, + info: { id: 'ses-1' }, + messages: [], + nextCursor: 'cursor-A', + omittedItemCount: 2, + }; + const { transport, fetchSnapshotPage, onInitialPageLoaded } = + createTransportWithPageFetch(page); + + transport.connect(); + await flushPromises(); + + expect(fetchSnapshotPage).toHaveBeenCalledTimes(1); + expect(fetchSnapshotPage).toHaveBeenCalledWith('ses-1', {}); + expect(onInitialPageLoaded).toHaveBeenCalledWith(page); + + transport.destroy(); + }); + + it('surfaces typed failures on the initial read via onError', async () => { + const { transport, serviceEvents } = createTransportWithPageFetch({ + kind: 'retryable_failure', + }); + + transport.connect(); + await flushPromises(); + + expect(serviceEvents.filter(e => e.type === 'session.created')).toHaveLength(0); + // The transport still connects the websocket so the user can recover + // via live events. + expect(webSocketConstructor).toHaveBeenCalledTimes(1); + + transport.destroy(); + }); + + it('surfaces invalid_data on the initial read via onError and still connects the websocket', async () => { + const errors: string[] = []; + const { transport, chatEvents, serviceEvents, onInitialPageLoaded } = + createTransportWithPageFetch({ kind: 'invalid_data' }, message => errors.push(message)); + + transport.connect(); + await flushPromises(); + + expect(errors).toEqual(['Session history is unavailable']); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(serviceEvents.filter(e => e.type === 'session.created')).toHaveLength(0); + expect(chatEvents).toHaveLength(0); + // Terminal typed failures still leave the websocket available so the user + // can recover via live events; this is the intended transport behavior. + expect(webSocketConstructor).toHaveBeenCalledTimes(1); + + transport.destroy(); + }); + + it('surfaces too_large on the initial read via onError and still connects the websocket', async () => { + const errors: string[] = []; + const { transport, chatEvents, serviceEvents, onInitialPageLoaded } = + createTransportWithPageFetch({ kind: 'too_large' }, message => errors.push(message)); + + transport.connect(); + await flushPromises(); + + expect(errors).toEqual(['Session history too large to load']); + expect(onInitialPageLoaded).not.toHaveBeenCalled(); + expect(serviceEvents.filter(e => e.type === 'session.created')).toHaveLength(0); + expect(chatEvents).toHaveLength(0); + // Terminal typed failures still leave the websocket available so the user + // can recover via live events; this is the intended transport behavior. + expect(webSocketConstructor).toHaveBeenCalledTimes(1); + + transport.destroy(); + }); + + it('falls back to fetchSnapshot when fetchSnapshotPage is not provided', async () => { + const chatEvents: ChatEvent[] = []; + const serviceEvents: ServiceEvent[] = []; + const fetchSnapshot = jest.fn(() => Promise.resolve(emptySnapshot)); + + const factory = createCloudAgentTransport({ + sessionId: cloudAgentId('ses-1'), + kiloSessionId: kiloId('ses-1'), + api: createMockApi(), + getTicket: () => 'test-ticket', + fetchSnapshot, + websocketBaseUrl: 'ws://localhost:9999', + }); + + const transport = factory({ + onChatEvent: event => chatEvents.push(event), + onServiceEvent: event => serviceEvents.push(event), + }); + + transport.connect(); + await flushPromises(); + + expect(fetchSnapshot).toHaveBeenCalledTimes(1); + expect(serviceEvents.filter(e => e.type === 'session.created')).toHaveLength(1); + + transport.destroy(); + }); + + it('reconnect (no eventId) uses fetchSnapshotPage and does NOT fire onInitialPageLoaded', async () => { + jest.useFakeTimers(); + try { + // Microtask-based flush that works under jest.useFakeTimers() + // (the top-level flushPromises uses setTimeout and hangs). + async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + } + + const page = { + kind: 'success' as const, + info: { id: 'ses-1' }, + messages: [], + nextCursor: 'cursor-A', + omittedItemCount: 0, + }; + const fetchSnapshotPage = jest.fn().mockResolvedValue(page); + const onInitialPageLoaded = jest.fn(); + const chatEvents: ChatEvent[] = []; + const serviceEvents: ServiceEvent[] = []; + + const factory = createCloudAgentTransport({ + sessionId: cloudAgentId('ses-1'), + kiloSessionId: kiloId('ses-1'), + api: createMockApi(), + getTicket: () => 'test-ticket', + fetchSnapshot: () => Promise.reject(new Error('should not be called')), + fetchSnapshotPage, + onInitialPageLoaded, + websocketBaseUrl: 'ws://localhost:9999', + }); + + const transport = factory({ + onChatEvent: event => chatEvents.push(event), + onServiceEvent: event => serviceEvents.push(event), + }); + + transport.connect(); + await flushMicrotasks(); + expect(onInitialPageLoaded).toHaveBeenCalledTimes(1); + + // Establish the first connection with a sentinel (eventId: 0) so the + // connection marks itself `connected` without advancing the replay + // cursor. That is what the production path looks like when the very + // first frame on a fresh socket is a status heartbeat. + const sentinel: CloudAgentEvent = { + eventId: 0, + executionId: null, + sessionId: 'ses-1', + streamEventType: 'kilocode', + timestamp: new Date().toISOString(), + data: { + type: 'session.status', + properties: { sessionID: 'ses-1', status: { type: 'busy' } }, + }, + }; + sendRaw(sentinel); + + // Trigger an unexpected disconnect on the first socket. + mockWs.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); + jest.advanceTimersByTime(2000); + await flushMicrotasks(); + + const newMockWs = webSocketConstructor.mock.results.at(-1)?.value as MockWebSocket; + newMockWs.onopen?.(new Event('open')); + // Send a sentinel on the new socket: the connection marks itself + // reconnected, then `onReconnected` falls back to `fetchSnapshotPage` + // because `lastEventId` is still null. + newMockWs.onmessage?.({ data: JSON.stringify(sentinel) } as MessageEvent); + await flushMicrotasks(); + await flushMicrotasks(); + + // fetchSnapshotPage was called again (initial + reconnect), but + // onInitialPageLoaded is still 1 — reconnect must never reset the + // user's older-pages cursor back to the latest 50. + expect(fetchSnapshotPage.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(onInitialPageLoaded).toHaveBeenCalledTimes(1); + + transport.destroy(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.ts b/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.ts index 20aa6d205a..3b82a315f5 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cloud-agent-transport.ts @@ -10,7 +10,13 @@ import { createConnection, type Connection } from './cloud-agent-connection'; import type { ConnectionLifecycleHooks, WebSocketHeaders } from './base-connection'; import { normalize, isChatEvent } from './normalizer'; import type { ServiceEvent } from './normalizer'; -import type { CloudAgentSessionId, KiloSessionId, SessionSnapshot } from './types'; +import type { + CloudAgentSessionId, + KiloSessionId, + SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, +} from './types'; import type { CloudAgentApi, CloudAgentSendPayload, @@ -45,6 +51,18 @@ type CloudAgentTransportConfig = { sessionId: CloudAgentSessionId ) => CloudAgentStreamTicketResult | Promise; fetchSnapshot: (kiloSessionId: KiloSessionId) => Promise; + /** + * Page-aware root snapshot fetch. When provided, the transport uses it for + * its initial bounded read (newest 50) instead of `fetchSnapshot`, and for + * any reconnect snapshot replays. The transport calls `onInitialPageLoaded` + * after a successful initial read so the manager can record the cursor. + */ + fetchSnapshotPage?: ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } + ) => Promise; + /** Called after a successful initial bounded page read. */ + onInitialPageLoaded?: (page: SessionSnapshotPage) => void; websocketBaseUrl: string; onError?: (message: string) => void; lifecycleHooks?: ConnectionLifecycleHooks; @@ -88,10 +106,10 @@ function createCloudAgentTransport(config: CloudAgentTransportConfig): Transport connection = null; } - function replaySnapshot(snapshot: SessionSnapshot): void { - sink.onServiceEvent({ type: 'session.created', info: snapshot.info }); + function replayPage(page: SessionSnapshotPage): void { + sink.onServiceEvent({ type: 'session.created', info: page.info }); - for (const msg of snapshot.messages) { + for (const msg of page.messages) { sink.onChatEvent({ type: 'message.updated', info: msg.info }); for (const part of msg.parts) { @@ -100,6 +118,63 @@ function createCloudAgentTransport(config: CloudAgentTransportConfig): Transport } } + function replaySnapshot(snapshot: SessionSnapshot): void { + // The legacy full-snapshot path is retained for callers that haven't + // migrated to the paginated endpoint yet. Same shape; same effect. + replayPage({ + info: snapshot.info, + messages: snapshot.messages, + nextCursor: null, + omittedItemCount: 0, + }); + } + + /** Fetch the initial bounded page (newest 50) and replay it. */ + async function fetchAndReplayInitial( + expectedGeneration: number + ): Promise<{ ticket: CloudAgentStreamTicketResult } | null> { + const fetchPage = config.fetchSnapshotPage; + if (fetchPage) { + const [ticket, page] = await Promise.all([ + Promise.resolve(config.getTicket(config.sessionId)), + fetchPage(config.kiloSessionId, {}), + ]); + if (expectedGeneration !== lifecycleGeneration) return null; + if (page === null) { + handleTicketError(new Error('Session not found'), expectedGeneration); + return null; + } + if (page.kind === 'success') { + config.onInitialPageLoaded?.(page); + replayPage(page); + return { ticket }; + } + // Typed failure on initial load — surface via the standard error + // channel (same path as a thrown fetch failure) and still try to + // connect so the user can recover via live events. + handleTicketError( + new Error( + page.kind === 'retryable_failure' + ? 'Session history temporarily unavailable' + : page.kind === 'too_large' + ? 'Session history too large to load' + : 'Session history is unavailable' + ), + expectedGeneration + ); + // Even on a typed failure, the websocket is still useful — connect + // without a snapshot replay. The manager will surface the error. + return { ticket }; + } + const [ticket, snapshot] = await Promise.all([ + Promise.resolve(config.getTicket(config.sessionId)), + config.fetchSnapshot(config.kiloSessionId), + ]); + if (expectedGeneration !== lifecycleGeneration) return null; + replaySnapshot(snapshot); + return { ticket }; + } + function connectWebSocket( ticket: CloudAgentStreamTicketResult, expectedGeneration: number @@ -151,15 +226,30 @@ function createCloudAgentTransport(config: CloudAgentTransportConfig): Transport // would overwrite newer parts. Only fall back to the snapshot when // no cursor exists yet. if (lastEventId !== null) return; - void config.fetchSnapshot(config.kiloSessionId).then( - snapshot => { - if (expectedGeneration !== lifecycleGeneration) return; - replaySnapshot(snapshot); - }, - () => { - // Snapshot refetch failure on reconnect — ignore, live events will still flow - } - ); + // Reconnect replays use the bounded page fetch (newest 50) to + // avoid overwriting older messages the user has already loaded via + // `loadOlderMessages`. The manager already has the cursor from + // the initial connect, so we deliberately skip `onInitialPageLoaded` + // here — a reconnect must never reset the user's older-pages + // cursor back to the latest 50. + const replayRefetch = config.fetchSnapshotPage + ? config.fetchSnapshotPage(config.kiloSessionId, {}).then( + page => { + if (expectedGeneration !== lifecycleGeneration) return; + if (page && page.kind === 'success') { + replayPage(page); + } + }, + () => undefined + ) + : config.fetchSnapshot(config.kiloSessionId).then( + snapshot => { + if (expectedGeneration !== lifecycleGeneration) return; + replaySnapshot(snapshot); + }, + () => undefined + ); + void replayRefetch; }, onDisconnected: () => {}, onUnexpectedDisconnect: () => { @@ -195,14 +285,10 @@ function createCloudAgentTransport(config: CloudAgentTransportConfig): Transport stoppedReceived = false; const expectedGeneration = lifecycleGeneration; - void Promise.all([ - Promise.resolve(config.getTicket(config.sessionId)), - config.fetchSnapshot(config.kiloSessionId), - ]) - .then(([ticket, snapshot]) => { - if (expectedGeneration !== lifecycleGeneration) return; - replaySnapshot(snapshot); - connectWebSocket(ticket, expectedGeneration); + void fetchAndReplayInitial(expectedGeneration) + .then(result => { + if (!result) return; + connectWebSocket(result.ticket, expectedGeneration); }) .catch(error => { handleTicketError(error, expectedGeneration); diff --git a/apps/web/src/lib/cloud-agent-sdk/index.ts b/apps/web/src/lib/cloud-agent-sdk/index.ts index 0e083919b4..06ef9597a3 100644 --- a/apps/web/src/lib/cloud-agent-sdk/index.ts +++ b/apps/web/src/lib/cloud-agent-sdk/index.ts @@ -127,6 +127,9 @@ export { calculateContextUsagePercentage } from './context-usage'; export type { ContextUsage } from './context-usage'; export type { + KiloSdkMessageHistory, + KiloSdkMessageHistoryPage, + KiloSdkStoredMessage, MessageInfo, ProcessedMessage, SessionPhase, @@ -145,6 +148,9 @@ export type { CloudAgentSessionId, ResolvedSession, SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, + OlderMessagesError, // Re-exported opencode types Part, TextPart, diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts index b0d706f9f3..0ee308da56 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts @@ -17,6 +17,8 @@ import type { ResolvedSession, SessionActivity, SessionInfo, + SessionSnapshotPage, + SessionSnapshotPageOutcome, } from './types'; import type { RemoteModelState } from './remote-model-catalog'; import type { NormalizedEvent } from './normalizer'; @@ -108,7 +110,14 @@ jest.mock('./session', () => ({ state: Extract ) => void; onError?: (message: string) => void; - transport?: { userWebConnection?: unknown }; + transport?: { + userWebConnection?: unknown; + fetchSnapshotPage?: ( + kiloSessionId: string, + options: { cursor?: string } + ) => Promise; + onInitialPageLoaded?: (page: unknown) => void; + }; }) => { latestStorage = sessionConfig.storage; mockSession.storage = sessionConfig.storage; @@ -120,6 +129,34 @@ jest.mock('./session', () => ({ kiloSessionId: kiloId(sessionConfig.kiloSessionId), cloudAgentSessionId: cloudAgentId('agent-1'), }); + // Simulate the transport's initial bounded read so the manager's + // pagination state is populated. The real transport would call + // `fetchSnapshotPage` and then `onInitialPageLoaded` with the page, + // or surface a typed failure via `onError`. + const transport = sessionConfig.transport; + if (transport?.fetchSnapshotPage) { + void Promise.resolve(transport.fetchSnapshotPage(sessionConfig.kiloSessionId, {})).then( + page => { + if (page && typeof page === 'object' && 'kind' in page) { + if (page.kind === 'success' && transport.onInitialPageLoaded) { + transport.onInitialPageLoaded(page); + } else if (page.kind !== 'success' && sessionConfig.onError) { + // Mirror the real transport's typed-failure handling: + // it surfaces a stable error message via the manager's + // `onError` channel so the standard session-error UI + // shows the failure. + const message = + page.kind === 'retryable_failure' + ? 'Session history temporarily unavailable' + : page.kind === 'too_large' + ? 'Session history too large to load' + : 'Session history is unavailable'; + sessionConfig.onError(message); + } + } + } + ); + } sessionConfig.onSessionCreated?.({ id: sessionConfig.kiloSessionId }); }); mockSessionCallbacks.onSessionCreated = sessionConfig.onSessionCreated; @@ -3216,3 +3253,408 @@ describe('isReadOnly during connecting phase', () => { expect(atomValue(config.store, mgr.atoms.isReadOnly)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Bounded initial snapshot + loadOlderMessages +// --------------------------------------------------------------------------- + +type SessionSnapshotPageFetch = NonNullable; + +function makePageMessage( + id: string, + sessionID: string, + text: string +): SessionSnapshotPage['messages'][number] { + return { + info: stubUserMessage({ id, sessionID }), + parts: [stubTextPart({ id: `${id}-text`, sessionID, messageID: id, text })], + }; +} + +function makePage( + options: { + kiloSessionId?: string; + messages?: SessionSnapshotPage['messages']; + nextCursor?: string | null; + omittedItemCount?: number; + } = {} +): SessionSnapshotPageOutcome { + return { + kind: 'success', + info: { id: options.kiloSessionId ?? 'ses-1' }, + messages: options.messages ?? [], + nextCursor: options.nextCursor ?? null, + omittedItemCount: options.omittedItemCount ?? 0, + }; +} + +function createPageFetchMock( + impl: SessionSnapshotPageFetch +): jest.MockedFunction { + return jest.fn(impl) as jest.MockedFunction; +} + +describe('createSessionManager — paginated initial snapshot + loadOlderMessages', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + mockSession.connect.mockClear(); + mockSession.disconnect.mockClear(); + mockSession.destroy.mockClear(); + mockSession.send.mockClear(); + mockSession.interrupt.mockClear(); + mockSession.respondToPermission.mockClear(); + mockSession.canSend = true; + mockSession.canInterrupt = true; + mockSession.state.subscribe.mockImplementation(callback => { + callback(); + return () => {}; + }); + mockSession.state.getStatus.mockReturnValue({ type: 'idle' }); + mockSession.state.getCloudStatus.mockReturnValue(null); + mockSession.state.getPendingMessages.mockReturnValue(new Map()); + mockSession.storage = latestStorage; + latestStorage = null; + mockSessionCallbacks.onSessionCreated = undefined; + mockSessionCallbacks.onSessionUpdated = undefined; + mockSessionCallbacks.onQuestionAsked = undefined; + mockSessionCallbacks.onQuestionResolved = undefined; + mockSessionCallbacks.onPermissionAsked = undefined; + mockSessionCallbacks.onPermissionResolved = undefined; + mockSessionCallbacks.onResolved = undefined; + }); + + it('loads the initial bounded page on switchSession and stores the cursor', async () => { + const fetchSnapshotPage = createPageFetchMock(async () => + makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A', omittedItemCount: 2 }) + ); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + expect(fetchSnapshotPage).toHaveBeenCalledWith('ses-1', {}); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + expect(atomValue(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(2); + }); + + it('does not set hasOlderMessages when the initial page has no cursor', async () => { + const fetchSnapshotPage = createPageFetchMock(async () => + makePage({ kiloSessionId: 'ses-1', nextCursor: null }) + ); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + }); + + it('loadOlderMessages fetches the next page with the stored cursor and merges messages', async () => { + const callArgs: Array<{ cursor?: string }> = []; + const fetchSnapshotPage = createPageFetchMock(async (_id, options) => { + callArgs.push({ ...options }); + if (!options.cursor) { + return makePage({ + kiloSessionId: 'ses-1', + messages: [makePageMessage('msg-2', 'ses-1', 'newer')], + nextCursor: 'cursor-A', + }); + } + if (options.cursor === 'cursor-A') { + return makePage({ + kiloSessionId: 'ses-1', + messages: [makePageMessage('msg-1', 'ses-1', 'older')], + nextCursor: null, + omittedItemCount: 3, + }); + } + return makePage({ kiloSessionId: 'ses-1' }); + }); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + + await mgr.loadOlderMessages(); + expect(callArgs).toEqual([{}, { cursor: 'cursor-A' }]); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + expect(atomValue(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(3); + expect( + atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError) + ).toBeNull(); + + const messages = atomValue(config.store, mgr.atoms.messagesList); + expect(messages.map(m => m.info.id)).toEqual(['msg-1', 'msg-2']); + }); + + it('loadOlderMessages is a no-op when there is no cursor', async () => { + const fetchSnapshotPage = createPageFetchMock(async () => + makePage({ kiloSessionId: 'ses-1', nextCursor: null }) + ); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.loadOlderMessages(); + + // Only the initial call + expect(fetchSnapshotPage).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent loadOlderMessages calls', async () => { + let resolvePage: (value: SessionSnapshotPageOutcome) => void = () => undefined; + const slowPage = new Promise(resolve => { + resolvePage = resolve; + }); + + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockReturnValueOnce(slowPage); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + const first = mgr.loadOlderMessages(); + const second = mgr.loadOlderMessages(); + + resolvePage(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-B' })); + + await Promise.all([first, second]); + + expect(fetchSnapshotPage.mock.calls).toHaveLength(2); + }); + + it('does not run loadOlderMessages again after a switchSession', async () => { + // Set up the slow promise and the one-time mocks in the order the + // session-manager will consume them: initial → older load → next initial. + let resolvePage: (value: SessionSnapshotPageOutcome) => void = () => undefined; + const slowPage = new Promise(resolve => { + resolvePage = resolve; + }); + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockReturnValueOnce(slowPage) + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-2', nextCursor: null })) + .mockResolvedValue(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + // Start loadOlder, but switch before it resolves. + const older = mgr.loadOlderMessages(); + const switching = mgr.switchSession(kiloId('ses-2')); + resolvePage(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-B' })); + await Promise.all([older, switching]); + + // After the switch, hasOlderMessages reflects the new session (no cursor). + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + }); + + it('surfaces retryable_failure on initial load via the standard error atom', async () => { + const fetchSnapshotPage = createPageFetchMock(async () => ({ + kind: 'retryable_failure' as const, + })); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + expect(atomValue(config.store, mgr.atoms.error)).not.toBeNull(); + expect( + atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError) + ).toBeNull(); + }); + + it('surfaces non-retryable failure on initial load and disables further older loads', async () => { + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce({ kind: 'too_large' as const }) + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + expect(atomValue(config.store, mgr.atoms.error)).not.toBeNull(); + // No cursor was set, so hasOlderMessages must remain false and the + // backend must not be hit again. + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + await mgr.loadOlderMessages(); + expect(fetchSnapshotPage.mock.calls).toHaveLength(1); + }); + + it('keeps existing messages and exposes retryable older error when loadOlderMessages fails retryably', async () => { + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockResolvedValueOnce({ kind: 'retryable_failure' as const }); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + const messageCountBefore = atomValue( + config.store, + mgr.atoms.messagesList + ).length; + expect(messageCountBefore).toBe(0); + + await mgr.loadOlderMessages(); + + expect(atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError)).toEqual({ + kind: 'retryable', + }); + // Cursor must remain so a retry can pick it up. + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + expect(fetchSnapshotPage).toHaveBeenLastCalledWith('ses-1', { cursor: 'cursor-A' }); + + // Retryable retry — backend should be hit again and succeed. + fetchSnapshotPage.mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: null })); + await mgr.loadOlderMessages(); + + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + expect( + atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError) + ).toBeNull(); + }); + + it('treats a null older page outcome as terminal invalid_data and stops hitting the backend', async () => { + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockResolvedValueOnce(null) + .mockResolvedValue(makePage({ kiloSessionId: 'ses-1', nextCursor: null })); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.loadOlderMessages(); + + expect(atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError)).toEqual({ + kind: 'invalid_data', + }); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + + // A second call should be a no-op (no backend hit). + await mgr.loadOlderMessages(); + expect(fetchSnapshotPage).toHaveBeenCalledTimes(2); + }); + + it('rejects an older page whose session id does not match the active session', async () => { + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockResolvedValueOnce( + makePage({ + kiloSessionId: 'ses-other', + messages: [makePageMessage('msg-other', 'ses-1', 'other')], + nextCursor: null, + omittedItemCount: 5, + }) + ); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.loadOlderMessages(); + + const messages = atomValue(config.store, mgr.atoms.messagesList); + expect(messages.map(m => m.info.id)).not.toContain('msg-other'); + // The cursor must stay at the previously known value so a later valid page can continue. + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + expect(atomValue(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(0); + expect( + atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError) + ).toBeNull(); + }); + + it('marks non-retryable older failures as terminal and stops hitting the backend', async () => { + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockResolvedValueOnce({ kind: 'invalid_data' as const }) + .mockResolvedValue(makePage({ kiloSessionId: 'ses-1', nextCursor: null })); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.loadOlderMessages(); + + expect(atomValue<{ kind: string } | null>(config.store, mgr.atoms.olderMessagesError)).toEqual({ + kind: 'invalid_data', + }); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + + // A second call should be a no-op (no backend hit). + await mgr.loadOlderMessages(); + expect(fetchSnapshotPage.mock.calls).toHaveLength(2); + }); + + it('advances the cursor on an empty older page with a non-null continuation', async () => { + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockResolvedValueOnce( + makePage({ kiloSessionId: 'ses-1', messages: [], nextCursor: 'cursor-B' }) + ) + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: null })); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + + await mgr.loadOlderMessages(); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + + await mgr.loadOlderMessages(); + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(false); + + // The empty-with-cursor page must not have caused an infinite loop — we + // made forward progress (3 calls total: initial, empty, final). + expect(fetchSnapshotPage.mock.calls).toHaveLength(3); + }); + + it('exposes isLoadingOlderMessages while a load is in flight', async () => { + let resolvePage: (value: SessionSnapshotPageOutcome) => void = () => undefined; + const slowPage = new Promise(resolve => { + resolvePage = resolve; + }); + + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage + .mockResolvedValueOnce(makePage({ kiloSessionId: 'ses-1', nextCursor: 'cursor-A' })) + .mockReturnValueOnce(slowPage); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + const loading = mgr.loadOlderMessages(); + expect(atomValue(config.store, mgr.atoms.isLoadingOlderMessages)).toBe(true); + + resolvePage(makePage({ kiloSessionId: 'ses-1', nextCursor: null })); + await loading; + + expect(atomValue(config.store, mgr.atoms.isLoadingOlderMessages)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts index 9b823f2326..781d38ab4f 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts @@ -23,6 +23,8 @@ import type { KiloSessionId, ResolvedSession, SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, SessionInfo, SessionActivity, AgentStatus, @@ -35,6 +37,7 @@ import type { MessageDeliveryState, MessageInfo, Part, + OlderMessagesError, } from './types'; import type { QuestionInfo } from '@/types/opencode.gen'; import { splitByContiguousPrefix } from './array-utils'; @@ -153,6 +156,17 @@ type SessionManagerConfig = { sessionId: CloudAgentSessionId ) => CloudAgentStreamTicketResult | Promise; fetchSnapshot: (kiloSessionId: KiloSessionId) => Promise; + /** + * Page-aware root snapshot fetch. Called by transports for the initial + * bounded load and by the manager for `loadOlderMessages`. Optional so the + * legacy `fetchSnapshot`-only path keeps working for callers that haven't + * migrated to the paginated endpoint yet (e.g. server-side, tests). The + * mobile adapter is the canonical provider. + */ + fetchSnapshotPage?: ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } + ) => Promise; websocketBaseUrl?: string; userWebConnection: UserWebConnection; api: CloudAgentApi; @@ -215,11 +229,27 @@ type SessionManagerAtoms = { contextUsage: Atom; childMessages: Atom<(childSessionId: string) => StoredMessage[]>; childSessionHydrationState: Atom<(childSessionId: string) => ChildSessionHydrationState>; + /** True when the latest page left a non-null cursor (more history to load). */ + hasOlderMessages: W; + /** True while `loadOlderMessages()` is fetching a page. */ + isLoadingOlderMessages: W; + /** Typed failure from the most recent older-messages load. */ + olderMessagesError: W; + /** Total items omitted across every page loaded so far (initial + older). */ + olderMessagesOmittedItemCount: W; }; type SessionManager = { switchSession(kiloSessionId: KiloSessionId): Promise; hydrateChildSession(childSessionId: KiloSessionId): Promise; + /** + * Load the next page of older messages for the active session using the + * stored cursor. Dedupes concurrent calls, never clears existing/live + * messages, and classifies typed failures into `olderMessagesError`. + * No-op when there is no cursor or a non-retryable terminal failure was + * already surfaced for the active session. + */ + loadOlderMessages(): Promise; send(input: { payload: SessionManagerSendPayload; attachments?: CloudAgentAttachments; @@ -389,6 +419,10 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { */ const availableCommandsAtom = atom([]); const childSessionHydrationStatesAtom = atom>(new Map()); + const hasOlderMessagesAtom = atom(false); + const isLoadingOlderMessagesAtom = atom(false); + const olderMessagesErrorAtom = atom(null); + const olderMessagesOmittedItemCountAtom = atom(0); // Derived atoms const messagesListAtom = atom(get => { @@ -455,6 +489,17 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { let indicatorTimer: ReturnType | null = null; let childSessionHydrationGeneration = 0; const childSessionHydrationRequests = new Map>(); + // Pagination state for `loadOlderMessages`. Reset on every switchSession. + let olderMessagesCursor: string | null = null; + // Monotonically increasing per-session generation; older-page results + // whose `expectedLoadOlderGeneration` doesn't match are dropped. + let loadOlderGeneration = 0; + // In-flight older-page load, used to dedupe concurrent calls and to let + // late callers await the same result. + let olderMessagesInFlight: Promise | null = null; + // Once a non-retryable terminal failure lands, we permanently disable + // further older-page loads for the active session. + let olderMessagesTerminal: boolean = false; function setIndicator(ind: SessionStatusIndicator | null): void { if (indicatorTimer !== null) { @@ -505,6 +550,14 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { store.set(childSessionHydrationStatesAtom, new Map()); store.set(chatUIAtom, { shouldAutoScroll: true }); store.set(availableCommandsAtom, []); + store.set(hasOlderMessagesAtom, false); + store.set(isLoadingOlderMessagesAtom, false); + store.set(olderMessagesErrorAtom, null); + store.set(olderMessagesOmittedItemCountAtom, 0); + olderMessagesCursor = null; + loadOlderGeneration += 1; + olderMessagesInFlight = null; + olderMessagesTerminal = false; } function setChildSessionHydrationState( @@ -750,6 +803,117 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { }); } + // Replay a `SessionSnapshotPageOutcome` into the active storage. Returns + // whether the page was applied (so the caller can also persist the cursor + // and atom updates). Generation-aware: a stale caller's result is + // discarded silently. Used by both `switchSession` (initial page) and + // `loadOlderMessages` (subsequent pages). + function applyPage(outcome: SessionSnapshotPageOutcome, expectedGeneration: number): boolean { + if (expectedGeneration !== loadOlderGeneration) return false; + if (outcome.kind !== 'success') return false; + + // Defense-in-depth: a stale or mismatched page must not overwrite the + // active session's messages or cursor. This catches races where a + // fetchSnapshotPage result arrives after switchSession has retargeted the + // manager to a different session. + if (activeSessionId === null || outcome.info.id !== activeSessionId) return false; + + const storage = store.get(sessionStorageAtom); + if (!storage) return false; + + const chatProcessor = createChatProcessor(storage); + for (const message of outcome.messages) { + chatProcessor.process({ type: 'message.updated', info: message.info }); + for (const part of message.parts) { + chatProcessor.process({ type: 'message.part.updated', part }); + } + } + + olderMessagesCursor = outcome.nextCursor; + store.set(hasOlderMessagesAtom, outcome.nextCursor !== null); + store.set( + olderMessagesOmittedItemCountAtom, + store.get(olderMessagesOmittedItemCountAtom) + outcome.omittedItemCount + ); + store.set(olderMessagesErrorAtom, null); + return true; + } + + async function loadOlderMessages(): Promise { + // Terminal failures block any further backend hits until the next + // switchSession (which resets `olderMessagesTerminal`). + if (olderMessagesTerminal) return; + // No cursor means nothing left to load. + if (olderMessagesCursor === null) return; + // Dedupe: if a load is already in flight, every caller awaits the + // same result instead of starting a parallel backend request. + if (olderMessagesInFlight) return olderMessagesInFlight; + + const kiloSessionId = activeSessionId; + if (!kiloSessionId) return; + if (!config.fetchSnapshotPage) return; + const fetchSnapshotPage = config.fetchSnapshotPage; + + const cursor = olderMessagesCursor; + const expectedGeneration = loadOlderGeneration; + + const loadPromise = (async (): Promise => { + store.set(isLoadingOlderMessagesAtom, true); + let outcome: SessionSnapshotPageOutcome | null; + try { + outcome = await fetchSnapshotPage(kiloSessionId, { cursor }); + } catch (_err) { + // Network/transport-level failures map to a retryable outcome so + // the UI exposes a Retry CTA. The cursor is preserved. + if (expectedGeneration !== loadOlderGeneration) return; + store.set(olderMessagesErrorAtom, { kind: 'retryable' }); + store.set(isLoadingOlderMessagesAtom, false); + return; + } + if (expectedGeneration !== loadOlderGeneration) return; + + if (outcome === null) { + // Access-not-found (worker 404). Treat as terminal; the session + // is no longer readable. + olderMessagesTerminal = true; + store.set(olderMessagesErrorAtom, { kind: 'invalid_data' }); + store.set(hasOlderMessagesAtom, false); + store.set(isLoadingOlderMessagesAtom, false); + return; + } + + if (outcome.kind === 'success') { + applyPage(outcome, expectedGeneration); + store.set(isLoadingOlderMessagesAtom, false); + return; + } + + if (outcome.kind === 'retryable_failure') { + store.set(olderMessagesErrorAtom, { kind: 'retryable' }); + // Keep the cursor so a subsequent retry continues from here. + store.set(isLoadingOlderMessagesAtom, false); + return; + } + + // `invalid_data` and `too_large` are non-retryable terminal states: + // don't auto-hide the older loader (the user may want to see the + // banner), and don't accept further loads for this session. + olderMessagesTerminal = true; + store.set(olderMessagesErrorAtom, { kind: outcome.kind }); + store.set(hasOlderMessagesAtom, false); + store.set(isLoadingOlderMessagesAtom, false); + })(); + + olderMessagesInFlight = loadPromise; + try { + await loadPromise; + } finally { + if (olderMessagesInFlight === loadPromise) { + olderMessagesInFlight = null; + } + } + } + async function switchSession(kiloSessionId: KiloSessionId): Promise { childSessionHydrationGeneration += 1; childSessionHydrationRequests.clear(); @@ -800,6 +964,14 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { config.onKiloSessionCreated?.(kiloSessionId); + // Persist the bounded page's cursor / hasOlderMessages / omittedItemCount + // so `loadOlderMessages` can continue from where the transport left off. + // This is a no-op for stale (pre-switchSession) callbacks because + // `loadOlderGeneration` advances on every switch. + const recordInitialPage = (page: SessionSnapshotPage): void => { + applyPage({ ...page, kind: 'success' }, loadOlderGeneration); + }; + const session = createCloudAgentSession({ kiloSessionId, resolveSession: config.resolveSession, @@ -807,6 +979,8 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { getTicket: config.getTicket, api: config.api, fetchSnapshot: config.fetchSnapshot, + ...(config.fetchSnapshotPage ? { fetchSnapshotPage: config.fetchSnapshotPage } : {}), + onInitialPageLoaded: recordInitialPage, userWebConnection: config.userWebConnection, lifecycleHooks: config.lifecycleHooks, websocketHeaders: config.websocketHeaders, @@ -1162,6 +1336,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { return { switchSession, hydrateChildSession, + loadOlderMessages, send, setRemoteModelOverride, retryRemoteModels, @@ -1215,6 +1390,10 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { contextUsage: contextUsageAtom, childMessages: childMessagesAtom, childSessionHydrationState: childSessionHydrationStateAtom, + hasOlderMessages: hasOlderMessagesAtom, + isLoadingOlderMessages: isLoadingOlderMessagesAtom, + olderMessagesError: olderMessagesErrorAtom, + olderMessagesOmittedItemCount: olderMessagesOmittedItemCountAtom, }, }; } diff --git a/apps/web/src/lib/cloud-agent-sdk/session.ts b/apps/web/src/lib/cloud-agent-sdk/session.ts index 941137e37a..a29d386ed8 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session.ts @@ -37,6 +37,8 @@ import type { ResolvedSession, SessionInfo, SessionSnapshot, + SessionSnapshotPage, + SessionSnapshotPageOutcome, } from './types'; type CloudAgentSessionConfig = { @@ -121,6 +123,20 @@ type CloudAgentSessionTransport = { // Shared fetchSnapshot?: (kiloSessionId: KiloSessionId) => Promise; + /** + * Page-aware root snapshot fetch. The transport uses this for its initial + * bounded read and any reconnect snapshot replays. After a successful + * initial fetch the transport calls `onInitialPageLoaded` so the manager + * can record the cursor and `omittedItemCount`; reconnect replays do NOT + * fire that callback so the user's already-advanced older-messages cursor + * isn't reset to the latest 50 on every reconnect. + */ + fetchSnapshotPage?: ( + kiloSessionId: KiloSessionId, + options: { cursor?: string } + ) => Promise; + /** Called by the transport after a successful initial bounded page read. */ + onInitialPageLoaded?: (page: SessionSnapshotPage) => void; lifecycleHooks?: ConnectionLifecycleHooks; websocketHeaders?: WebSocketHeaders; @@ -257,6 +273,8 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes kiloSessionId: resolved.kiloSessionId, userWebConnection: config.transport.userWebConnection, fetchSnapshot: config.transport.fetchSnapshot, + fetchSnapshotPage: config.transport.fetchSnapshotPage, + onInitialPageLoaded: config.transport.onInitialPageLoaded, onError: config.onError, onRemoteModelStateChange: config.onRemoteModelStateChange, onCapabilityChange: config.onTransportCapabilityChange, @@ -287,6 +305,8 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes api: config.transport.api, getTicket: config.transport.getTicket, fetchSnapshot: config.transport.fetchSnapshot, + fetchSnapshotPage: config.transport.fetchSnapshotPage, + onInitialPageLoaded: config.transport.onInitialPageLoaded, websocketBaseUrl: config.websocketBaseUrl, onError: config.onError, lifecycleHooks: config.transport.lifecycleHooks, @@ -302,6 +322,8 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes return createCliHistoricalTransport({ kiloSessionId: resolved.kiloSessionId, fetchSnapshot: config.transport.fetchSnapshot, + fetchSnapshotPage: config.transport.fetchSnapshotPage, + onInitialPageLoaded: config.transport.onInitialPageLoaded, onError: config.onError, }); } diff --git a/apps/web/src/lib/cloud-agent-sdk/types.ts b/apps/web/src/lib/cloud-agent-sdk/types.ts index fa92cba257..87ce7fcab5 100644 --- a/apps/web/src/lib/cloud-agent-sdk/types.ts +++ b/apps/web/src/lib/cloud-agent-sdk/types.ts @@ -24,6 +24,12 @@ export type { import type { UserMessage, AssistantMessage, Part } from '@/types/opencode.gen'; +export type { + KiloSdkMessageHistory, + KiloSdkMessageHistoryPage, + KiloSdkStoredMessage, +} from '@kilocode/session-ingest-contracts'; + // --------------------------------------------------------------------------- // Branded session ID types — prevent accidental mixing of kilo vs cloud agent IDs // --------------------------------------------------------------------------- @@ -170,3 +176,43 @@ export type SessionSnapshot = { parts: Part[]; }>; }; + +/** + * Bounded page of persisted SDK messages for a Kilo session. Returned by the + * `fetchSnapshotPage` seam that the mobile client uses to walk the history + * one page at a time. `nextCursor` is the opaque cursor to pass to the next + * page (or `null` when the history has been fully read); `omittedItemCount` + * reports how many individual items the worker filtered out before the page + * left the DO so the UI can faithfully report omissions. + */ +export type SessionSnapshotPage = { + info: SessionInfo; + messages: SessionSnapshot['messages']; + nextCursor: string | null; + omittedItemCount: number; +}; + +/** + * Result of a single `fetchSnapshotPage` call. The discriminated `kind` lets + * the caller distinguish a successful bounded read from typed worker-side + * failures (`retryable_failure` for transient DO read issues, + * `invalid_data` for shape mismatches, `too_large` for oversize pages) so + * retry semantics can be surfaced without inferring them from the worker's + * text. `null` represents an access-not-found outcome (worker returns 404). + */ +export type SessionSnapshotPageOutcome = + | (SessionSnapshotPage & { kind: 'success' }) + | { kind: 'retryable_failure' } + | { kind: 'invalid_data' } + | { kind: 'too_large' }; + +/** + * Typed failure state for the manager's older-messages load. Mirrors the + * worker-side `SessionSnapshotPageOutcome` failure kinds so the UI can + * surface a Retry CTA for `retryable` and a terminal no-CTA state for + * `invalid_data` / `too_large` without re-deriving retry semantics. + */ +export type OlderMessagesError = + | { kind: 'retryable' } + | { kind: 'invalid_data' } + | { kind: 'too_large' }; From 265aa4c15ffdc18ecb7eaf99f28a04182a082efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 13:56:23 +0200 Subject: [PATCH 3/5] feat(mobile): load earlier session messages --- .../agents/session-detail-content.tsx | 44 ++---- .../agents/session-message-list-state.test.ts | 91 ++++++++++++ .../agents/session-message-list-state.ts | 50 +++++++ .../agents/session-message-list.tsx | 138 ++++++++++++++++++ ...ion-pagination-header-render-model.test.ts | 92 ++++++++++++ .../session-pagination-header-render-model.ts | 77 ++++++++++ .../agents/session-pagination-header.tsx | 76 ++++++++++ .../use-session-auto-scroll-state.test.ts | 112 ++++++++++++++ .../agents/use-session-auto-scroll-state.ts | 48 ++++++ ...oll.ts => use-session-list-auto-scroll.ts} | 75 ++++++++-- 10 files changed, 760 insertions(+), 43 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-message-list-state.test.ts create mode 100644 apps/mobile/src/components/agents/session-message-list-state.ts create mode 100644 apps/mobile/src/components/agents/session-message-list.tsx create mode 100644 apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts create mode 100644 apps/mobile/src/components/agents/session-pagination-header-render-model.ts create mode 100644 apps/mobile/src/components/agents/session-pagination-header.tsx create mode 100644 apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts create mode 100644 apps/mobile/src/components/agents/use-session-auto-scroll-state.ts rename apps/mobile/src/components/agents/{use-session-auto-scroll.ts => use-session-list-auto-scroll.ts} (70%) diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 9b368ca6c7..7a7ec09486 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -4,7 +4,7 @@ import { type Href, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { MessageSquare } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FlatList, KeyboardAvoidingView, Platform, View } from 'react-native'; +import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -33,8 +33,8 @@ import { import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers'; -import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll'; import { useSessionConfigSync } from '@/components/agents/use-session-config-sync'; +import { SessionMessageList } from '@/components/agents/session-message-list'; import { WorkingIndicator } from '@/components/agents/working-indicator'; import { ChildSessionSheet } from '@/components/agents/child-session-sheet'; import { PartRenderer } from '@/components/agents/part-renderer'; @@ -110,6 +110,10 @@ export function SessionDetailContent({ const observedModel = useAtomValue(manager.atoms.observedModel); const remoteModelOverride = useAtomValue(manager.atoms.remoteModelOverride); const contextUsage = useAtomValue(manager.atoms.contextUsage); + const hasOlderMessages = useAtomValue(manager.atoms.hasOlderMessages); + const isLoadingOlderMessages = useAtomValue(manager.atoms.isLoadingOlderMessages); + const olderMessagesError = useAtomValue(manager.atoms.olderMessagesError); + const olderMessagesOmittedItemCount = useAtomValue(manager.atoms.olderMessagesOmittedItemCount); const [openContextSheetIdentity, setOpenContextSheetIdentity] = useState(null); @@ -233,17 +237,6 @@ export function SessionDetailContent({ selectedVariant: sessionModels.selectedVariant, }); - const { - flatListRef, - handleContentSizeChange, - handleListLayout, - handleScroll, - handleScrollBeginDrag, - handleScrollEndDrag, - handleMomentumScrollBegin, - handleMomentumScrollEnd, - } = useSessionAutoScroll({ itemCount: messages.length, resetKey: sessionId }); - const viewTrackedRef = useRef(null); useEffect(() => { if (fetchedData?.kiloSessionId !== sessionId || viewTrackedRef.current === sessionId) { @@ -604,28 +597,23 @@ export function SessionDetailContent({ ); } return ( - item.info.id} + { + void manager.loadOlderMessages(); + }} renderItem={renderItem} - onScroll={handleScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleScrollEndDrag} - onMomentumScrollBegin={handleMomentumScrollBegin} - onMomentumScrollEnd={handleMomentumScrollEnd} - onContentSizeChange={handleContentSizeChange} - onLayout={handleListLayout} - scrollEventThrottle={16} ListFooterComponent={ <> {statusIndicator ? : null} } - contentContainerClassName="py-2" - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" /> ); } diff --git a/apps/mobile/src/components/agents/session-message-list-state.test.ts b/apps/mobile/src/components/agents/session-message-list-state.test.ts new file mode 100644 index 0000000000..247b834f49 --- /dev/null +++ b/apps/mobile/src/components/agents/session-message-list-state.test.ts @@ -0,0 +1,91 @@ +import { type OlderMessagesError } from 'cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; +import { selectSessionMessageListHeaderState } from '@/components/agents/session-message-list-state'; + +function error(kind: OlderMessagesError['kind']): OlderMessagesError { + return { kind }; +} + +describe('selectSessionMessageListHeaderState', () => { + it('returns hidden when nothing is loading, no error, and no omitted items', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: false, + olderMessagesError: null, + olderMessagesOmittedItemCount: 0, + }) + ).toEqual({ kind: 'hidden' }); + }); + + it('returns loading while a page is in flight and there is no error yet', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: true, + olderMessagesError: null, + olderMessagesOmittedItemCount: 0, + }) + ).toEqual({ kind: 'loading' }); + }); + + it('returns retryable when the most recent older load failed retryably', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: false, + olderMessagesError: error('retryable'), + olderMessagesOmittedItemCount: 0, + }) + ).toEqual({ kind: 'retryable' }); + }); + + it('prefers the retryable error over an in-flight retry so the CTA appears on the first frame after failure', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: true, + olderMessagesError: error('retryable'), + olderMessagesOmittedItemCount: 0, + }) + ).toEqual({ kind: 'retryable' }); + }); + + it('returns invalid_data for a non-retryable invalid_data failure and hides omitted noise', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: false, + olderMessagesError: error('invalid_data'), + olderMessagesOmittedItemCount: 7, + }) + ).toEqual({ kind: 'invalid_data' }); + }); + + it('returns too_large for a non-retryable too_large failure and hides omitted noise', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: false, + olderMessagesError: error('too_large'), + olderMessagesOmittedItemCount: 2, + }) + ).toEqual({ kind: 'too_large' }); + }); + + it('returns omitted with the running count when prior pages skipped items and no error is set', () => { + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: false, + olderMessagesError: null, + olderMessagesOmittedItemCount: 5, + }) + ).toEqual({ kind: 'omitted', count: 5 }); + }); + + it('hides omitted noise while a page is loading and the count is non-zero', () => { + // The skeleton replaces the calm informational message; once the page + // resolves, the omitted message returns only if no error overrides it. + expect( + selectSessionMessageListHeaderState({ + isLoadingOlderMessages: true, + olderMessagesError: null, + olderMessagesOmittedItemCount: 5, + }) + ).toEqual({ kind: 'loading' }); + }); +}); diff --git a/apps/mobile/src/components/agents/session-message-list-state.ts b/apps/mobile/src/components/agents/session-message-list-state.ts new file mode 100644 index 0000000000..2e11ff4c89 --- /dev/null +++ b/apps/mobile/src/components/agents/session-message-list-state.ts @@ -0,0 +1,50 @@ +import { type OlderMessagesError } from 'cloud-agent-sdk'; + +/** + * Pagination header state for `SessionMessageList`. The component renders + * exactly one of these per render: a loading skeleton, a calm inline + * message (with or without a Retry CTA), or nothing. + * + * Priority is enforced by `selectSessionMessageListHeaderState`: + * 1. The most recent typed failure wins so the user can always act on it + * (or, for non-retryable terminals, sees a stable final message). + * 2. While a page is loading, the skeleton replaces the omitted message + * so the two never collide visually. + * 3. The omitted-item count only surfaces when the load path is healthy. + */ +type SessionMessageListHeaderState = + | { kind: 'hidden' } + | { kind: 'loading' } + | { kind: 'retryable' } + | { kind: 'invalid_data' } + | { kind: 'too_large' } + | { kind: 'omitted'; count: number }; + +export type SessionMessageListHeaderStateInputs = { + isLoadingOlderMessages: boolean; + olderMessagesError: OlderMessagesError | null; + olderMessagesOmittedItemCount: number; +}; + +export function selectSessionMessageListHeaderState({ + isLoadingOlderMessages, + olderMessagesError, + olderMessagesOmittedItemCount, +}: SessionMessageListHeaderStateInputs): SessionMessageListHeaderState { + if (olderMessagesError) { + if (olderMessagesError.kind === 'retryable') { + return { kind: 'retryable' }; + } + if (olderMessagesError.kind === 'invalid_data') { + return { kind: 'invalid_data' }; + } + return { kind: 'too_large' }; + } + if (isLoadingOlderMessages) { + return { kind: 'loading' }; + } + if (olderMessagesOmittedItemCount > 0) { + return { kind: 'omitted', count: olderMessagesOmittedItemCount }; + } + return { kind: 'hidden' }; +} diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx new file mode 100644 index 0000000000..46bfc52c8e --- /dev/null +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -0,0 +1,138 @@ +import { FlashList, type FlashListRef, type ListRenderItem } from '@shopify/flash-list'; +import { type OlderMessagesError, type StoredMessage } from 'cloud-agent-sdk'; +import { useCallback, useEffect, useRef } from 'react'; +import { type ViewStyle } from 'react-native'; + +import { useSessionListAutoScroll } from '@/components/agents/use-session-list-auto-scroll'; +import { SessionPaginationHeader } from '@/components/agents/session-pagination-header'; + +const listStyle = { flex: 1 } satisfies ViewStyle; +const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle; + +// Prevent `onStartReached` from firing while an older page is already in +// flight. The manager dedupes too, but the UI guard keeps us from issuing +// repeated `onStartReached` callbacks during a single drag, which would +// otherwise spam the FlashList event log. +const ON_START_REACHED_THRESHOLD = 0.5; + +type SessionMessageListProps = { + sessionId: string; + messages: readonly StoredMessage[]; + hasOlderMessages: boolean; + isLoadingOlderMessages: boolean; + olderMessagesError: OlderMessagesError | null; + olderMessagesOmittedItemCount: number; + onLoadOlderMessages: () => void; + renderItem: ListRenderItem; + ListFooterComponent?: React.ComponentType | React.ReactElement | null; +}; + +export function SessionMessageList({ + sessionId, + messages, + hasOlderMessages, + isLoadingOlderMessages, + olderMessagesError, + olderMessagesOmittedItemCount, + onLoadOlderMessages, + renderItem, + ListFooterComponent, +}: Readonly) { + // FlashList v2 renders the list in chronological order (oldest → newest). + // `startRenderingFromBottom` keeps the viewport anchored at the newest + // message on first render and after prepended older pages, which is the + // exact behavior we want for the agent session transcript. + const { + listRef, + handleContentSizeChange, + handleListLayout, + handleScroll, + handleScrollBeginDrag, + handleScrollEndDrag, + handleMomentumScrollBegin, + handleMomentumScrollEnd, + } = useSessionListAutoScroll({ + itemCount: messages.length, + resetKey: sessionId, + }); + + // Coalesce the trigger: only fire `onLoadOlderMessages` while there is + // actually a cursor, we are not already loading, and we are not in a + // terminal failure state. The manager enforces the same rules; this + // prevents noisy re-fires from FlashList's onStartReached callback. + const inFlightRef = useRef(false); + const handleStartReached = useCallback(() => { + if (!hasOlderMessages) { + return; + } + if (isLoadingOlderMessages) { + return; + } + if (inFlightRef.current) { + return; + } + if (olderMessagesError && olderMessagesError.kind !== 'retryable') { + return; + } + inFlightRef.current = true; + try { + onLoadOlderMessages(); + } finally { + // Microtask-deferred reset lets the manager's loading atom update + // before the next onStartReached cycle. + queueMicrotask(() => { + inFlightRef.current = false; + }); + } + }, [hasOlderMessages, isLoadingOlderMessages, onLoadOlderMessages, olderMessagesError]); + + // Reset the in-flight guard whenever the session changes so a new + // transcript doesn't inherit a stale lock. + useEffect(() => { + inFlightRef.current = false; + }, [sessionId]); + + // Defensive: the structural list ref is required by the hook but + // downstream types may infer it as nullable. + const listRefSafe = listRef as unknown as React.RefObject>; + + return ( + + ref={listRefSafe} + style={listStyle} + contentContainerStyle={listContentContainerStyle} + data={messages} + keyExtractor={item => item.info.id} + renderItem={renderItem} + onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} + onContentSizeChange={handleContentSizeChange} + onLayout={handleListLayout} + scrollEventThrottle={16} + onStartReached={hasOlderMessages ? handleStartReached : undefined} + onStartReachedThreshold={ON_START_REACHED_THRESHOLD} + maintainVisibleContentPosition={{ + // Start rendering from the bottom so the newest message is visible + // on first render. `autoscrollToTopThreshold` is left at its default + // so the viewport only repositions when the user is far enough away + // from the top — preserving the existing auto-follow behavior on + // streaming insertions at the bottom. + startRenderingFromBottom: true, + }} + ListHeaderComponent={ + + } + ListFooterComponent={ListFooterComponent} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + /> + ); +} diff --git a/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts b/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts new file mode 100644 index 0000000000..5282f50bc1 --- /dev/null +++ b/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts @@ -0,0 +1,92 @@ +import { type OlderMessagesError } from 'cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; +import { selectSessionPaginationHeaderRenderModel } from '@/components/agents/session-pagination-header-render-model'; +import { type SessionMessageListHeaderStateInputs } from '@/components/agents/session-message-list-state'; + +function headerModel(overrides: Partial = {}) { + return selectSessionPaginationHeaderRenderModel({ + isLoadingOlderMessages: false, + olderMessagesError: null, + olderMessagesOmittedItemCount: 0, + ...overrides, + }); +} + +function error(kind: OlderMessagesError['kind']): OlderMessagesError { + return { kind }; +} + +describe('selectSessionPaginationHeaderRenderModel', () => { + it('returns hidden when the header has nothing to show', () => { + expect(headerModel()).toEqual({ kind: 'hidden' }); + }); + + it('returns loading with testID and progressbar role', () => { + expect(headerModel({ isLoadingOlderMessages: true })).toEqual({ + kind: 'loading', + testID: 'session-pagination-header-loading', + accessibilityRole: 'progressbar', + text: null, + }); + }); + + it('renders retryable text and a Retry CTA', () => { + expect(headerModel({ olderMessagesError: error('retryable') })).toEqual({ + kind: 'retryable', + testID: 'session-pagination-header-retryable', + text: "Couldn't load earlier messages.", + retry: { + label: 'Retry', + accessibilityHint: 'Reattempts the older-messages load for this session.', + }, + }); + }); + + it('renders invalid_data text with no Retry CTA', () => { + expect(headerModel({ olderMessagesError: error('invalid_data') })).toEqual({ + kind: 'invalid_data', + testID: 'session-pagination-header-invalid-data', + text: "Earlier messages aren't available.", + }); + }); + + it('renders too_large text with no Retry CTA', () => { + expect(headerModel({ olderMessagesError: error('too_large') })).toEqual({ + kind: 'too_large', + testID: 'session-pagination-header-too-large', + text: 'Earlier messages are too large to load.', + }); + }); + + it('renders singular omitted text with no Retry CTA', () => { + expect(headerModel({ olderMessagesOmittedItemCount: 1 })).toEqual({ + kind: 'omitted', + testID: 'session-pagination-header-omitted', + text: 'Some earlier items from this session could not be displayed.', + }); + }); + + it('renders plural omitted text with no Retry CTA', () => { + expect(headerModel({ olderMessagesOmittedItemCount: 5 })).toEqual({ + kind: 'omitted', + testID: 'session-pagination-header-omitted', + text: '5 earlier items from this session could not be displayed.', + }); + }); + + it('only includes a retry CTA for the retryable state', () => { + const hidden = headerModel(); + const loading = headerModel({ isLoadingOlderMessages: true }); + const invalidData = headerModel({ olderMessagesError: error('invalid_data') }); + const tooLarge = headerModel({ olderMessagesError: error('too_large') }); + const omitted = headerModel({ olderMessagesOmittedItemCount: 3 }); + const retryable = headerModel({ olderMessagesError: error('retryable') }); + + expect('retry' in hidden).toBe(false); + expect('retry' in loading).toBe(false); + expect('retry' in invalidData).toBe(false); + expect('retry' in tooLarge).toBe(false); + expect('retry' in omitted).toBe(false); + expect('retry' in retryable).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/agents/session-pagination-header-render-model.ts b/apps/mobile/src/components/agents/session-pagination-header-render-model.ts new file mode 100644 index 0000000000..eb11df0320 --- /dev/null +++ b/apps/mobile/src/components/agents/session-pagination-header-render-model.ts @@ -0,0 +1,77 @@ +import { + selectSessionMessageListHeaderState, + type SessionMessageListHeaderStateInputs, +} from '@/components/agents/session-message-list-state'; + +const RETRY_LABEL = 'Retry'; +const RETRY_HINT = 'Reattempts the older-messages load for this session.'; + +function omittedMessage(count: number): string { + if (count === 1) { + return 'Some earlier items from this session could not be displayed.'; + } + return `${count} earlier items from this session could not be displayed.`; +} + +export type SessionPaginationHeaderRenderModel = + | { kind: 'hidden' } + | { kind: 'loading'; testID: string; accessibilityRole: 'progressbar'; text: null } + | { + kind: 'retryable'; + testID: string; + text: string; + retry: { label: string; accessibilityHint: string }; + } + | { kind: 'invalid_data'; testID: string; text: string } + | { kind: 'too_large'; testID: string; text: string } + | { kind: 'omitted'; testID: string; text: string }; + +export function selectSessionPaginationHeaderRenderModel( + inputs: SessionMessageListHeaderStateInputs +): SessionPaginationHeaderRenderModel { + const state = selectSessionMessageListHeaderState(inputs); + + if (state.kind === 'hidden') { + return { kind: 'hidden' }; + } + + if (state.kind === 'loading') { + return { + kind: 'loading', + testID: 'session-pagination-header-loading', + accessibilityRole: 'progressbar', + text: null, + }; + } + + if (state.kind === 'retryable') { + return { + kind: 'retryable', + testID: 'session-pagination-header-retryable', + text: "Couldn't load earlier messages.", + retry: { label: RETRY_LABEL, accessibilityHint: RETRY_HINT }, + }; + } + + if (state.kind === 'invalid_data') { + return { + kind: 'invalid_data', + testID: 'session-pagination-header-invalid-data', + text: "Earlier messages aren't available.", + }; + } + + if (state.kind === 'too_large') { + return { + kind: 'too_large', + testID: 'session-pagination-header-too-large', + text: 'Earlier messages are too large to load.', + }; + } + + return { + kind: 'omitted', + testID: 'session-pagination-header-omitted', + text: omittedMessage(state.count), + }; +} diff --git a/apps/mobile/src/components/agents/session-pagination-header.tsx b/apps/mobile/src/components/agents/session-pagination-header.tsx new file mode 100644 index 0000000000..010e864df7 --- /dev/null +++ b/apps/mobile/src/components/agents/session-pagination-header.tsx @@ -0,0 +1,76 @@ +import { View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { + selectSessionPaginationHeaderRenderModel, + type SessionPaginationHeaderRenderModel, +} from '@/components/agents/session-pagination-header-render-model'; + +type SessionPaginationHeaderProps = { + isLoadingOlderMessages: boolean; + olderMessagesError: Parameters< + typeof selectSessionPaginationHeaderRenderModel + >[0]['olderMessagesError']; + olderMessagesOmittedItemCount: number; + onRetry: () => void; +}; + +export function SessionPaginationHeader({ + isLoadingOlderMessages, + olderMessagesError, + olderMessagesOmittedItemCount, + onRetry, +}: Readonly) { + const model: SessionPaginationHeaderRenderModel = selectSessionPaginationHeaderRenderModel({ + isLoadingOlderMessages, + olderMessagesError, + olderMessagesOmittedItemCount, + }); + + if (model.kind === 'hidden') { + return null; + } + + if (model.kind === 'loading') { + return ( + + + + ); + } + + if (model.kind === 'retryable') { + return ( + + {model.text} + + + ); + } + + return ( + + {model.text} + + ); +} diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts new file mode 100644 index 0000000000..fc24995038 --- /dev/null +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + isSessionListAtBottom, + shouldScheduleSessionAutoScroll, +} from '@/components/agents/use-session-auto-scroll-state'; + +describe('isSessionListAtBottom', () => { + it('returns true when the viewport bottom is within the bottom threshold', () => { + expect( + isSessionListAtBottom({ + contentHeight: 1000, + viewportHeight: 600, + offsetY: 350, + }) + ).toBe(true); + }); + + it('returns true when the viewport bottom touches the end of the content', () => { + expect( + isSessionListAtBottom({ + contentHeight: 1000, + viewportHeight: 600, + offsetY: 400, + }) + ).toBe(true); + }); + + it('returns false when the user has scrolled past the bottom threshold', () => { + expect( + isSessionListAtBottom({ + contentHeight: 2000, + viewportHeight: 600, + offsetY: 900, + }) + ).toBe(false); + }); + + it('returns true at the very top of a short list whose content fits the viewport', () => { + // A short list is fully visible: there is no "below the fold" content + // and the viewport bottom equals the content bottom. + expect( + isSessionListAtBottom({ + contentHeight: 300, + viewportHeight: 600, + offsetY: 0, + }) + ).toBe(true); + }); + + it('respects a custom threshold for the bottom-stickiness band', () => { + expect( + isSessionListAtBottom({ + contentHeight: 1000, + viewportHeight: 600, + offsetY: 350, + thresholdPx: 50, + }) + ).toBe(false); + expect( + isSessionListAtBottom({ + contentHeight: 1000, + viewportHeight: 600, + offsetY: 360, + thresholdPx: 50, + }) + ).toBe(true); + }); +}); + +describe('shouldScheduleSessionAutoScroll', () => { + it('schedules when the user is at the bottom, not auto-scrolling, and not actively dragging', () => { + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: false, + isUserScrolling: false, + shouldAutoScroll: true, + }) + ).toBe(true); + }); + + it('does not schedule while the user is actively dragging or in momentum fling', () => { + // Programmatic scroll during a drag yanks the viewport and the user's + // drag appears to "bounce back". + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: false, + isUserScrolling: true, + shouldAutoScroll: true, + }) + ).toBe(false); + }); + + it('does not schedule when the hook believes it just issued a programmatic scroll', () => { + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: true, + isUserScrolling: false, + shouldAutoScroll: true, + }) + ).toBe(false); + }); + + it('does not schedule when the user has scrolled away from the bottom', () => { + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: false, + isUserScrolling: false, + shouldAutoScroll: false, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts new file mode 100644 index 0000000000..a0c3a424ce --- /dev/null +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts @@ -0,0 +1,48 @@ +// Default 100px matches the existing auto-scroll behaviour: as long as the +// viewport bottom is within ~100px of the content bottom, the user is +// considered "at the bottom" and the next content-size growth auto-scrolls. +export const SESSION_LIST_BOTTOM_THRESHOLD_PX = 100; + +export function isSessionListAtBottom({ + contentHeight, + viewportHeight, + offsetY, + thresholdPx = SESSION_LIST_BOTTOM_THRESHOLD_PX, +}: { + contentHeight: number; + viewportHeight: number; + offsetY: number; + thresholdPx?: number; +}): boolean { + const distanceFromBottom = contentHeight - offsetY - viewportHeight; + return distanceFromBottom < thresholdPx; +} + +/** + * Decide whether a programmatic scroll-to-latest should be scheduled. + * + * Mirrors the four guards inside `useSessionAutoScroll`'s `scheduleScrollToLatestMessage`: + * - `isAutoScrolling` – a programmatic scroll is in flight, skip the retry. + * - `isUserScrolling` – user is dragging or in momentum, never yank. + * - `shouldAutoScroll` – the user has scrolled away from the bottom. + */ +export function shouldScheduleSessionAutoScroll({ + isAutoScrolling, + isUserScrolling, + shouldAutoScroll, +}: { + isAutoScrolling: boolean; + isUserScrolling: boolean; + shouldAutoScroll: boolean; +}): boolean { + if (!shouldAutoScroll) { + return false; + } + if (isUserScrolling) { + return false; + } + if (isAutoScrolling) { + return false; + } + return true; +} diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll.ts b/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts similarity index 70% rename from apps/mobile/src/components/agents/use-session-auto-scroll.ts rename to apps/mobile/src/components/agents/use-session-list-auto-scroll.ts index 45a50963a3..7300fb6268 100644 --- a/apps/mobile/src/components/agents/use-session-auto-scroll.ts +++ b/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts @@ -1,13 +1,30 @@ +import { type FlashListRef } from '@shopify/flash-list'; import { useCallback, useEffect, useRef } from 'react'; -import { type FlatList, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'; +import { type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'; -type UseSessionAutoScrollParams = { +import { + isSessionListAtBottom, + SESSION_LIST_BOTTOM_THRESHOLD_PX, + shouldScheduleSessionAutoScroll, +} from '@/components/agents/use-session-auto-scroll-state'; + +type UseSessionListAutoScrollParams = { itemCount: number; resetKey: string; }; -export function useSessionAutoScroll({ itemCount, resetKey }: UseSessionAutoScrollParams) { - const flatListRef = useRef>(null); +/** + * FlashList-compatible companion to `useSessionAutoScroll`. Mirrors the + * "follow the latest message" behavior: keep auto-following while the user + * is at the bottom, stop once they scroll away, never yank during drag or + * momentum. Pure decisions live in `use-session-auto-scroll-state` for + * unit testing without React. + */ +export function useSessionListAutoScroll({ + itemCount, + resetKey, +}: UseSessionListAutoScrollParams) { + const listRef = useRef>(null); const shouldAutoScrollRef = useRef(true); const isAutoScrollingRef = useRef(false); // Tracks whether the user is currently dragging or the list is still in a @@ -47,10 +64,9 @@ export function useSessionAutoScroll({ itemCount, resetKey }: UseSessionA const scrollToLatestMessage = useCallback(() => { isAutoScrollingRef.current = true; clearAutoScrollResetTimeout(); - flatListRef.current?.scrollToOffset({ - offset: lastContentHeightRef.current, - animated: false, - }); + // FlashList in v2 supports `scrollToEnd` directly. The list is rendered + // in chronological order, so the end is the newest message. + listRef.current?.scrollToEnd({ animated: false }); autoScrollResetTimeoutRef.current = setTimeout(() => { isAutoScrollingRef.current = false; autoScrollResetTimeoutRef.current = null; @@ -58,14 +74,26 @@ export function useSessionAutoScroll({ itemCount, resetKey }: UseSessionA }, [clearAutoScrollResetTimeout]); const scheduleScrollToLatestMessage = useCallback(() => { - if (isUserScrollingRef.current) { + if ( + !shouldScheduleSessionAutoScroll({ + isAutoScrolling: isAutoScrollingRef.current, + isUserScrolling: isUserScrollingRef.current, + shouldAutoScroll: shouldAutoScrollRef.current, + }) + ) { return; } scrollToLatestMessage(); clearAutoScrollRetryTimeout(); autoScrollRetryTimeoutRef.current = setTimeout(() => { autoScrollRetryTimeoutRef.current = null; - if (shouldAutoScrollRef.current && !isUserScrollingRef.current) { + if ( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: isAutoScrollingRef.current, + isUserScrolling: isUserScrollingRef.current, + shouldAutoScroll: shouldAutoScrollRef.current, + }) + ) { scrollToLatestMessage(); } }, 80); @@ -94,8 +122,12 @@ export function useSessionAutoScroll({ itemCount, resetKey }: UseSessionA const updateAutoScrollFromEvent = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; - shouldAutoScrollRef.current = distanceFromBottom < 100; + shouldAutoScrollRef.current = isSessionListAtBottom({ + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + offsetY: contentOffset.y, + thresholdPx: SESSION_LIST_BOTTOM_THRESHOLD_PX, + }); }, [] ); @@ -152,7 +184,14 @@ export function useSessionAutoScroll({ itemCount, resetKey }: UseSessionA (_width: number, height: number) => { const didContentHeightChange = height !== lastContentHeightRef.current; lastContentHeightRef.current = height; - if (shouldAutoScrollRef.current && didContentHeightChange && !isUserScrollingRef.current) { + if ( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: isAutoScrollingRef.current, + isUserScrolling: isUserScrollingRef.current, + shouldAutoScroll: shouldAutoScrollRef.current, + }) && + didContentHeightChange + ) { scheduleScrollToLatestMessage(); } }, @@ -160,13 +199,19 @@ export function useSessionAutoScroll({ itemCount, resetKey }: UseSessionA ); const handleListLayout = useCallback(() => { - if (shouldAutoScrollRef.current && !isUserScrollingRef.current) { + if ( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: isAutoScrollingRef.current, + isUserScrolling: isUserScrollingRef.current, + shouldAutoScroll: shouldAutoScrollRef.current, + }) + ) { scheduleScrollToLatestMessage(); } }, [scheduleScrollToLatestMessage]); return { - flatListRef, + listRef, handleContentSizeChange, handleListLayout, handleScroll, From 1029fa2563d68d6ed9b224f60eaba5eb2b3696f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 14:18:14 +0200 Subject: [PATCH 4/5] test(mobile): cover session pagination trigger guards --- .../agents/session-message-list-state.test.ts | 97 ++++++++++++++++++- .../agents/session-message-list-state.ts | 38 ++++++++ .../agents/session-message-list.tsx | 19 ++-- 3 files changed, 143 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/components/agents/session-message-list-state.test.ts b/apps/mobile/src/components/agents/session-message-list-state.test.ts index 247b834f49..0c8d8f0773 100644 --- a/apps/mobile/src/components/agents/session-message-list-state.test.ts +++ b/apps/mobile/src/components/agents/session-message-list-state.test.ts @@ -1,6 +1,9 @@ import { type OlderMessagesError } from 'cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; -import { selectSessionMessageListHeaderState } from '@/components/agents/session-message-list-state'; +import { + selectSessionMessageListHeaderState, + shouldTriggerOlderMessagesLoad, +} from '@/components/agents/session-message-list-state'; function error(kind: OlderMessagesError['kind']): OlderMessagesError { return { kind }; @@ -89,3 +92,95 @@ describe('selectSessionMessageListHeaderState', () => { ).toEqual({ kind: 'loading' }); }); }); + +describe('shouldTriggerOlderMessagesLoad', () => { + it('returns false when there are no older messages', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: false, + isLoadingOlderMessages: false, + isInFlight: false, + olderMessagesError: null, + }) + ).toBe(false); + }); + + it('returns false when a page is already loading', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: true, + isInFlight: false, + olderMessagesError: null, + }) + ).toBe(false); + }); + + it('returns false while the local in-flight latch is still set', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: false, + isInFlight: true, + olderMessagesError: null, + }) + ).toBe(false); + }); + + it('returns false for a non-retryable invalid_data terminal failure', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: false, + isInFlight: false, + olderMessagesError: error('invalid_data'), + }) + ).toBe(false); + }); + + it('returns false for a non-retryable too_large terminal failure', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: false, + isInFlight: false, + olderMessagesError: error('too_large'), + }) + ).toBe(false); + }); + + it('returns true for a retryable failure so the gesture can re-trigger', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: false, + isInFlight: false, + olderMessagesError: error('retryable'), + }) + ).toBe(true); + }); + + it('returns true in the happy path with no error and a cursor', () => { + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: false, + isInFlight: false, + olderMessagesError: null, + }) + ).toBe(true); + }); + + it('gives the loading/in-flight guards priority over the retryable path', () => { + // Loading and in-flight are checked before the error kind, so even a + // retryable error cannot re-trigger while work is outstanding. + expect( + shouldTriggerOlderMessagesLoad({ + hasOlderMessages: true, + isLoadingOlderMessages: true, + isInFlight: true, + olderMessagesError: error('retryable'), + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/session-message-list-state.ts b/apps/mobile/src/components/agents/session-message-list-state.ts index 2e11ff4c89..cc1e8e93fe 100644 --- a/apps/mobile/src/components/agents/session-message-list-state.ts +++ b/apps/mobile/src/components/agents/session-message-list-state.ts @@ -48,3 +48,41 @@ export function selectSessionMessageListHeaderState({ } return { kind: 'hidden' }; } + +type ShouldTriggerOlderMessagesLoadInputs = { + hasOlderMessages: boolean; + isLoadingOlderMessages: boolean; + isInFlight: boolean; + olderMessagesError: OlderMessagesError | null; +}; + +/** + * Decide whether `onStartReached` should trigger an older-page load. + * + * The guard is intentionally conservative: it blocks the call when there is no + * older cursor, when a page is already loading, when the component's local + * in-flight latch is still set, or when the most recent failure is a + * non-retryable terminal state. Retryable failures are allowed to re-fire so + * the FlashList gesture can retry without requiring an explicit tap on the + * Retry CTA. + */ +export function shouldTriggerOlderMessagesLoad({ + hasOlderMessages, + isLoadingOlderMessages, + isInFlight, + olderMessagesError, +}: ShouldTriggerOlderMessagesLoadInputs): boolean { + if (!hasOlderMessages) { + return false; + } + if (isLoadingOlderMessages) { + return false; + } + if (isInFlight) { + return false; + } + if (olderMessagesError && olderMessagesError.kind !== 'retryable') { + return false; + } + return true; +} diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index 46bfc52c8e..41b7d4b889 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -5,6 +5,7 @@ import { type ViewStyle } from 'react-native'; import { useSessionListAutoScroll } from '@/components/agents/use-session-list-auto-scroll'; import { SessionPaginationHeader } from '@/components/agents/session-pagination-header'; +import { shouldTriggerOlderMessagesLoad } from '@/components/agents/session-message-list-state'; const listStyle = { flex: 1 } satisfies ViewStyle; const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle; @@ -62,16 +63,14 @@ export function SessionMessageList({ // prevents noisy re-fires from FlashList's onStartReached callback. const inFlightRef = useRef(false); const handleStartReached = useCallback(() => { - if (!hasOlderMessages) { - return; - } - if (isLoadingOlderMessages) { - return; - } - if (inFlightRef.current) { - return; - } - if (olderMessagesError && olderMessagesError.kind !== 'retryable') { + if ( + !shouldTriggerOlderMessagesLoad({ + hasOlderMessages, + isLoadingOlderMessages, + isInFlight: inFlightRef.current, + olderMessagesError, + }) + ) { return; } inFlightRef.current = true; From 9fedc64f604ecaf2626cc1be2df9e51b52839717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 20:35:03 +0200 Subject: [PATCH 5/5] fix(agent-sessions): guard pagination and auto-scroll races --- .../use-session-auto-scroll-state.test.ts | 86 +++++++++++++++++++ .../agents/use-session-auto-scroll-state.ts | 57 ++++++++++++ .../agents/use-session-list-auto-scroll.ts | 30 +++++-- .../cloud-agent-sdk/session-manager.test.ts | 68 +++++++++++++++ .../lib/cloud-agent-sdk/session-manager.ts | 12 ++- 5 files changed, 243 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts index fc24995038..e2044c50ee 100644 --- a/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { isSessionListAtBottom, + shouldFollowSessionContentSize, + shouldRetrySessionAutoScroll, shouldScheduleSessionAutoScroll, } from '@/components/agents/use-session-auto-scroll-state'; @@ -110,3 +112,87 @@ describe('shouldScheduleSessionAutoScroll', () => { ).toBe(false); }); }); + +describe('shouldRetrySessionAutoScroll', () => { + it('permits the 80ms safety-net retry while a programmatic scroll is still in flight', () => { + // The whole point of the 80ms retry is to recover when the first + // scroll didn't reach the bottom. A programmatic scroll that's still + // within its 150ms `isAutoScrolling` window must not suppress the + // retry: that would make the retry dead during the highest-frequency + // streaming window. + expect( + shouldRetrySessionAutoScroll({ + isUserScrolling: false, + shouldAutoScroll: true, + }) + ).toBe(true); + }); + + it('blocks the retry while the user is actively dragging or in momentum fling', () => { + // A retry during a drag would yank the viewport and the user's drag + // appears to "bounce back". + expect( + shouldRetrySessionAutoScroll({ + isUserScrolling: true, + shouldAutoScroll: true, + }) + ).toBe(false); + }); + + it('blocks the retry when the user has scrolled away from the bottom', () => { + expect( + shouldRetrySessionAutoScroll({ + isUserScrolling: false, + shouldAutoScroll: false, + }) + ).toBe(false); + }); +}); + +describe('shouldFollowSessionContentSize', () => { + it('permits a content-size follow scroll while a programmatic scroll is still in flight', () => { + // Rapid streaming content-size changes that arrive during the 150ms + // programmatic-scroll window must still keep the viewport pinned to + // the bottom. Gating on `!isAutoScrolling` here would silently drop + // every streaming update that lands inside the debounce window. + expect( + shouldFollowSessionContentSize({ + isUserScrolling: false, + shouldAutoScroll: true, + didContentHeightChange: true, + }) + ).toBe(true); + }); + + it('blocks the follow when the content height has not actually changed', () => { + // A redundant measurement (e.g. layout pass with the same height) + // must not trigger another programmatic scroll. + expect( + shouldFollowSessionContentSize({ + isUserScrolling: false, + shouldAutoScroll: true, + didContentHeightChange: false, + }) + ).toBe(false); + }); + + it('blocks the follow while the user is actively dragging or in momentum fling', () => { + expect( + shouldFollowSessionContentSize({ + isUserScrolling: true, + shouldAutoScroll: true, + didContentHeightChange: true, + }) + ).toBe(false); + }); + + it('blocks the follow when the user has scrolled away from the bottom', () => { + expect( + shouldFollowSessionContentSize({ + isUserScrolling: false, + shouldAutoScroll: false, + didContentHeightChange: true, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts index a0c3a424ce..8eb8f7015f 100644 --- a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts @@ -46,3 +46,60 @@ export function shouldScheduleSessionAutoScroll({ } return true; } + +/** + * Decide whether the 80ms safety-net retry should re-scroll to the latest + * message. Unlike `shouldScheduleSessionAutoScroll`, this does NOT gate on + * `isAutoScrolling`: the whole point of the retry is to recover when the + * first scroll didn't reach the bottom, and a programmatic scroll that's + * still in flight (within the 150ms `isAutoScrolling` window) must not + * suppress the retry. It must still honour the user-facing guards: + * - `isUserScrolling` – the user is dragging or in momentum, never yank. + * - `shouldAutoScroll` – the user has scrolled away from the bottom. + */ +export function shouldRetrySessionAutoScroll({ + isUserScrolling, + shouldAutoScroll, +}: { + isUserScrolling: boolean; + shouldAutoScroll: boolean; +}): boolean { + if (!shouldAutoScroll) { + return false; + } + if (isUserScrolling) { + return false; + } + return true; +} + +/** + * Decide whether a streaming content-size change should trigger a follow + * scroll to the latest message. Like `shouldRetrySessionAutoScroll`, this + * does NOT gate on `isAutoScrolling`: rapid streaming content-size changes + * that arrive during the 150ms programmatic-scroll window must still keep + * the viewport pinned to the bottom. It still honours the user-facing + * guards and additionally requires the content height to have actually + * changed since the last call (otherwise every redundant measurement would + * re-scroll even when no new content was added). + */ +export function shouldFollowSessionContentSize({ + isUserScrolling, + shouldAutoScroll, + didContentHeightChange, +}: { + isUserScrolling: boolean; + shouldAutoScroll: boolean; + didContentHeightChange: boolean; +}): boolean { + if (!shouldAutoScroll) { + return false; + } + if (isUserScrolling) { + return false; + } + if (!didContentHeightChange) { + return false; + } + return true; +} diff --git a/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts b/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts index 7300fb6268..c2486e708a 100644 --- a/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts +++ b/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts @@ -5,6 +5,8 @@ import { type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native' import { isSessionListAtBottom, SESSION_LIST_BOTTOM_THRESHOLD_PX, + shouldFollowSessionContentSize, + shouldRetrySessionAutoScroll, shouldScheduleSessionAutoScroll, } from '@/components/agents/use-session-auto-scroll-state'; @@ -87,9 +89,13 @@ export function useSessionListAutoScroll({ clearAutoScrollRetryTimeout(); autoScrollRetryTimeoutRef.current = setTimeout(() => { autoScrollRetryTimeoutRef.current = null; + // The 80ms safety-net retry must not gate on `isAutoScrolling`: + // a programmatic scroll that's still within its 150ms window + // would otherwise suppress the retry and make it dead during the + // highest-frequency streaming window. It still honours the + // user-facing and follow-bottom guards. if ( - shouldScheduleSessionAutoScroll({ - isAutoScrolling: isAutoScrollingRef.current, + shouldRetrySessionAutoScroll({ isUserScrolling: isUserScrollingRef.current, shouldAutoScroll: shouldAutoScrollRef.current, }) @@ -184,18 +190,26 @@ export function useSessionListAutoScroll({ (_width: number, height: number) => { const didContentHeightChange = height !== lastContentHeightRef.current; lastContentHeightRef.current = height; + // Content-size follow must not gate on `isAutoScrolling`: rapid + // streaming content-size changes that arrive during the 150ms + // programmatic-scroll window must still keep the viewport pinned + // to the bottom. Gating on `!isAutoScrolling` here would silently + // drop every streaming update that lands inside the debounce + // window. Bypass `scheduleScrollToLatestMessage` (which keeps + // the `!isAutoScrolling` guard for the initial itemCount / + // handleListLayout triggers) and trigger the programmatic scroll + // directly. if ( - shouldScheduleSessionAutoScroll({ - isAutoScrolling: isAutoScrollingRef.current, + shouldFollowSessionContentSize({ isUserScrolling: isUserScrollingRef.current, shouldAutoScroll: shouldAutoScrollRef.current, - }) && - didContentHeightChange + didContentHeightChange, + }) ) { - scheduleScrollToLatestMessage(); + scrollToLatestMessage(); } }, - [scheduleScrollToLatestMessage] + [scrollToLatestMessage] ); const handleListLayout = useCallback(() => { diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts index 0ee308da56..57f4d4c0a4 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts @@ -3657,4 +3657,72 @@ describe('createSessionManager — paginated initial snapshot + loadOlderMessage expect(atomValue(config.store, mgr.atoms.isLoadingOlderMessages)).toBe(false); }); + + it('does not let a late initial page from an earlier switchSession clobber the active session when both target the same session id', async () => { + // Regression: the initial-page callback used to read `loadOlderGeneration` + // at invocation time. A second `switchSession` to the same session id + // advances the generation in `clearAllAtoms()` before the first + // switch's `onInitialPageLoaded` callback runs, so the stale page + // passed the generation check (equal to the new generation) and + // overwrote the active session's cursor / messages / omitted-item + // count. The fix captures the generation synchronously when the + // callback is created. + let resolveFirstPage: (value: SessionSnapshotPageOutcome) => void = () => undefined; + const firstPagePromise = new Promise(resolve => { + resolveFirstPage = resolve; + }); + const fetchSnapshotPage = jest.fn() as jest.MockedFunction; + fetchSnapshotPage.mockReturnValueOnce(firstPagePromise).mockResolvedValueOnce( + makePage({ + kiloSessionId: 'ses-1', + nextCursor: 'current-cursor', + messages: [makePageMessage('msg-current', 'ses-1', 'current')], + omittedItemCount: 0, + }) + ); + + const config = createMockConfig({ fetchSnapshotPage }); + const mgr = createSessionManager(config); + + // Wait for the first switchSession to fully set up: the first + // session.connect() must have kicked off the slow fetchSnapshotPage + // and wired its `onInitialPageLoaded` callback before the second + // switchSession advances `switchGeneration` and tears the first + // switchSession's setup down. + const first = mgr.switchSession(kiloId('ses-1')); + await first; + + const second = mgr.switchSession(kiloId('ses-1')); + await second; + // Flush microtasks so the second switch's page is delivered through + // `onInitialPageLoaded` and the active session's pagination state is + // settled before we resolve the stale first page. + await new Promise(resolve => setImmediate(resolve)); + + // The active session's pagination state reflects the second switch. + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + expect(atomValue(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(0); + expect( + atomValue(config.store, mgr.atoms.messagesList).map(m => m.info.id) + ).toEqual(['msg-current']); + + // The first switch's slow page eventually resolves. Its stale + // cursor, messages, and omitted count must not overwrite the + // active session's pagination state. + resolveFirstPage( + makePage({ + kiloSessionId: 'ses-1', + nextCursor: 'stale-cursor', + messages: [makePageMessage('msg-stale', 'ses-1', 'stale')], + omittedItemCount: 99, + }) + ); + await new Promise(resolve => setImmediate(resolve)); + + expect(atomValue(config.store, mgr.atoms.hasOlderMessages)).toBe(true); + expect(atomValue(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(0); + expect( + atomValue(config.store, mgr.atoms.messagesList).map(m => m.info.id) + ).toEqual(['msg-current']); + }); }); diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts index 781d38ab4f..152e968dad 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts @@ -967,9 +967,17 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { // Persist the bounded page's cursor / hasOlderMessages / omittedItemCount // so `loadOlderMessages` can continue from where the transport left off. // This is a no-op for stale (pre-switchSession) callbacks because - // `loadOlderGeneration` advances on every switch. + // `loadOlderGeneration` advances on every switch. The generation is + // captured synchronously here, at callback creation time: reading + // `loadOlderGeneration` from inside the callback would be tautological + // when the same session id is switched twice in a row, because the + // second switch's `clearAllAtoms()` advance lands before the first + // switch's `onInitialPageLoaded` callback runs, letting a stale page + // pass the generation check and clobber the active session's cursor + // and omitted-item count. + const initialPageGeneration = loadOlderGeneration; const recordInitialPage = (page: SessionSnapshotPage): void => { - applyPage({ ...page, kind: 'success' }, loadOlderGeneration); + applyPage({ ...page, kind: 'success' }, initialPageGeneration); }; const session = createCloudAgentSession({