Merge branch 'master' into feat/web-preview-badge
This commit is contained in:
@@ -124,11 +124,13 @@ describe('AskQuestionRow', () => {
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
it('askQuestionToolview injects the toolview declaration directly', () => {
|
||||
expect(askQuestionToolview.name).toBe('ask-question-toolview')
|
||||
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
askQuestionToolview.apply({ slots: { register } } as never)
|
||||
expect(askQuestionToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
askQuestionToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
|
||||
AskQuestionRow,
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
|
||||
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all strict
|
||||
// session entries, and the bash sample + todo row mount through the
|
||||
// load-order seam as keyed entries. Full-chain rendering belongs to the
|
||||
// session entries, and the bash sample + todo row mount through declaration
|
||||
// injection as keyed entries. Full-chain rendering belongs to the
|
||||
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
|
||||
// stops at the assembly surface.
|
||||
|
||||
@@ -90,10 +90,9 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the tool rows as keyed entries through declaration injection', async () => {
|
||||
const b = await bench()
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// The actual toolview declaration activates every registrant. The
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// Remaining chat branch tails: MessageItem context/unknown arms,
|
||||
// user IconActions, StatsLine no-cache join,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
@@ -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).
|
||||
@@ -102,16 +111,10 @@ describe('MessageItem arms', () => {
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
it('user copy never claims success when the host rejects the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
@@ -122,15 +125,94 @@ describe('MessageItem arms', () => {
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('consumed steering renders copy and branch actions without a badge', () => {
|
||||
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
fireEvent.click(copy)
|
||||
fireEvent.click(copy)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
// Two microtask ticks: writeClipboard's own await, then the .then that
|
||||
// lands the success chrome.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const done = screen.getByRole('button', { name: '复制成功' })
|
||||
fireEvent.click(done)
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears copy feedback work when the message unmounts', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishWrite!: () => void
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
finishWrite()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const mounted = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 2, time: 1_000,
|
||||
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
mounted.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -145,7 +227,7 @@ describe('MessageItem arms', () => {
|
||||
onFork={fork}
|
||||
/>,
|
||||
)
|
||||
expect(view.queryByText('插话')).toBeNull()
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
@@ -154,47 +236,480 @@ describe('MessageItem arms', () => {
|
||||
expect(fork).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
content: [{ type: 'text', text: 'line one\n\nline two' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
|
||||
provenance: { role: 'inject', label: 'fixture' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
|
||||
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
expect(ctxView.container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
|
||||
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
|
||||
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
|
||||
)
|
||||
// An unknown form renders the opaque body: the model-facing text keeps its
|
||||
// real line breaks instead of being escaped into one JSON line, and the
|
||||
// remaining provenance follows it as fields.
|
||||
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
|
||||
.toBe('line one\n\nline two')
|
||||
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'empty', 'list'])
|
||||
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
it('the instructions form names the files it reconciled above their text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
baseline: true,
|
||||
changes: [
|
||||
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
|
||||
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
|
||||
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
|
||||
// The `<system-reminder>` framing is part of what the model read, so the
|
||||
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent)
|
||||
.toContain('<system-reminder>')
|
||||
})
|
||||
|
||||
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'delta' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
changes: [
|
||||
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
|
||||
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
|
||||
})
|
||||
|
||||
it('keeps an interleaved unknown block in the order the model received it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'future-block', payload: 1 },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
|
||||
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
|
||||
expect(texts).toEqual(['before', 'after'])
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
|
||||
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
|
||||
expect(view.container.querySelector('[data-context-text]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a replacement catalog says so above its entries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
|
||||
})
|
||||
|
||||
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
|
||||
// All-or-nothing: a body that replaces the model-facing text must not show
|
||||
// a confident, incomplete account of what the model read.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
// The marker reports what rendered, not what was declared.
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
|
||||
// No invented separator: showing a line break the model never saw would
|
||||
// misreport the request.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('bounds an oversized provenance field, not only the model-facing text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'short' }],
|
||||
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
|
||||
// `renderCatalogUpdate` legitimately publishes zero entries when the last
|
||||
// skill disappears; falling back would hide that the catalog was cleared.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBe('catalog')
|
||||
})
|
||||
|
||||
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
})
|
||||
|
||||
it('bounds a large catalog and says how many rows it withheld', () => {
|
||||
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
|
||||
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
|
||||
})
|
||||
|
||||
it('a catalog keeps a content block this version does not know', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an instruction change with an unrecognized action falls back whole', () => {
|
||||
// The action decides the word the row shows, so an unknown one cannot be
|
||||
// presented as loaded or updated.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
})
|
||||
|
||||
it('the opaque fallback keeps a form declaration this version cannot present', () => {
|
||||
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
|
||||
provenance: { role: 'inject', label: 'later' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
|
||||
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'form'])
|
||||
})
|
||||
|
||||
it('the snapshot form attributes each part to the subsystem that produced it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: '@deepseek-ai/dsh-system-prompt',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
|
||||
})
|
||||
|
||||
it('a notice puts its account on the collapsed row', () => {
|
||||
// The whole point of the form: readable without expanding.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')?.textContent)
|
||||
.toBe('bash pnpm test [status: completed]')
|
||||
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a notice without its account falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('each form falls back to the opaque body when its required facts are unreadable', () => {
|
||||
// The fallback chain is the load-bearing wall: every dedicated form must
|
||||
// reach it, and the row marker must not claim a form that did not render.
|
||||
const cases = [
|
||||
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
|
||||
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
|
||||
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
|
||||
] as const
|
||||
for (const { form, source, label } of cases) {
|
||||
cleanup()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
|
||||
source, provenance: { role: 'inject', label }, form,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('a snapshot states the supersession its framing line carries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
|
||||
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
|
||||
.toBe('取代先前的快照')
|
||||
})
|
||||
|
||||
it('a relay names the agent that sent it above what it said', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'child report body' }],
|
||||
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
|
||||
provenance: { role: 'inject', label: 'subagent-report' },
|
||||
form: 'relay',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
|
||||
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
|
||||
})
|
||||
|
||||
it('a recall reports how much of each source session survived the read', () => {
|
||||
// Recalled context is bounded on the way in, so hiding the omitted count
|
||||
// would overstate what the model received.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'recalled material' }],
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [
|
||||
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
|
||||
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
|
||||
form: 'recall',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
@@ -496,12 +1011,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -133,7 +168,7 @@ describe('StatsLine', () => {
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
// No timing on the fixture: the duration group drops out whole. Tokens come
|
||||
// from the projection, so paging the window cannot change them.
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
const empty = makeSource()
|
||||
const emptyView = render(<StatsLine {...props(empty.source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
@@ -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', () => {
|
||||
@@ -196,7 +268,7 @@ describe('StatsLine', () => {
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('includes cache writes in billed input and the cache-hit denominator', () => {
|
||||
@@ -210,7 +282,7 @@ describe('StatsLine', () => {
|
||||
},
|
||||
})} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
|
||||
.toBe('1 turns · 1 steps| Cache hit 45%| Input 200 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
@@ -244,7 +316,7 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: {
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
|
||||
@@ -59,6 +59,7 @@ const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
describe('tool-call-model', () => {
|
||||
it('classifies known tools and falls back to others', () => {
|
||||
expect(classifyTool('bash')).toBe('bash')
|
||||
expect(classifyTool('pwsh')).toBe('bash')
|
||||
expect(classifyTool('read')).toBe('read')
|
||||
expect(classifyTool('web_fetch')).toBe('read')
|
||||
expect(classifyTool('web_search')).toBe('search')
|
||||
@@ -71,6 +72,12 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
it('gives the pwsh shell row the bash family treatment with its own title', () => {
|
||||
const m = toolRowModel('pwsh', running())
|
||||
expect(m.variant).toBe('bash')
|
||||
expect(m.title).toBe('Pwsh')
|
||||
})
|
||||
|
||||
it('derives state across running/ok/error/interrupted', () => {
|
||||
expect(toolRowModel('bash', running()).state).toBe('running')
|
||||
expect(toolRowModel('bash', result()).state).toBe('ok')
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
// flips rows in place, duplicate keys fail loud, the inject channel feeds
|
||||
// (sessionId) => I into row components, and a registrant's
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
// (sessionId) => I into row components, and a registrant can activate before
|
||||
// the declaration then land through slots.inject when the chat entry appears.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
@@ -191,8 +190,8 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
describe('registrant declaration injection', () => {
|
||||
it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
@@ -200,31 +199,26 @@ describe('registrant load-order seam', () => {
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
|
||||
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
|
||||
// functions carry a prototype and trip the fiber's isConstructor branch.)
|
||||
// Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs,
|
||||
// while slots.inject waits for the declaration itself.
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null))
|
||||
}
|
||||
const late = runtime.ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots'],
|
||||
apply: registrantApply,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(applyRuns).toBe(0)
|
||||
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
|
||||
// Mounting the package declares the slot and activates the waiting entry.
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
await runtime.dispose()
|
||||
|
||||
@@ -274,11 +274,7 @@ describe('chat-flow derivation', () => {
|
||||
user(6, 'second'),
|
||||
assistant(7, 'clean tail', 2),
|
||||
user(10, 'user-only tail'),
|
||||
{
|
||||
kind: 'steering', messageId: 'steering-tail' as never,
|
||||
seq: 13, time: 13_000, turn: 4,
|
||||
content: [{ type: 'text', text: 'steering tail' }], source: null,
|
||||
},
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7, 10, 13])
|
||||
@@ -380,6 +376,9 @@ describe('ChatView', () => {
|
||||
expect(view.queryByText('later')).toBeNull()
|
||||
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
|
||||
expect(pendingBubble).not.toBeNull()
|
||||
// Pending and durable steering carry the same interjection caption, so the
|
||||
// hand-off does not change what the row says it is.
|
||||
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
@@ -393,7 +392,7 @@ describe('ChatView', () => {
|
||||
assistant(1, 'working'),
|
||||
{
|
||||
kind: 'steering', messageId: pending.messageId,
|
||||
seq: 2, time: 2_000, turn: 1,
|
||||
seq: 2, time: 2_000,
|
||||
content: [{ type: 'text', text: 'interrupt now' }], source: null,
|
||||
},
|
||||
],
|
||||
@@ -401,6 +400,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByText('插话')).toHaveLength(1)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
|
||||
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
|
||||
@@ -430,8 +430,7 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({
|
||||
queue: [pending],
|
||||
nodes: [{
|
||||
kind: 'steering', messageId: pending.messageId,
|
||||
seq: 2, time: 2_000, turn: 1,
|
||||
kind: 'user', seq: 2, time: 2_000,
|
||||
content: pending.content, source: null,
|
||||
}],
|
||||
running: true,
|
||||
@@ -447,6 +446,8 @@ describe('ChatView', () => {
|
||||
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
|
||||
const context = {
|
||||
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as const satisfies ConversationNode
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -540,6 +541,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')],
|
||||
@@ -732,9 +772,13 @@ describe('ChatView', () => {
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
||||
act(() => {
|
||||
h.set({ nodes: [trigger, {
|
||||
kind: 'steering', messageId: 'st' as never, seq: 2, time: Date.now(), turn: 1,
|
||||
content: [{ type: 'text', text: 'also' }], source: null,
|
||||
h.set({ queue: [{
|
||||
id: 'steering-occurrence' as never,
|
||||
messageId: 'steering-message' as never,
|
||||
placement: 'steering',
|
||||
content: [{ type: 'text', text: 'also' }],
|
||||
preview: 'also',
|
||||
text: 'also',
|
||||
}] })
|
||||
})
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
|
||||
158
packages/client/ui-conversation/tests/context-meter.spec.tsx
Normal file
158
packages/client/ui-conversation/tests/context-meter.spec.tsx
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -93,7 +93,7 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('chat row diff body', () => {
|
||||
describe('FileMutationRow diff card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
@@ -274,8 +274,14 @@ describe('fileMutationToolview registration', () => {
|
||||
it('registers one component under both edit and write, and each disposes', () => {
|
||||
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
|
||||
const disposers: (() => void)[] = []
|
||||
let disposeInjection = (): void => {}
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
const active = [...callback()]
|
||||
disposeInjection = () => { for (const dispose of active.reverse()) dispose() }
|
||||
return disposeInjection
|
||||
},
|
||||
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
|
||||
const entry = { key, locale, disposed: false }
|
||||
registered.push(entry)
|
||||
@@ -289,10 +295,9 @@ describe('fileMutationToolview registration', () => {
|
||||
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
|
||||
// Both keys claim the conversation locale seat ToolRow's body copy needs.
|
||||
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
|
||||
// The registrant's inject seam is the load-order contract the row relies on.
|
||||
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(fileMutationToolview.inject).toEqual(['slots'])
|
||||
// Disposal removes each contribution (packages/AGENTS.md registry contract).
|
||||
for (const dispose of disposers) dispose()
|
||||
disposeInjection()
|
||||
expect(registered.every(r => r.disposed)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -306,7 +311,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -143,7 +143,6 @@ function bench(over?: BenchOptions) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
// aria-label (not role name): title carries the same label and would double-match.
|
||||
const stopping = over?.running === true && over.subagent === undefined
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
||||
@@ -724,6 +723,8 @@ describe('command launcher chrome and control seats', () => {
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Read Only')
|
||||
expect([...trigger.querySelectorAll('svg')]
|
||||
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
|
||||
@@ -375,8 +375,10 @@ describe('QueueDock', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
queueDockEntry.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => {
|
||||
describe('ReadRow keyed toolview', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
@@ -237,11 +237,14 @@ describe('ReadRow keyed toolview', () => {
|
||||
|
||||
it('registers under the read key of the keyed toolview slot', () => {
|
||||
const registered: { name: unknown; key?: unknown }[] = []
|
||||
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
|
||||
const ctx = { slots: {
|
||||
inject: (_name: string, callback: () => () => void) => callback(),
|
||||
register: (options: { name: unknown; key?: unknown }) => { registered.push(options); return () => undefined },
|
||||
} } as unknown as Context
|
||||
readToolview.apply(ctx)
|
||||
// The row composes ToolRow, so it declares its locale namespace at the seat.
|
||||
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
|
||||
expect(readToolview.inject).toContain('conversation')
|
||||
expect(readToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -254,7 +257,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -349,8 +349,13 @@ describe('SearchRow keyed card', () => {
|
||||
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const _dispose of callback()) { /* exhaust transactional setup */ }
|
||||
return () => undefined
|
||||
},
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => undefined
|
||||
},
|
||||
},
|
||||
} as never
|
||||
@@ -361,7 +366,7 @@ describe('SearchRow keyed card', () => {
|
||||
// One component, two keys.
|
||||
expect(registered[0]!.component).toBe(SearchRow)
|
||||
expect(registered[1]!.component).toBe(SearchRow)
|
||||
expect(searchToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(searchToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -94,10 +94,10 @@ function mount(
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
|
||||
}
|
||||
const listed = options.omitSummaryRow !== true
|
||||
|
||||
@@ -342,7 +342,7 @@ describe('chat row terminal body', () => {
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
@@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -38,15 +38,24 @@ describe('TodoPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('starts collapsed with the progress summary visible', () => {
|
||||
it('starts collapsed with the per-status count summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('任务清单')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('任务')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the completed segment while nothing is done yet', () => {
|
||||
render(<TodoPanel todos={[
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]} t={t} />)
|
||||
expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText(/已完成/)).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
@@ -65,17 +74,18 @@ describe('TodoPanel', () => {
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成')).toBeTruthy()
|
||||
expect(screen.queryByText(/进行中|待处理/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,7 +103,7 @@ describe('TodoDock', () => {
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
||||
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ value: null }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
@@ -101,9 +111,11 @@ describe('TodoDock', () => {
|
||||
|
||||
it('registers before the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(todoDockEntry.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoDockEntry.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
@@ -186,11 +198,13 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
it('todoToolview injects the toolview declaration directly', () => {
|
||||
expect(todoToolview.name).toBe('todo-toolview')
|
||||
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoToolview.apply({ slots: { register } } as never)
|
||||
expect(todoToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
})
|
||||
})
|
||||
|
||||
154
packages/client/ui-conversation/tests/turn-metrics.spec.ts
Normal file
154
packages/client/ui-conversation/tests/turn-metrics.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -272,6 +272,10 @@ describe('web toolview registration', () => {
|
||||
const registered: { key: string; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const _dispose of callback()) { /* exhaust transactional setup */ }
|
||||
return () => undefined
|
||||
},
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => {}
|
||||
@@ -285,7 +289,6 @@ describe('web toolview registration', () => {
|
||||
// One component under both keys, not two thin rows.
|
||||
expect(registered[0]?.component).toBe(WebRow)
|
||||
expect(registered[1]?.component).toBe(WebRow)
|
||||
// The load-order seam the render site depends on.
|
||||
expect(webToolview.inject).toEqual(['slots', 'conversation'])
|
||||
expect(webToolview.inject).toEqual(['slots'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user