mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
983 lines
38 KiB
TypeScript
983 lines
38 KiB
TypeScript
// @vitest-environment jsdom
|
||
// 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
|
||
// renderSlot.)
|
||
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||
import type {
|
||
ChatConversationViewNode, ConversationNode,
|
||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
|
||
import {
|
||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||
} from '../src/client/chat/message-chrome.ts'
|
||
import {
|
||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView,
|
||
UserMessageNodeView,
|
||
} from '../src/client/chat/MessageItem.tsx'
|
||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||
import { zh } from '../src/client/locales.ts'
|
||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.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).
|
||
const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
|
||
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
|
||
const RETRY_ID = 'retry-fixture' as Extract<ConversationNode, { kind: 'model-retry' }>['retryId']
|
||
|
||
interface MessageItemProps {
|
||
readonly node: ConversationNode
|
||
readonly t: ChatNodeViewProps['t']
|
||
}
|
||
|
||
/** Legacy-node fixture adapter for the independently registered renderers. */
|
||
function MessageItem({ node, t: translate }: MessageItemProps) {
|
||
const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind
|
||
const viewNode: ChatConversationViewNode = {
|
||
key: `fixture:${node.kind}:${node.seq}`,
|
||
kind,
|
||
id: String(node.seq),
|
||
target: 'chat',
|
||
anchorSeq: node.seq,
|
||
location: { kind: 'session' },
|
||
visibility: 'visible',
|
||
data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node,
|
||
}
|
||
const props = { node: viewNode, t: translate, renderMessageImages } as ChatNodeViewProps
|
||
switch (node.kind) {
|
||
case 'user':
|
||
case 'steering':
|
||
return <UserMessageNodeView {...props as ChatNodeViewProps<'user' | 'steering'>} />
|
||
case 'context':
|
||
return <ContextMessageNodeView {...props as ChatNodeViewProps<'context'>} />
|
||
case 'compaction':
|
||
return <CompactionNodeView {...props as ChatNodeViewProps<'compaction'>} />
|
||
case 'model-retry':
|
||
return <RetryNodeView {...props as ChatNodeViewProps<'model-retry'>} />
|
||
case 'unknown':
|
||
return <UnknownNodeView {...props as ChatNodeViewProps<'unknown'>} />
|
||
default:
|
||
throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`)
|
||
}
|
||
}
|
||
|
||
describe('MessageItem arms', () => {
|
||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: { writeText },
|
||
})
|
||
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
|
||
const now = new Date()
|
||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||
render(
|
||
<MessageItem t={t} node={{
|
||
kind: 'user', seq: 1, time,
|
||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||
source: null,
|
||
}}
|
||
/>,
|
||
)
|
||
expect(screen.getByText('14:24')).toBeTruthy()
|
||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||
expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
|
||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||
})
|
||
|
||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: undefined,
|
||
})
|
||
const exec = vi.fn().mockReturnValue(true)
|
||
Object.defineProperty(document, 'execCommand', {
|
||
configurable: true,
|
||
value: exec,
|
||
})
|
||
render(
|
||
<MessageItem t={t} node={{
|
||
kind: 'user', seq: 1, time: 1_000,
|
||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||
source: null,
|
||
}}
|
||
/>,
|
||
)
|
||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||
expect(exec).toHaveBeenCalledWith('copy')
|
||
})
|
||
|
||
it('user copy never claims success when the host rejects the write', async () => {
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||
})
|
||
render(
|
||
<MessageItem t={t} node={{
|
||
kind: 'user', seq: 1, time: 1_000,
|
||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||
source: null,
|
||
}}
|
||
/>,
|
||
)
|
||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||
await act(async () => {
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
})
|
||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||
})
|
||
|
||
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 renders as a plain user bubble and keeps copy without branch', () => {
|
||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: { writeText },
|
||
})
|
||
const view = render(
|
||
<MessageItem t={t} node={{
|
||
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
|
||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||
} as never}
|
||
/>,
|
||
)
|
||
expect(view.queryByText('插话')).toBeNull()
|
||
expect(view.getByText('steer!')).toBeTruthy()
|
||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||
expect(writeText).toHaveBeenCalledWith('steer!')
|
||
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||
})
|
||
|
||
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: '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: /^上下文注入\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')
|
||
// 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 source data 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('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: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
|
||
source: {
|
||
kind: 'agent-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: 'agent-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: '上下文注入' }))
|
||
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: 'agent-instructions', form: 'instructions', changes: [{ action: 'set' }] },
|
||
provenance: { role: 'inject', label: 'agent-instructions' },
|
||
form: 'instructions',
|
||
} as never}
|
||
/>,
|
||
)
|
||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-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 source 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: 'agent-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
|
||
provenance: { role: 'inject', label: 'agent-instructions' },
|
||
form: 'instructions',
|
||
} as never}
|
||
/>,
|
||
)
|
||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-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 job bash-1 finished.' }],
|
||
source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'bash pnpm test [status: completed]' },
|
||
provenance: { role: 'inject', label: 'tool-jobs' },
|
||
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-jobs', form: 'notice' },
|
||
provenance: { role: 'inject', label: 'tool-jobs' },
|
||
form: 'notice',
|
||
} as never}
|
||
/>,
|
||
)
|
||
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
|
||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-jobs$/ }))
|
||
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} />,
|
||
)
|
||
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
|
||
})
|
||
|
||
it('a compaction marker discloses its summary and never shows the framed checkpoint', () => {
|
||
const view = render(
|
||
<MessageItem t={t} node={{
|
||
kind: 'compaction', seq: 5, time: 1_000,
|
||
summary: '## 摘要标题\n\n保留的事实。',
|
||
summaryEventSeq: 4,
|
||
shadowedItemCount: 16,
|
||
shadowedTokenCount: 11_309,
|
||
}}
|
||
/>,
|
||
)
|
||
const row = view.getByRole('button', { name: /上下文已压缩/ })
|
||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
|
||
expect(view.queryByText(/保留的事实/)).toBeNull()
|
||
fireEvent.click(row)
|
||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||
expect(view.getByRole('heading', { name: '摘要标题' })).toBeTruthy()
|
||
fireEvent.click(row)
|
||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||
})
|
||
|
||
it('a marker whose cited summary event fell outside the window is not expandable', () => {
|
||
const view = render(<MessageItem t={t} node={{
|
||
kind: 'compaction', seq: 6, time: 1_000, summary: null,
|
||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||
}} />)
|
||
const row = view.getByRole('button', { name: /上下文已压缩/ })
|
||
expect(row).toHaveProperty('disabled', true)
|
||
expect(row.getAttribute('aria-expanded')).toBeNull()
|
||
expect(view.getByText('压缩摘要不可用')).toBeTruthy()
|
||
fireEvent.click(row) // a disabled control stays collapsed
|
||
expect(row.getAttribute('aria-expanded')).toBeNull()
|
||
})
|
||
|
||
it('collapses retry details behind the durable model retry status', () => {
|
||
vi.useFakeTimers()
|
||
vi.setSystemTime(10_000)
|
||
const view = render(
|
||
<MessageItem
|
||
t={t}
|
||
node={{
|
||
kind: 'model-retry',
|
||
retryId: RETRY_ID,
|
||
seq: 5,
|
||
time: 10_000,
|
||
retryState: 'scheduled',
|
||
turn: 1,
|
||
step: 0,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: 'mock-normal',
|
||
retry: 1,
|
||
maxRetries: 2,
|
||
delayMs: 2_500.4,
|
||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||
}}
|
||
/>,
|
||
)
|
||
const details = view.container.querySelector('details')
|
||
const summary = view.container.querySelector('summary')
|
||
expect(details?.open).toBe(false)
|
||
expect(details?.dataset.active).toBe('true')
|
||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s')
|
||
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
|
||
expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
|
||
|
||
act(() => { vi.advanceTimersByTime(1_100) })
|
||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s')
|
||
act(() => { vi.advanceTimersByTime(1_000) })
|
||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||
|
||
view.rerender(
|
||
<MessageItem
|
||
t={t}
|
||
node={{
|
||
kind: 'model-retry',
|
||
retryId: RETRY_ID,
|
||
seq: 6,
|
||
time: 12_100,
|
||
retryState: 'scheduled',
|
||
turn: 2,
|
||
step: 0,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: 'mock-normal',
|
||
retry: 2,
|
||
maxRetries: 2,
|
||
delayMs: 3_500.4,
|
||
failure: { code: 'TRANSPORT', message: '再次断开' },
|
||
}}
|
||
/>,
|
||
)
|
||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s')
|
||
|
||
if (summary === null) throw new Error('retry summary missing')
|
||
fireEvent.click(summary)
|
||
expect(details?.open).toBe(true)
|
||
|
||
view.rerender(
|
||
<MessageItem t={t} node={{
|
||
kind: 'model-retry',
|
||
retryId: RETRY_ID,
|
||
seq: 6,
|
||
time: 12_100,
|
||
retryState: 'started',
|
||
turn: 2,
|
||
step: 0,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: 'mock-normal',
|
||
retry: 2,
|
||
maxRetries: 2,
|
||
delayMs: 3_500.4,
|
||
failure: { code: 'TRANSPORT', message: '再次断开' },
|
||
}}
|
||
/>,
|
||
)
|
||
expect(details?.dataset.active).toBeUndefined()
|
||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
|
||
|
||
view.rerender(
|
||
<MessageItem t={t} node={{
|
||
kind: 'model-retry',
|
||
retryId: RETRY_ID,
|
||
seq: 7,
|
||
time: 12_100,
|
||
retryState: 'started',
|
||
turn: 3,
|
||
step: 0,
|
||
provider: 'mock',
|
||
mode: 'always',
|
||
policyKey: 'mock-always',
|
||
retry: 3,
|
||
delayMs: 3_500.4,
|
||
failure: { code: 'TRANSPORT', message: '继续重试' },
|
||
}}
|
||
/>,
|
||
)
|
||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
|
||
|
||
view.rerender(
|
||
<MessageItem t={t} node={{
|
||
kind: 'model-retry',
|
||
retryId: RETRY_ID,
|
||
seq: 8,
|
||
time: 12_100,
|
||
retryState: 'cancelled',
|
||
turn: 4,
|
||
step: 0,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: 'mock-normal',
|
||
retry: 1,
|
||
maxRetries: 2,
|
||
delayMs: 3_500.4,
|
||
failure: { code: 'TRANSPORT', message: '用户取消' },
|
||
}}
|
||
/>,
|
||
)
|
||
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
|
||
})
|
||
|
||
})
|
||
|
||
describe('formatMessageClock', () => {
|
||
const now = new Date(2026, 6, 29, 10, 0).getTime()
|
||
|
||
it('keeps HH:mm on the same calendar day', () => {
|
||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
|
||
})
|
||
|
||
it('prefixes month and day across days in the same year', () => {
|
||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
|
||
})
|
||
|
||
it('prefixes year, month, and day across years', () => {
|
||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
|
||
})
|
||
|
||
it('arms the next local midnight from an in-day instant', () => {
|
||
const noon = new Date(2026, 6, 29, 12, 0).getTime()
|
||
expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime())
|
||
expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000)
|
||
})
|
||
})
|
||
|
||
describe('useCalendarDay boundary refresh', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers()
|
||
})
|
||
afterEach(() => {
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('widens a same-day user clock after local midnight', () => {
|
||
const dayStart = new Date(2026, 6, 29, 23, 50).getTime()
|
||
vi.setSystemTime(dayStart)
|
||
const time = new Date(2026, 6, 29, 14, 24).getTime()
|
||
render(
|
||
<MessageItem t={t} node={{
|
||
kind: 'user', seq: 1, time,
|
||
content: [{ type: 'text', text: 'night bubble' }] as never,
|
||
source: null,
|
||
}}
|
||
/>,
|
||
)
|
||
expect(screen.getByText('14:24')).toBeTruthy()
|
||
act(() => {
|
||
vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1)
|
||
})
|
||
expect(screen.getByText('7月29日 14:24')).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
describe('small branch tails', () => {
|
||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||
const view = render(
|
||
<AssistantMarkdown
|
||
t={t}
|
||
blocks={[{ kind: 'reasoning', text: 'one-liner' }]}
|
||
streaming={false}
|
||
renderMessageImages={renderMessageImages}
|
||
/>,
|
||
)
|
||
expect(view.getByText('one-liner')).toBeTruthy()
|
||
})
|
||
|
||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||
// output accounting) — any billed input makes it a real 0%.
|
||
const nodes = [{
|
||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 },
|
||
}] as const
|
||
const snap = { chat: chatSnapshotFixture({ nodes }), nodes }
|
||
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 轮 · 1 步| 输入 0 tok · 输出 10 tok')
|
||
})
|
||
})
|