mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(native-chat): restore practical structured history window
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user