mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* feat(chat): add structured session rewind backend * fix(chat): make interrupted session rewinds recover safely * fix(native-chat): negotiate rewind runtime capability * fix(native-chat): consolidate remaining adapter imports --------- Co-authored-by: Merge Sim <sim@local>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { CodexAppServerRequestError } from './codex-app-server-connection'
|
|
import type { CodexSession } from './codex-structured-session-state'
|
|
import { recoverCodexRewind, rewindCodexSession } from './codex-structured-rewind'
|
|
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
|
|
import { openCodexThread } from './codex-structured-thread-open'
|
|
|
|
function fixture(reverted = true) {
|
|
const request = vi.fn(async (method: string): Promise<unknown> => {
|
|
if (method === 'thread/read') {
|
|
return { thread: { id: 'thread', historyMode: 'paginated', status: { type: 'idle' } } }
|
|
}
|
|
if (method === 'thread/revert') {
|
|
reverted = true
|
|
return {
|
|
thread: { id: 'thread', turns: [] },
|
|
turnsBackwardsCursor: 'turn-cursor',
|
|
itemsBackwardsCursor: 'item-cursor'
|
|
}
|
|
}
|
|
if (method === 'thread/turns/list') {
|
|
return { data: [...(reverted ? [] : [{ id: 'drop' }]), { id: 'kept' }], nextCursor: null }
|
|
}
|
|
return {
|
|
data: [
|
|
{
|
|
turnId: 'kept',
|
|
item: {
|
|
id: 'item-1',
|
|
type: 'userMessage',
|
|
content: [{ type: 'text', text: 'kept prompt' }]
|
|
}
|
|
}
|
|
],
|
|
nextCursor: null
|
|
}
|
|
})
|
|
const session = {
|
|
connection: { request },
|
|
threadId: 'thread',
|
|
fence: 2,
|
|
ended: false,
|
|
historyMode: 'paginated',
|
|
activeTurnIds: new Set()
|
|
} as unknown as CodexSession
|
|
return { request, session }
|
|
}
|
|
|
|
describe('Codex rewind', () => {
|
|
it('recovers verified history from fresh cursors without repeating revert', async () => {
|
|
const { session, request } = fixture()
|
|
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
|
|
ok: true,
|
|
items: [{ body: { kind: 'message', blocks: [{ type: 'text', text: 'kept prompt' }] } }]
|
|
})
|
|
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
|
'thread/read',
|
|
'thread/turns/list',
|
|
'thread/items/list'
|
|
])
|
|
for (const method of ['thread/turns/list', 'thread/items/list']) {
|
|
expect(request).toHaveBeenCalledWith(
|
|
method,
|
|
expect.objectContaining({ cursor: null, sortDirection: 'desc' }),
|
|
expect.anything()
|
|
)
|
|
}
|
|
})
|
|
it('recognizes an unapplied rewind from the still-present target', async () => {
|
|
const { session, request } = fixture()
|
|
const original = request.getMockImplementation()!
|
|
request.mockImplementation(async (method) =>
|
|
method === 'thread/turns/list'
|
|
? { data: [{ id: 'drop' }, { id: 'kept' }], nextCursor: null }
|
|
: original(method)
|
|
)
|
|
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
|
ok: false,
|
|
reason: 'provider-refused'
|
|
})
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
})
|
|
it.each(['cycle', 'pages', 'entries', 'bytes'] as const)(
|
|
'bounds recovery by %s and never returns partial history',
|
|
async (limit) => {
|
|
const { session, request } = fixture()
|
|
const original = request.getMockImplementation()!
|
|
let pages = 0
|
|
request.mockImplementation(async (method) => {
|
|
if (method !== 'thread/turns/list') {
|
|
return original(method)
|
|
}
|
|
pages++
|
|
if (limit === 'entries') {
|
|
return {
|
|
data: Array.from({ length: 1025 }, (_, i) => ({ id: String(i) })),
|
|
nextCursor: null
|
|
}
|
|
}
|
|
if (limit === 'bytes') {
|
|
return {
|
|
data: [],
|
|
padding: 'x'.repeat(AGENT_SESSION_HISTORY_MAX_PAGE_BYTES),
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return { data: [], nextCursor: limit === 'cycle' ? 'repeated' : String(pages) }
|
|
})
|
|
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
|
'history-limit'
|
|
)
|
|
expect(pages).toBeLessThanOrEqual(100)
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
}
|
|
)
|
|
it('keeps an interrupted recovery retryable with read-only requests', async () => {
|
|
const { session, request } = fixture()
|
|
const original = request.getMockImplementation()!
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/items/list') {
|
|
throw new Error('offline')
|
|
}
|
|
return original(method)
|
|
})
|
|
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
|
'offline'
|
|
)
|
|
request.mockImplementation(original)
|
|
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
|
|
ok: true
|
|
})
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
})
|
|
it('refuses activity arriving during recovery hydration', async () => {
|
|
const { session, request } = fixture()
|
|
const original = request.getMockImplementation()!
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/items/list') {
|
|
session.activeTurnIds!.add('racing-turn')
|
|
}
|
|
return original(method)
|
|
})
|
|
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
|
ok: false,
|
|
reason: 'busy'
|
|
})
|
|
})
|
|
it('uses native revert and reads both retained indexes despite empty response turns', async () => {
|
|
const { session, request } = fixture(false)
|
|
const onPrepared = vi.fn<NonNullable<Parameters<typeof rewindCodexSession>[1]['onPrepared']>>(
|
|
async (items) => {
|
|
expect(items).toMatchObject([{ identity: { turnId: 'kept' } }])
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
}
|
|
)
|
|
expect(
|
|
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onPrepared })
|
|
).toMatchObject({
|
|
ok: true,
|
|
items: [{ body: { kind: 'message' } }]
|
|
})
|
|
expect(onPrepared).toHaveBeenCalledTimes(1)
|
|
expect(request).toHaveBeenCalledWith(
|
|
'thread/revert',
|
|
{ threadId: 'thread', beforeTurnId: 'drop' },
|
|
{ timeoutMs: undefined }
|
|
)
|
|
expect(request).toHaveBeenCalledWith(
|
|
'thread/turns/list',
|
|
expect.objectContaining({ cursor: 'turn-cursor', sortDirection: 'desc' }),
|
|
expect.anything()
|
|
)
|
|
expect(request).toHaveBeenCalledWith(
|
|
'thread/items/list',
|
|
expect.objectContaining({ cursor: 'item-cursor', sortDirection: 'desc' }),
|
|
expect.anything()
|
|
)
|
|
})
|
|
it('refuses a known legacy thread before making a request', async () => {
|
|
const { session, request } = fixture()
|
|
session.historyMode = 'legacy'
|
|
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
|
ok: false,
|
|
reason: 'history-not-paginated'
|
|
})
|
|
expect(request).not.toHaveBeenCalled()
|
|
})
|
|
it('refuses history exceeding hydration capacity before mutating the provider', async () => {
|
|
const { session, request } = fixture(false)
|
|
const original = request.getMockImplementation()!
|
|
const turns = Array.from({ length: 600 }, (_, i) => String(i))
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/turns/list') {
|
|
return { data: [{ id: 'drop' }, ...turns.map((id) => ({ id }))], nextCursor: null }
|
|
}
|
|
if (method === 'thread/items/list') {
|
|
return {
|
|
data: turns.map((turnId) => ({
|
|
turnId,
|
|
item: { id: turnId, type: 'userMessage', content: [{ type: 'text', text: 'x' }] }
|
|
})),
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return original(method)
|
|
})
|
|
const onReverted = vi.fn()
|
|
expect(
|
|
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted })
|
|
).toEqual({ ok: false, reason: 'history-limit' })
|
|
expect(onReverted).not.toHaveBeenCalled()
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
})
|
|
it('refuses a missing target before mutation', async () => {
|
|
const { session, request } = fixture()
|
|
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'missing' })).toEqual({
|
|
ok: false,
|
|
reason: 'invalid-target'
|
|
})
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
})
|
|
it('rechecks provider idleness after preflight hydration', async () => {
|
|
const { session, request } = fixture(false)
|
|
const original = request.getMockImplementation()!
|
|
let reads = 0
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/read' && ++reads === 2) {
|
|
return { thread: { id: 'thread', status: { type: 'active' } } }
|
|
}
|
|
return original(method)
|
|
})
|
|
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
|
ok: false,
|
|
reason: 'busy'
|
|
})
|
|
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
|
})
|
|
it('maps native legacy refusal without exposing provider text or falling back', async () => {
|
|
const { session, request } = fixture(false)
|
|
const original = request.getMockImplementation()!
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/read') {
|
|
return { thread: { id: 'thread', status: { type: 'idle' } } }
|
|
}
|
|
if (method !== 'thread/revert') {
|
|
return original(method)
|
|
}
|
|
throw new CodexAppServerRequestError(
|
|
'thread/revert',
|
|
-32600,
|
|
'thread/revert only supports paginated threads'
|
|
)
|
|
})
|
|
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
|
ok: false,
|
|
reason: 'history-not-paginated'
|
|
})
|
|
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
|
'thread/read',
|
|
'thread/turns/list',
|
|
'thread/items/list',
|
|
'thread/read',
|
|
'thread/revert'
|
|
])
|
|
})
|
|
it('refuses activity arriving during the preflight await', async () => {
|
|
const { session, request } = fixture()
|
|
request.mockImplementationOnce(async () => {
|
|
session.activeTurnIds!.add('racing-turn')
|
|
return { thread: { id: 'thread', status: { type: 'idle' } } }
|
|
})
|
|
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
|
ok: false,
|
|
reason: 'busy'
|
|
})
|
|
expect(request).toHaveBeenCalledTimes(1)
|
|
})
|
|
it('treats hydration failure after revert as unknown and never retries revert', async () => {
|
|
const { session, request } = fixture(false)
|
|
const original = request.getMockImplementation()!
|
|
let reverted = false
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/revert') {
|
|
reverted = true
|
|
}
|
|
if (method === 'thread/items/list' && reverted) {
|
|
throw new Error('offline')
|
|
}
|
|
return original(method)
|
|
})
|
|
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
|
'offline'
|
|
)
|
|
expect(request.mock.calls.filter(([method]) => method === 'thread/revert')).toHaveLength(1)
|
|
})
|
|
it('captures history mode at both start and resume without changing defaults', async () => {
|
|
for (const resumeThreadId of [null, 'thread']) {
|
|
const request = vi.fn(async (_method: string, _params?: unknown) => ({
|
|
thread: { id: 'thread', historyMode: 'legacy' }
|
|
}))
|
|
expect(
|
|
await openCodexThread({ request }, { cwd: '/workspace', resumeThreadId }, 10)
|
|
).toMatchObject({ historyMode: 'legacy' })
|
|
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('historyMode')
|
|
}
|
|
})
|
|
it('rejects post-revert history missing an item within a retained turn', async () => {
|
|
const { session, request } = fixture(false)
|
|
const original = request.getMockImplementation()!
|
|
let reverted = false
|
|
request.mockImplementation(async (method) => {
|
|
if (method === 'thread/revert') {
|
|
reverted = true
|
|
}
|
|
if (method === 'thread/items/list' && !reverted) {
|
|
return {
|
|
data: [2, 1].map((i) => ({
|
|
turnId: 'kept',
|
|
item: {
|
|
id: `item-${i}`,
|
|
type: 'userMessage',
|
|
content: [{ type: 'text', text: `prompt ${i}` }]
|
|
}
|
|
})),
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return original(method)
|
|
})
|
|
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
|
'proof-mismatch'
|
|
)
|
|
})
|
|
})
|