fix: 分页问题

This commit is contained in:
07akioni
2026-08-12 20:26:47 +08:00
parent 3784607f3d
commit dabe6207f5
46 changed files with 1594 additions and 66 deletions
@@ -98,9 +98,11 @@ describe('deriveStats', () => {
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
// Window-scoped by design: the paged window is not an accounting source, so
// the fold exposes no billing fields (billing rides the projection);
// decodeTokens is a throughput input, not a billed total.
// The window fold's counts are only the fallback for assemblies without
// the sessionStats projection; the paged window is not an accounting
// source either, so the fold exposes no billing fields (billing rides the
// tokenUsage projection); decodeTokens is a throughput input, not a
// billed total.
expect(Object.keys(stats).sort()).toEqual(
['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
)
@@ -169,6 +171,14 @@ describe('formatters', () => {
describe('StatsLine', () => {
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
/** A whole-log sessionStats value: zeros plus overrides. */
function sessionStats(overrides: Record<string, number>): Record<string, number> {
return {
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
...overrides,
}
}
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
return (key: string) => values[key]
@@ -281,6 +291,58 @@ describe('StatsLine', () => {
expect(view.container.textContent).toBe('1 turns · 1 steps')
})
it('renders whole-session counts from the sessionStats projection over the paged window', () => {
// The bug's acceptance at unit level: one loaded page must not scope the
// counter — the durable projection's totals win over the window fold.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
sessionStats: sessionStats({ turns: 10, steps: 89 }),
})} />)
expect(view.container.textContent)
.toBe('10 turns · 89 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
})
it('treats a defined zero-count projection as empty, not as fallback', () => {
// A composed unit always serves the key; all-zero genuinely means no
// closed step in the whole log, so nothing renders on a brand-new session.
const empty = makeSource()
const view = render(<StatsLine {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
sessionStats: sessionStats({}),
})} />)
expect(view.container.textContent).toBe('')
})
it('keeps the counts group over an empty visible window when the projection carries totals', () => {
// Extends the durable-groups guarantee: full-session counts survive a
// window that compaction (or paging) left without assistant nodes.
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
sessionStats: sessionStats({ turns: 7, steps: 44 }),
})} />)
expect(view.container.textContent)
.toBe('7 turns · 44 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
})
it('renders whole-log wall times and speeds from the projection, not the loaded window', () => {
// The 加载更早 hazard beyond counts: LLM/tool durations and the TTFT and
// throughput figures must not grow per loaded page either. An untimed
// 1-node window renders the projection's whole-log figures verbatim.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
sessionStats: sessionStats({
turns: 200, steps: 200, llmMs: 100_000, toolMs: 62_000,
ttftMs: 1_600, ttftSteps: 2, decodeMs: 3_000, decodeTokens: 60,
}),
})} />)
expect(view.container.textContent).toBe(
'200 turns · 200 steps| LLM 1m40s · Tool call 1m2s| TTFT avg 0.8s · 20 tok/s| Cache hit 90%| Input 100 tok · Output 5 tok',
)
})
it('omits cache hit when nothing was billed on the input side', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
@@ -70,9 +70,11 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine counts window nodes but drops every token group without a projection', () => {
// Node `usage` is deliberately ignored: billing rides the durable
// tokenUsage projection, so an absent projection leaves counts only.
it('StatsLine falls back to window-node counts and drops every token group without projections', () => {
// No sessionStats key → the window fold supplies the counts (the
// assembly-without-the-unit fallback). Node `usage` is deliberately
// ignored: billing rides the durable tokenUsage projection, so an absent
// projection leaves counts only.
const nodes = [
{ kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },