fix(native-chat): restore practical structured history window

This commit is contained in:
Brennan Benson
2026-08-14 23:35:13 -07:00
parent 3b4d1d1072
commit b36a3a2022
4 changed files with 217 additions and 6 deletions
@@ -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')
})
})
@@ -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<typeof setTimeout> | null = null
@@ -132,7 +139,7 @@ export function useStructuredAgentSessionRead(args: {
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
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<AgentSessionHistoryResult>(
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<AgentSessionHistoryResult>(
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 })
@@ -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',
@@ -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
}