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 5c4fadf7a2..a603de2231 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.test.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.test.ts @@ -1,6 +1,7 @@ /* eslint-disable require-await, @typescript-eslint/require-await -- injectable query/sleep fakes settle without await */ /* eslint-disable max-lines -- the manager suite pins retry cadence and attachment mints in one file. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore } from 'jotai'; import { RequestDeadlineError } from '@kilocode/event-service'; import { type AgentAttachmentSubmissionPayload } from '@/lib/agent-attachments/agent-attachment-types'; @@ -46,14 +47,8 @@ vi.mock('@/components/agents/mobile-session-diagnostics', () => ({ vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ fetchMobileSessionSnapshotPage: vi.fn(), })); -// The transcript cache owns the encrypted KV (SQLCipher) chain; this suite is -// pure and only needs the call seams. -vi.mock('@/lib/persist/session-transcript-cache', () => ({ - readSessionTranscriptPage: vi.fn(async () => null), - writeSessionTranscriptPage: vi.fn(async () => undefined), - clearSessionTranscriptPage: vi.fn(async () => undefined), -})); -// Same seam for the resolved-delivery-failure memory: it shares that chain. +// The resolved-delivery-failure memory owns the encrypted KV (SQLCipher) +// chain; this suite is pure and only needs the call seam. vi.mock('@/lib/persist/resolved-delivery-failures', () => ({ readResolvedDeliveryFailures: vi.fn(async () => []), persistResolvedDeliveryFailure: vi.fn(async () => undefined), @@ -814,3 +809,45 @@ describe('createMobileAgentSessionManager metadata memo', () => { expect(getSessionQuery).toHaveBeenCalledTimes(1); }); }); + +describe('createMobileAgentSessionManager cold open', () => { + beforeEach(() => { + configHolder.current = null; + mockCreateSessionManager.mockClear(); + getWithRuntimeStateQuery.mockReset(); + getSessionQuery.mockReset(); + getSessionMessagesQuery.mockReset(); + }); + + it('omits the cached-snapshot reader so a cold open cannot paint stale rows', () => { + const options = { store: {}, userWebConnection: {} }; + createMobileAgentSessionManager(options as never); + const config = configHolder.current; + if (config === null) { + throw new Error('createSessionManager did not capture a config'); + } + expect(config.readCachedSnapshotPage).toBeUndefined(); + }); + + it('leaves the transcript empty while the live page has not landed', async () => { + const store = createStore(); + // The live metadata read never settles: the open is still in flight, so the + // screen keeps its full-page skeleton instead of painting rows. + const pending = Promise.withResolvers(); + getWithRuntimeStateQuery.mockReturnValue(pending.promise); + const options = { store, userWebConnection: {} }; + createMobileAgentSessionManager(options as never); + const config = configHolder.current; + if (config === null) { + throw new Error('createSessionManager did not capture a config'); + } + const { createSessionManager } = await import('@kilocode/cloud-agent-sdk/session-manager'); + const manager = createSessionManager(config); + + void manager.switchSession(SESSION_ID); + + expect(store.get(manager.atoms.messagesList)).toHaveLength(0); + expect(store.get(manager.atoms.isLoading)).toBe(true); + expect(store.get(manager.atoms.isRefreshingCachedTranscript)).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 1d90d98c2f..379e73121d 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -33,10 +33,6 @@ import { createNativeUserWebConnectionLifecycleHooks } from '@/lib/user-web-conn import { answerSessionPermission } from '@/lib/glanceable/approve-ask'; import { cacheToolAttachment } from '@/components/agents/tool-card-image-cache'; import { cacheFilePart } from '@/components/agents/file-part-cache'; -import { - readSessionTranscriptPage, - writeSessionTranscriptPage, -} from '@/lib/persist/session-transcript-cache'; import { persistResolvedDeliveryFailure, readResolvedDeliveryFailures, @@ -177,11 +173,12 @@ type CreateMobileAgentSessionManagerOptions = { userWebConnection: UserWebConnection; organizationId?: string; /** - * The authenticated owner the cached transcript is scoped to. Empty means - * the owner is not confirmed yet, in which case the cache is skipped - * entirely rather than writing to a shared anonymous scope. + * The authenticated owner the resolved-delivery-failure memory is scoped to. + * The manager's own persisted transcript cache is gone, so this scope is only + * read by that memory; an absent owner skips it rather than writing to a + * shared anonymous scope. */ - userId: string; + userId?: string; }; const skipBatchOptions = { context: { skipBatch: true } }; @@ -209,27 +206,24 @@ export function createMobileAgentSessionManager({ sessionId: KiloSessionId; cloudAgentSessionId: CloudAgentSessionId | null; } | null = null; - // The auth epoch this manager was created under. A transcript-cache write - // captured before a sign-out/sign-in must not land in the previous account's - // scope, so the write path re-checks this epoch (same fence as the read - // cache's persister). - const transcriptOwner = { userId, authEpoch: currentAuthEpoch() }; + // The auth epoch this manager was created under. A resolved-delivery-failure + // write captured before a sign-out/sign-in must not land in the previous + // account's scope, so the write path re-checks this epoch. An empty owner + // skips the memory entirely. + const resolvedDeliveryOwner = { userId: userId ?? '', authEpoch: currentAuthEpoch() }; return createSessionManager({ store, websocketBaseUrl: CLOUD_AGENT_WS_URL, websocketHeaders: { Origin: WEB_BASE_URL }, lifecycleHooks: createNativeUserWebConnectionLifecycleHooks(), userWebConnection, - // Thin cache passthrough: `readSessionTranscriptPage` already returns the - // promise and no-ops on an empty owner. - // eslint-disable-next-line @typescript-eslint/promise-function-async -- passthrough returns the promise directly - readCachedSnapshotPage: (id: KiloSessionId) => readSessionTranscriptPage(userId, id), // Durable memory of retried delivery failures, so the DO's stored-event // replay on the next open cannot restore a footer the retry cleared. // eslint-disable-next-line @typescript-eslint/promise-function-async -- passthrough returns the promise directly - readResolvedDeliveryFailures: (id: KiloSessionId) => readResolvedDeliveryFailures(userId, id), + readResolvedDeliveryFailures: (id: KiloSessionId) => + readResolvedDeliveryFailures(resolvedDeliveryOwner.userId, id), persistResolvedDeliveryFailure: (id: KiloSessionId, messageId: string) => { - void persistResolvedDeliveryFailure(transcriptOwner, id, messageId); + void persistResolvedDeliveryFailure(resolvedDeliveryOwner, id, messageId); }, // A tRPC call whose client control-plane deadline expired never got an // answer: the open is stalled, not failed. The manager keeps the skeleton @@ -315,12 +309,6 @@ export function createMobileAgentSessionManager({ }, fetchSnapshotPage: async (id: KiloSessionId, options: { cursor?: string }) => { const outcome = await fetchMobileSessionSnapshotPage(id, options); - // Only the first page (no cursor) is cached: it holds the newest - // messages, which is what a warm open paints before the live refresh. - // Best effort — the write never affects the returned page. - if (outcome.kind === 'success' && options.cursor === undefined) { - void writeSessionTranscriptPage(transcriptOwner, id, outcome); - } return outcome; }, api: { diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts index 79037c7b14..fdff9fe9d2 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts +++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts @@ -119,6 +119,10 @@ vi.mock('@/components/ui/button', () => ({ Button: 'Button', })); vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' })); +// The loading profile row renders the reanimated `Skeleton`; stub it so this +// node suite neither loads reanimated nor loses the `findElementByType` +// assertion for the loading placeholder. +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/segmented-control', () => ({ SegmentedControl: 'SegmentedControl', diff --git a/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts b/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts index 135d386a77..ae74747321 100644 --- a/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content-helpers.test.ts @@ -100,11 +100,13 @@ describe('retryFailedMessage', () => { ['m-retried', { status: 'failed', error: 'boom', reason: 'execution' }], ]); + // The marker folds onto the message row as `timeMarker`, so the failed + // submission is the only item and its key no longer carries a `time:` row. expect( mergeSessionTranscript([submission], [], deliveryStates).map(item => getSessionTranscriptItemKey(item) ) - ).toEqual(['time:m-retried', 'm-retried']); + ).toEqual(['m-retried']); await retryFailedMessage({ message: submission, diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 396c579ef1..a82400b286 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -51,6 +51,8 @@ import { SessionGoalSection } from '@/components/agents/session-goal-section'; import { SessionSkeletonMessages } from '@/components/agents/session-detail-skeleton'; import { SESSION_SLOW_LOAD_MS } from '@/components/agents/session-slow-load'; import { SessionMessageList } from '@/components/agents/session-message-list'; +import type * as SessionTranscript from '@/components/agents/session-transcript'; +import { type SessionTranscriptItem } from '@/components/agents/session-transcript'; import { WorkingIndicator } from '@/components/agents/working-indicator'; import { resolveSendAttachmentKind, @@ -273,7 +275,10 @@ vi.mock('@/components/agents/text-part-renderer', () => ({ vi.mock('@/components/agents/chat-markdown-text', () => ({ ChatMarkdownText: ({ value }: { value: string }) => createElement('Text', null, value), })); -vi.mock('@/components/agents/tool-cards', () => ({ TaskToolCard: 'TaskToolCard' })); +vi.mock('@/components/agents/tool-cards', () => ({ + TaskToolCard: 'TaskToolCard', + ReadToolCard: 'ReadToolCard', +})); vi.mock('@/components/agents/suggest-tool-card', () => ({ SuggestToolCard: 'SuggestToolCard' })); vi.mock('@/components/agents/session-message-list', () => ({ SessionMessageList: function MessageList(props: ComponentProps>) { @@ -401,6 +406,21 @@ vi.mock('@/lib/hooks/use-condense-tool-calls-preference', () => ({ setCondenseToolCalls: vi.fn(), }), })); +// The part→item-key map is only read back by the condensed build, so the +// component must not walk the transcript for it while condensing is off. +const transcriptKeyCollection = vi.hoisted(() => ({ calls: 0 })); +vi.mock('@/components/agents/session-transcript', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + collectTranscriptItemKeysByPart: ( + ...args: Parameters + ) => { + transcriptKeyCollection.calls += 1; + return actual.collectTranscriptItemKeysByPart(...args); + }, + }; +}); vi.mock('@/lib/hooks/use-session-model-options', () => ({ useSessionModelOptions: () => ({ options: [], selectedValue: '', selectedVariant: '' }), })); @@ -579,6 +599,23 @@ function messageLists(renderer: ReactTestRenderer): ReactTestInstance[] { return renderer.root.findAll(node => Object.is(node.type, 'MessageList')); } +/** + * The FlashList keys the first message list would mount rows under. The list is + * stubbed, so read the props the stub was handed: the same `keyExtractor` the + * real FlashList uses for its viewport anchor. + */ +function transcriptKeys(renderer: ReactTestRenderer): string[] { + const list = renderer.root.findAllByType(SessionMessageList)[0]; + if (!list) { + return []; + } + const { items, keyExtractor } = list.props as { + items: readonly SessionTranscriptItem[]; + keyExtractor: (item: SessionTranscriptItem) => string; + }; + return items.map(item => keyExtractor(item)); +} + beforeEach(() => { navigationRoutes.splice(0, navigationRoutes.length, 'session-detail'); openRenameModal.mockClear(); @@ -1909,6 +1946,45 @@ describe('SessionDetailContent condensed tool runs', () => { expect(runRows).toHaveLength(1); expect(runRows[0]?.parent?.type).toBe('MessageErrorBoundary'); }); + + it('keeps the condensed row key when an older tool-only page prepends', async () => { + condensePreference.value = true; + rootPageNextCursor = 'older-cursor'; + const view = await mountDetails([toolRunMessage(ROOT_ID, 'm2', ['t2'])]); + // A lone tool part condenses to its message row, keyed by the message id. + expect(transcriptKeys(view.renderer)).toEqual(['m2']); + + // Loading older messages prepends an older tool-only message whose part + // joins the run. The row FlashList anchored on must keep its key, or the + // viewport jumps (the reported defect). + await act(async () => { + void view.manager.loadOlderMessages(); + await Promise.resolve(); + }); + await view.respond(ROOT_ID, [toolRunMessage(ROOT_ID, 'm1', ['t1'])]); + + expect(transcriptKeys(view.renderer)).toEqual(['m2']); + }); +}); + +describe('SessionDetailContent transcript key collection', () => { + it('does not walk the transcript for part keys while condensing is off', async () => { + condensePreference.value = false; + transcriptKeyCollection.calls = 0; + + await mountDetails([toolRunMessage(ROOT_ID, 'm-tool-run', ['t1', 't2'])]); + + expect(transcriptKeyCollection.calls).toBe(0); + }); + + it('collects part keys once condensing is on', async () => { + condensePreference.value = true; + transcriptKeyCollection.calls = 0; + + await mountDetails([toolRunMessage(ROOT_ID, 'm-tool-run', ['t1', 't2'])]); + + expect(transcriptKeyCollection.calls).toBeGreaterThan(0); + }); }); describe('session detail exit retry row', () => { @@ -1949,6 +2025,69 @@ describe('session detail exit retry row', () => { }); }); +describe('transcript time markers', () => { + it.each(['message', 'tool-run'] as const)( + 'keeps the %s subtree mounted when a prepend moves its marker', + async kind => { + condensePreference.value = kind === 'tool-run'; + rootPageNextCursor = 'older-cursor'; + const message = + kind === 'tool-run' + ? toolRunMessage(ROOT_ID, 'm2', ['t2a', 't2b']) + : childMessage(ROOT_ID, 'Existing answer'); + message.info.time.created = 1_000_000_000; + const view = await mountDetails([message]); + const findRow = () => + kind === 'tool-run' + ? view.renderer.root.find(node => Object.is(node.type, 'CondensedToolRunRow')) + : view.renderer.root.findByProps({ children: 'Existing answer' }); + const before = findRow(); + expect(before).toBeDefined(); + const keys = transcriptKeys(view.renderer); + + await act(async () => { + void view.manager.loadOlderMessages(); + await Promise.resolve(); + }); + const older = childMessage(ROOT_ID, 'Older answer'); + older.info = { ...older.info, id: 'm1', time: { created: 999_999_000 } }; + older.parts = [ + stubTextPart({ id: 'text-m1', sessionID: ROOT_ID, messageID: 'm1', text: 'Older answer' }), + ]; + await view.respond(ROOT_ID, [older]); + + expect(transcriptKeys(view.renderer)).toEqual(['m1', ...keys]); + expect( + view.renderer.root.findAll(node => Object.is(node.type, 'TranscriptTimeMarker')) + ).toHaveLength(1); + expect(findRow() === before).toBe(true); + } + ); + + it('renders the marker in the same row as the message that opens the burst', async () => { + const message: StoredMessage = { + info: { ...assistantMessage('msg-marker').info, sessionID: ROOT_ID }, + parts: [ + stubTextPart({ + id: 'text-msg-marker', + sessionID: ROOT_ID, + messageID: 'msg-marker', + text: 'Marked answer', + }), + ], + }; + + const view = await mountDetails([message]); + + // The first message of the page opens the burst, so its row carries the + // marker above the bubble instead of the marker being an item of its own. + expect( + view.renderer.root.findAll(node => Object.is(node.type, 'TranscriptTimeMarker')) + ).toHaveLength(1); + expect(renderedText(view.renderer.root)).toContain('Marked answer'); + }); +}); + describe('hide thinking preference', () => { function partMessage(id: string, parts: StoredMessage['parts']): StoredMessage { return { info: { ...assistantMessage(id).info, sessionID: ROOT_ID }, parts }; diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 6311270ffb..e127d4d6f8 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -123,11 +123,13 @@ import { } from '@/components/agents/session-slow-load'; import { SessionMessageList } from '@/components/agents/session-message-list'; import { + collectTranscriptItemKeysByPart, condenseTranscriptToolRuns, getSessionTranscriptItemKey, getSessionTranscriptItemType, mergeSessionTranscript, type SessionTranscriptItem, + type TranscriptItemKeysByPart, } from '@/components/agents/session-transcript'; import { resolveSessionTranscriptView } from '@/components/agents/session-transcript-view'; import { useSessionDetailRename } from '@/components/agents/use-session-detail-rename'; @@ -960,10 +962,33 @@ export function SessionDetailContent({ ); // Condensing is opt-in: with the preference off the derived transcript is the // same array identity, so nothing below re-renders differently. + // + // The previous build's part→item-key map. A later build that folds new parts + // into an existing run — an older page prepending, or a tool part streaming + // into the run — reuses the key the row was already on screen under, so + // FlashList's viewport anchor survives. The effect refreshes the map after the + // commit, so the render that first shows the change still reads the old one. + const carriedTranscriptKeysByPartRef = useRef(null); const transcript = useMemo( - () => (condenseToolCalls ? condenseTranscriptToolRuns(baseTranscript) : baseTranscript), + () => + condenseToolCalls + ? condenseTranscriptToolRuns( + baseTranscript, + carriedTranscriptKeysByPartRef.current ?? undefined + ) + : baseTranscript, [condenseToolCalls, baseTranscript] ); + // Only the condensed build reads the map back, so while condensing is off the + // walk over every content-rendering part and its `Map` allocation would be + // dead work on every streaming update. The guard skips both; the map is + // refreshed again on the commit after condensing turns back on. + useEffect(() => { + if (!condenseToolCalls) { + return; + } + carriedTranscriptKeysByPartRef.current = collectTranscriptItemKeysByPart(transcript); + }, [condenseToolCalls, transcript]); // The list branch must never mount with zero items: a zero-item FlashList // paints blank dead space with no loading and no empty state (mobile-app @@ -1273,19 +1298,29 @@ export function SessionDetailContent({ if (item.type === 'preparation') { return ; } - if (item.type === 'time') { - return ; - } if (item.type === 'tool-run') { // Match the inset and row rhythm of a message row so the condensed row // sits flush with its neighbours rather than full-bleed. - return ( + const run = ( ); + // A condensed run can open a burst: its message's marker rides here so + // marker and row share one FlashList key and one measured height. + return ( + + {item.timeMarker && ( + + )} + {run} + + ); } // Delivery events can lag a successful drop. The retained row must expose Restore immediately. const deliveryState = @@ -1294,7 +1329,7 @@ export function SessionDetailContent({ : undefined; // Suppress Retry on an assistant failure with no preceding user row. const retryPrompt = resolveRetryPrompt(item.message, messages); - return ( + const bubble = ( ); + // The burst marker rides on its message row so the row keeps one FlashList + // key and one measured height: a prepend that moves the marker to an older + // message changes no key that is already on screen. Keep the wrapper and + // bubble's child slot stable so moving the marker does not remount it. + return ( + + {item.timeMarker && ( + + )} + {bubble} + + ); }, [ lastAssistantMessageId, diff --git a/apps/mobile/src/components/agents/session-detail-queue.test.ts b/apps/mobile/src/components/agents/session-detail-queue.test.ts index dd73a9895f..06c14ffedc 100644 --- a/apps/mobile/src/components/agents/session-detail-queue.test.ts +++ b/apps/mobile/src/components/agents/session-detail-queue.test.ts @@ -1,6 +1,12 @@ /* eslint-disable max-lines -- the session test renders the full SessionDetailContent and mocks its RN/expo/SDK surface, so the wiring is long. */ /* eslint-disable require-await, @typescript-eslint/require-await -- mock factories settle without await because they resolve immediately */ -import { createElement, type ElementType, type ReactElement } from 'react'; +import { + createElement, + type ElementType, + isValidElement, + type ReactElement, + type ReactNode, +} from 'react'; import { Modal, Pressable } from 'react-native'; import { act, TestRenderer } from '@/test/renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +17,7 @@ import type * as ReactI18next from 'react-i18next'; import { type SessionTranscriptItem } from '@/components/agents/session-transcript'; import { SessionMessageList } from '@/components/agents/session-message-list'; import { MessageDetailsSheet } from '@/components/agents/message-details-sheet'; +import { MessageBubble } from '@/components/agents/message-bubble'; import { AccessibleStatus } from '@/components/ui/accessible-status'; import { Text } from '@/components/ui/text'; import { assistantMessage } from './message-bubble-test-utils'; @@ -87,14 +94,10 @@ vi.mock('@/components/agents/mobile-session-diagnostics', () => ({ vi.mock('@/components/agents/mobile-session-page-adapter', () => ({ fetchMobileSessionSnapshotPage: vi.fn(), })); -// Keep the real queue-error classifier without loading the native encrypted KV. -vi.mock('@/lib/persist/session-transcript-cache', () => ({ - readSessionTranscriptPage: vi.fn(async () => null), - writeSessionTranscriptPage: vi.fn(async () => undefined), -})); -// Same seam for the resolved-delivery-failure memory: it shares that chain, so -// without this mock `mobile-session-manager.ts` pulls the native encrypted KV -// (and its `react-native` promise shim) into this node suite. +// Keep the real queue-error classifier without loading the native encrypted KV: +// `mobile-session-manager.ts`'s resolved-delivery-failure memory shares that +// chain, so without this mock it pulls the native encrypted KV (and its +// `react-native` promise shim) into this node suite. vi.mock('@/lib/persist/resolved-delivery-failures', () => ({ readResolvedDeliveryFailures: vi.fn(async () => []), persistResolvedDeliveryFailure: vi.fn(async () => undefined), @@ -648,7 +651,9 @@ function readBubble( const listProps = lists[0]?.props as | { items?: SessionTranscriptItem[]; - renderItem?: (args: { item: SessionTranscriptItem }) => ReactElement; + renderItem?: (args: { + item: SessionTranscriptItem; + }) => ReactElement<{ children: ReactNode[] }>; } | undefined; const item = listProps?.items?.find( @@ -657,7 +662,10 @@ function readBubble( if (!item || !listProps?.renderItem) { return undefined; } - return listProps.renderItem({ item }); + const row = listProps.renderItem({ item }); + return row.props.children.find( + (child): child is ReactElement => isValidElement(child) && child.type === MessageBubble + ); } function bubbleProps( diff --git a/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx b/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx index f1d52cbd23..b573a949ef 100644 --- a/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx @@ -1,5 +1,5 @@ -/* eslint-disable max-lines -- the landscape-inset suite and the resume-anchor suite share this file's mocked FlashList and auto-scroll harness. */ -import { createElement } from 'react'; +/* eslint-disable max-lines -- the older-page, landscape-inset, resume-anchor and anchor-reporting suites share this file's real FlashList and auto-scroll harness. */ +import { createElement, type ReactElement, type Ref, useImperativeHandle } from 'react'; import { act, TestRenderer } from '@/test/renderer'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -9,20 +9,22 @@ import { MAX_RESUME_OLDER_LOADS } from '@/lib/session-resume'; import { stubTextPart, stubUserMessage } from '@kilocode/cloud-agent-sdk/test-helpers'; const flashListProps = vi.hoisted(() => ({ current: null as Record | null })); -const controls = vi.hoisted(() => ({ - isAtBottom: true, - leftInset: 0, - rightInset: 0, - scrollToIndex: vi.fn(), - autoScrollParams: null as Record | null, -})); +const scrollMock = vi.hoisted(() => ({ scrollToEnd: vi.fn(), scrollToIndex: vi.fn() })); +const insets = vi.hoisted(() => ({ left: 0, right: 0 })); +// The real `useSessionListAutoScroll` hook is exercised here (not a stub), so +// the ref it hands FlashList must expose `scrollToEnd` for the auto-scroll +// assertions to observe. `useImperativeHandle` wires that ref. vi.mock('@shopify/flash-list', () => ({ FlashList: (props: Record) => { flashListProps.current = props; + useImperativeHandle(props.ref as Ref, () => scrollMock, []); return null; }, })); +vi.mock('@/lib/a11y/motion', () => ({ + useMotionPolicy: () => ({ reducedMotion: false, scrollAnimated: true }), +})); vi.mock('react-native', () => ({ AccessibilityInfo: { announceForAccessibility: vi.fn() }, Keyboard: { addListener: vi.fn(() => ({ remove: vi.fn() })) }, @@ -34,8 +36,8 @@ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, - left: controls.leftInset, - right: controls.rightInset, + left: insets.left, + right: insets.right, }), })); vi.mock('react-native-reanimated', () => ({ @@ -47,122 +49,199 @@ vi.mock('@/components/ui/icons', () => ({ ChevronDown: 'ChevronDown' })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ foreground: 'black' }), })); -vi.mock('@/components/agents/use-session-list-auto-scroll', () => ({ - useSessionListAutoScroll: (params: Record) => { - controls.autoScrollParams = params; - return { - isAtBottom: controls.isAtBottom, - listRef: { current: { scrollToIndex: controls.scrollToIndex } }, - scrollToLatestAnimated: vi.fn(), - suppressAutoFollow: vi.fn(), - followTailFromSend: vi.fn(), - isUserScrollingRef: { current: false }, - userInteractedRef: { current: false }, - sendTakeoverRef: { current: false }, - handleContentSizeChange: vi.fn(), - handleKeyboardShow: vi.fn(), - handleListLayout: vi.fn(), - handleScroll: vi.fn(), - handleScrollBeginDrag: vi.fn(), - handleScrollEndDrag: vi.fn(), - handleMomentumScrollBegin: vi.fn(), - handleMomentumScrollEnd: vi.fn(), - }; - }, -})); vi.mock('@/components/agents/session-pagination-header', () => ({ SessionPaginationHeader: () => null, })); -describe('SessionMessageList', () => { - it('disables clipped subviews to avoid Android Fabric reattachment races', () => { - act(() => { - TestRenderer.create( - createElement(SessionMessageList, { - sessionId: 'session-1', - items: ['message-1'], - keyExtractor: item => item, - hasOlderMessages: false, - isLoadingOlderMessages: false, - olderMessagesError: null, - olderMessagesOmittedItemCount: 0, - onLoadOlderMessages: () => undefined, - renderItem: () => null, - }) - ); - }); +const baseProps = { + sessionId: 'session-1', + items: ['message-1'], + keyExtractor: (item: string) => item, + hasOlderMessages: false, + isLoadingOlderMessages: false, + olderMessagesError: null, + olderMessagesOmittedItemCount: 0, + onLoadOlderMessages: () => undefined, + renderItem: () => null, +}; + +const mounted: TestRenderer.ReactTestRenderer[] = []; + +function mountList( + overrides: Partial>[0]> = {} +): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create( + createElement(SessionMessageList, { ...baseProps, ...overrides }) + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + mounted.push(renderer); + return renderer; +} - expect(flashListProps.current?.removeClippedSubviews).toBe(false); +function rerenderList( + renderer: TestRenderer.ReactTestRenderer, + overrides: Partial>[0]> = {} +): void { + act(() => { + renderer.update(createElement(SessionMessageList, { ...baseProps, ...overrides })); }); -}); +} // `Object.is` keeps the host-string comparison off the ElementType union. function scrollButton(renderer: TestRenderer.ReactTestRenderer) { return renderer.root.find(node => Object.is(node.type, 'AnimatedView')); } -describe('SessionMessageList landscape side insets', () => { - const baseProps = { - sessionId: 'session-1', - items: ['message-1'], - keyExtractor: (item: string) => item, - hasOlderMessages: false, - isLoadingOlderMessages: false, - olderMessagesError: null, - olderMessagesOmittedItemCount: 0, - onLoadOlderMessages: () => undefined, - renderItem: () => null, - }; +type ScrollHandler = ((event?: unknown) => void) | undefined; - function mountList( - overrides: Partial>[0]> = {} - ): TestRenderer.ReactTestRenderer { - const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; +function fire(name: string, event?: unknown): void { + const props = (flashListProps.current ?? {}) as Record; + const handler = props[name]; + act(() => { + handler?.(event); + }); +} + +const AT_BOTTOM_EVENT = { + nativeEvent: { + contentOffset: { y: 950 }, + contentSize: { height: 1500, width: 0 }, + layoutMeasurement: { height: 500, width: 0 }, + }, +}; + +const AWAY_FROM_BOTTOM_EVENT = { + nativeEvent: { + contentOffset: { y: 0 }, + contentSize: { height: 5000, width: 0 }, + layoutMeasurement: { height: 500, width: 0 }, + }, +}; + +// Ends the mount-time programmatic-scroll window and lands the hook in the +// "user is at the bottom" state, as a real transcript does a beat after open. +function settleAtBottom(): void { + fire('onScrollBeginDrag'); + fire('onScrollEndDrag', AT_BOTTOM_EVENT); + fire('onMomentumScrollEnd', AT_BOTTOM_EVENT); +} + +// Mimics the user's upward drag: clears the auto-scroll latch and moves the +// viewport away from the bottom so the "scroll to bottom" control renders. +function scrollAwayFromBottom(): void { + fire('onScrollBeginDrag'); + fire('onScroll', AWAY_FROM_BOTTOM_EVENT); +} + +afterEach(() => { + for (const renderer of mounted.splice(0)) { + renderer.unmount(); + } + scrollMock.scrollToEnd.mockClear(); + scrollMock.scrollToIndex.mockClear(); + flashListProps.current = null; + insets.left = 0; + insets.right = 0; + vi.useRealTimers(); +}); + +describe('SessionMessageList', () => { + it('disables clipped subviews to avoid Android Fabric reattachment races', () => { + mountList(); + expect(flashListProps.current?.removeClippedSubviews).toBe(false); + }); + + it('mounts rows well ahead of the viewport during a fast fling', () => { + mountList(); + expect(flashListProps.current?.drawDistance).toBe(2000); + }); +}); + +describe('SessionMessageList older-page auto-scroll', () => { + it('does not yank the viewport when an older page is prepended', () => { + const renderer = mountList({ items: ['m1', 'm2'], hasOlderMessages: true }); + settleAtBottom(); + scrollMock.scrollToEnd.mockClear(); + + rerenderList(renderer, { items: ['m0', 'm1', 'm2'], hasOlderMessages: true }); + + expect(scrollMock.scrollToEnd).not.toHaveBeenCalled(); + }); + + it('still follows a new newest message appended at the bottom', () => { + const renderer = mountList({ items: ['m1', 'm2'], hasOlderMessages: true }); + settleAtBottom(); + scrollMock.scrollToEnd.mockClear(); + + rerenderList(renderer, { items: ['m1', 'm2', 'm3'], hasOlderMessages: true }); + + expect(scrollMock.scrollToEnd).toHaveBeenCalled(); + }); +}); + +describe('SessionMessageList pagination header', () => { + it('keeps the header element when only the load-handler identity changes', () => { + const renderer = mountList(); + const header = flashListProps.current?.ListHeaderComponent; + expect(header).toBeTruthy(); + + // The host passes a fresh inline arrow on every render; that alone must not + // hand FlashList a new header element (which would remount/reflow it). + rerenderList(renderer, { onLoadOlderMessages: () => undefined }); + + expect(flashListProps.current?.ListHeaderComponent).toBe(header); + }); + + it('retries through the newest load handler after the host re-renders', () => { + const first = vi.fn<() => void>(); + const second = vi.fn<() => void>(); + const renderer = mountList({ onLoadOlderMessages: first }); + const header = flashListProps.current?.ListHeaderComponent as ReactElement<{ + onRetry: () => void; + }>; + + rerenderList(renderer, { onLoadOlderMessages: second }); act(() => { - ref.current = TestRenderer.create( - createElement(SessionMessageList, { ...baseProps, ...overrides }) - ); + header.props.onRetry(); }); - const renderer = ref.current; - if (!renderer) { - throw new Error('renderer was not created'); - } - return renderer; - } - afterEach(() => { - controls.isAtBottom = true; - controls.leftInset = 0; - controls.rightInset = 0; - controls.scrollToIndex.mockClear(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); }); +}); +describe('SessionMessageList landscape side insets', () => { it('keeps the module style reference and a 16pt control offset in portrait', () => { - controls.isAtBottom = false; const renderer = mountList(); + scrollAwayFromBottom(); const style = flashListProps.current?.contentContainerStyle; expect(style).toEqual({ paddingVertical: 8 }); expect(scrollButton(renderer).props.style).toEqual({ right: 16 }); // Unchanged inputs keep the same style reference so FlashList's portrait // behavior (including `maintainVisibleContentPosition`) is untouched. - act(() => { - renderer.update(createElement(SessionMessageList, { ...baseProps })); - }); + rerenderList(renderer); expect(flashListProps.current?.contentContainerStyle).toBe(style); // A fresh mount shares the reference too: it is the module-level constant, // not a per-mount allocation. const remounted = mountList(); expect(flashListProps.current?.contentContainerStyle).toBe(style); + scrollAwayFromBottom(); expect(scrollButton(remounted).props.style).toEqual({ right: 16 }); }); it('pads the transcript and offsets the control by the landscape side insets', () => { - controls.isAtBottom = false; - controls.leftInset = 47; - controls.rightInset = 59; + insets.left = 47; + insets.right = 59; const renderer = mountList(); + scrollAwayFromBottom(); expect(flashListProps.current?.contentContainerStyle).toEqual({ paddingTop: 8, paddingBottom: 8, @@ -194,7 +273,7 @@ function resumeItem(id: string): SessionTranscriptItem { } describe('SessionMessageList resume anchor', () => { - const baseProps = { + const resumeBaseProps = { sessionId: 'session-1', keyExtractor: (item: SessionTranscriptItem) => getSessionTranscriptItemKey(item), hasOlderMessages: false, @@ -209,7 +288,7 @@ describe('SessionMessageList resume anchor', () => { function resumeElement(overrides: Partial) { return createElement(SessionMessageList, { - ...baseProps, + ...resumeBaseProps, ...overrides, // Restated so the required `items` prop keeps its non-optional type // through the partial spread. @@ -226,14 +305,10 @@ describe('SessionMessageList resume anchor', () => { if (!renderer) { throw new Error('renderer was not created'); } + mounted.push(renderer); return renderer; } - afterEach(() => { - controls.scrollToIndex.mockClear(); - controls.autoScrollParams = null; - }); - it('opens without tail auto-follow so the resume scroll is not overridden', () => { mountResumeList({ items: [resumeItem('msg-1'), resumeItem('msg-2'), resumeItem('msg-3')], @@ -242,15 +317,16 @@ describe('SessionMessageList resume anchor', () => { // The mount-time scroll to the newest message (and its 80ms retry) would // otherwise discard the resume position before the user sees it. - expect(controls.autoScrollParams?.initialAutoScroll).toBe(false); + expect(scrollMock.scrollToEnd).not.toHaveBeenCalled(); }); it('keeps tail auto-follow for a session opened without an anchor', () => { mountResumeList({ items: [resumeItem('msg-1')] }); - expect(controls.autoScrollParams?.initialAutoScroll).toBe(true); + expect(scrollMock.scrollToEnd).toHaveBeenCalled(); + scrollMock.scrollToEnd.mockClear(); mountResumeList({ items: [resumeItem('msg-1')], resumeAt: '' }); - expect(controls.autoScrollParams?.initialAutoScroll).toBe(true); + expect(scrollMock.scrollToEnd).toHaveBeenCalled(); }); it('scrolls to the anchor row index once the rows are present', () => { @@ -264,11 +340,11 @@ describe('SessionMessageList resume anchor', () => { // the cold-open race to FlashList's own bottom-start initial scroll and // the estimate settle (device-proven), so the retries perform the scroll // once the rows are measured. - expect(controls.scrollToIndex).not.toHaveBeenCalled(); + expect(scrollMock.scrollToIndex).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(250); }); - expect(controls.scrollToIndex).toHaveBeenCalledWith({ + expect(scrollMock.scrollToIndex).toHaveBeenCalledWith({ index: 1, viewPosition: 0, viewOffset: 1, @@ -285,7 +361,7 @@ describe('SessionMessageList resume anchor', () => { onLoadOlderMessages: onLoad, }); - expect(controls.scrollToIndex).not.toHaveBeenCalled(); + expect(scrollMock.scrollToIndex).not.toHaveBeenCalled(); expect(onLoad).not.toHaveBeenCalled(); }); @@ -314,7 +390,7 @@ describe('SessionMessageList resume anchor', () => { } expect(onLoad).toHaveBeenCalledTimes(MAX_RESUME_OLDER_LOADS); - expect(controls.scrollToIndex).not.toHaveBeenCalled(); + expect(scrollMock.scrollToIndex).not.toHaveBeenCalled(); }); it('spends the page budget on requests, not on re-runs while a page is in flight', () => { @@ -358,7 +434,7 @@ describe('SessionMessageList resume anchor', () => { ); }); - expect(controls.scrollToIndex).toHaveBeenCalledWith({ + expect(scrollMock.scrollToIndex).toHaveBeenCalledWith({ index: 0, viewPosition: 0, viewOffset: 1, @@ -388,7 +464,7 @@ describe('SessionMessageList resume anchor', () => { ); }); - expect(controls.scrollToIndex).toHaveBeenCalledWith({ + expect(scrollMock.scrollToIndex).toHaveBeenCalledWith({ index: 0, viewPosition: 0, viewOffset: 1, @@ -403,7 +479,7 @@ describe('SessionMessageList resume anchor', () => { type AnchorViewToken = { item: SessionTranscriptItem; index: number }; describe('SessionMessageList anchor reporting', () => { - const baseProps = { + const anchorBaseProps = { sessionId: 'session-1', keyExtractor: (item: SessionTranscriptItem) => getSessionTranscriptItemKey(item), hasOlderMessages: false, @@ -417,15 +493,21 @@ describe('SessionMessageList anchor reporting', () => { type AnchorProps = Parameters>[0]; function mountAnchorList(overrides: Partial): void { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { - TestRenderer.create( + ref.current = TestRenderer.create( createElement(SessionMessageList, { - ...baseProps, + ...anchorBaseProps, ...overrides, items: overrides.items ?? [], }) ); }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + mounted.push(renderer); } function fireViewability(tokens: readonly AnchorViewToken[]): void { diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index 2424df996d..030f3105c5 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -38,7 +38,7 @@ const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle; // otherwise spam the FlashList event log. const ON_START_REACHED_THRESHOLD = 2; -const DRAW_DISTANCE = 1000; +const DRAW_DISTANCE = 2000; type SessionMessageListProps = { sessionId: string; @@ -135,6 +135,12 @@ export function SessionMessageList({ // `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. + // + // `newestItemKey` is the key of `items.at(-1)`. The auto-scroll hook only + // schedules a scroll when that key changes, so growing the list with an + // older page can never yank the viewport back to the newest message. + const newestItem = items.at(-1); + const newestItemKey = newestItem === undefined ? null : keyExtractor(newestItem); const { isAtBottom, listRef, @@ -154,6 +160,7 @@ export function SessionMessageList({ handleMomentumScrollEnd, } = useSessionListAutoScroll({ itemCount: items.length, + newestItemKey, resetKey: sessionId, initialAutoScroll: followTailAtMount, resumeKey: resumeAnchor, @@ -292,8 +299,6 @@ export function SessionMessageList({ olderArrivalNewestKeyRef.current = null; }, [sessionId]); useEffect(() => { - const newestItem = items.at(-1); - const nextNewestKey = newestItem === undefined ? null : keyExtractor(newestItem); const nextCount = items.length; if ( shouldAnnounceOlderMessagesArrival({ @@ -301,15 +306,15 @@ export function SessionMessageList({ previousCount: olderArrivalCountRef.current, nextCount, previousNewestKey: olderArrivalNewestKeyRef.current, - nextNewestKey, + nextNewestKey: newestItemKey, }) ) { AccessibilityInfo.announceForAccessibility(getOlderMessagesArrivedAnnouncement()); } olderArrivalInitializedRef.current = true; olderArrivalCountRef.current = nextCount; - olderArrivalNewestKeyRef.current = nextNewestKey; - }, [items, keyExtractor]); + olderArrivalNewestKeyRef.current = newestItemKey; + }, [items, newestItemKey]); // When the optional `contentBottomInset` is omitted and the landscape side // insets are 0 (portrait) we return the original module-level @@ -332,6 +337,39 @@ export function SessionMessageList({ [contentBottomInset, left, right] ); + // The host passes a fresh inline arrow for `onLoadOlderMessages` on every + // render, so the handler is held in a ref (the same pattern as + // `onReachedBottomRef` above) and exposed to the header through a stable + // callback. Without this the memo below would still produce a new element on + // every parent render, which is the remount/reflow it exists to prevent. + const onLoadOlderMessagesRef = useRef(onLoadOlderMessages); + onLoadOlderMessagesRef.current = onLoadOlderMessages; + const handleRetryOlderMessages = useCallback(() => { + onLoadOlderMessagesRef.current(); + }, []); + + // The header element is memoized on the pagination props so a new element + // identity is not handed to FlashList on every render (which would remount + // and reflow the header while the transcript streams). The pagination + // prompts only change when their own props change; the retry callback is + // stable and always calls the newest handler. + const listHeaderComponent = useMemo( + () => ( + + ), + [ + isLoadingOlderMessages, + olderMessagesError, + olderMessagesOmittedItemCount, + handleRetryOlderMessages, + ] + ); + return ( @@ -344,7 +382,7 @@ export function SessionMessageList({ renderItem={renderItem} // Transcript rows are tall and parse markdown on mount. The 250 dp // default draws under half a screen ahead, so a fast fling shows blank - // space until the rows mount. Four screens of lookahead hides that. + // space until the rows mount. A 2000 dp lookahead hides that. drawDistance={DRAW_DISTANCE} // Android Fabric can race clipped-view reattachment with rapid transcript updates. // Kept explicit: flash-list ≥ 2.3.2 defaults this to false (PR #2202); the pin @@ -371,14 +409,7 @@ export function SessionMessageList({ // streaming insertions at the bottom. startRenderingFromBottom: true, }} - ListHeaderComponent={ - - } + ListHeaderComponent={listHeaderComponent} ListFooterComponent={ListFooterComponent} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/components/agents/session-provider.tsx b/apps/mobile/src/components/agents/session-provider.tsx index d961443dcb..48bd23e19c 100644 --- a/apps/mobile/src/components/agents/session-provider.tsx +++ b/apps/mobile/src/components/agents/session-provider.tsx @@ -20,11 +20,10 @@ type AgentSessionProviderProps = { children: ReactNode; organizationId?: string; /** - * The account the manager's persisted transcript is scoped to when the live - * owner is not confirmed — a cold start with the API unreachable, where the - * route resolves the scope from the encrypted read cache instead. The live - * owner always wins, so this only ever restores a transcript the same - * credentials already own. + * The account resolved from the encrypted read cache on a cold start whose + * live owner is not confirmed yet — the API is unreachable, so the route + * cannot confirm the credentials. The live owner always wins; a restored id + * only keeps this manager alive while the same credentials still own it. */ restoredUserId?: string; }; @@ -37,11 +36,11 @@ export function AgentSessionProvider({ const userWebConnection = useUserWebConnection(); const storeRef = useRef(createStore()); const managerRef = useRef(null); - // Capture the owner before the manager is created so its transcript cache is - // scoped to this account. The route keys the provider on the owner, so a new - // account gets a new manager; the scope falls back to the restored id only - // while the live owner is unconfirmed, and `?? ''` makes the manager skip the - // cache if neither is known. + // Capture the owner before the manager is created so its resolved-delivery + // failure memory is scoped to this account. The route keys the provider on + // the owner, so a new account gets a new manager; the scope falls back to the + // restored id only while the live owner is unconfirmed, and `?? ''` makes the + // manager skip the memory if neither is known. const owner = useRef(getAuthenticatedOwner()).current; const scopeUserId = owner.userId ?? restoredUserId ?? ''; managerRef.current ??= createMobileAgentSessionManager({ diff --git a/apps/mobile/src/components/agents/session-transcript.test.ts b/apps/mobile/src/components/agents/session-transcript.test.ts index eadeb83101..c3060f2594 100644 --- a/apps/mobile/src/components/agents/session-transcript.test.ts +++ b/apps/mobile/src/components/agents/session-transcript.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + collectTranscriptItemKeysByPart, condenseTranscriptToolRuns, getSessionTranscriptItemKey, getSessionTranscriptItemMessageId, @@ -309,16 +310,27 @@ function keysOf(items: ReturnType): string[] { return items.map(item => getSessionTranscriptItemKey(item)); } +/** Whether `original` still appears as one unbroken run at the end of `next`. */ +function isContiguousSuffix(original: string[], next: string[]): boolean { + if (original.length > next.length) { + return false; + } + const offset = next.length - original.length; + return original.every((key, index) => next[offset + index] === key); +} + function messageIdsOf(items: ReturnType): (string | null)[] { return items.map(item => getSessionTranscriptItemMessageId(item)); } describe('getSessionTranscriptItemMessageId', () => { - it('maps message and time rows to their message id', () => { + it('maps a message row to its message id', () => { const transcript = mergeSessionTranscript([message('msg_001')], []); - expect(keysOf(transcript)).toEqual(['time:msg_001', 'msg_001']); - expect(messageIdsOf(transcript)).toEqual(['msg_001', 'msg_001']); + // The burst marker rides on the message item, so the row count is one per + // message and the marker adds no row of its own. + expect(keysOf(transcript)).toEqual(['msg_001']); + expect(messageIdsOf(transcript)).toEqual(['msg_001']); }); it('maps a preparation row to null — it renders no message row of its own', () => { @@ -327,7 +339,7 @@ describe('getSessionTranscriptItemMessageId', () => { [attempt('attempt_001', 'msg_001')] ); - expect(messageIdsOf(transcript)).toEqual(['msg_001', 'msg_001', null]); + expect(messageIdsOf(transcript)).toEqual(['msg_001', null]); }); it("maps a condensed tool run to its first part's message id", () => { @@ -342,8 +354,8 @@ describe('getSessionTranscriptItemMessageId', () => { ) ); - expect(keysOf(condensed)).toEqual(['time:msg_tool_a', 'tool-run:ta1']); - expect(messageIdsOf(condensed)).toEqual(['msg_tool_a', 'msg_tool_a']); + expect(keysOf(condensed)).toEqual(['tool-run:ta1']); + expect(messageIdsOf(condensed)).toEqual(['msg_tool_a']); }); }); @@ -354,12 +366,7 @@ describe('session transcript', () => { const transcript = mergeSessionTranscript(messages, attempts); - expect(keysOf(transcript)).toEqual([ - 'time:msg_001', - 'msg_001', - 'preparation:attempt_001', - 'msg_002', - ]); + expect(keysOf(transcript)).toEqual(['msg_001', 'preparation:attempt_001', 'msg_002']); }); it('keeps orphaned preparation attempts visible after paginated prepends', () => { @@ -368,7 +375,7 @@ describe('session transcript', () => { [attempt('attempt_older', 'msg_001')] ); - expect(keysOf(transcript)).toEqual(['time:msg_011', 'msg_011', 'preparation:attempt_older']); + expect(keysOf(transcript)).toEqual(['msg_011', 'preparation:attempt_older']); }); it('hides warm-reuse completed attempts that only ran synthetic sandbox markers', () => { @@ -377,7 +384,7 @@ describe('session transcript', () => { [warmReuseAttempt('attempt_warm', 'msg_001')] ); - expect(keysOf(transcript)).toEqual(['time:msg_001', 'msg_001']); + expect(keysOf(transcript)).toEqual(['msg_001']); }); it('keeps a running attempt even if it only has synthetic markers so far', () => { @@ -388,19 +395,22 @@ describe('session transcript', () => { }; const transcript = mergeSessionTranscript([message('msg_001')], [running]); - expect(keysOf(transcript)).toEqual(['time:msg_001', 'msg_001', 'preparation:attempt_running']); + expect(keysOf(transcript)).toEqual(['msg_001', 'preparation:attempt_running']); }); - it('opens a burst of ten visible messages inside one minute with exactly one marker, the first item', () => { + it('opens a burst of ten visible messages inside one minute with exactly one marker, on the first message', () => { const messages = Array.from({ length: 10 }, (_, i) => userMessageAt(`msg_burst_${i}`, 1_000_000_000 + i * 1000) ); const transcript = mergeSessionTranscript(messages, []); - expect(keysOf(transcript)).toEqual(['time:msg_burst_0', ...messages.map(m => m.info.id)]); - expect(transcript.filter(item => item.type === 'time')).toHaveLength(1); - expect(transcript[0]).toMatchObject({ type: 'time', messageId: 'msg_burst_0' }); + expect(keysOf(transcript)).toEqual(messages.map(m => m.info.id)); + const marked = transcript.filter( + item => item.type === 'message' && item.timeMarker !== undefined + ); + expect(marked).toHaveLength(1); + expect(marked[0]).toMatchObject({ message: { info: { id: 'msg_burst_0' } } }); }); it('marks a resumption when the gap reaches the threshold, and not one millisecond below it', () => { @@ -413,7 +423,11 @@ describe('session transcript', () => { ], [] ); - expect(keysOf(atGap)).toEqual(['time:msg_gap_a', 'msg_gap_a', 'time:msg_gap_b', 'msg_gap_b']); + expect(keysOf(atGap)).toEqual(['msg_gap_a', 'msg_gap_b']); + expect(atGap.map(item => (item.type === 'message' ? item.timeMarker : undefined))).toEqual([ + { created: base, dayChanged: false }, + { created: base + TRANSCRIPT_TIME_MARKER_GAP_MS, dayChanged: false }, + ]); const belowGap = mergeSessionTranscript( [ @@ -422,7 +436,59 @@ describe('session transcript', () => { ], [] ); - expect(keysOf(belowGap)).toEqual(['time:msg_gap_c', 'msg_gap_c', 'msg_gap_d']); + expect(keysOf(belowGap)).toEqual(['msg_gap_c', 'msg_gap_d']); + expect(belowGap[1]).toMatchObject({ type: 'message' }); + expect(belowGap[1]?.type === 'message' ? belowGap[1].timeMarker : undefined).toBeUndefined(); + }); + + it('keeps the page-2 key sequence a contiguous suffix after an older message is prepended', () => { + const minute = 60_000; + const base = 100 * minute; + // Page 2 as the reader first sees it: two user messages a minute apart. + const page2 = [userMessageAt('m2', base), userMessageAt('m3', base + minute)]; + const before = keysOf(mergeSessionTranscript(page2, [])); + expect(before).toEqual(['m2', 'm3']); + + // Loading older messages prepends `m1` a minute before the page's first row. + const merged = mergeSessionTranscript([userMessageAt('m1', base - minute), ...page2], []); + const after = keysOf(merged); + + // FlashList anchors on row keys: the keys that were on screen must still be + // present, unchanged and contiguous. The burst marker now rides on the + // message item, so it moving from `m2` to `m1` changes no key. + expect(isContiguousSuffix(before, after)).toBe(true); + expect(after).toEqual(['m1', 'm2', 'm3']); + expect(merged[0]).toMatchObject({ type: 'message', timeMarker: { created: base - minute } }); + expect(merged[1]).toMatchObject({ type: 'message' }); + expect(merged[1]?.type === 'message' ? merged[1].timeMarker : undefined).toBeUndefined(); + }); + + it('keeps a condensed tool run key when an older tool-only page is prepended', () => { + const base = 1_000_000_000; + // Page 2 as the reader first sees it: a lone tool-only message condenses to + // its message row, because a run of one falls back to the message. + const page2 = [assistantToolOnlyMessageAt('m2', base, ['t2'])]; + const before = condenseTranscriptToolRuns(mergeSessionTranscript(page2, [])); + expect(keysOf(before)).toEqual(['m2']); + + // Loading older messages prepends an older tool-only message in the same + // burst: its part joins t2 into one run. The carried map pins the run to the + // key the row was already on screen under, so FlashList holds the viewport. + const merged = mergeSessionTranscript( + [assistantToolOnlyMessageAt('m1', base - 1000, ['t1']), ...page2], + [] + ); + const after = keysOf( + condenseTranscriptToolRuns(merged, collectTranscriptItemKeysByPart(before)) + ); + + expect(after).toEqual(['m2']); + expect(isContiguousSuffix(keysOf(before), after)).toBe(true); + + // With no carried map the output is byte-identical to before this change: + // the run still names itself after its first part. The component is what + // threads the map; the pure contract stays the same. + expect(keysOf(condenseTranscriptToolRuns(merged))).toEqual(['tool-run:t1']); }); it('marks a day change even when the gap is small, carrying dayChanged on the marker', () => { @@ -434,14 +500,9 @@ describe('session transcript', () => { [] ); - expect(keysOf(transcript)).toEqual([ - 'time:msg_day_a', - 'msg_day_a', - 'time:msg_day_b', - 'msg_day_b', - ]); - expect(transcript[0]).toMatchObject({ type: 'time', dayChanged: false }); - expect(transcript[2]).toMatchObject({ type: 'time', dayChanged: true }); + expect(keysOf(transcript)).toEqual(['msg_day_a', 'msg_day_b']); + expect(transcript[0]).toMatchObject({ type: 'message', timeMarker: { dayChanged: false } }); + expect(transcript[1]).toMatchObject({ type: 'message', timeMarker: { dayChanged: true } }); }); it('carries dayChanged false on every marker except a true day change', () => { @@ -458,7 +519,9 @@ describe('session transcript', () => { [] ); - const markers = transcript.filter(item => item.type === 'time'); + const markers = transcript.flatMap(item => + item.type === 'message' && item.timeMarker ? [item.timeMarker] : [] + ); expect(markers.map(marker => marker.dayChanged)).toEqual([false, true]); }); @@ -480,12 +543,7 @@ describe('session transcript', () => { [] ); - expect(keysOf(withInvisible)).toEqual([ - 'time:msg_vis_a', - 'msg_vis_a', - 'time:msg_vis_b', - 'msg_vis_b', - ]); + expect(keysOf(withInvisible)).toEqual(['msg_vis_a', 'msg_vis_b']); expect(keysOf(withInvisible)).toEqual(keysOf(withoutInvisible)); }); @@ -498,7 +556,7 @@ describe('session transcript', () => { new Map([[failedMessage.info.id, { status: 'failed', error: 'nope', reason: 'exhausted' }]]) ); - expect(keysOf(transcript)).toEqual(['time:msg_failed', 'msg_failed']); + expect(keysOf(transcript)).toEqual(['msg_failed']); }); it('keeps an invalid-timestamp message visible without a marker and without resetting the run', () => { @@ -512,12 +570,7 @@ describe('session transcript', () => { [] ); - expect(keysOf(transcript)).toEqual([ - 'time:msg_time_a', - 'msg_time_a', - 'msg_time_b', - 'msg_time_c', - ]); + expect(keysOf(transcript)).toEqual(['msg_time_a', 'msg_time_b', 'msg_time_c']); const maxValueTranscript = mergeSessionTranscript( [ @@ -527,62 +580,38 @@ describe('session transcript', () => { ], [] ); - expect(keysOf(maxValueTranscript)).toEqual([ - 'time:msg_max_a', - 'msg_max_a', - 'msg_max_b', - 'msg_max_c', - ]); + expect(keysOf(maxValueTranscript)).toEqual(['msg_max_a', 'msg_max_b', 'msg_max_c']); }); - it('keeps every fixture free of a trailing marker and of adjacent markers', () => { + it('assigns each marker to the message that opens its burst and to no other', () => { const base = 1_000_000_000; const beforeMidnight = new Date(2026, 0, 1, 23, 59, 30).getTime(); const afterMidnight = new Date(2026, 0, 2, 0, 0, 10).getTime(); - const transcripts = [ - mergeSessionTranscript( - [message('msg_001'), message('msg_002')], - [attempt('attempt_001', 'msg_001')] - ), - mergeSessionTranscript([message('msg_011')], [attempt('attempt_older', 'msg_001')]), - mergeSessionTranscript([message('msg_001')], [warmReuseAttempt('attempt_warm', 'msg_001')]), - mergeSessionTranscript( - [ - userMessageAt('msg_gap_a', base), - userMessageAt('msg_gap_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), - ], - [] - ), - mergeSessionTranscript( - [userMessageAt('msg_day_a', beforeMidnight), userMessageAt('msg_day_b', afterMidnight)], - [] - ), - mergeSessionTranscript( - [ - userMessageAt('msg_vis_a', base), - assistantMessageWithStepStartOnly('msg_hidden', base + 10_000), - userMessageAt('msg_vis_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), - ], - [] - ), - mergeSessionTranscript( - [ - userMessageAt('msg_time_a', base), - userMessageWithCreatedAt('msg_time_b', undefined), - userMessageAt('msg_time_c', base + 2000), - ], - [] - ), - ]; + const transcript = mergeSessionTranscript( + [ + userMessageAt('msg_open_a', base), + userMessageAt('msg_burst_b', base + 1000), + userMessageAt('msg_gap_c', base + 1000 + TRANSCRIPT_TIME_MARKER_GAP_MS), + userMessageAt('msg_day_d', beforeMidnight), + userMessageAt('msg_day_e', afterMidnight), + ], + [] + ); - for (const transcript of transcripts) { - const keys = keysOf(transcript); - expect(keys.at(-1)?.startsWith('time:')).toBe(false); - for (let i = 1; i < keys.length; i += 1) { - expect(keys[i - 1]?.startsWith('time:') && keys[i]?.startsWith('time:')).toBe(false); - } - } + // The burst opener, the resumption after the gap, and the first message of + // the new day carry a marker; the message inside each burst does not. + const markedIds = transcript.flatMap(item => + item.type === 'message' && item.timeMarker ? [item.message.info.id] : [] + ); + expect(markedIds).toEqual(['msg_open_a', 'msg_gap_c', 'msg_day_d', 'msg_day_e']); + expect(keysOf(transcript)).toEqual([ + 'msg_open_a', + 'msg_burst_b', + 'msg_gap_c', + 'msg_day_d', + 'msg_day_e', + ]); }); it('renders visible assistant messages and markers alongside user messages', () => { @@ -594,7 +623,7 @@ describe('session transcript', () => { [] ); - expect(keysOf(transcript)).toEqual(['time:msg_user', 'msg_user', 'msg_asst']); + expect(keysOf(transcript)).toEqual(['msg_user', 'msg_asst']); }); it('renders one unconfirmed submission as one message item', () => { @@ -608,7 +637,7 @@ describe('session transcript', () => { const transcript = mergeSessionTranscript([optimistic], []); expect(transcript.filter(item => item.type === 'message')).toHaveLength(1); - expect(keysOf(transcript)).toEqual(['time:msg_opt', 'msg_opt']); + expect(keysOf(transcript)).toEqual(['msg_opt']); // The recorded failed run keeps that one row, so the typed failure footer // stays attached to the submission that failed. @@ -619,7 +648,7 @@ describe('session transcript', () => { ['msg_opt', { status: 'failed', error: 'Unauthorized: Unauthorized', reason: 'exhausted' }], ]) ); - expect(keysOf(failed)).toEqual(['time:msg_opt', 'msg_opt']); + expect(keysOf(failed)).toEqual(['msg_opt']); }); it('keeps two submissions that carry the same prompt as two rows', () => { @@ -660,11 +689,11 @@ describe('session transcript', () => { [], new Map([['msg_stub', { status: 'failed', error: 'boom', reason: 'execution' }]]) ); - expect(keysOf(failed)).toEqual(['time:msg_stub', 'msg_stub']); + expect(keysOf(failed)).toEqual(['msg_stub']); // A confirmed zero-part row keeps the transient rendering. const transient = mergeSessionTranscript([message('msg_transient')], []); - expect(keysOf(transient)).toEqual(['time:msg_transient', 'msg_transient']); + expect(keysOf(transient)).toEqual(['msg_transient']); }); }); @@ -678,11 +707,131 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); - expect(keysOf(condensed)).toEqual(['time:msg_tool_a', 'tool-run:ta1']); + expect(keysOf(condensed)).toEqual(['tool-run:ta1']); const run = condensed.find(item => item.type === 'tool-run'); expect(run?.parts.map(part => part.id)).toEqual(['ta1', 'ta2', 'tb1']); }); + it('keeps a run key when a lone tool row becomes a run while streaming', () => { + const base = 1_000_000_000; + // A lone tool-only message first renders as its message row (a run of one). + const page1 = [assistantToolOnlyMessageAt('m1', base, ['t1'])]; + const before = condenseTranscriptToolRuns(mergeSessionTranscript(page1, [])); + expect(keysOf(before)).toEqual(['m1']); + + // A later assistant message's leading tool part streams in and joins the run. + // The carried map pins the run to the key the row already had. + const after = condenseTranscriptToolRuns( + mergeSessionTranscript( + [ + assistantToolOnlyMessageAt('m1', base, ['t1']), + assistantToolOnlyMessageAt('m2', base + 1000, ['t2']), + ], + [] + ), + collectTranscriptItemKeysByPart(before) + ); + + expect(keysOf(after)).toEqual(['m1']); + }); + + it('falls back to the first part id when a carried key is already taken', () => { + const base = 1_000_000_000; + // One run holds four parts and keys every one of them `tool-run:t1`. + const before = condenseTranscriptToolRuns( + mergeSessionTranscript([assistantToolOnlyMessageAt('m1', base, ['t1', 't2', 't3', 't4'])], []) + ); + expect(keysOf(before)).toEqual(['tool-run:t1']); + + // The run now splits at a user message. The first half reuses the carried + // key; the second half can no longer take it and falls back to its own + // first part's id, so one carried key is never emitted twice. + const after = condenseTranscriptToolRuns( + mergeSessionTranscript( + [ + assistantToolOnlyMessageAt('m1', base, ['t1', 't2']), + userMessageWithTextAt('u', base + 500, 'between'), + assistantToolOnlyMessageAt('m2', base + 1000, ['t3', 't4']), + ], + [] + ), + collectTranscriptItemKeysByPart(before) + ); + + expect(keysOf(after)).toEqual(['tool-run:t1', 'u', 'tool-run:t3']); + }); + + it.each([true, false])( + 'reserves a later failed message key after a run adopted it (retryable: %s)', + isRetryable => { + const base = 1_000_000_000; + const owner = assistantToolOnlyMessageAt('m2', base + 1000, ['t2']); + const lone = condenseTranscriptToolRuns(mergeSessionTranscript([owner], [])); + const older = assistantToolOnlyMessageAt('m1', base, ['t1a', 't1b']); + const joined = condenseTranscriptToolRuns( + mergeSessionTranscript([older, owner], []), + collectTranscriptItemKeysByPart(lone) + ); + expect(keysOf(joined)).toEqual(['m2']); + + const failed = { + ...owner, + info: { + ...owner.info, + error: { name: 'APIError' as const, data: { message: 'boom', isRetryable } }, + }, + }; + const after = condenseTranscriptToolRuns( + mergeSessionTranscript([older, failed], []), + collectTranscriptItemKeysByPart(joined) + ); + + expect(keysOf(after)).toEqual(['tool-run:t1a', 'm2']); + expect(after[1]).toMatchObject({ type: 'message', message: failed }); + expect(toolPartCount(after)).toBe(3); + } + ); + + it('reserves a later plain fragment key after a run adopted it', () => { + const base = 1_000_000_000; + const owner = assistantToolOnlyMessageAt('m2', base + 1000, ['t2']); + const lone = condenseTranscriptToolRuns(mergeSessionTranscript([owner], [])); + const older = assistantToolOnlyMessageAt('m1', base, ['t1a', 't1b']); + const joined = condenseTranscriptToolRuns( + mergeSessionTranscript([older, owner], []), + collectTranscriptItemKeysByPart(lone) + ); + const after = condenseTranscriptToolRuns( + mergeSessionTranscript( + [older, assistantTextThenToolsMessageAt('m2', base + 1000, ['t2'])], + [] + ), + collectTranscriptItemKeysByPart(joined) + ); + + expect(keysOf(after)).toEqual(['tool-run:t1a', 'm2']); + expect(toolPartCount(after)).toBe(3); + }); + + it('reserves the default key of a later run when an adopted run splits again', () => { + const base = 1_000_000_000; + const owner = assistantToolOnlyMessageAt('m2', base + 1000, ['t2a', 't2b']); + const before = condenseTranscriptToolRuns(mergeSessionTranscript([owner], [])); + const older = assistantToolOnlyMessageAt('m1', base, ['t1a', 't1b']); + const joined = condenseTranscriptToolRuns( + mergeSessionTranscript([older, owner], []), + collectTranscriptItemKeysByPart(before) + ); + expect(keysOf(joined)).toEqual(['tool-run:t2a']); + const after = condenseTranscriptToolRuns( + mergeSessionTranscript([older, userMessageWithTextAt('u', base + 500, 'between'), owner], []), + collectTranscriptItemKeysByPart(joined) + ); + + expect(keysOf(after)).toEqual(['tool-run:t1a', 'u', 'tool-run:t2a']); + expect(toolPartCount(after)).toBe(4); + }); + it('splits the run around a user message', () => { const base = 1_000_000_000; const messages = [ @@ -693,15 +842,10 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); - expect(keysOf(condensed)).toEqual([ - 'time:msg_tool_a', - 'tool-run:ta1', - 'msg_user', - 'tool-run:tb1', - ]); + expect(keysOf(condensed)).toEqual(['tool-run:ta1', 'msg_user', 'tool-run:tb1']); }); - it('splits the run around a time marker', () => { + it('splits the run at every message that carries a time marker, keeping the markers', () => { const base = 1_000_000_000; const messages = [ assistantToolOnlyMessageAt('msg_tool_a', base, ['ta1', 'ta2']), @@ -713,12 +857,15 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); - expect(keysOf(condensed)).toEqual([ - 'time:msg_tool_a', - 'tool-run:ta1', - 'time:msg_tool_b', - 'tool-run:tb1', - ]); + // A marked message is a burst boundary exactly as the standalone marker item + // was, and each marker rides on the run that opens its burst. + expect(keysOf(condensed)).toEqual(['tool-run:ta1', 'tool-run:tb1']); + expect(condensed.map(item => (item.type === 'tool-run' ? item.timeMarker : undefined))).toEqual( + [ + { created: base, dayChanged: false }, + { created: base + TRANSCRIPT_TIME_MARKER_GAP_MS, dayChanged: false }, + ] + ); }); it('carries a mixed message trailing tool run into the following message', () => { @@ -732,11 +879,7 @@ describe('condenseTranscriptToolRuns', () => { // The text fragment precedes the run: text, read, bash reads as text then a // single "<2> items" row instead of two expanded cards. - expect(keysOf(condensed)).toEqual([ - 'time:msg_mixed', - 'message-parts:msg_mixed:msg_mixed:text', - 'tool-run:tm1', - ]); + expect(keysOf(condensed)).toEqual(['message-parts:msg_mixed:msg_mixed:text', 'tool-run:tm1']); const run = condensed.find(item => item.type === 'tool-run'); expect(run?.parts.map(part => part.id)).toEqual(['tm1', 'tb1']); const fragment = condensed.find(item => item.type === 'message'); @@ -755,11 +898,7 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); - expect(keysOf(condensed)).toEqual([ - 'time:msg_tool_a', - 'tool-run:ta1', - 'message-parts:msg_mixed:msg_mixed:text', - ]); + expect(keysOf(condensed)).toEqual(['tool-run:ta1', 'message-parts:msg_mixed:msg_mixed:text']); const run = condensed.find(item => item.type === 'tool-run'); expect(run?.parts.map(part => part.id)).toEqual(['ta1', 'ta2', 'tm1']); }); @@ -775,7 +914,6 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); expect(keysOf(condensed)).toEqual([ - 'time:msg_tool_a', 'tool-run:ta1', 'message-parts:msg_mixed:msg_mixed:text', 'tool-run:tb1', @@ -800,7 +938,7 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); // A run of one is not condensed, so the message item is unchanged. - expect(keysOf(condensed)).toEqual(['time:msg_mixed', 'msg_mixed']); + expect(keysOf(condensed)).toEqual(['msg_mixed']); const lone = condensed.find(item => item.type === 'message'); expect(lone?.type === 'message' ? lone.parts : undefined).toBeUndefined(); }); @@ -811,10 +949,47 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); - expect(keysOf(condensed)).toEqual(['time:msg_lone', 'msg_lone']); + expect(keysOf(condensed)).toEqual(['msg_lone']); expect(condensed.some(item => item.type === 'tool-run')).toBe(false); }); + it('keeps the burst marker when a marked message opens a run of one', () => { + const base = 1_000_000_000; + // The first message of the page opens a burst, and its only visible part is + // a lone tool call: the run collapses back to the message fragment, which + // must still carry the marker or the reader loses the timestamp. + const messages = [assistantToolOnlyMessageAt('msg_lone_marked', base, ['t1'])]; + + const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); + + expect(keysOf(condensed)).toEqual(['msg_lone_marked']); + const lone = condensed.find(item => item.type === 'message'); + expect(lone?.type === 'message' ? lone.timeMarker : undefined).toEqual({ + created: base, + dayChanged: false, + }); + }); + + it('keeps the burst marker when a lone tool run follows a hidden message', () => { + const base = 1_000_000_000; + // A marked resumption whose only visible part is a lone tool call: the run + // of one falls back to the message fragment and keeps its marker. + const messages = [ + userMessageAt('msg_open', base), + assistantToolOnlyMessageAt('msg_resume', base + TRANSCRIPT_TIME_MARKER_GAP_MS, ['t1']), + ]; + + const condensed = condenseTranscriptToolRuns(mergeSessionTranscript(messages, [])); + + const resumed = condensed.find( + item => item.type === 'message' && item.message.info.id === 'msg_resume' + ); + expect(resumed?.type === 'message' ? resumed.timeMarker : undefined).toEqual({ + created: base + TRANSCRIPT_TIME_MARKER_GAP_MS, + dayChanged: false, + }); + }); + it('keeps a failed last tool call in the run so the row can show its status', () => { const base = 1_000_000_000; const failedPart = { @@ -869,7 +1044,7 @@ describe('condenseTranscriptToolRuns', () => { const condensed = condenseTranscriptToolRuns(mergeSessionTranscript([planThenRead], [])); - expect(keysOf(condensed)).toEqual(['time:msg_plan_lone', 'msg_plan_lone']); + expect(keysOf(condensed)).toEqual(['msg_plan_lone']); expect(condensed.some(item => item.type === 'tool-run')).toBe(false); }); @@ -911,12 +1086,7 @@ describe('condenseTranscriptToolRuns', () => { const transcript = mergeSessionTranscript(messages, []); const condensed = condenseTranscriptToolRuns(transcript); - expect(keysOf(condensed)).toEqual([ - 'time:msg_tool_a', - 'tool-run:ta1', - 'msg_failed', - 'tool-run:tc1', - ]); + expect(keysOf(condensed)).toEqual(['tool-run:ta1', 'msg_failed', 'tool-run:tc1']); const failedItem = condensed.find(item => keysOf([item])[0] === 'msg_failed'); expect(failedItem?.type).toBe('message'); for (const item of condensed) { diff --git a/apps/mobile/src/components/agents/session-transcript.ts b/apps/mobile/src/components/agents/session-transcript.ts index d0c9f1f83b..4961e8589d 100644 --- a/apps/mobile/src/components/agents/session-transcript.ts +++ b/apps/mobile/src/components/agents/session-transcript.ts @@ -11,6 +11,13 @@ import { isSameLocalDay, isValidTranscriptTime } from './message-time-label'; import { messageRendersContent, partRendersContent } from './message-visibility'; import { isCondensableToolPart } from './session-tool-run'; +/** + * A burst-opening time marker. It rides on the first item a message row emits so + * a prepend can never add, remove, or re-key a row that is already on screen: the + * marker moves to the older message while every message keeps its `info.id` key. + */ +type SessionTranscriptTimeMarker = { created: number; dayChanged: boolean }; + export type SessionTranscriptItem = | { type: 'message'; @@ -21,6 +28,7 @@ export type SessionTranscriptItem = * message, whose full part list is rendered. */ parts?: Part[]; + timeMarker?: SessionTranscriptTimeMarker; } | { type: 'preparation'; attempt: PreparationAttempt } | { @@ -29,8 +37,8 @@ export type SessionTranscriptItem = /** The run's first part's message id, for resume-anchor matching. */ messageId: string; parts: ToolPart[]; - } - | { type: 'time'; created: number; messageId: string; dayChanged: boolean }; + timeMarker?: SessionTranscriptTimeMarker; + }; /** * A time marker opens a run of messages. Below this gap the messages belong to the @@ -66,20 +74,67 @@ export function getSessionTranscriptItemKey(item: SessionTranscriptItem): string if (item.type === 'preparation') { return `preparation:${item.attempt.id}`; } - if (item.type === 'tool-run') { - return item.id; + return item.id; +} + +/** + * The item key each rendered part had in one transcript build, keyed by part id. + * A condensed row's key is derived from the parts it holds, so a later build + * cannot recompute the key a row was born with from its parts alone. Carrying + * this map from the previous build lets `condenseTranscriptToolRuns` keep a row's + * existing key when a prepend or a streaming part changes the run's first part. + */ +export type TranscriptItemKeysByPart = ReadonlyMap; + +/** + * Records, for every part an item renders, the key of the item that renders it. + * A `message` item maps the subset it actually renders (`item.parts` when a + * condensed run split the message, else every content-rendering part); a + * `tool-run` item maps the parts it holds; a preparation attempt renders no part + * and is skipped. + */ +export function collectTranscriptItemKeysByPart( + items: readonly SessionTranscriptItem[] +): Map { + const keysByPart = new Map(); + for (const item of items) { + if (item.type !== 'preparation') { + const key = getSessionTranscriptItemKey(item); + const parts = + item.type === 'tool-run' + ? item.parts + : (item.parts ?? item.message.parts).filter(part => partRendersContent(part)); + for (const part of parts) { + keysByPart.set(part.id, key); + } + } } - return `time:${item.messageId}`; + return keysByPart; } +/** + * FlashList's recycling bucket. A row's view shape follows its kind and, for a + * message, its role and failure state: a user bubble and an assistant bubble + * share no layout, and a failed turn adds a footer with Retry. Recycling across + * those shapes would reuse the wrong view, so the bucket names them separately. + * The time marker never changes the bucket: its presence flips for the boundary + * row on every prepend, and a changing bucket would only defeat recycling. + */ export function getSessionTranscriptItemType(item: SessionTranscriptItem): string { + if (item.type === 'message') { + const info = item.message.info; + if (info.role === 'assistant' && info.error) { + return 'message-error'; + } + return info.role === 'user' ? 'message-user' : 'message-assistant'; + } return item.type; } /** - * The message id a resume anchor matches this item by: a message row's own id, - * a condensed run's first part's message, or a time marker's message (the - * marker renders above that message). A preparation attempt has no message row + * The message id a resume anchor matches this item by: a message row's own id + * (the burst marker now rides on that row, so it adds no case of its own), or a + * condensed run's first part's message. A preparation attempt has no message row * of its own, so it can never be an anchor target. */ export function getSessionTranscriptItemMessageId(item: SessionTranscriptItem): string | null { @@ -147,7 +202,8 @@ export function mergeSessionTranscript( if (transcriptRendersMessage(message, deliveryStates)) { const created = message.info.time.created; // One validity rule, shared with the marker component: a timestamp the label - // cannot format must never produce a marker row. + // cannot format must never produce a marker. + let timeMarker: { created: number; dayChanged: boolean } | undefined = undefined; if (isValidTranscriptTime(created)) { const dayChanged = previousCreated !== undefined && !isSameLocalDay(created, previousCreated); @@ -156,11 +212,11 @@ export function mergeSessionTranscript( dayChanged || created - previousCreated >= TRANSCRIPT_TIME_MARKER_GAP_MS ) { - items.push({ type: 'time', created, messageId: message.info.id, dayChanged }); + timeMarker = { created, dayChanged }; } previousCreated = created; } - items.push({ type: 'message', message }); + items.push({ type: 'message', message, ...(timeMarker ? { timeMarker } : {}) }); } for (const attempt of byMessageId.get(message.info.id) ?? []) { items.push({ type: 'preparation', attempt }); @@ -184,42 +240,78 @@ export function mergeSessionTranscript( * * A message whose visible parts all stay plain is re-emitted unchanged. A message * split around a run is emitted as one `message` item per plain stretch, carrying - * the subset of parts to render in `parts`. Every other item — a time marker, a - * preparation attempt, a user message, or a message-level failure (`info.error`) - * — flushes the run and passes through whole, so a failed turn keeps its failure - * footer and Retry. The id is derived from the run's first part, so it stays - * stable while later parts stream into the same run and the FlashList key never - * changes. + * the subset of parts to render in `parts`. Every other item — a preparation + * attempt, a user message, or a message-level failure (`info.error`) — flushes the + * run and passes through whole, so a failed turn keeps its failure footer and + * Retry. Without `carriedKeysByPart` the id is derived from the run's first part, + * so it stays stable while later parts stream into the same run. + * + * `carriedKeysByPart` is the previous build's part→item-key map (see + * `collectTranscriptItemKeysByPart`). When a run of two or more holds a part that + * an earlier build rendered under its own key — a lone tool part that has just + * become a run, or a run whose earlier parts a prepend pushed behind it — the run + * reuses that key instead of re-keying. FlashList anchors the viewport on the + * first visible row's key, so keeping the key is what stops the jump when an older + * page prepends. + * + * A time marker now rides on the message that opens its burst. It stays a run + * boundary here, exactly as the standalone marker item was, and moves onto the + * first item that message emits: condensing changes neither the rows nor the + * markers the reader saw before the marker was folded into the message. */ export function condenseTranscriptToolRuns( - items: readonly SessionTranscriptItem[] + items: readonly SessionTranscriptItem[], + carriedKeysByPart?: TranscriptItemKeysByPart ): SessionTranscriptItem[] { const condensed: SessionTranscriptItem[] = []; + const emit = (item: SessionTranscriptItem) => { + condensed.push(item); + }; + // The maximal run of consecutive condensable tool parts, each with its source // message so a run of one can fall back to that message's plain rendering. let run: { message: StoredMessage; part: ToolPart }[] = []; + // The marker of the message that opened the current run, moved onto the run + // item when it closes. + let runMarker: SessionTranscriptTimeMarker | undefined = undefined; + + // The marker of the message being processed that has not reached an item yet. + // A marked message emits at most one item that can open it — its first tool + // run or its first plain fragment — and the marker lands on that one. + let pendingMarker: SessionTranscriptTimeMarker | undefined = undefined; + // The pending plain stretch of one message: the visible parts that are not in // the current run, emitted as a `message` item once the run closes. - let fragment: { message: StoredMessage; parts: Part[]; visibleCount: number } | null = null; + let fragment: { + message: StoredMessage; + parts: Part[]; + visibleCount: number; + marker?: SessionTranscriptTimeMarker; + } | null = null; const flushFragment = () => { if (fragment === null) { return; } - const { message, parts, visibleCount } = fragment; + const { message, parts, visibleCount, marker } = fragment; fragment = null; if (parts.length === 0) { return; } // A fragment holding every visible part is the unchanged message: emit it // without `parts` so its item key and render path stay identical. - condensed.push( - parts.length === visibleCount - ? { type: 'message', message } - : { type: 'message', message, parts } - ); + if (parts.length === visibleCount) { + emit({ type: 'message', message, ...(marker ? { timeMarker: marker } : {}) }); + } else { + emit({ + type: 'message', + message, + parts, + ...(marker ? { timeMarker: marker } : {}), + }); + } }; const appendPlain = (message: StoredMessage, part: Part, visibleCount: number) => { @@ -227,7 +319,13 @@ export function condenseTranscriptToolRuns( flushFragment(); } if (fragment === null) { - fragment = { message, parts: [part], visibleCount }; + fragment = { + message, + parts: [part], + visibleCount, + ...(pendingMarker ? { marker: pendingMarker } : {}), + }; + pendingMarker = undefined; } else { fragment.parts.push(part); } @@ -243,31 +341,51 @@ export function condenseTranscriptToolRuns( if (run.length >= 2) { flushFragment(); const first = run[0]; - condensed.push({ + emit({ type: 'tool-run', id: `tool-run:${first?.part.id ?? ''}`, messageId: first?.message.info.id ?? '', parts: run.map(entry => entry.part), + ...(runMarker ? { timeMarker: runMarker } : {}), }); } else { const only = run[0]; if (only) { + // A run of one falls back to its message's plain rendering. Forward the + // marker that opened the run onto that fallback fragment, or a marked + // message whose first visible part is a single tool call loses its + // marker. `pendingMarker` is always clear here (starting the run + // consumed it), so resetting it after the append cannot drop a live + // marker meant for the next message. + pendingMarker = runMarker; appendPlain(only.message, only.part, visiblePartCount(only.message)); + pendingMarker = undefined; } } run = []; + runMarker = undefined; }; - const appendVisibleParts = (message: StoredMessage) => { + const appendVisibleParts = (item: Extract) => { + const { message } = item; + pendingMarker = item.timeMarker; const visible = message.parts.filter(part => partRendersContent(part)); if (visible.length === 0) { + const marker = pendingMarker; + pendingMarker = undefined; flushRun(); flushFragment(); - condensed.push({ type: 'message', message }); + emit( + marker ? { type: 'message', message, timeMarker: marker } : { type: 'message', message } + ); return; } for (const part of visible) { if (isCondensableToolPart(part)) { + if (run.length === 0) { + runMarker = pendingMarker; + pendingMarker = undefined; + } run.push({ message, part }); } else { flushRun(); @@ -282,14 +400,46 @@ export function condenseTranscriptToolRuns( item.message.info.role === 'assistant' && !item.message.info.error ) { - appendVisibleParts(item.message); + // A marker opens a burst, so it ends the previous run before its own + // message can join one — the split the standalone marker item forced. + if (item.timeMarker) { + flushRun(); + flushFragment(); + } + appendVisibleParts(item); } else { flushRun(); flushFragment(); - condensed.push(item); + emit(item); } } flushRun(); flushFragment(); + if (!carriedKeysByPart) { + return condensed; + } + + // Reserve every current row's key before reusing old ones. A run can carry a + // later message's id (or another run's fallback key) after a prepend, then + // split away from that row on the next build. + const reservedKeys = new Set(condensed.map(item => getSessionTranscriptItemKey(item))); + const emittedKeys = new Set(); + for (const [index, item] of condensed.entries()) { + if (item.type === 'tool-run') { + const carriedKey = item.parts + .map(part => carriedKeysByPart.get(part.id)) + .find( + key => + key !== undefined && + !emittedKeys.has(key) && + (!reservedKeys.has(key) || key === item.id) + ); + const id = carriedKey ?? item.id; + emittedKeys.add(id); + if (id !== item.id) { + condensed[index] = { ...item, id }; + } + } + } return condensed; } 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 1cd7d1027b..53eb4fcc64 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 @@ -169,6 +169,43 @@ describe('shouldScheduleSessionAutoScroll', () => { }) ).toBe(false); }); + + it('does not schedule when only an older page was prepended (newest key unchanged)', () => { + // The item count grew, but the tail of the list did not move: an older + // page landed. Scheduling a scroll here would yank the viewport back to + // the newest message while the user is reading history. + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: false, + isUserScrolling: false, + shouldAutoScroll: true, + newestKeyChanged: false, + }) + ).toBe(false); + }); + + it('schedules when the newest item key changed', () => { + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: false, + isUserScrolling: false, + shouldAutoScroll: true, + newestKeyChanged: true, + }) + ).toBe(true); + }); + + it('keeps scheduling for callers that do not track the newest key', () => { + // Layout and keyboard triggers have no item identity; omitting the new + // guard must preserve the previous behavior. + expect( + shouldScheduleSessionAutoScroll({ + isAutoScrolling: false, + isUserScrolling: false, + shouldAutoScroll: true, + }) + ).toBe(true); + }); }); describe('shouldRetrySessionAutoScroll', () => { 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 19a30efcb9..e0e940dd03 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 @@ -41,19 +41,26 @@ export function getInitialSessionListAutoScrollVisibility({ /** * Decide whether a programmatic scroll-to-latest should be scheduled. * - * Mirrors the four guards inside `useSessionAutoScroll`'s `scheduleScrollToLatestMessage`: + * Mirrors the 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. + * - `newestKeyChanged` – the newest item actually changed. Prepending an + * older page grows the list without moving the tail, so scheduling a + * scroll there would yank the viewport back to the newest message while + * the user is reading history. Callers that do not track item identity + * (layout/keyboard triggers) omit it and keep the previous behavior. */ export function shouldScheduleSessionAutoScroll({ isAutoScrolling, isUserScrolling, shouldAutoScroll, + newestKeyChanged = true, }: { isAutoScrolling: boolean; isUserScrolling: boolean; shouldAutoScroll: boolean; + newestKeyChanged?: boolean; }): boolean { if (!shouldAutoScroll) { return false; @@ -64,6 +71,9 @@ export function shouldScheduleSessionAutoScroll({ if (isAutoScrolling) { return false; } + if (!newestKeyChanged) { + return false; + } return true; } diff --git a/apps/mobile/src/components/agents/use-session-list-auto-scroll.mounted.test.tsx b/apps/mobile/src/components/agents/use-session-list-auto-scroll.mounted.test.tsx index 7fe8ca8e64..fa9c080012 100644 --- a/apps/mobile/src/components/agents/use-session-list-auto-scroll.mounted.test.tsx +++ b/apps/mobile/src/components/agents/use-session-list-auto-scroll.mounted.test.tsx @@ -42,7 +42,14 @@ function mountAutoScroll(itemCount: number) { const probe: { current: AutoScrollApi | null } = { current: null }; function Probe({ count }: Readonly<{ count: number }>) { - probe.current = useSessionListAutoScroll({ itemCount: count, resetKey: 'session-1' }); + probe.current = useSessionListAutoScroll({ + itemCount: count, + // The hook gates the item-count follow on the newest key changing. This + // test only exercises viewport resize, but the param is required and a + // constant list has a constant newest key. + newestItemKey: `item-${count}`, + resetKey: 'session-1', + }); return createElement('View', null); } 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 cc7595efd6..1cec93f44f 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 @@ -19,6 +19,13 @@ import { useMotionPolicy } from '@/lib/a11y/motion'; type UseSessionListAutoScrollParams = { itemCount: number; + /** + * Key of `items.at(-1)` (the newest item) or `null` for an empty list. + * The item-count effect only schedules a scroll when this key changes, so + * prepending an older page (count grows, newest unchanged) can never yank + * the viewport back to the newest message. + */ + newestItemKey: string | null; resetKey: string; /** * Whether the session opens following the newest message. Default true keeps @@ -47,6 +54,7 @@ type UseSessionListAutoScrollParams = { */ export function useSessionListAutoScroll({ itemCount, + newestItemKey, resetKey, initialAutoScroll = true, resumeKey = null, @@ -81,6 +89,10 @@ export function useSessionListAutoScroll({ // (see the reset effect) while a drag's claim outranks the link. const sendTakeoverRef = useRef(false); const lastContentHeightRef = useRef(0); + // Newest item key seen by the previous render. The item-count effect + // compares against it to tell a genuine append (newest key changed) from + // an older page landing (count grew, newest key untouched). + const lastNewestItemKeyRef = useRef(null); // The list's own height, tracked so a viewport resize (the fixed status row // mounting outside the list) can re-pin the tail. See `handleListLayout`. const lastViewportHeightRef = useRef(0); @@ -201,35 +213,39 @@ export function useSessionListAutoScroll({ [clearAutoScrollResetTimeout] ); - const scheduleScrollToLatestMessage = useCallback(() => { - if ( - !shouldScheduleSessionAutoScroll({ - isAutoScrolling: isAutoScrollingRef.current, - isUserScrolling: isUserScrollingRef.current, - shouldAutoScroll: shouldAutoScrollRef.current, - }) - ) { - return; - } - scrollToLatestMessage(); - 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. + const scheduleScrollToLatestMessage = useCallback( + (newestKeyChanged = true) => { if ( - shouldRetrySessionAutoScroll({ + !shouldScheduleSessionAutoScroll({ + isAutoScrolling: isAutoScrollingRef.current, isUserScrolling: isUserScrollingRef.current, shouldAutoScroll: shouldAutoScrollRef.current, + newestKeyChanged, }) ) { - scrollToLatestMessage(); + return; } - }, 80); - }, [clearAutoScrollRetryTimeout, scrollToLatestMessage]); + scrollToLatestMessage(); + 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 ( + shouldRetrySessionAutoScroll({ + isUserScrolling: isUserScrollingRef.current, + shouldAutoScroll: shouldAutoScrollRef.current, + }) + ) { + scrollToLatestMessage(); + } + }, 80); + }, + [clearAutoScrollRetryTimeout, scrollToLatestMessage] + ); // A new session resets the follow policy and the sticky takeover flag. A // policy that flips on its own mid-session — a `?at=` resume whose anchor @@ -259,16 +275,19 @@ export function useSessionListAutoScroll({ const initial = getInitialSessionListAutoScrollVisibility({ followTail: initialAutoScroll }); shouldAutoScrollRef.current = initial.shouldAutoScroll; lastContentHeightRef.current = 0; + lastNewestItemKeyRef.current = null; userInteractedRef.current = false; sendTakeoverRef.current = false; setIsAtBottom(prev => (prev === initial.isAtBottom ? prev : initial.isAtBottom)); }, [resetKey, initialAutoScroll, resumeKey]); useEffect(() => { - if (itemCount > 0 && shouldAutoScrollRef.current && !isUserScrollingRef.current) { - scheduleScrollToLatestMessage(); + const newestKeyChanged = lastNewestItemKeyRef.current !== newestItemKey; + lastNewestItemKeyRef.current = newestItemKey; + if (itemCount > 0) { + scheduleScrollToLatestMessage(newestKeyChanged); } - }, [itemCount, scheduleScrollToLatestMessage]); + }, [itemCount, newestItemKey, scheduleScrollToLatestMessage]); useEffect( () => () => { diff --git a/apps/mobile/src/lib/glanceable/approve-front-agent.ts b/apps/mobile/src/lib/glanceable/approve-front-agent.ts index 863d673377..ac720db9b9 100644 --- a/apps/mobile/src/lib/glanceable/approve-front-agent.ts +++ b/apps/mobile/src/lib/glanceable/approve-front-agent.ts @@ -333,7 +333,6 @@ async function defaultFrontApprovalDeps(): Promise { store, userWebConnection: connection, organizationId: approvalScope.organizationId ?? undefined, - userId: approvalScope.userId ?? '', }); headlessConnections.set(manager, connection); return { manager, store }; diff --git a/apps/mobile/src/lib/persist/session-transcript-cache.test.ts b/apps/mobile/src/lib/persist/session-transcript-cache.test.ts deleted file mode 100644 index b1f602826a..0000000000 --- a/apps/mobile/src/lib/persist/session-transcript-cache.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* eslint-disable require-await, @typescript-eslint/require-await -- the in-memory KV fake settles without await */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// The transcript cache stores through the encrypted-kv module; the mock below -// is an in-memory per-scope store with the real map semantics, keeping the -// native SQLCipher chain out of this node suite. -const kvMock = vi.hoisted(() => { - const scopes = new Map>(); - let clock = 0; - return { - scopes, - getItem: vi.fn<(scope: string, k: string) => Promise>( - async (scope, k) => scopes.get(scope)?.get(k)?.v ?? null - ), - setItem: vi.fn(async (scope: string, k: string, v: string) => { - clock += 1; - let bucket = scopes.get(scope); - if (!bucket) { - bucket = new Map(); - scopes.set(scope, bucket); - } - bucket.set(k, { v, updatedAt: clock }); - }), - removeItem: vi.fn(async (scope: string, k: string) => { - scopes.get(scope)?.delete(k); - }), - listEntries: vi.fn(async (scope: string) => - [...(scopes.get(scope)?.entries() ?? [])] - .map(([k, entry]) => ({ k, updatedAt: entry.updatedAt })) - .sort((a, b) => a.updatedAt - b.updatedAt) - ), - }; -}); - -vi.mock('@/lib/persist/encrypted-kv', () => ({ - getItem: kvMock.getItem, - setItem: kvMock.setItem, - removeItem: kvMock.removeItem, - listEntries: kvMock.listEntries, -})); - -// `readCacheScope` is imported from read-cache, which loads expo-secure-store. -vi.mock('expo-secure-store', () => ({ - getItemAsync: vi.fn(async () => null), - setItemAsync: vi.fn(async () => undefined), - deleteItemAsync: vi.fn(async () => undefined), -})); - -/* eslint-disable import/first */ -import { type SessionSnapshotPage } from '@kilocode/cloud-agent-sdk'; - -import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; -import { setSignOutActive } from '@/lib/auth/sign-out-state'; -import { readCacheScope } from '@/lib/persist/read-cache'; -import { - clearSessionTranscriptPage, - readSessionTranscriptPage, - SESSION_TRANSCRIPT_MAX_BYTES, - SESSION_TRANSCRIPT_MAX_ENTRIES, - writeSessionTranscriptPage, -} from './session-transcript-cache'; -/* eslint-enable import/first */ - -const USER_ID = 'u1'; -const SESSION_ID = 'ses-1'; -const AUTH_EPOCH = currentAuthEpoch(); -const OWNER = { userId: USER_ID, authEpoch: AUTH_EPOCH }; - -function makePage(sessionId: string, messageId: string, text: string): SessionSnapshotPage { - return { - info: { id: sessionId }, - messages: [ - { - info: { id: messageId, sessionID: sessionId, role: 'user' }, - parts: [ - { - id: `part-${messageId}`, - sessionID: sessionId, - messageID: messageId, - type: 'text', - text, - }, - ], - }, - ], - nextCursor: null, - omittedItemCount: 0, - } as unknown as SessionSnapshotPage; -} - -beforeEach(() => { - vi.clearAllMocks(); - kvMock.scopes.clear(); -}); - -describe('session transcript cache', () => { - it('round-trips a first page through the per-user read-cache scope', async () => { - const page = makePage(SESSION_ID, 'msg-1', 'hello'); - - await writeSessionTranscriptPage(OWNER, SESSION_ID, page); - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toEqual(page); - - expect(kvMock.setItem).toHaveBeenCalledTimes(1); - const [scope, key] = vi.mocked(kvMock.setItem).mock.calls[0] ?? []; - expect(scope).toBe(readCacheScope(USER_ID)); - expect(scope).toBe('cache:u1:1'); - expect(key).toBe(`transcript:${SESSION_ID}`); - }); - - it('never leaks one account page to another account', async () => { - await writeSessionTranscriptPage(OWNER, SESSION_ID, makePage(SESSION_ID, 'msg-1', 'hello')); - - await expect(readSessionTranscriptPage('u2', SESSION_ID)).resolves.toBeNull(); - }); - - it('returns null for malformed JSON and for a wrong shape', async () => { - const scope = readCacheScope(USER_ID); - kvMock.scopes.set( - scope, - new Map([[`transcript:${SESSION_ID}`, { v: 'not-json', updatedAt: 1 }]]) - ); - - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toBeNull(); - - kvMock.scopes.set( - scope, - new Map([[`transcript:${SESSION_ID}`, { v: '{"nope":true}', updatedAt: 1 }]]) - ); - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toBeNull(); - }); - - it('drops an oversized page instead of writing it', async () => { - const scope = readCacheScope(USER_ID); - kvMock.scopes.set( - scope, - new Map([[`transcript:${SESSION_ID}`, { v: 'previous', updatedAt: 1 }]]) - ); - - const oversized = makePage(SESSION_ID, 'msg-big', 'x'.repeat(SESSION_TRANSCRIPT_MAX_BYTES)); - await writeSessionTranscriptPage(OWNER, SESSION_ID, oversized); - - expect(kvMock.setItem).not.toHaveBeenCalled(); - expect(kvMock.removeItem).toHaveBeenCalledWith(scope, `transcript:${SESSION_ID}`); - expect(kvMock.scopes.get(scope)?.has(`transcript:${SESSION_ID}`)).toBe(false); - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toBeNull(); - }); - - it('clears one session page', async () => { - await writeSessionTranscriptPage(OWNER, SESSION_ID, makePage(SESSION_ID, 'msg-1', 'hello')); - - await clearSessionTranscriptPage(USER_ID, SESSION_ID); - - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toBeNull(); - expect(kvMock.removeItem).toHaveBeenCalledWith( - readCacheScope(USER_ID), - `transcript:${SESSION_ID}` - ); - }); - - it('is a no-op for an empty owner or session id', async () => { - await writeSessionTranscriptPage( - { userId: '', authEpoch: AUTH_EPOCH }, - SESSION_ID, - makePage(SESSION_ID, 'msg-1', 'hello') - ); - - expect(kvMock.setItem).not.toHaveBeenCalled(); - await expect(readSessionTranscriptPage('', SESSION_ID)).resolves.toBeNull(); - await expect(readSessionTranscriptPage(USER_ID, '')).resolves.toBeNull(); - }); - - it('evicts the oldest sessions beyond the entry cap', async () => { - const total = SESSION_TRANSCRIPT_MAX_ENTRIES + 2; - for (let index = 0; index < total; index += 1) { - const sessionId = `ses-${index}`; - // Sequential writes keep `updated_at` strictly increasing so the evicted - // entries are deterministic. - // eslint-disable-next-line no-await-in-loop -- eviction order depends on write order. - await writeSessionTranscriptPage(OWNER, sessionId, makePage(sessionId, `msg-${index}`, 'x')); - } - - const scope = readCacheScope(USER_ID); - const transcripts = [...(kvMock.scopes.get(scope)?.keys() ?? [])].filter(key => - key.startsWith('transcript:') - ); - expect(transcripts).toHaveLength(SESSION_TRANSCRIPT_MAX_ENTRIES); - expect(transcripts).not.toContain('transcript:ses-0'); - expect(transcripts).not.toContain('transcript:ses-1'); - expect(transcripts).toContain(`transcript:ses-${total - 1}`); - }); - - it('does not evict the read-cache blob that shares the scope', async () => { - const scope = readCacheScope(USER_ID); - kvMock.scopes.set(scope, new Map([['read-cache', { v: '{"blob":true}', updatedAt: 0 }]])); - - for (let index = 0; index < SESSION_TRANSCRIPT_MAX_ENTRIES + 1; index += 1) { - const sessionId = `ses-${index}`; - // eslint-disable-next-line no-await-in-loop -- eviction order depends on write order. - await writeSessionTranscriptPage(OWNER, sessionId, makePage(sessionId, `msg-${index}`, 'x')); - } - - expect(kvMock.scopes.get(scope)?.has('read-cache')).toBe(true); - }); - - it('refuses to write while a sign-out is active', async () => { - setSignOutActive(true); - try { - await writeSessionTranscriptPage(OWNER, SESSION_ID, makePage(SESSION_ID, 'msg-1', 'hello')); - expect(kvMock.setItem).not.toHaveBeenCalled(); - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toBeNull(); - } finally { - setSignOutActive(false); - } - }); - - it('refuses a write whose captured auth epoch has moved', async () => { - await writeSessionTranscriptPage( - { userId: USER_ID, authEpoch: AUTH_EPOCH - 1 }, - SESSION_ID, - makePage(SESSION_ID, 'msg-1', 'hello') - ); - - expect(kvMock.setItem).not.toHaveBeenCalled(); - await expect(readSessionTranscriptPage(USER_ID, SESSION_ID)).resolves.toBeNull(); - }); -}); diff --git a/apps/mobile/src/lib/persist/session-transcript-cache.ts b/apps/mobile/src/lib/persist/session-transcript-cache.ts deleted file mode 100644 index 6a79e69908..0000000000 --- a/apps/mobile/src/lib/persist/session-transcript-cache.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { type SessionSnapshotPage } from '@kilocode/cloud-agent-sdk'; -import { z } from 'zod'; - -import { isCurrentAuthEpoch } from '@/lib/auth/auth-epoch'; -import { isSignOutActive } from '@/lib/auth/sign-out-state'; -import * as encryptedKv from '@/lib/persist/encrypted-kv'; -import { readCacheScope } from '@/lib/persist/read-cache'; -import { utf8ByteLength } from '@/lib/utf8-utils'; - -/** - * Bounded, per-user cache of a session's first transcript page (the newest - * messages), so a warm open paints content before the live snapshot refresh - * settles. - * - * Scope and retirement reuse the read cache: the blob lives in - * `readCacheScope(userId)` (`cache::`), so sign-out's - * `cache::` prefix clear removes it and a `SCHEMA_VERSION` bump - * retires it without any extra cleanup. The item key is - * `transcript:`. - * - * Writes share the read cache's publication fence: an epoch captured when the - * owning session manager was created must still be current and no sign-out may - * be in progress, so a page that resolves after teardown cannot repopulate the - * scope sign-out just cleared. The number of sessions is bounded by - * {@link SESSION_TRANSCRIPT_MAX_ENTRIES}; the oldest entries are evicted after - * each write. - * - * The blob is JSON. A page larger than {@link SESSION_TRANSCRIPT_MAX_BYTES} is - * dropped instead of written (the previous entry is removed), and every - * failure is swallowed: this is a warm-start optimization, never a source of - * truth, so it must never affect the open path. - */ - -export const SESSION_TRANSCRIPT_MAX_BYTES = 512 * 1024; - -/** - * At most this many sessions keep a cached first page per user. The read cache - * is cleared only on sign-out, so without a cap a long-lived install would - * accumulate one entry per session it ever opened. - */ -export const SESSION_TRANSCRIPT_MAX_ENTRIES = 10; - -const TRANSCRIPT_KEY_PREFIX = 'transcript:'; - -// Decode at the I/O boundary (the KV is encrypted but still untrusted storage -// after a restore). Only the fields the SDK reads before replay are validated; -// the original parsed value is returned so message payloads survive intact. -const sessionTranscriptPageSchema = z.object({ - info: z.object({ id: z.string() }), - messages: z.array(z.object({ parts: z.array(z.unknown()) })), - nextCursor: z.string().nullable(), - omittedItemCount: z.number(), -}); - -function transcriptItemKey(sessionId: string): string { - return `${TRANSCRIPT_KEY_PREFIX}${sessionId}`; -} - -/** Reads the cached first page, or null when absent, malformed, or unreadable. */ -export async function readSessionTranscriptPage( - userId: string, - sessionId: string -): Promise { - if (userId === '' || sessionId === '') { - return null; - } - try { - const raw = await encryptedKv.getItem(readCacheScope(userId), transcriptItemKey(sessionId)); - if (raw === null) { - return null; - } - const parsed: unknown = JSON.parse(raw); - if (!sessionTranscriptPageSchema.safeParse(parsed).success) { - return null; - } - return parsed as SessionSnapshotPage; - } catch { - // Any failure (missing key, parse error, KV unavailable) is a cache miss. - return null; - } -} - -/** - * Identity a transcript page is written under. `userId` scopes the entry; - * `authEpoch` is captured when the owning session manager is created, so the - * write can be refused once a sign-out/sign-in moves it. - */ -export type SessionTranscriptOwner = { - userId: string; - authEpoch: number; -}; - -/** - * Writes the cached first page. A page over the byte budget is not stored: the - * previous entry for the session is removed so a stale page cannot survive the - * write that replaced it. Refused while sign-out is active or the owner's - * `authEpoch` has moved, so a write that raced a sign-out cannot land in a - * scope that teardown cleared. Never throws. - */ -export async function writeSessionTranscriptPage( - owner: SessionTranscriptOwner, - sessionId: string, - page: SessionSnapshotPage -): Promise { - const { userId, authEpoch } = owner; - if (userId === '' || sessionId === '') { - return; - } - const scope = readCacheScope(userId); - const key = transcriptItemKey(sessionId); - try { - // Publication fence, the same one `createReadCachePersister` applies: - // sign-out flips its flag synchronously and bumps the epoch before the - // scope is cleared, so a late write is refused either way. - if (isSignOutActive() || !isCurrentAuthEpoch(authEpoch)) { - return; - } - const serialized = JSON.stringify(page); - if (utf8ByteLength(serialized) > SESSION_TRANSCRIPT_MAX_BYTES) { - await encryptedKv.removeItem(scope, key); - return; - } - await encryptedKv.setItem(scope, key, serialized); - await evictOldestBeyondCap(scope); - } catch { - // Best effort: a cache write failure never affects the open path. - } -} - -/** - * Keeps at most {@link SESSION_TRANSCRIPT_MAX_ENTRIES} transcripts in one - * scope. Only `transcript:` keys are counted: the read-cache blob shares the - * scope and must never be evicted by this cache. `listEntries` is oldest-first - * by `updated_at`, so the oldest transcripts go first. - */ -async function evictOldestBeyondCap(scope: string): Promise { - const entries = await encryptedKv.listEntries(scope); - const transcripts = entries.filter(entry => entry.k.startsWith(TRANSCRIPT_KEY_PREFIX)); - const overflow = transcripts.length - SESSION_TRANSCRIPT_MAX_ENTRIES; - if (overflow <= 0) { - return; - } - await Promise.all( - transcripts.slice(0, overflow).map(async entry => { - await encryptedKv.removeItem(scope, entry.k); - }) - ); -} - -/** Removes one session's cached page. Never throws. */ -export async function clearSessionTranscriptPage(userId: string, sessionId: string): Promise { - if (userId === '' || sessionId === '') { - return; - } - try { - await encryptedKv.removeItem(readCacheScope(userId), transcriptItemKey(sessionId)); - } catch { - // Best effort. - } -} diff --git a/dev/seed/app/condensed-history.ts b/dev/seed/app/condensed-history.ts new file mode 100644 index 0000000000..d2adc16345 --- /dev/null +++ b/dev/seed/app/condensed-history.ts @@ -0,0 +1,419 @@ +import { execFileSync } from 'node:child_process'; + +import { cli_sessions_v2, kilocode_users } from '@kilocode/db/schema'; +import { signKiloToken } from '@kilocode/worker-utils'; +import { and, eq, or } from 'drizzle-orm'; + +import type { SeedResult } from '../index'; +import { getSeedDb } from '../lib/db'; +import { normalizeSeedEmail } from '../lib/email'; +import { isValidEmail } from '../lib/users'; +import { + buildAssistantMessageItem, + buildSessionItem, + buildToolPartItem, + buildUserMessageItem, + parseSessionIngestServiceStatus, + type SessionIngestItem, +} from '../lib/mobile-sheet-fixtures'; + +export const usage = ''; + +export const SESSION_ID = 'ses_000000000006CondensedHist1'; +export const SESSION_TITLE = 'Condensed history'; +export const SESSION_SLUG = 'condensed-history-fixture'; +export const MESSAGE_COUNT = 60; + +const TOKEN_EXPIRES_SECONDS = 3600; +const POLL_TIMEOUT_MS = 30_000; +const POLL_INTERVAL_MS = 500; +const BASE_CREATED_AT = 1_700_200_000_000; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:condensed-history ${usage}`); + console.log(''); + console.log('Seeds one read-only cloud-agent session titled "Condensed history"'); + console.log('with 60 messages so the first page (50) overflows and older history'); + console.log('stays behind a scroll-up. Messages 9-11 are consecutive assistant'); + console.log('messages holding only completed read-tool calls, so the 50-message'); + console.log('page boundary (between messages 10 and 11) splits one condensed'); + console.log('tool run: before the prepend the run is message 11 alone, after the'); + console.log('prepend messages 9-11 merge into one six-call condensed row.'); + console.log('No cloud-agent session ID is set, so the UI is historical/read-only.'); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:seed app:condensed-history evgeny@kilocode.ai'); + console.log(' pnpm -s dev:seed app:condensed-history evgeny@kilocode.ai --json'); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseArgs(args: string[]): string { + const positionals: string[] = []; + for (const arg of args) { + if (arg.startsWith('--')) { + throw new Error(`Unknown argument: ${arg}`); + } + positionals.push(arg.trim()); + } + + const [email, ...rest] = positionals; + if (!email) { + printUsage(); + throw new Error('email is required'); + } + if (rest.length > 0) { + printUsage(); + throw new Error(`Unexpected extra arguments: ${rest.join(' ')}`); + } + if (!isValidEmail(email)) { + throw new Error(`email is not a valid address: ${email}`); + } + return email; +} + +function readSessionIngestStatusJson(): string { + try { + return execFileSync('pnpm', ['-s', 'dev:status', '--json'], { encoding: 'utf8' }); + } catch (error) { + throw new Error( + `dev:status --json failed: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function paddedIndex(index: number): string { + return String(index).padStart(10, '0'); +} + +function messageIdFor(index: number): string { + return `msgCond${paddedIndex(index)}`; +} + +function partIdFor(index: number, slot: number): string { + return `prtCond${paddedIndex(index)}${slot}`; +} + +function callIdFor(index: number, slot: number): string { + return `callCond${paddedIndex(index)}${slot}`; +} + +function messageBody(role: 'User' | 'Assistant', index: number): string { + return [ + `${role} message ${index} of ${MESSAGE_COUNT}.`, + '', + 'Padded so fifty messages overflow the chat viewport.', + `Marker line A for message ${index}.`, + `Marker line B for message ${index}.`, + `Marker line C for message ${index}.`, + `Marker line D for message ${index}.`, + `Marker line E for message ${index}.`, + `End of ${role.toLowerCase()} message ${index}.`, + ].join('\n'); +} + +function buildTextPartItem(params: { + partId: string; + sessionId: string; + messageId: string; + text: string; +}): SessionIngestItem { + return { + type: 'part', + data: { + id: params.partId, + sessionID: params.sessionId, + messageID: params.messageId, + type: 'text', + text: params.text, + }, + }; +} + +/** + * One completed `read` call whose card subtitle is the file's basename, so the + * transcript rows and the condensed "N items; …" label carry stable, greppable + * text for the device scene to assert. + */ +function buildBoundaryToolPartItems(params: { + index: number; + createdAt: number; + slotCount: number; +}): SessionIngestItem[] { + const items: SessionIngestItem[] = []; + for (let slot = 0; slot < params.slotCount; slot += 1) { + items.push( + buildToolPartItem({ + partId: partIdFor(params.index, slot), + sessionId: SESSION_ID, + messageId: messageIdFor(params.index), + callId: callIdFor(params.index, slot), + tool: 'read', + status: 'completed', + input: { + filePath: `/repo/condensed-run-msg${params.index}-call${slot + 1}.txt`, + }, + output: `fixture read output for message ${params.index} call ${slot + 1}`, + title: `Read condensed-run-msg${params.index}-call${slot + 1}.txt`, + metadata: {}, + start: params.createdAt + slot * 100, + end: params.createdAt + slot * 100 + 50, + }) + ); + } + return items; +} + +export function buildCondensedHistoryIngestItems(): SessionIngestItem[] { + const items: SessionIngestItem[] = [ + buildSessionItem({ + sessionId: SESSION_ID, + slug: SESSION_SLUG, + title: SESSION_TITLE, + }), + ]; + + for (let index = 1; index <= MESSAGE_COUNT; index += 1) { + const createdAt = BASE_CREATED_AT + index * 1_000; + const messageId = messageIdFor(index); + + // The page boundary sits between messages 10 and 11 (first page holds the + // newest 50, messages 11-60). Messages 9-11 are consecutive assistant + // messages whose only parts are completed tool calls, so the 50-message + // page splits one condensed tool run exactly at that boundary. + if (index >= 9 && index <= 11) { + items.push( + buildAssistantMessageItem({ + messageId, + sessionId: SESSION_ID, + parentId: messageIdFor(index - 1), + createdAt, + completedAt: createdAt + 200, + cost: 0.001, + tokens: { + total: 40, + input: 20, + output: 16, + reasoning: 0, + cache: { read: 2, write: 2 }, + }, + }), + ...buildBoundaryToolPartItems({ index, createdAt, slotCount: 2 }) + ); + continue; + } + + const isUser = index % 2 === 1; + if (isUser) { + items.push( + buildUserMessageItem({ + messageId, + sessionId: SESSION_ID, + createdAt, + }), + buildTextPartItem({ + partId: partIdFor(index, 0), + sessionId: SESSION_ID, + messageId, + text: messageBody('User', index), + }) + ); + continue; + } + + items.push( + buildAssistantMessageItem({ + messageId, + sessionId: SESSION_ID, + parentId: messageIdFor(index - 1), + createdAt, + completedAt: createdAt + 200, + cost: 0.001, + tokens: { + total: 40, + input: 20, + output: 16, + reasoning: 0, + cache: { read: 2, write: 2 }, + }, + }), + buildTextPartItem({ + partId: partIdFor(index, 0), + sessionId: SESSION_ID, + messageId, + text: messageBody('Assistant', index), + }) + ); + } + + return items; +} + +async function ingestSession( + baseUrl: string, + sessionId: string, + token: string, + items: SessionIngestItem[] +): Promise { + const response = await fetch(`${baseUrl}/api/session/${sessionId}/ingest?v=1`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: items }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Ingest of ${sessionId} failed (${response.status}): ${body}`); + } +} + +async function pollForMessages(baseUrl: string, sessionId: string, token: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + + for (;;) { + const response = await fetch(`${baseUrl}/api/session/${sessionId}/messages?limit=100`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Messages read of ${sessionId} failed (${response.status})`); + } + + const payload: unknown = await response.json(); + if (!isRecord(payload) || payload.success !== true) { + throw new Error(`Messages read of ${sessionId} returned an unexpected shape`); + } + + const history = payload.history; + if (history === null) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for history of ${sessionId}`); + } + await sleep(POLL_INTERVAL_MS); + continue; + } + if (!isRecord(history)) { + throw new Error(`Messages read of ${sessionId} returned an unexpected history shape`); + } + if (history.kind !== undefined) { + throw new Error(`session-ingest reported ${String(history.kind)} for ${sessionId}`); + } + if (!Array.isArray(history.messages)) { + throw new Error(`Messages read of ${sessionId} returned an unexpected history shape`); + } + + if (history.messages.length === MESSAGE_COUNT) { + return history.messages.length; + } + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for ${MESSAGE_COUNT} messages in ${sessionId}; saw ${history.messages.length}` + ); + } + await sleep(POLL_INTERVAL_MS); + } +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const email = parseArgs(args); + + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) { + throw new Error( + 'NEXTAUTH_SECRET is not set for this worktree. Ensure local env is prepared (pnpm dev:worktree:prepare).' + ); + } + + const normalizedEmail = normalizeSeedEmail(email); + const db = getSeedDb(); + const matches = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + apiTokenPepper: kilocode_users.api_token_pepper, + isAdmin: kilocode_users.is_admin, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.google_user_email, email), + eq(kilocode_users.normalized_email, normalizedEmail) + ) + ); + + if (matches.length === 0) { + throw new Error( + `No user found for email ${email}. Sign in locally first, or seed a user (pnpm dev:seed app:create-user).` + ); + } + + const exactMatches = matches.filter(match => match.email === email); + const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; + if (resolvedMatches.length > 1) { + const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); + throw new Error(`Multiple users matched ${email}: ${matchList}`); + } + + const [user] = resolvedMatches; + + const { token } = await signKiloToken({ + userId: user.userId, + pepper: user.apiTokenPepper, + secret, + expiresInSeconds: TOKEN_EXPIRES_SECONDS, + env: process.env.NODE_ENV ?? 'development', + extra: user.isAdmin ? { isAdmin: true } : undefined, + }); + + const serviceStatus = parseSessionIngestServiceStatus(readSessionIngestStatusJson()); + if (serviceStatus.status !== 'up') { + throw new Error( + `cloudflare-session-ingest is not up (status=${serviceStatus.status}). Start the local stack first.` + ); + } + const sessionIngestUrl = `http://localhost:${serviceStatus.port}`; + + await db + .delete(cli_sessions_v2) + .where( + and(eq(cli_sessions_v2.kilo_user_id, user.userId), eq(cli_sessions_v2.session_id, SESSION_ID)) + ); + + await db.insert(cli_sessions_v2).values({ + session_id: SESSION_ID, + kilo_user_id: user.userId, + title: SESSION_TITLE, + created_on_platform: 'cloud-agent-web', + } satisfies typeof cli_sessions_v2.$inferInsert); + + await ingestSession(sessionIngestUrl, SESSION_ID, token, buildCondensedHistoryIngestItems()); + const messageCount = await pollForMessages(sessionIngestUrl, SESSION_ID, token); + + console.log(''); + console.log('Seeded a read-only "Condensed history" cloud-agent transcript (60 messages).'); + console.log('The 50-message page boundary splits a tool run between messages 10 and 11:'); + console.log('after the older page prepends, messages 9-11 merge into one 6-item condensed row.'); + console.log('Open kiloapp://agent-chat/' + SESSION_ID + ' with Condense tool calls enabled.'); + + return { + userId: user.userId, + email: user.email, + sessionId: SESSION_ID, + messageCount, + sessionIngestPort: serviceStatus.port, + sessionIngestUrl, + deepLink: `kiloapp://agent-chat/${SESSION_ID}`, + }; +} diff --git a/dev/seed/app/paged-history.ts b/dev/seed/app/paged-history.ts index 1e98e248e1..b973ff6b33 100644 --- a/dev/seed/app/paged-history.ts +++ b/dev/seed/app/paged-history.ts @@ -23,6 +23,15 @@ export const SESSION_TITLE = '60-message pagination fixture'; export const SESSION_SLUG = 'paged-history-fixture'; export const MESSAGE_COUNT = 60; +/** + * The first message of the loaded page (the newest 50 are messages 11-60) and + * the first loaded assistant message. It opens the burst whose marker moves + * onto the prepended older page, so it carries an inline reasoning part: the e2 + * scenario expands that row's thinking and proves the prepend neither remounts + * (and so collapses) it nor jumps the viewport. + */ +export const REASONING_MESSAGE_INDEX = 11; + const TOKEN_EXPIRES_SECONDS = 3600; const POLL_TIMEOUT_MS = 30_000; const POLL_INTERVAL_MS = 500; @@ -33,8 +42,11 @@ function printUsage(): void { console.log(''); console.log('Seeds one read-only cloud-agent session with 60 tall transcript'); console.log('messages so the first page (50) overflows and older history stays'); - console.log('behind a scroll-up. Writes the cli_sessions_v2 row, then ingests'); - console.log('through the local cloudflare-session-ingest worker.'); + console.log('behind a scroll-up. The first loaded assistant message (message 11)'); + console.log('carries an inline reasoning part, so "Auto expand thinking" renders its'); + console.log('expanded thinking on the burst-opening row that the prepend re-marks.'); + console.log('Writes the cli_sessions_v2 row, then ingests through the local'); + console.log('cloudflare-session-ingest worker.'); console.log('No cloud-agent session ID is set, so the UI is historical/read-only.'); console.log(''); console.log('Examples:'); @@ -50,6 +62,28 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** Whether the materialized history holds the fixture's inline reasoning part. */ +function historyHasReasoningPart(history: Record): boolean { + if (!Array.isArray(history.messages)) { + return false; + } + for (const message of history.messages) { + if (!isRecord(message) || !Array.isArray(message.parts)) { + continue; + } + for (const part of message.parts) { + if ( + isRecord(part) && + part.id === reasoningPartIdFor(REASONING_MESSAGE_INDEX) && + part.type === 'reasoning' + ) { + return true; + } + } + } + return false; +} + function parseArgs(args: string[]): string { const positionals: string[] = []; for (const arg of args) { @@ -96,6 +130,10 @@ function partIdFor(index: number): string { return `prtPaged${paddedIndex(index)}`; } +function reasoningPartIdFor(index: number): string { + return `prtPagedReason${paddedIndex(index)}`; +} + function messageBody(role: 'User' | 'Assistant', index: number): string { return [ `${role} message ${index} of ${MESSAGE_COUNT}.`, @@ -113,6 +151,17 @@ function messageBody(role: 'User' | 'Assistant', index: number): string { ].join('\n'); } +function reasoningBody(index: number): string { + return [ + `Reasoning for message ${index} of ${MESSAGE_COUNT}.`, + '', + 'Expanded thinking stays mounted when the older page prepends.', + 'Reasoning marker line A.', + 'Reasoning marker line B.', + `End of reasoning for message ${index}.`, + ].join('\n'); +} + function buildTextPartItem(params: { partId: string; sessionId: string; @@ -131,6 +180,32 @@ function buildTextPartItem(params: { }; } +/** + * A reasoning part must carry `time: { start, end }`: the read contract declares + * it non-optional, so the read seam drops a reasoning part without it instead of + * rendering the row (packages/session-ingest-contracts/src/rpc-contract.ts). + */ +function buildReasoningPartItem(params: { + partId: string; + sessionId: string; + messageId: string; + text: string; + start: number; + end: number; +}): SessionIngestItem { + return { + type: 'part', + data: { + id: params.partId, + sessionID: params.sessionId, + messageID: params.messageId, + type: 'reasoning', + text: params.text, + time: { start: params.start, end: params.end }, + }, + }; +} + export function buildPagedHistoryIngestItems(): SessionIngestItem[] { const items: SessionIngestItem[] = [ buildSessionItem({ @@ -143,6 +218,45 @@ export function buildPagedHistoryIngestItems(): SessionIngestItem[] { for (let index = 1; index <= MESSAGE_COUNT; index += 1) { const createdAt = BASE_CREATED_AT + index * 1_000; const messageId = messageIdFor(index); + + // The burst opener is an assistant turn: the loaded page starts at message + // 11 and its marker moves to message 1 when the older page prepends. Its + // inline reasoning is what the e2 scenario expands before that prepend. + if (index === REASONING_MESSAGE_INDEX) { + items.push( + buildAssistantMessageItem({ + messageId, + sessionId: SESSION_ID, + parentId: messageIdFor(index - 1), + createdAt, + completedAt: createdAt + 200, + cost: 0.001, + tokens: { + total: 40, + input: 20, + output: 16, + reasoning: 8, + cache: { read: 2, write: 2 }, + }, + }), + buildReasoningPartItem({ + partId: reasoningPartIdFor(index), + sessionId: SESSION_ID, + messageId, + text: reasoningBody(index), + start: createdAt + 1, + end: createdAt + 100, + }), + buildTextPartItem({ + partId: partIdFor(index), + sessionId: SESSION_ID, + messageId, + text: messageBody('Assistant', index), + }) + ); + continue; + } + const isUser = index % 2 === 1; if (isUser) { @@ -244,12 +358,15 @@ async function pollForMessages(baseUrl: string, sessionId: string, token: string throw new Error(`Messages read of ${sessionId} returned an unexpected history shape`); } - if (history.messages.length === MESSAGE_COUNT) { + // The read seam drops a reasoning part whose `time` is absent, so a + // materialized reasoning part is the fixture's own proof that the e2 row + // will render. Poll for it rather than counting messages alone. + if (history.messages.length === MESSAGE_COUNT && historyHasReasoningPart(history)) { return history.messages.length; } if (Date.now() >= deadline) { throw new Error( - `Timed out waiting for ${MESSAGE_COUNT} messages in ${sessionId}; saw ${history.messages.length}` + `Timed out waiting for ${MESSAGE_COUNT} messages and the reasoning part in ${sessionId}; saw ${history.messages.length}` ); } await sleep(POLL_INTERVAL_MS); @@ -338,6 +455,8 @@ export async function run(...args: string[]): Promise { console.log(''); console.log('Seeded a read-only 60-message cloud-agent transcript.'); + console.log('Message 11 (the first loaded assistant message) carries inline reasoning.'); + console.log('Turn on "Auto expand thinking" to see it expanded.'); console.log('Hard-refresh /cloud/chat?sessionId=' + SESSION_ID + '.'); console.log('Newest 50 should be on screen; scroll up for the older 10.'); @@ -346,6 +465,8 @@ export async function run(...args: string[]): Promise { email: user.email, sessionId: SESSION_ID, messageCount, + reasoningMessageId: messageIdFor(REASONING_MESSAGE_INDEX), + reasoningPartId: reasoningPartIdFor(REASONING_MESSAGE_INDEX), sessionIngestPort: serviceStatus.port, sessionIngestUrl, chatPath: `/cloud/chat?sessionId=${SESSION_ID}`,