From ac77cefba75f05443172afa81f3c4e27cf16bf63 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 18:53:01 +0800 Subject: [PATCH] test(chat): cover work detail modes and preparation subscriptions --- .../src/client/chat/TurnProcessNodeView.tsx | 3 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 52 +++++--- .../client/ui-chat/tests/config.host.spec.ts | 9 +- .../tests/presentation-policy.client.spec.tsx | 15 ++- .../tests/reasoning-row.client.spec.tsx | 6 +- .../transcript-view-policy.client.spec.ts | 22 +++- .../tests/transcript-view-row.client.spec.tsx | 28 ++-- .../ui-tool/tests/diff-card.client.spec.tsx | 1 + .../tests/preparing-rows.client.spec.tsx | 12 +- ...ool-call-arguments-partial.client.spec.tsx | 122 ++++++++++++++++++ 11 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index c163938af8..b404787a1a 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -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 diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index d96660c893..f73e0dbd08 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -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') diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 3068bff875..78f45f1693 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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('[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('[data-chat-node-key="fixture:tool:partial"]')! let group = view.container.querySelector('[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('[data-process-activity]') if (grouped && manualOpen) { - act(() => { h.setTranscriptView('detailed') }) + act(() => { h.setTranscriptView('standard') }) fireEvent.click(groupToggle!) expect(groupToggle?.getAttribute('aria-expanded')).toBe('true') } diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index cd68309593..e8591c98b1 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -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() diff --git a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx index de1319c00c..dd0f2a9dd5 100644 --- a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx +++ b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx @@ -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') }) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 0d6b505ff9..5a875f31cb 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -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('detailed') + const mode = createSnapshotStore('standard') const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) const view = render() 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') diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 76351cea2b..28a9212054 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -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() 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() 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() 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() + 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() }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 1c36d876ab..7ba68130cb 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -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(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() }) }) diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 3f9282b29f..a80446e058 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -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, diff --git a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx index 1140e6498c..037a59a804 100644 --- a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx +++ b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx @@ -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() - 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() - 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() expect(view.container.querySelector('[data-tool="write"]')).toBe(row) + expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled() expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) diff --git a/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx new file mode 100644 index 0000000000..f1b82985cb --- /dev/null +++ b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx @@ -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[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(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 {raw} +} + +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(<> + + + ) + 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() + expect(view.getByLabelText('raw').textContent).toBe('') + act(() => { old.set(data('unrelated')) }) + expect(view.getByLabelText('raw').textContent).toBe('') + view.rerender() + expect(old.listeners.size).toBe(0) + expect(next.listeners.size).toBe(1) + expect(view.getByLabelText('raw').textContent).toBe('new step') + view.rerender() + 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[0] + const view = render() + 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() + 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() + expect(assistant.listeners.size).toBe(0) + expect(view.getByText('file.txt')).toBeTruthy() + }) +})