Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	docs/core-data-structures/session.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-08-06 12:00:33 +08:00
90 changed files with 2789 additions and 440 deletions
@@ -18,9 +18,18 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
@@ -545,12 +554,13 @@ describe('small branch tails', () => {
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
t={t}
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
useProjection={(key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
})
})
@@ -3,25 +3,40 @@
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
// chrome (Bash · description) without a row click target.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
afterEach(cleanup)
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
const SID = 's1' as SessionId
@@ -66,8 +81,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 token fields at all (billing rides the projection).
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
// the fold exposes no billing fields (billing rides the projection);
// decodeTokens is a throughput input, not a billed total.
expect(Object.keys(stats).sort()).toEqual(
['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
)
})
it('ignores tool results with no call time', () => {
@@ -97,6 +115,23 @@ describe('deriveStats', () => {
expect(stats.llmMs).toBe(2_500)
expect(stats.toolMs).toBe(3_000)
})
it('sums ttft per recorded step and decode throughput inputs per usage-carrying step', () => {
const sampled: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 40 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const ttftOnly: AssistantMessageNode = {
...assistant(2, 1),
timing: { stepStartTime: 5_000, firstTokenTime: 5_400, completedTime: 7_400 },
}
const stats = deriveStats([sampled, ttftOnly, assistant(3, 2)])
expect(stats.ttftMs).toBe(1_200)
expect(stats.ttftSteps).toBe(2)
// The usage-less step contributes no decode share, keeping the ratio honest.
expect(stats.decodeMs).toBe(3_000)
expect(stats.decodeTokens).toBe(40)
})
})
describe('formatters', () => {
@@ -125,7 +160,7 @@ describe('StatsLine', () => {
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsLineProps {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
@@ -142,47 +177,84 @@ describe('StatsLine', () => {
expect(emptyView.container.textContent).toBe('')
})
it('keeps durable token and context groups after the visible step window is empty', () => {
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
vi.useFakeTimers()
// jsdom lays nothing out; fake a row narrower than its content.
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
.toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
})
it('suppresses the tooltip while the row fits without truncation', () => {
vi.useFakeTimers()
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(500) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
})
it('renders window latency and throughput beside the wall-time group', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} />)
expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s')
})
it('takes every stats label from the active locale', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} t={t} />)
expect(view.container.textContent)
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
})
it('renders without ResizeObserver support', () => {
vi.unstubAllGlobals()
const { source } = makeSource({ nodes: [assistant(1, 1)] })
expect(() => render(<StatsLine {...props(source)} />)).not.toThrow()
})
it('keeps durable token groups after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
// Context occupancy lives on the composer's ContextMeter ring, not here.
expect(view.container.textContent)
.toBe('Context 25% of 128K| Cache hit 90%| Input 100 tok · Output 5 tok')
.toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
})
it('renders context occupancy only when the projection knows a capacity', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const withCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
// Pressure without capacity has no denominator: the group drops out.
const noCapacity = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000 },
})} />)
expect(noCapacity.container.textContent).not.toContain('Context')
// Capacity arrives before usage in the log; no provider sample means there
// is no numerator yet, rather than a synthetic 0%.
const noPressure = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { contextWindow: 128_000 },
})} />)
expect(noPressure.container.textContent).not.toContain('Context')
})
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
// Capacity and pressure are independent last-wins fields, so a model switch
// can pair a smaller new window with the previous route's larger prompt.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent).toContain('Context 100% of 128K')
it('computes context occupancy only when both a numerator and capacity are known', () => {
// The projected figure wins: it is the provider sample carried forward over
// the surface's movement, so a compaction shows without waiting a request.
expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
.toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
// A log whose projection predates the field still reads its bare sample.
expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
.toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
// A numerator without capacity has no denominator; capacity without a
// provider sample has no numerator yet, rather than a synthetic 0%.
expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
expect(contextOccupancy(undefined)).toBeNull()
// Capacity and the sample are independent last-wins fields, so a model
// switch can pair a smaller new window with the previous route's prompt.
expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
})
it('drops every token group when no projection is composed', () => {
@@ -534,6 +534,45 @@ describe('ChatView', () => {
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
})
it('the settled footer appends first-step ttft and turn decode throughput', () => {
const first: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
usage: { outputTokens: 40 },
}
const second: AssistantMessageNode = {
kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
usage: { outputTokens: 60 },
}
const h = makeHarness({
nodes: [user(1, 'hi'), first, second],
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
turnEnds: new Map([[1, 20]]),
})
const view = render(<h.ChatView {...h.props} />)
// First-step ttft (1.2s) plus 100 tokens over 5s of decode.
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
})
it('withholds ttft and throughput while the turn is still running', () => {
const settled: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 },
usage: { outputTokens: 10 },
}
const h = makeHarness({
nodes: [user(1, 'hi'), settled],
turnTimings: new Map([[1, { startTime: 1_000 }]]),
turnEnds: new Map(),
running: true,
})
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
})
it('user and assistant message containers scope the hover-revealed time chrome', () => {
const h = makeHarness({
nodes: [user(1, 'hi'), assistant(2, 'answer')],
@@ -0,0 +1,158 @@
// @vitest-environment jsdom
// ContextMeter (composer trailing control): occupancy ring gating, the
// click-open breakdown panel, and its close gestures.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
import css from '../src/client/skeleton/ContextMeter.module.css'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
// Mirrors the real lookup chain (conversation namespace, then common).
const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
const segmentClass = css.segment
if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css')
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] {
return (key: string) => values[key]
}
function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) {
return render(<ContextMeter useProjection={projections(values)} t={translate} />)
}
describe('ContextMeter', () => {
it('renders nothing until both pressure and capacity are known', () => {
expect(meter({}).container.textContent).toBe('')
expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')
expect(meter({ contextPressure: { contextWindow: 128_000 } }).container.textContent).toBe('')
})
it('shows the occupancy ring and opens the breakdown panel on click', () => {
const view = meter({
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
fireEvent.click(trigger)
const panel = view.container.querySelector('[role="dialog"]')!
expect(panel.textContent).toContain('~32K / 128K')
expect(panel.textContent).toContain('25%')
expect(panel.textContent).toContain('上下文已用')
expect(panel.textContent).toContain('系统提示词~120')
expect(panel.textContent).toContain('工具~21.5K')
expect(panel.textContent).toContain('对话消息~477K')
// The occupancy bar splits into one colored segment per composition row.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
// Clicking the trigger again toggles the panel shut.
fireEvent.click(trigger)
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
})
it('lets each locale own the headline word order around the reading', () => {
const values = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
}
const zhView = meter(values)
fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
// The reading follows the label in Chinese and leads it in English; both
// headers read as one sentence rather than a concatenated fragment.
expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
.toMatch(/^上下文已用25%/)
const enView = meter(values, tEn)
fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
expect(enView.container.querySelector('[role="dialog"]')!.textContent)
.toMatch(/^25%of context used/)
})
it('draws no bar segment at zero occupancy', () => {
const view = meter({
contextPressure: { pressureTokens: 0, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
const panel = view.container.querySelector('[role="dialog"]')!
// `.segment` carries a min-width, so a zero-width part would still paint a
// filled sliver over an empty context.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
expect(panel.textContent).toContain('~0 / 128K')
})
it('reads the ring from the projected figure so a compaction shows at once', () => {
// Same provider sample, a surface a compaction just shrank: the ring must
// follow the projection rather than the sample it is anchored to.
const view = meter({
contextPressure: { pressureTokens: 32_000, projectedTokens: 3_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
const trigger = view.getByRole('button', { name: '上下文已用 2%' })
fireEvent.click(trigger)
expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
})
it('omits the composition rows while the contextBreakdown projection is absent', () => {
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
const panel = view.container.querySelector('[role="dialog"]')!
expect(panel.textContent).toContain('~32K / 128K')
expect(panel.textContent).not.toContain('系统提示词')
expect(panel.textContent).not.toContain('对话消息')
// Without composition shares, the bar falls back to one plain segment.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1)
})
it('closes when capacity disappears and stays closed when it returns', () => {
let values: Record<string, unknown> = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
}
const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
expect(view.container.textContent).toBe('')
values = {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
}
view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
})
it('closes on outside pointerdown and Escape — but not inside clicks', () => {
const view = meter({
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
contextBreakdown: BREAKDOWN,
})
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
const openPanel = () => {
fireEvent.click(trigger)
return view.container.querySelector('[role="dialog"]')!
}
// A pointerdown inside the panel keeps it open; outside closes it.
const again = openPanel()
fireEvent.pointerDown(again)
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
fireEvent.pointerDown(document.body)
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
// Escape.
openPanel()
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
})
})
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,7 +18,18 @@ import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const SID = 's1' as SessionId
@@ -56,11 +67,12 @@ describe('render branch tails', () => {
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
t={t}
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useProjection={() => undefined}
/>,
)
expect(view.container.textContent).toBe('2 turns · 3 steps')
expect(view.container.textContent).toBe('2 轮 · 3 步')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
@@ -0,0 +1,154 @@
// Per-turn latency/throughput fold and the footer figure formatters.
import { describe, expect, it } from 'vitest'
import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
interface StepSpec {
seq: number
turn: number
step: number
timing?: AssistantMessageNode['timing']
usage?: unknown
}
const assistant = ({ seq, turn, step, timing, usage }: StepSpec): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text: `t${seq}` }],
...(timing === undefined ? {} : { timing }),
...(usage === undefined ? {} : { usage }),
})
const user = (seq: number): UserMessageNode => ({
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text: 'hi' }] as never, source: null,
})
describe('assistantStepReading', () => {
it('derives ttft, decode time, and output tokens from a fully recorded step', () => {
const reading = assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 6_800 },
usage: { outputTokens: 200 },
}))
expect(reading).toEqual({ ttftMs: 800, decodeMs: 5_000, outputTokens: 200 })
})
it('returns nulls when timing is absent', () => {
const reading = assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, usage: { outputTokens: 5 } }))
expect(reading).toEqual({ ttftMs: null, decodeMs: null, outputTokens: 5 })
})
it('needs both boundaries for ttft and clamps negative spans to zero', () => {
expect(assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: null, firstTokenTime: 1_800, completedTime: 6_800 },
}))).toEqual({ ttftMs: null, decodeMs: 5_000, outputTokens: null })
expect(assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: null, completedTime: 6_800 },
}))).toEqual({ ttftMs: null, decodeMs: null, outputTokens: null })
expect(assistantStepReading(assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 2_000, firstTokenTime: 1_500, completedTime: 1_200 },
}))).toEqual({ ttftMs: 0, decodeMs: 0, outputTokens: null })
})
it('rejects non-object, missing, and non-finite usage token counts', () => {
const timing = { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 }
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: 'weird' })).outputTokens).toBeNull()
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: {} })).outputTokens).toBeNull()
const nan = assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: Number.NaN } })
expect(assistantStepReading(nan).outputTokens).toBeNull()
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: -3 } })).outputTokens).toBeNull()
})
})
describe('deriveTurnMetrics', () => {
it('takes ttft from the lowest step and throughput over all sampled steps', () => {
const nodes: ConversationNode[] = [
user(1),
// Out of step order on purpose: the lowest step owns the ttft slot.
assistant({
seq: 4, turn: 1, step: 2,
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
usage: { outputTokens: 60 },
}),
assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
usage: { outputTokens: 40 },
}),
]
// 100 tokens over 5s of decode.
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 1_200, tokensPerSecond: 20 })
})
it('emits ttft without throughput when no step carries usage', () => {
const nodes = [assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_900, completedTime: 3_000 },
})]
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 900 })
})
it('emits throughput without ttft when only a later step is recorded', () => {
const nodes = [
assistant({ seq: 2, turn: 1, step: 1 }),
assistant({
seq: 4, turn: 1, step: 2,
timing: { stepStartTime: 10_000, firstTokenTime: 10_500, completedTime: 12_500 },
usage: { outputTokens: 30 },
}),
]
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ tokensPerSecond: 15 })
})
it('omits turns with no readings and zero-decode throughput', () => {
const nodes = [
assistant({ seq: 2, turn: 1, step: 1 }),
assistant({
seq: 4, turn: 2, step: 1,
timing: { stepStartTime: null, firstTokenTime: 5_000, completedTime: 5_000 },
usage: { outputTokens: 10 },
}),
]
expect(deriveTurnMetrics(nodes).size).toBe(0)
})
it('keeps turns independent and ignores non-assistant nodes', () => {
const nodes: ConversationNode[] = [
user(1),
assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_400, completedTime: 2_400 },
usage: { outputTokens: 10 },
}),
user(3),
assistant({
seq: 4, turn: 2, step: 1,
timing: { stepStartTime: 4_000, firstTokenTime: 4_100, completedTime: 6_100 },
usage: { outputTokens: 100 },
}),
]
const metrics = deriveTurnMetrics(nodes)
expect(metrics.get(1)).toEqual({ ttftMs: 400, tokensPerSecond: 10 })
expect(metrics.get(2)).toEqual({ ttftMs: 100, tokensPerSecond: 50 })
})
})
describe('footer figure formatters', () => {
it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
expect(formatLatencySeconds(840)).toBe('0.8')
expect(formatLatencySeconds(1_000)).toBe('1')
expect(formatLatencySeconds(9_949)).toBe('9.9')
expect(formatLatencySeconds(12_400)).toBe('12')
expect(formatLatencySeconds(-5)).toBe('0')
})
it('formats throughput with whole tokens from ten up and one decimal below', () => {
expect(formatTokensPerSecond(34.4)).toBe('34')
expect(formatTokensPerSecond(9.96)).toBe('10')
expect(formatTokensPerSecond(3.14)).toBe('3.1')
expect(formatTokensPerSecond(-1)).toBe('0')
})
})