fix(client): resolve trajectory review follow-ups

This commit is contained in:
_Kerman
2026-07-28 22:54:46 +08:00
parent a245c8a011
commit 943ef7403e
11 changed files with 392 additions and 96 deletions

View File

@@ -178,6 +178,45 @@ describe('FoldAdapter', () => {
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('indexes assistant timing and the active request header in one replay pass', () => {
const adapter = new FoldAdapter()
adapter.reset([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
adapter.append(ev.stepStart(5, 2, 1))
adapter.append(ev.chunkText(6, 2, 'next', 1))
adapter.append(ev.assistant(7, 2, 'next done', 1))
expect(adapter.nodes().nodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)

View File

@@ -109,6 +109,56 @@ describe('inspectRequests', () => {
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
const retryUsage = {
inputTokens: 5,
outputTokens: 2,
cacheReadTokens: 8,
reasoningTokens: 1,
}
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: chunkUsage },
}),
at(2, 'llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 100,
failure: { message: 'rate limited' },
}),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: retryUsage },
}),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'recovered' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
}),
]))
expect(snapshot.requests[0]).toMatchObject({
status: 'complete',
usage: {
inputTokens: 26,
outputTokens: 5,
cacheReadTokens: 8,
reasoningTokens: 1,
},
})
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),

View File

@@ -292,6 +292,26 @@ describe('paging', () => {
.toEqual([1, 3, 7, 9, 13, 15])
})
it('stops complete-history loading between pages after its consumer aborts', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
: middle.promise
await session.open()
const controller = new AbortController()
const completeHistory = session.loadAllHistory(controller.signal)
controller.abort()
middle.resolve(ok({
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
hasMore: true,
}))
await completeHistory
expect(api.callsOf('session.history')).toHaveLength(2)
expect(session.getSnapshot().hasMore).toBe(true)
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
@@ -767,6 +787,32 @@ describe('resync', () => {
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
it('starts fresh paging while an older generation page is still pending', async () => {
const stalePage = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const { api, session } = makeSession()
let call = 0
api.onHistory = () => {
call++
if (call === 1) return histResponse(plainTurn(6, 1, '新', '页'), true)
if (call === 2) return stalePage.promise
if (call === 3) return histResponse(plainTurn(6, 1, '新', '代'), true)
return histResponse(plainTurn(0, 0, '旧', '页'), false)
}
await session.open()
const stale = session.loadOlder()
await session.resync()
const fresh = session.loadAllHistory()
await vi.waitFor(() => { expect(call).toBe(4) })
stalePage.resolve(ok({
events: entries(plainTurn(0, 0, '废', '弃')) as never[],
hasMore: false,
}))
await Promise.all([stale, fresh])
expect(session.getSnapshot().hasMore).toBe(false)
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9])
})
})
describe('run_code sub-dispatch indexing', () => {