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:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user