diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx new file mode 100644 index 00000000000..3942c40eaaa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalCursor, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryPage +} from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' + +const LOCAL_TARGET = { kind: 'local' } as const + +function message(id: string, sequence: number, role: 'user' | 'assistant'): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role, blocks: [{ type: 'text', text: id }] } + } +} + +function providerFrame(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + text: id, + providerFrame: { + provider: 'codex', + kind: 'notification:item/commandExecution/outputDelta', + payload: { head: id, byteLength: id.length, digest: id, truncated: false } + } + } + } +} + +function page( + direction: 'tail' | 'before', + items: AgentJournalRenderItem[], + hasOlder: boolean +): AgentSessionHistoryPage { + const cursor = (sequence: number): AgentJournalCursor => ({ epoch: 'epoch-a', sequence }) + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? oldest + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction, + items, + removedItemIds: [], + submissions: [], + window: { + oldest: items.length > 0 ? cursor(oldest) : null, + newest: items.length > 0 ? cursor(newest) : null, + nextCursor: cursor(oldest) + }, + liveCursor: cursor(500), + hasOlder, + hasNewer: direction === 'before' + } +} + +describe('useStructuredAgentSessionRead history window', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.subscribe.mockResolvedValue({ unsubscribe: vi.fn() }) + }) + + it('restores a realistic 21-turn window across the wire-safe bridge-sized read', async () => { + const items = Array.from({ length: 21 }, (_, turn) => [ + message(`user-${turn}`, turn * 2 + 1, 'user'), + message(`assistant-${turn}`, turn * 2 + 2, 'assistant') + ]).flat() + const olderItems = items.slice(0, 12) + const tailItems = [ + ...Array.from({ length: 170 }, (_, index) => providerFrame(`delta-${index}`, 43 + index)), + ...items.slice(12).map((item, index) => ({ ...item, sequence: 213 + index })) + ] + mocks.call + .mockResolvedValueOnce({ ok: true, page: page('tail', tailItems, true) }) + .mockResolvedValueOnce({ ok: true, page: page('before', olderItems, false) }) + + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + await waitFor(() => + expect( + result.current.state.items.filter((item) => item.body.kind === 'message') + ).toHaveLength(items.length) + ) + expect(mocks.call).toHaveBeenNthCalledWith(1, LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'tail', + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + expect(mocks.call).toHaveBeenNthCalledWith(2, LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'before', + cursor: { epoch: 'epoch-a', sequence: tailItems[0].sequence }, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + }) + + it('loads each earlier page at the wire maximum', async () => { + const tailItems = Array.from({ length: 200 }, (_, index) => + message(`tail-${index}`, 301 + index, 'assistant') + ) + const initialOlderItems = Array.from({ length: 100 }, (_, index) => + message(`middle-${index}`, 201 + index, 'assistant') + ) + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', tailItems, true) + }) + .mockResolvedValueOnce({ + ok: true, + page: page('before', initialOlderItems, true) + }) + .mockResolvedValueOnce({ + ok: true, + page: page('before', [message('oldest', 1, 'user')], false) + }) + + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + await waitFor(() => expect(result.current.state.hasOlder).toBe(true)) + + await act(async () => result.current.loadOlder()) + + expect(mocks.call).toHaveBeenLastCalledWith(LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'before', + cursor: { epoch: 'epoch-a', sequence: 201 }, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + expect(result.current.state.items).toHaveLength(301) + expect(result.current.state.items[0]?.itemId).toBe('oldest') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts index 59f2fcc5001..a336bb917df 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useReducer, useRef, useState } from 'react' -import type { - AgentSessionHistoryResult, - AgentSessionSubscribeEvent +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryResult, + type AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' import { EMPTY_STRUCTURED_AGENT_SESSION, @@ -15,6 +17,11 @@ import { callStructuredAgentSession, subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { NATIVE_CHAT_INITIAL_LIMIT } from './native-chat-pagination' + +function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean { + return item.body.kind !== 'status' || !item.body.providerFrame +} function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) { let timer: ReturnType | null = null @@ -132,7 +139,7 @@ export function useStructuredAgentSessionRead(args: { const result = await callStructuredAgentSession( target, 'agentSession.history', - { sessionId, direction: 'tail', limit: 40 } + { sessionId, direction: 'tail', limit: AGENT_SESSION_HISTORY_MAX_LIMIT } ) if (stopped) { return @@ -140,6 +147,31 @@ export function useStructuredAgentSessionRead(args: { if (result.ok) { dispatch({ type: 'tail-page', page: result.page }) resumeCursorRef.current = result.page.liveCursor ?? null + let page = result.page + let restored = page.items.filter(countsTowardInitialHistory).length + while (page.hasOlder && restored < NATIVE_CHAT_INITIAL_LIMIT) { + const oldest = page.window.oldest + if (!oldest || stopped) { + break + } + const missing = NATIVE_CHAT_INITIAL_LIMIT - restored + const older = await callStructuredAgentSession( + target, + 'agentSession.history', + { + sessionId, + direction: 'before', + cursor: oldest, + limit: Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, missing) + } + ) + if (!older.ok || older.page.window.oldest?.sequence === oldest.sequence) { + break + } + dispatch({ type: 'older-page', requestedEpoch: oldest.epoch, page: older.page }) + restored += older.page.items.filter(countsTowardInitialHistory).length + page = older.page + } } else { dispatch({ type: 'event', @@ -192,7 +224,7 @@ export function useStructuredAgentSessionRead(args: { const result = await callStructuredAgentSession( target, 'agentSession.history', - { sessionId, direction: 'before', cursor, limit: 40 } + { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } ) if (result.ok) { dispatch({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index f94eb769012..f9445651c01 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -29,6 +29,26 @@ function submission(index: number) { } describe('structured agent session reducer', () => { + it('does not offer older history after receiving a full snapshot', () => { + const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + snapshot: { + sessionId: 'session-a', + cursor: { epoch: 'epoch-a', sequence: 84 }, + items: Array.from({ length: 84 }, (_, index) => item(`item-${index}`, index + 1)), + submissions: [] + } + } + }) + + expect(restored.items).toHaveLength(84) + expect(restored.hasOlder).toBe(false) + }) + it('does not let a stale focus refresh replace newer streamed state', () => { const streamed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index d423a77573a..51455fe2266 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -54,7 +54,8 @@ function replaceSnapshot( fence, items: [...snapshot.items].sort((left, right) => left.sequence - right.sequence), submissions: snapshot.submissions, - hasOlder: snapshot.items.length >= 40, + // Snapshots contain the full reduced journal, unlike bounded history pages. + hasOlder: false, status: 'ready', handoff: handoff ?? null }