test(chat): cover work detail modes and preparation subscriptions

This commit is contained in:
imccyu committed 2026-09-23 19:48:45 +08:00
1 parent d6fb1351b3
commit ac77cefba7
11 files changed
+217 -55

No files matched your search

@@ -16,13 +16,14 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
? node.location.turn
: undefined
const [now, setNow] = useState(Date.now)
const ticking = turn?.status === 'open'
const ticking = turn?.status === 'open' && turn.start !== undefined
useEffect(() => {
if (!ticking) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
}, [ticking])
if (turn?.start === undefined && turn?.status !== 'closed') return null
const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
const running = turn?.status === 'open'
const reason = turn?.end?.data.reason.kind
@@ -114,7 +114,7 @@ describe('Chat apply wiring', () => {
.find(entry => entry.options.id === 'transcript-view')!
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
expect(face.hooks.transcriptView.getSnapshot()).toBe('standard')
face.setTranscriptView('detailed')
expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed')
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed')
@@ -681,7 +681,7 @@ describe('ChatView', () => {
expect(parent?.tagName).toBe('DIV')
fireEvent.change(input, { target: { value: 'retained local input' } })
const observe = vi.spyOn(ResizeObserver.prototype, 'observe')
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
expect(input.value).toBe('retained local input')
@@ -690,7 +690,7 @@ describe('ChatView', () => {
}
fireEvent.click(groupHeader)
expect(groupHeader.getAttribute('aria-expanded')).toBe('false')
act(() => { h.setTranscriptView('expanded') })
act(() => { h.setTranscriptView('detailed') })
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
act(() => { h.setTranscriptView('compact') })
expect(view.queryByRole('textbox', { name: inside.key })).toBeNull()
@@ -737,10 +737,10 @@ describe('ChatView', () => {
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([true, true, true])
fireEvent.click(headers[0]!)
for (const mode of ['detailed', 'expanded', 'compact', 'expanded'] as const) {
for (const mode of ['standard', 'detailed', 'compact', 'detailed'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'expanded'])
expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'expanded'])
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'detailed'])
expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'detailed'])
expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots)
}
@@ -753,6 +753,16 @@ describe('ChatView', () => {
expect(bodies[2]!.hasAttribute('hidden')).toBe(true)
expect(bodies[0]!.hasAttribute('hidden')).toBe(false)
expect(bodies[1]!.hasAttribute('hidden')).toBe(true)
act(() => { h.setTranscriptView('verbose') })
expect(roots.every(root => root.closest('[hidden]') === null)).toBe(true)
expect(headers.every(header => header.closest('[hidden]') !== null)).toBe(true)
expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true)
expect(control.disabled).toBe(true)
expect(control.textContent).toContain(h.props.t('message.turnProcess.took', { duration: formatRunDuration(3_000, h.props.t) }))
fireEvent.click(control)
expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true)
expect([...view.container.querySelectorAll<HTMLElement>('[data-chat-group-key]')]).toEqual(roots)
})
it.each([
@@ -918,7 +928,7 @@ describe('ChatView', () => {
expect(headers).toHaveLength(40)
const titles = headers.map(header => header.textContent)
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
translate.mockClear()
act(() => { h.setTranscriptView(mode) })
expect(translate.mock.calls.filter(([key]) => key === 'message.stepProcess.thinking')).toHaveLength(0)
@@ -936,7 +946,7 @@ describe('ChatView', () => {
groupStore.publish()
})
translate.mockClear()
act(() => { h.setTranscriptView('detailed') })
act(() => { h.setTranscriptView('standard') })
expect(translate.mock.calls.filter(([key]) => key.startsWith('message.stepProcess.'))).toHaveLength(0)
})
@@ -1319,9 +1329,9 @@ describe('ChatView', () => {
})
it.each([
{ mode: 'compact', anchor: 'group' }, { mode: 'detailed', anchor: 'group' },
{ mode: 'expanded', anchor: 'group' },
{ mode: 'compact', anchor: 'node' }, { mode: 'detailed', anchor: 'node' }, { mode: 'expanded', anchor: 'node' },
{ mode: 'compact', anchor: 'group' }, { mode: 'standard', anchor: 'group' },
{ mode: 'detailed', anchor: 'group' },
{ mode: 'compact', anchor: 'node' }, { mode: 'standard', anchor: 'node' }, { mode: 'detailed', anchor: 'node' },
] as const)(
'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', ({ mode, anchor }) => {
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
@@ -1387,14 +1397,14 @@ describe('ChatView', () => {
it.each([
{ mode: 'compact', hasProcess: false, hasSteering: false },
{ mode: 'standard', hasProcess: false, hasSteering: false },
{ mode: 'detailed', hasProcess: false, hasSteering: false },
{ mode: 'expanded', hasProcess: false, hasSteering: false },
{ mode: 'compact', hasProcess: true, hasSteering: false },
{ mode: 'standard', hasProcess: true, hasSteering: false },
{ mode: 'detailed', hasProcess: true, hasSteering: false },
{ mode: 'expanded', hasProcess: true, hasSteering: false },
{ mode: 'compact', hasProcess: false, hasSteering: true },
{ mode: 'standard', hasProcess: false, hasSteering: true },
{ mode: 'detailed', hasProcess: false, hasSteering: true },
{ mode: 'expanded', hasProcess: false, hasSteering: true },
] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', ({ mode, hasProcess, hasSteering }) => {
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
const initial = {
@@ -1716,7 +1726,7 @@ describe('ChatView', () => {
expect(view.getAllByText('即发即显')).toHaveLength(1)
})
it.each(['compact', 'detailed', 'expanded'] as const)(
it.each(['compact', 'standard', 'detailed'] as const)(
'keeps the opening echo above a new Turn title and steering below it (%s)', (mode) => {
const opening = {
requestId: 'opening' as never, placement: 'transcript' as const,
@@ -1825,7 +1835,7 @@ describe('ChatView', () => {
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
it.each(['compact', 'detailed', 'expanded'] as const)(
it.each(['compact', 'standard', 'detailed'] as const)(
'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => {
const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({
id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never },
@@ -2345,7 +2355,7 @@ describe('ChatView', () => {
expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
})
it.each(['compact', 'detailed', 'expanded'] as const)(
it.each(['compact', 'standard', 'detailed'] as const)(
'preserves steering-separated process groups in %s mode after Turn completion', (mode) => {
const nodes = [
userInTurn(1, 'question', 1),
@@ -2389,7 +2399,7 @@ describe('ChatView', () => {
view.getByText('second direction').closest('[data-chat-flow-kind]'),
roots[2], view.getByText('final answer').closest('[data-chat-flow-kind]'),
])
for (const next of ['compact', 'detailed', 'expanded', mode] as const) {
for (const next of ['compact', 'standard', 'detailed', mode] as const) {
act(() => { h.setTranscriptView(next) })
expect(visibleOrder()).toEqual(order)
expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots)
@@ -2476,14 +2486,14 @@ describe('ChatView', () => {
expect(processRow.getAttribute('hidden')).toBe('until-found')
const toggle = turnProcessControl(view.container)!
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
}
fireEvent.click(toggle)
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
@@ -2674,7 +2684,7 @@ describe('ChatView', () => {
const toggle = view.getByRole('button', { name: h.props.t('message.turnProcess.worked') })
let row = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:tool:partial"]')!
let group = view.container.querySelector<HTMLElement>('[data-chat-group-key]')
for (const mode of ['compact', 'detailed', 'expanded'] as const) {
for (const mode of ['compact', 'standard', 'detailed'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(row.getAttribute('hidden')).toBe('until-found')
@@ -2689,7 +2699,7 @@ describe('ChatView', () => {
const beforePageGroup = group
const groupToggle = group?.querySelector<HTMLButtonElement>('[data-process-activity]')
if (grouped && manualOpen) {
act(() => { h.setTranscriptView('detailed') })
act(() => { h.setTranscriptView('standard') })
fireEvent.click(groupToggle!)
expect(groupToggle?.getAttribute('aria-expanded')).toBe('true')
}
@@ -17,7 +17,14 @@ describe('ui-chat Host settings', () => {
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' })
await configuration.update({ transcriptView: 'normal' })
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' })
await expect(configuration.update({ transcriptView: 'dense' })).rejects.toThrow()
for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) {
await configuration.update({ transcriptView: mode })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode })
}
for (const mode of ['dense', '', 42, false, {}, null, undefined]) {
await configuration.update({ transcriptView: mode })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: 'standard' })
}
await configuration.update({ performanceUsage: 'compact' })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' })
await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow()
@@ -10,12 +10,13 @@ afterEach(cleanup)
describe('Chat presentation policy', () => {
it.each([
['compact', 'collapsed', false, false],
['detailed', 'collapsed', true, true],
['expanded', 'none', true, true],
] as const)('maps %s to stable presentation capabilities', (mode, stepGrouping, settledReasoningPreview, liveProcessDetail) => {
['compact', true, 'collapsed', false, false],
['standard', true, 'collapsed', true, true],
['detailed', true, 'history', true, true],
['verbose', false, 'none', true, false],
] as const)('maps %s to stable presentation capabilities', (mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail) => {
const policy = presentationPolicyFor(mode)
expect(policy).toEqual({ mode, foldCompletedTurns: true, stepGrouping, settledReasoningPreview, liveProcessDetail })
expect(policy).toEqual({ mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail })
expect(presentationPolicyFor(mode)).toBe(policy)
})
@@ -29,7 +30,7 @@ describe('Chat presentation policy', () => {
expect(listener).toHaveBeenCalledTimes(1)
expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed'))
dispose()
mode.set('expanded')
mode.set('verbose')
expect(listener).toHaveBeenCalledTimes(1)
})
@@ -54,7 +55,7 @@ describe('Chat presentation policy', () => {
act(() => { mode.set('detailed') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(2)
act(() => { mode.set('expanded') })
act(() => { mode.set('standard') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(2)
act(() => { mode.set('compact') })
@@ -45,14 +45,14 @@ describe('ReasoningRow', () => {
it('retains summaries and expanded Markdown when the work-details mode changes', () => {
const reset = createSnapshotStore(0)
const useDisclosure = bindDisclosure(reset)
const mode = createSnapshotStore<TranscriptViewMode>('detailed')
const mode = createSnapshotStore<TranscriptViewMode>('standard')
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode))
const view = render(<ReasoningRow useDisclosure={useDisclosure}
text={'First line\n\nDetailed body'} running={false} usePresentation={usePresentation} t={t} />)
const root = view.container.querySelector('[data-variant="think"]')!
const summary = view.getByText('First line')
const toggle = view.getByRole('button')
for (const next of ['compact', 'detailed', 'expanded', 'compact'] as const) {
for (const next of ['compact', 'standard', 'detailed', 'verbose', 'compact'] as const) {
act(() => { mode.set(next) })
expect(view.getByText('First line')).toBe(summary)
expect(root.hasAttribute('data-preview')).toBe(next !== 'compact')
@@ -60,7 +60,7 @@ describe('ReasoningRow', () => {
}
fireEvent.click(toggle)
const body = view.getByText('Detailed body')
for (const next of ['detailed', 'expanded', 'compact'] as const) {
for (const next of ['standard', 'detailed', 'verbose', 'compact'] as const) {
act(() => { mode.set(next) })
expect(view.getByText('Detailed body')).toBe(body)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
@@ -5,7 +5,7 @@ import type { ChatSettings } from '../src/chat-settings.ts'
import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
describe('TranscriptViewPolicy', () => {
it('defaults to Compact and publishes explicit choices before persistence settles', () => {
it('defaults to Standard and publishes explicit choices before persistence settles', () => {
const host = stubConfigForm<ChatSettings>()
const observed: string[] = []
let current = (): string => 'unconstructed'
@@ -19,20 +19,20 @@ describe('TranscriptViewPolicy', () => {
const policy = new TranscriptViewPolicy(scope)
current = () => policy.mode.getSnapshot()
expect(policy.mode.getSnapshot()).toBe('compact')
expect(policy.mode.getSnapshot()).toBe('standard')
policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('detailed')
expect(observed).toEqual(['transcriptView=detailed:detailed'])
expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed')
})
it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => {
it('adopts Host state, reads normal as Standard, and ignores identical writes', () => {
const host = stubConfigForm<ChatSettings>()
const policy = new TranscriptViewPolicy(host.scope)
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(policy.mode.getSnapshot()).toBe('detailed')
policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('standard')
policy.setMode('standard')
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
@@ -42,7 +42,17 @@ describe('TranscriptViewPolicy', () => {
it('adopts an accepted section standing at construction', () => {
const host = stubConfigForm<ChatSettings>()
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded')
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('detailed')
expect(host.set).not.toHaveBeenCalled()
})
it.each(['compact', 'standard', 'detailed', 'verbose'] as const)('preserves an explicit %s setting without migration writes', (mode) => {
const host = stubConfigForm<ChatSettings>()
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
const policy = new TranscriptViewPolicy(host.scope)
expect(policy.mode.getSnapshot()).toBe(mode)
expect(host.set).not.toHaveBeenCalled()
policy.dispose()
})
})
@@ -34,7 +34,7 @@ function noPendingInteraction() {
// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) {
function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | typeof zh = en) {
const source = createSnapshotStore<TranscriptViewMode>(mode)
const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) })
const props: TranscriptViewRowProps = {
@@ -53,20 +53,22 @@ function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typ
}
describe('TranscriptViewRow', () => {
it('explains the preference and shows Compact by default', () => {
it('explains the preference and shows Standard by default', () => {
mount()
expect(screen.getByText('Work details')).toBeDefined()
expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined()
expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.getByText('Choose how much detail to show for tool calls')).toBeDefined()
expect(screen.getByRole('button', { name: /Standard/ }).getAttribute('aria-expanded')).toBe('false')
})
it.each([
['compact', 'Compact'],
['detailed', 'Detailed'],
['expanded', 'Expanded'],
['verbose', 'Verbose'],
] as const)('selects %s and follows the mirrored value', (mode, label) => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: /Compact/ }))
fireEvent.click(screen.getByRole('button', { name: /Standard/ }))
expect(screen.queryByRole('menuitem', { name: 'Normal' })).toBeNull()
expect(screen.queryByRole('menuitem', { name: 'Expanded' })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: label }))
expect(b.setTranscriptView).toHaveBeenCalledWith(mode)
const trigger = screen.getByRole('button', { name: label })
@@ -76,21 +78,25 @@ describe('TranscriptViewRow', () => {
expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
})
it('shows all three work-detail values in Chinese', () => {
it('shows all four work-detail values in Chinese', () => {
const b = mount('compact', zh)
expect(screen.getByText('工作过程展示')).toBeDefined()
expect(screen.getByText('工作步骤展示')).toBeDefined()
expect(screen.getByText('选择希望看到多少工具调用细节')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: '简洁' }))
fireEvent.click(screen.getByRole('menuitem', { name: '标准' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('standard')
fireEvent.click(screen.getByRole('button', { name: '标准' }))
fireEvent.click(screen.getByRole('menuitem', { name: '详细' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('detailed')
fireEvent.click(screen.getByRole('button', { name: '详细' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('expanded')
expect(b.setTranscriptView).toHaveBeenLastCalledWith('verbose')
expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined()
})
it('returns focus before publishing a mode change without refocusing after the menu closes', async () => {
const b = mount()
const trigger = screen.getByRole('button', { name: /Compact/ })
const trigger = screen.getByRole('button', { name: /Standard/ })
fireEvent.click(trigger)
const item = screen.getByRole('menuitem', { name: 'Detailed' })
item.focus()
@@ -173,7 +179,7 @@ describe('PerformanceUsageRow', () => {
fireEvent.click(screen.getByRole('button', { name: 'Detailed' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Compact' }))
expect(setPerformanceUsage).toHaveBeenCalledWith('compact')
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(1)
expect(b.setTranscriptView).not.toHaveBeenCalled()
})
})
@@ -207,6 +207,7 @@ describe('FileMutationRow diff card', () => {
})
const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
useToolCallArgumentsPartial: () => { throw new Error('dispatched calls must not subscribe to preparing arguments') },
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
@@ -27,7 +27,7 @@ function preparation(name: string): Props {
return {
phase: 'preparing', callId: 'call', toolName: name,
block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(),
t: makeTranslate(en, common), useDisclosure, useToolCallArgumentsPartial: vi.fn(() => ''), openFile: vi.fn(), loadImage: vi.fn(),
useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null),
} as Props
}
@@ -53,16 +53,19 @@ describe('argument-free tool preparation', () => {
expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull()
})
it('keeps the common write row mounted as complete arguments and a result arrive', () => {
it('replaces preparation with the dispatched row and retains that row through the result', () => {
const props = preparation('write')
const view = render(<FileMutationRow {...props} />)
const row = view.container.querySelector('[data-tool="write"]')
const preparingRow = view.container.querySelector('[data-tool="write"]')
vi.mocked(props.useToolCallArgumentsPartial).mockClear()
const started: StartedToolCall = {
phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [],
argsRaw: '{"file_path":"hello.txt","content":"hello"}',
}
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
const row = view.container.querySelector('[data-tool="write"]')
expect(row).not.toBe(preparingRow)
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
const result: ToolResultNode = {
@@ -71,6 +74,7 @@ describe('argument-free tool preparation', () => {
}
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
/** Call-scoped preparation subscriptions and file-mutation progress. */
import { useMemo } from 'react'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AssistantChatData, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import type { ToolCallHookContext } from '../src/client/contract/slots.ts'
import { bindToolCallArgumentsPartial } from '../src/client/tool/tool-call-arguments-partial.ts'
import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx'
afterEach(cleanup)
const standard = {} as Parameters<typeof bindToolCallArgumentsPartial>[0]
const data = (first: string, second = ''): AssistantChatData => ({
status: 'running', turn: 1, step: 1, time: 1,
blocks: [
{ kind: 'text', text: 'Preparing changes' },
{ kind: 'tool-call', callId: 'first', name: 'write', argsRaw: first },
{ kind: 'tool-call', callId: 'second', name: 'edit', argsRaw: second },
],
})
function source(initial: AssistantChatData | undefined) {
const store = createSnapshotStore<AssistantChatData | undefined>(initial)
const listeners = new Set<() => void>()
return {
set: store.set,
listeners,
getSnapshot: store.getSnapshot,
subscribe: (listener: () => void) => {
listeners.add(listener)
const dispose = store.subscribe(listener)
return () => { listeners.delete(listener); dispose() }
},
}
}
function Probe({ context, label, rendered }: { context: ToolCallHookContext; label: string; rendered: () => void }) {
const usePartial = useMemo(() => bindToolCallArgumentsPartial(standard, context), [context])
const raw = usePartial()
rendered()
return <output aria-label={label}>{raw}</output>
}
describe('tool argument prefix Hook', () => {
it('does not subscribe until used and refreshes only the call whose prefix changed', () => {
const assistant = source(data('{', 'second prefix'))
const first = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
expect(assistant.listeners.size).toBe(0)
const firstRendered = vi.fn()
const secondRendered = vi.fn()
const firstContext = { assistant, callId: 'first' }
const secondContext = { assistant, callId: 'second' }
const view = render(<>
<Probe context={firstContext} label="first" rendered={firstRendered} />
<Probe context={secondContext} label="second" rendered={secondRendered} />
</>)
expect(first).toBeTypeOf('function')
expect(assistant.listeners.size).toBe(2)
firstRendered.mockClear()
secondRendered.mockClear()
act(() => { assistant.set(data('{"file_path":', 'second prefix')) })
expect(view.getByLabelText('first').textContent).toBe('{"file_path":')
expect(firstRendered).toHaveBeenCalled()
expect(secondRendered).not.toHaveBeenCalled()
firstRendered.mockClear()
act(() => { assistant.set(data('{"file_path":', 'more second prefix')) })
expect(firstRendered).not.toHaveBeenCalled()
expect(view.getByLabelText('second').textContent).toBe('more second prefix')
view.unmount()
expect(assistant.listeners.size).toBe(0)
})
it('handles missing sources and calls and detaches a replaced Step source', () => {
const old = source(undefined)
const next = source(data('new step'))
const rendered = vi.fn()
const view = render(<Probe context={{ assistant: old, callId: 'missing' }} label="raw" rendered={rendered} />)
expect(view.getByLabelText('raw').textContent).toBe('')
act(() => { old.set(data('unrelated')) })
expect(view.getByLabelText('raw').textContent).toBe('')
view.rerender(<Probe context={{ assistant: next, callId: 'first' }} label="raw" rendered={rendered} />)
expect(old.listeners.size).toBe(0)
expect(next.listeners.size).toBe(1)
expect(view.getByLabelText('raw').textContent).toBe('new step')
view.rerender(<Probe context={{ assistant: undefined, callId: 'first' }} label="raw" rendered={rendered} />)
expect(next.listeners.size).toBe(0)
expect(view.getByLabelText('raw').textContent).toBe('')
view.unmount()
})
it.each([
['write', en, 'Preparing content'], ['edit', zh, '正在准备内容'],
] as const)('%s subscribes only during preparation and rounds raw length upward', (toolName, dictionary, label) => {
const assistant = source(data(''))
const usePartial = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
const props = {
phase: 'preparing', toolName, callId: 'first', useToolCallArgumentsPartial: usePartial,
block: { phase: 'preparing', callId: 'first', name: toolName, turn: 1, step: 1, time: 1, subCalls: [] },
useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), t: makeTranslate(dictionary),
} as Parameters<typeof FileMutationRow>[0]
const view = render(<FileMutationRow {...props} />)
for (const [length, kilobytes] of [[0, 0], [1, 1], [1024, 1], [1025, 2], [12 * 1024, 12]]) {
act(() => { assistant.set(data('中'.repeat(length!))) })
expect(view.getByText(`${label} ${kilobytes}KB`)).toBeTruthy()
expect(view.queryByRole('button')).toBeNull()
}
expect(assistant.listeners.size).toBe(1)
const started = { phase: 'start' as const, callId: 'first', name: toolName, turn: 1, step: 1, time: 2, subCalls: [], argsRaw: '{"file_path":"file.txt","content":"hello"}' }
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(assistant.listeners.size).toBe(0)
expect(view.getByText('file.txt')).toBeTruthy()
const result: ToolResultNode = { kind: 'tool-result', callId: 'first', seq: 3, time: 3, callTime: 2, call: { name: toolName, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [] }
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(assistant.listeners.size).toBe(0)
expect(view.getByText('file.txt')).toBeTruthy()
})
})