Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
_Kerman
2026-08-10 10:21:56 +08:00
3852 changed files with 105025 additions and 31302 deletions

View File

@@ -1,139 +0,0 @@
// @vitest-environment jsdom
/**
* ask_user_question toolview acceptance: `waiting` summary while running,
* answered-count from the result JSON once settled (skipped answers
* excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
* ASK_ABORTED, shared ToolRow state
* semantics for interrupted/failed calls, and generic fallbacks on
* malformed results.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// Export discipline: packages/client/AGENTS.md.
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, callView: null, resultView: null, ...over,
})
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
// Standard locale seat stub mirroring the real ns → common → key chain.
const t = makeTranslate(zh, commonZh)
function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
return {
callId: 'c1', toolName: 'ask_user_question', block, t,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as Parameters<typeof AskQuestionRow>[0]
}
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
describe('AskQuestionRow', () => {
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
expect(screen.getByText('提问')).toBeTruthy()
expect(screen.getByText('等待回答')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('settled result counts answered entries (selected choices or custom text)', () => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
{ id: 'a', selected: ['x'] },
{ id: 'b', selected: [], custom: 'freeform' },
{ id: 'c', selected: ['y', 'z'], custom: '' },
])))} />)
expect(screen.getByText('3/3 已回答')).toBeTruthy()
})
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
{ id: 'a', selected: ['x'] },
{ id: 'b', selected: [], custom: '' },
{ id: 'c' },
])))} />)
expect(screen.getByText('1/3 已回答')).toBeTruthy()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it.each([
{ label: 'non-JSON result text', text: 'oops' },
{ label: 'non-object result root', text: '"str"' },
{ label: 'null result root', text: 'null' },
{ label: 'missing answers array', text: '{"other":1}' },
{ label: 'null answer entries', text: '{"answers":[null]}' },
{ label: 'empty result content', text: null },
])('settled result falls back to the generic summary on $label', ({ text }) => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, text))} />)
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('user cancellation names the verdict instead of the generic failed shape', () => {
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
expect(screen.getByText('已取消')).toBeTruthy()
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('a turn abort while pending reads interrupted with stopped semantics', () => {
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
expect(screen.getByText('已中断')).toBeTruthy()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('an interrupted turn reads as stopped, not cancelled', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(screen.queryByText('已取消')).toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('other tool errors keep the generic summary with the error state', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null, { isError: true }))} />)
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('window-truncated result (call head lost) falls back to the callId summary', () => {
render(<AskQuestionRow {...rowProps(resultNode('', null, { call: null }))} />)
expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
})
it('leading toggle expands the raw args body', () => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])))} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
})
it('askQuestionToolview injects the toolview declaration directly', () => {
expect(askQuestionToolview.name).toBe('ask-question-toolview')
expect(askQuestionToolview.inject).toEqual(['slots'])
const register = vi.fn(() => () => undefined)
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
askQuestionToolview.apply({ slots: { inject, register } } as never)
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
expect(register).toHaveBeenCalledWith(
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
AskQuestionRow,
)
})
})

View File

@@ -1,35 +1,14 @@
// @vitest-environment jsdom
/**
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
* machinery, real renderer; data fed as fixtures) for surfaces that were
* previously pinned only by the assembled-app jsdom snapshots
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
*
* - the todo_write turn reaches BOTH surfaces through the product
* registrations (keyed toolview row in the flow, plan strip in the input
* dock via the 'todos' projection) and the strip follows projection
* retirement;
* - the bash keyed row carries its resident terminal card, and the fallback
* row reaches the same card through its expand control;
* - the resident composer textarea survives the blank→active conversion as
* the SAME DOM node (focus/IME continuity rides React reconciliation:
* component identity + tree position, which this assembled tree pins).
*
* Component-level behavior (collapse interaction, card model arms, summary
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
* suite only proves the assembled wiring.
*/
/** Conversation assembly acceptance independent of Tool presentation. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const SID = 's1' as SessionId
@@ -50,30 +29,6 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const TODOS: TodoItem[] = [
{ content: '梳理需求', status: 'completed' },
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
...over,
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
@@ -84,7 +39,6 @@ const LAYOUT_CHILDREN = {
'details': { kind: 'single', scope: 'session' },
} as const
/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
const [count, setCount] = useState(0)
return (
@@ -94,7 +48,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
)
}
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
@@ -104,7 +58,7 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
snapshot: {
nodes,
nodes: [],
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
},
session: {
@@ -117,69 +71,6 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
return runtime
}
describe('todo_write assembly (product registrations, no outlet twins)', () => {
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
const runtime = await bench([todoResult(3)])
// The dock strip reads the host-computed 'todos' projection.
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
const view = runtime.renderRoot()
// Keyed toolview registration took the row (summary derived from args).
const row = view.container.querySelector('[data-tool="todo_write"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
// The plan strip sits in the input dock, fed by the projection
// (default-collapsed: the header summary shows; rows appear on expand).
const panel = view.container.querySelector('[data-testid="todo-panel"]')
expect(panel).not.toBeNull()
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
fireEvent.click(panel!.querySelector('button')!)
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
.toEqual(['completed', 'in_progress', 'pending'])
// Next turn retires the standing plan (host pushes null): the strip
// clears while the historical row stays in the flow.
await runtime.flush()
runtime.sessions.behavior(SID).projections.set('todos', null)
await waitFor(() => {
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
})
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
await runtime.dispose()
})
})
describe('terminal card assembly', () => {
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
const runtime = await bench([
bashResult(3, 'c-keyed'),
// An unregistered tool with terminal views: GenericToolCard fallback.
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
])
const view = runtime.renderRoot()
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
const keyedRow = view.container.querySelector('[data-sample="bash"]')
const keyed = keyedRow?.parentElement
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(keyedRow!)
await waitFor(() => {
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
})
// Fallback row: same unified expand interaction.
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
expect(fallback).not.toBeNull()
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
await waitFor(() => {
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
})
await runtime.dispose()
})
})
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
@@ -190,8 +81,6 @@ describe('resident composer', () => {
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
// No session entity: the inert twin renders (disabled textarea), and the
// workspace picker chip is the only live control.
const textarea = view.container.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea!.disabled).toBe(true)
@@ -242,12 +131,8 @@ describe('resident composer', () => {
await runtime.dispose()
})
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
const runtime = await bench([], { blank: true })
// The hero renders the LIVE composer only when the blank session's
// workspace resolves a chip title; an ownerless blank session shows the
// disabled twin instead (deleted-workspace semantics).
const runtime = await bench({ blank: true })
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
})
@@ -256,13 +141,11 @@ describe('resident composer', () => {
expect(hero).not.toBeNull()
expect(hero!.disabled).toBe(false)
// First acceptance: the session leaves blank and the composer docks.
await runtime.sessions.updateSnapshot(SID, (draft) => {
draft.blank = false
draft.composerPhase = 'active'
})
const docked = view.container.querySelector('textarea')
expect(docked).toBe(hero)
expect(view.container.querySelector('textarea')).toBe(hero)
await runtime.dispose()
})
})
@@ -291,8 +174,6 @@ describe('prompt rejection through the assembled composer', () => {
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
// The rejection lands in snapshot.promptError (the Session's own path);
// the fixture mirrors that hop — the assembled InputBar renders it.
await runtime.sessions.updateSnapshot(SID, (draft) => {
draft.promptError = {
op: 'send',
@@ -301,7 +182,6 @@ describe('prompt rejection through the assembled composer', () => {
})
const alert = await view.findByRole('alert')
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
// Failure restore: the machine returned the draft to the same textarea.
await waitFor(() => {
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
})
@@ -311,7 +191,7 @@ describe('prompt rejection through the assembled composer', () => {
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the current-session crumb', async () => {
const runtime = await bench([])
const runtime = await bench()
const view = runtime.renderRoot()
const hierarchy = view.getByRole('navigation', { name: '会话层级' })
expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)

View File

@@ -1,12 +1,9 @@
// @vitest-environment jsdom
// apply wiring: the conversation service provided, the chat view registered
// as the first 'conversation.view' ring entry declaring the keyed toolview
// hole, the slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all strict
// session entries, and the bash sample + todo row mount through declaration
// injection as keyed entries. Full-chain rendering belongs to the
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
// stops at the assembly surface.
// as the first 'conversation.view' ring entry declaring the whole-Tool seat,
// the slot registrations land against a root entry's children declarations
// (the AppFrame role), and the shared store handle rides all strict session
// entries. Tool composition belongs to ui-tool and its machinery spec.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
@@ -56,7 +53,7 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
it('registers the chat view and its keyed business-node seat', async () => {
const b = await bench()
const entries = b.slots.entries('conversation.view')
expect(entries.map(e => e.options.id)).toEqual(['chat'])
@@ -65,7 +62,9 @@ describe('apply wiring', () => {
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
const nodeSlot = b.slots.spec('conversation.chat.node')
expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' })
expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function')
await b.runtime.dispose()
})
@@ -92,14 +91,13 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the tool rows as keyed entries through declaration injection', async () => {
it('leaves per-Tool rows to the ui-tool plugin', async () => {
const b = await bench()
// The actual toolview declaration activates every registrant. The
// file-mutation registrant claims both write and edit for the diff card; the
// one search row registers under both grep and glob; the web rows register
// one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call')
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()
@@ -112,8 +110,8 @@ describe('apply wiring', () => {
// The declared ring collapses with its declaring entry, and the chat
// entry's keyed hole (with the sample's registration) collapses with it.
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('conversation.chat.node')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.node')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
expect(b.runtime.ctx.get('conversation')).toBeUndefined()

View File

@@ -10,13 +10,21 @@ 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 { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView,
UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
@@ -33,7 +41,44 @@ afterEach(() => {
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
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 } 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', () => {
@@ -226,7 +271,7 @@ describe('MessageItem arms', () => {
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 provenance follows it as fields.
// 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)
@@ -418,7 +463,7 @@ describe('MessageItem arms', () => {
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
})
it('bounds an oversized provenance field, not only the model-facing text', () => {
it('bounds an oversized source field, not only the model-facing text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
@@ -691,11 +736,15 @@ describe('MessageItem arms', () => {
<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')
@@ -704,8 +753,11 @@ describe('MessageItem arms', () => {
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a marker whose provenance fell outside the window is not expandable', () => {
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
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()
@@ -720,9 +772,9 @@ describe('MessageItem arms', () => {
const view = render(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 5,
time: 10_000,
retryState: 'scheduled',
@@ -754,9 +806,9 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 6,
time: 12_100,
retryState: 'scheduled',
@@ -781,6 +833,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 6,
time: 12_100,
retryState: 'started',
@@ -802,6 +855,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 7,
time: 12_100,
retryState: 'started',
@@ -821,6 +875,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 8,
time: 12_100,
retryState: 'cancelled',
@@ -839,31 +894,6 @@ describe('MessageItem arms', () => {
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消1/2 · 4s')
})
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const node = {
kind: 'model-retry',
seq: 5,
time: 10_000,
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 5_000,
failure: { code: 'TRANSPORT', message: '连接被重置' },
} as const
const view = render(<MessageItem t={t} node={node} />)
expect(view.getByRole('status').textContent).toBe('等待重试模型请求1/2 · 5s')
act(() => { vi.advanceTimersByTime(4_200) })
view.rerender(<MessageItem t={t} node={node} retryActive />)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
})
})
describe('formatMessageClock', () => {
@@ -924,84 +954,13 @@ describe('small branch tails', () => {
expect(view.getByText('one-liner')).toBeTruthy()
})
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
seq={3}
onFork={onFork}
/>,
)
expect(settled.getByText('14:24')).toBeTruthy()
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
fireEvent.click(settled.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('answer body')
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(3)
settled.unmount()
const thinkOnly = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
/>,
)
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
expect(thinkOnly.queryByText('14:24')).toBeNull()
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
expect(streaming.queryByText('14:24')).toBeNull()
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
streaming={false}
time={1_000}
seq={1}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
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 snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
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

View File

@@ -1,297 +0,0 @@
// @vitest-environment jsdom
// Code Mode sub-call acceptance on the REAL machinery stack (same bench as
// chat-toolview-slot.spec): a run_code result renders the 'code' variant row
// (description summary, program body), its logged sub-dispatches render as
// always-visible nested rows through the SAME keyed toolview hole — the bash
// sub-call lands in the bash sample plugin's registration exactly like a
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
// and a file sub-row click opens the host path. Running parents
// (runningCalls) nest their so-far dispatches the same way.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the notes directory' })
const codeResult = (seq: number, callId: string): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
})
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
})
function snapshotWith(
nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
async function bench(snapshot: ConversationSnapshot) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
// Provide-channel contributions land in this bundle the way the runtime
// materializes them; the renderer host serves it through provideInfo.
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
// materialized on first render after the provide contributions landed.
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
const sessionsFake = {
list,
binding: (id: SessionId) => (id === SID
? { sessionId: SID, session, ctx: { effect: () => {}, on: () => () => {} } }
: undefined),
scope: () => ({ get: () => scoped }),
scopeOf: () => SID,
provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> } }) => {
const contribution = descriptor.resolve(sessionsFake.binding(SID))
Object.assign(provided.hooks, contribution.hooks ?? {})
Object.assign(provided.props, contribution.props ?? {})
return () => {}
},
provideInfo: (id: string) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: undefined),
currentProvideInfo: {
getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
subscribe: () => () => {},
},
create: vi.fn(),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
slots.installLocale(locale)
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, layout, workspaces }
}
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description.
const codeRoot = view.container.querySelector('[data-variant="code"]')
expect(codeRoot).not.toBeNull()
expect(view.getByText('Code')).toBeTruthy()
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
// description chrome, same as a top-level bash row — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = nest.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:
// assert the whole text and the highlighted tree rather than one node.
const pre = view.container.querySelector('pre.shiki')
expect(pre).not.toBeNull()
expect(pre!.textContent).toContain('const listing = await tools.bash')
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
})
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull()
})
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
})
view.getByText('List notes').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
})
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live'
const dispatches = new Map([[parent, [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull()
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
})
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: CodeSubCall = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null,
}
const dispatches = new Map([[parent, [runningSub]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
it('an ordinary tool row renders no sub-call nest', async () => {
const parent = 'call-64'
const plain: ToolResultNode = {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
}
const b = await bench(snapshotWith([plain], new Map()))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})
})

View File

@@ -0,0 +1,298 @@
import type {
AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore,
ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall,
ToolCallBlock, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
const EMPTY: readonly never[] = []
function sameValues<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function nodeSource(node: ChatConversationViewNode): unknown {
if (node.kind === 'assistant-step') {
const data = node.data as ReturnType<typeof assistantData>
return data.finalNode ?? data.blocks
}
if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root
if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current
if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq
return node.data
}
class FixtureNodeStore implements ChatNodeStore {
private byKey = new Map<string, ChatConversationViewNode>()
private list: readonly ChatConversationViewNode[] = EMPTY
get(key: string): ChatConversationViewNode | undefined {
return this.byKey.get(key)
}
values(): readonly ChatConversationViewNode[] {
return this.list
}
replace(candidates: readonly ChatConversationViewNode[]): void {
const next = new Map<string, ChatConversationViewNode>()
const list = candidates.map((candidate) => {
const previous = this.byKey.get(candidate.key)
const node = previous !== undefined
&& previous.kind === candidate.kind
&& previous.anchorSeq === candidate.anchorSeq
&& previous.visibility === candidate.visibility
&& nodeSource(previous) === nodeSource(candidate)
? previous
: candidate
next.set(node.key, node)
return node
})
this.byKey = next
this.list = sameValues(this.list, list) ? this.list : list
}
}
class FixtureLocationIndex implements ChatLocationNodeIndex {
private turns = new Map<number, readonly string[]>()
getTurn(turn: number): readonly string[] {
return this.turns.get(turn) ?? EMPTY
}
getStep(): readonly string[] {
return EMPTY
}
replace(next: ReadonlyMap<number, readonly string[]>): void {
const stable = new Map<number, readonly string[]>()
for (const [turn, keys] of next) {
const previous = this.turns.get(turn) ?? EMPTY
stable.set(turn, sameValues(previous, keys) ? previous : keys)
}
this.turns = stable
}
}
class FixtureTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
): Readonly<ConversationTurnDataMap[Key]> | undefined {
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
}
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
value: ConversationTurnDataMap[Key],
): void {
this.values.set(key, value)
}
}
function assistantData(node: AssistantMessageNode) {
return {
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
turn: node.turn,
step: node.step,
blocks: node.blocks,
time: node.time,
finalNode: node,
}
}
function settledNode(
node: ConversationNode,
turns: ReadonlyMap<number, TurnLocation>,
): ChatConversationViewNode {
const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined
const base = {
key: `fixture:${node.kind}:${node.seq}`,
id: String(node.seq),
target: 'chat' as const,
anchorSeq: node.seq,
location: turn === undefined
? { kind: 'session' as const }
: { kind: 'turn' as const, turn },
visibility: 'visible' as const,
}
switch (node.kind) {
case 'assistant':
return { ...base, kind: 'assistant-step', data: assistantData(node) }
case 'tool-result':
return { ...base, key: `fixture:tool:${node.callId}`, kind: 'tool-call', data: { root: node } }
case 'model-retry':
return { ...base, key: 'fixture:model-retry', kind: 'model-retry', data: { attempts: [node], current: node } }
default:
return { ...base, kind: node.kind, data: node }
}
}
/** Build the canonical Chat fixture corresponding to one legacy test slice. */
export function chatSnapshotFixture(input: {
readonly nodes?: readonly ConversationNode[]
readonly partial?: PartialAssistant | null
readonly runningCalls?: readonly RunningToolCall[]
readonly turnTimings?: LegacyConversationSlice['turnTimings']
readonly turnEnds?: LegacyConversationSlice['turnEnds']
} = {}, previous?: ChatSnapshot): ChatSnapshot {
const legacy: LegacyConversationSlice = {
nodes: input.nodes ?? EMPTY,
partial: input.partial ?? null,
runningCalls: input.runningCalls ?? EMPTY,
turnTimings: input.turnTimings ?? new Map(),
turnEnds: input.turnEnds ?? new Map(),
}
const turnNumbers = new Set([...legacy.turnTimings.keys(), ...legacy.turnEnds.keys()])
for (const node of legacy.nodes) {
if ('turn' in node && typeof node.turn === 'number') turnNumbers.add(node.turn)
}
if (legacy.partial !== null) turnNumbers.add(legacy.partial.turn)
for (const call of legacy.runningCalls) turnNumbers.add(call.turn)
const turns = new Map<number, TurnLocation>()
const turnData = new Map<number, FixtureTurnDataStore>()
for (const turn of [...turnNumbers].sort((left, right) => left - right)) {
const timing = legacy.turnTimings.get(turn)
const endSeq = legacy.turnEnds.get(turn)
const data = new FixtureTurnDataStore()
turnData.set(turn, data)
turns.set(turn, {
turn,
start: timing === undefined ? undefined : {
type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn,
} as never,
end: timing?.endTime === undefined || endSeq === undefined ? undefined : {
type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed',
} as never,
status: endSeq === undefined ? 'open' : 'closed',
steps: EMPTY,
data,
})
}
const linkedCompactions = new Set<CompactionSummaryNode>()
const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => {
if (node.kind === 'command' && node.name === 'compact') {
const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined
const candidates = sourceSeq === undefined
? []
: legacy.nodes.filter((candidate): candidate is CompactionSummaryNode =>
candidate.kind === 'compaction' && candidate.summaryEventSeq === sourceSeq)
const compaction = candidates.length === 1 ? candidates[0] : undefined
if (node.outcome === null || compaction !== undefined) {
if (compaction !== undefined) linkedCompactions.add(compaction)
const base = settledNode(node, turns)
return [{
...base,
key: `fixture:manual-compaction:${node.commandId}`,
kind: 'manual-compaction',
anchorSeq: compaction?.seq ?? node.seq,
data: { command: node, compaction: compaction ?? null },
}]
}
}
if (node.kind === 'compaction' && linkedCompactions.has(node)) return []
return [settledNode(node, turns)]
})
if (legacy.partial !== null) {
const turn = turns.get(legacy.partial.turn)
nodes.push({
key: `fixture:assistant:${legacy.partial.turn}:${legacy.partial.step}`,
id: `${legacy.partial.turn}:${legacy.partial.step}`,
target: 'chat',
kind: 'assistant-step',
anchorSeq: Number.MAX_SAFE_INTEGER - 1,
location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn },
visibility: 'visible',
data: {
status: 'running',
turn: legacy.partial.turn,
step: legacy.partial.step,
blocks: legacy.partial.blocks,
time: 0,
},
})
}
for (const call of legacy.runningCalls) {
const turn = turns.get(call.turn)
nodes.push({
key: `fixture:tool:${call.callId}`,
id: call.callId,
target: 'chat',
kind: 'tool-call',
anchorSeq: Number.MAX_SAFE_INTEGER,
location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn },
visibility: 'visible',
data: { root: call },
})
}
for (const [turnNumber, endSeq] of legacy.turnEnds) {
const turn = turns.get(turnNumber)
const dataStore = turnData.get(turnNumber)
if (turn === undefined || dataStore === undefined) continue
const closing = legacy.nodes
.filter((candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant'
&& candidate.turn === turnNumber
&& candidate.blocks.some(block => block.kind === 'text' && block.text.trim() !== ''))
.map(assistantData)
.at(-1) ?? null
const preceding = nodes.findLast((candidate) => {
const location = candidate.location
return (location.kind === 'turn' || location.kind === 'step')
&& location.turn.turn === turnNumber
})
const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber)
const tailData = {
turn: turnNumber,
seq: endSeq,
time: turn.end?.time ?? 0,
closing,
branchUnavailable: closing === null
|| preceding?.kind !== 'assistant-step'
|| (preceding.data as ReturnType<typeof assistantData>).finalNode.seq !== closing.finalNode.seq,
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
}
dataStore.set('turn-tail', tailData)
nodes.push({
key: `fixture:turn-tail:${turnNumber}`,
id: String(turnNumber),
target: 'chat',
kind: 'turn-tail',
anchorSeq: endSeq,
location: { kind: 'turn', turn },
visibility: 'visible',
data: tailData,
})
}
const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore()
store.replace(nodes)
const byKey = new Map(store.values().map(node => [node.key, node]))
const nextOrder = nodes.map(node => node.key)
const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder
const byTurn = new Map<number, readonly string[]>()
for (const turn of turns.keys()) {
byTurn.set(turn, order.filter((key) => {
const location = byKey.get(key)?.location
return location?.kind === 'turn' && location.turn.turn === turn
|| location?.kind === 'step' && location.turn.turn === turn
}))
}
const locations = previous?.locations instanceof FixtureLocationIndex
? previous.locations
: new FixtureLocationIndex()
locations.replace(byTurn)
const timeline = previous !== undefined
&& previous.legacy.turnTimings === legacy.turnTimings
&& previous.legacy.turnEnds === legacy.turnEnds
? previous.timeline
: { turnOrder: [...turns.keys()], turns }
return {
order,
nodes: store,
locations,
timeline,
legacy,
}
}

View File

@@ -1,26 +1,22 @@
// @vitest-environment jsdom
// StatsLine (composer.dock entry): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
// chrome (Bash · description) without a row click target.
// StatsLine (composer.dock entry): totals derivation + the RFC hard
// acceptance — zero renders during streaming.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { en, zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
@@ -47,18 +43,39 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: chatSnapshotFixture(),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
}
function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const initial = { ...snapshotBase(), ...init }
let snap: ConversationSnapshot = {
...initial,
chat: init?.chat ?? chatSnapshotFixture({
nodes: initial.nodes,
partial: initial.partial,
runningCalls: initial.runningCalls,
turnTimings: initial.turnTimings,
turnEnds: initial.turnEnds,
}),
}
const subs = new Set<() => void>()
return {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
const merged = { ...snap, ...next }
snap = {
...merged,
chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({
nodes: merged.nodes,
partial: merged.partial,
runningCalls: merged.runningCalls,
turnTimings: merged.turnTimings,
turnEnds: merged.turnEnds,
})),
}
for (const fn of [...subs]) fn()
},
source: {
@@ -91,7 +108,7 @@ describe('deriveStats', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
isError: false, callView: null, resultView: null, subCalls: [],
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
@@ -109,7 +126,7 @@ describe('deriveStats', () => {
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
isError: false, callView: null, resultView: null,
isError: false, callView: null, resultView: null, subCalls: [],
}
const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500)
@@ -301,43 +318,3 @@ describe('StatsLine', () => {
expect(renders).toBe(before)
})
})
describe('bash sample row', () => {
const SID = 'root-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId,
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000,
content: [], isError: false, callView: null, resultView: null,
})
function listStore() {
return createSnapshotStore<SessionListState>({
ids: [SID],
byId: {
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
},
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
}
const rowProps = (): BashRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openFile: vi.fn(),
sessionId: SID,
useSessions: bindSnapshotSelector(listStore()),
t,
} as unknown as BashRowProps)
it('summarizes as Bash · description without a row click target', () => {
const view = render(<BashRow {...rowProps()} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.textContent).toContain('Bash')
expect(row.textContent).toContain('Build')
expect(row.getAttribute('data-clickable')).toBeNull()
})
})

View File

@@ -1,515 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, time: 1_000, callView: null, ...over,
})
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null, ...over,
})
describe('tool-call-model', () => {
it('classifies known tools and falls back to others', () => {
expect(classifyTool('bash')).toBe('bash')
expect(classifyTool('pwsh')).toBe('bash')
expect(classifyTool('read')).toBe('read')
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('cordis_inspect')).toBe('read')
expect(classifyTool('cordis_mount')).toBe('code')
expect(classifyTool('cordis_unmount')).toBe('others')
expect(classifyTool('todo_write')).toBe('others')
})
it('gives the pwsh shell row the bash family treatment with its own title', () => {
const m = toolRowModel('pwsh', running())
expect(m.variant).toBe('bash')
expect(m.title).toBe('Pwsh')
})
it('derives state across running/ok/error/interrupted', () => {
expect(toolRowModel('bash', running()).state).toBe('running')
expect(toolRowModel('bash', result()).state).toBe('ok')
expect(toolRowModel('bash', result({ isError: true })).state).toBe('error')
expect(toolRowModel('bash', result({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
})
it('derives the bash summary from description over command', () => {
const m = toolRowModel('bash', running())
expect(m.title).toBe('Bash')
expect(m.summary).toBe('List files')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
})
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
})
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
.toBeUndefined()
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
})
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
})
it('displays workspace-rooted paths relative to the session cwd', () => {
const cwd = '/Users/u/ws/'
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
// Paths outside the workspace (and non-path summaries) stay verbatim.
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
})
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
})
it('a code row with an empty program falls back to the args JSON envelope', () => {
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
.toBe('{\n "code": ""\n}')
})
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
.toBe('ToolError: denied')
expect(resultText(result({ content: [] }))).toBe('')
})
it('derives output from the settled result and null while running or blank', () => {
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
expect(toolRowModel('bash', running()).output).toBeNull()
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
})
it('derives errorSummary as the first output line on error rows only', () => {
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
argsRaw: '{"what":"api","name":"tools"}',
}))).toMatchObject({
variant: 'read',
title: 'Inspect',
summary: 'api',
})
expect(toolRowModel('cordis_mount', running({
name: 'cordis_mount',
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
}))).toMatchObject({
variant: 'code',
title: 'Mount temporary Plugin',
summary: 'return { name: "audit", apply(ctx) {} }',
body: 'return { name: "audit", apply(ctx) {} }',
})
expect(toolRowModel('cordis_unmount', result({
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({
variant: 'others',
title: 'Unmount temporary Plugin',
summary: 'dyn-2',
})
})
})
describe('ToolRow', () => {
const rowProps = {
t,
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
}
it('renders leading icon, title and summary while collapsed', () => {
const view = render(<ToolRow {...rowProps} />)
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List files')).toBeTruthy()
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
})
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.getByText('List files')).toBeTruthy()
expect(view.getByText(/"a": 1/)).toBeTruthy()
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
const runningView = render(<ToolRow {...rowProps} state="running" />)
expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
// The dot rides the idle slot, so an expandable error row keeps the
// icon→chevron hover preview instead of losing it with the icon.
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
})
it('non-expandable rows render a passive leading slot and no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} />)
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('the row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} />)
const row = view.getByRole('button')
fireEvent.keyDown(row, { key: 'Tab' })
expect(row.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(row, { key: 'Enter' })
expect(row.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(row, { key: ' ' })
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('file rows expand from the row while the path link opens without toggling', () => {
const open = vi.fn()
const view = render(
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
)
const row = view.getByRole('button', { name: /Read/ })
// Path click opens the file and leaves the row collapsed.
fireEvent.click(view.getByText('src/a.ts'))
expect(open).toHaveBeenCalledWith('src/a.ts')
expect(row.getAttribute('aria-expanded')).toBe('false')
// Row click (outside the link) expands the args body.
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
const view = render(
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
)
expect(view.container.querySelector('button')).toBeNull()
const row = view.getByRole('button')
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('non-file rows do not open anything when the summary is clicked', () => {
const open = vi.fn()
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
fireEvent.click(view.getByText('List files'))
expect(open).not.toHaveBeenCalled()
})
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
const view = render(
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
)
expect(view.getByText('boom')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.getByText(/detail/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')).not.toBeNull()
})
it('an error row without an error summary keeps the args summary', () => {
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
expect(view.getByText('List files')).toBeTruthy()
})
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
const summary = view.getByText('List files')
const suffix = view.getByText('+2')
// Separate spans: .summary truncates, the suffix must not travel inside it.
expect(summary.contains(suffix)).toBe(false)
view.unmount()
// The failure line replaces the summary wholesale, so the suffix goes with it.
const failed = render(
<ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
)
expect(failed.queryByText('+2')).toBeNull()
})
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
const open = vi.fn()
const view = render(
<ToolRow
{...rowProps}
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
filePath="src/a.ts" onOpenFile={open}
/>,
)
fireEvent.click(view.getByText('cannot overwrite'))
expect(open).not.toHaveBeenCalled()
// The failure line renders as plain text, not the underlined link button.
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
})
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
const inspect = vi.fn()
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
// Collapsed: no pill.
expect(view.queryByText('Inspect')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
const pill = view.getByText('Inspect')
fireEvent.click(pill)
expect(inspect).toHaveBeenCalledTimes(1)
// The pill click must not collapse the row (body is a .row sibling).
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
})
it('no inspect callback, no pill', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.getByRole('button'))
expect(view.queryByText('Inspect')).toBeNull()
})
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
const both = render(<ToolRow {...rowProps} output="result text" />)
fireEvent.click(both.getByRole('button'))
expect(both.getByText('IN')).toBeTruthy()
expect(both.getByText('OUT')).toBeTruthy()
expect(both.getByText('result text')).toBeTruthy()
cleanup()
const inputOnly = render(<ToolRow {...rowProps} />)
fireEvent.click(inputOnly.getByRole('button'))
expect(inputOnly.getByText('IN')).toBeTruthy()
expect(inputOnly.queryByText('OUT')).toBeNull()
cleanup()
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
fireEvent.click(outputOnly.getByRole('button'))
expect(outputOnly.queryByText('IN')).toBeNull()
expect(outputOnly.getByText('OUT')).toBeTruthy()
expect(outputOnly.getByText('only out')).toBeTruthy()
})
})
describe('ThinkRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
/>,
)
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
fireEvent.click(view.getByText('Think'))
// The summary (first line) is gone from the row; only the body carries it.
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), t,
})
it('renders the classified variant row from the frozen slice', () => {
const view = render(<GenericToolCard {...props('bash', result())} />)
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List files')).toBeTruthy()
expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
})
it('unknown tools land on the others variant titled Tool call', () => {
const view = render(
<GenericToolCard {...props('todo_write', running({ name: 'todo_write', argsRaw: '{"note":"x"}' }))} />,
)
expect(view.getByText('Tool call')).toBeTruthy()
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('renders edit with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('edit', running({
name: 'edit',
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
}))} />,
)
expect(view.getByText('Edit')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('renders write with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('write', running({
name: 'write',
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
}))} />,
)
expect(view.getByText('Write')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('passes the owner inspect callback through to the expanded row pill', () => {
const inspect = vi.fn()
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(inspect).toHaveBeenCalledTimes(1)
})
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const fileView = render(<GenericToolCard {...file} />)
fireEvent.click(fileView.getByText('src/x.ts'))
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
const bash = props('bash', result())
const bashView = render(<GenericToolCard {...bash} />)
fireEvent.click(bashView.getByText('List files'))
expect(bash.openFile).not.toHaveBeenCalled()
})
})

View File

@@ -1,226 +0,0 @@
// @vitest-environment jsdom
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
// renderer) + this package's own apply — no outlet twins. Proves the keyed
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
// entryKey (the bash sample lands through its plugin), unregistered tools
// fall back to GenericToolCard at the render site, live registration/unload
// flips rows in place, duplicate keys fail loud, the inject channel feeds
// (sessionId) => I into row components, and a registrant can activate before
// the declaration then land through slots.inject when the chat entry appears.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent } from '@testing-library/react'
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: args },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
const LAYOUT_CHILDREN = {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
} as const
/**
* Real-stack bench: SlotTestRuntime with the session/layout doubles at the
* service seams only (external boundaries), the package apply on its own
* fiber, and the test AppFrame occupying 'root'.
*/
async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layout)
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S' },
snapshot: { nodes },
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
const feature = await runtime.mount({ inject: [...inject], apply })
return { runtime, slots: runtime.slots, feature, layout }
}
describe('keyed toolview hole through the real machinery', () => {
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
const b = await bench([
toolResult(3, 'c1', 'bash'),
toolResult(4, 'c2', 'mystery', '{"n":1}'),
])
const view = b.runtime.renderRoot()
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()
await b.runtime.dispose()
})
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const b = await bench([
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
])
const view = b.runtime.renderRoot()
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = view.container.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
await b.runtime.dispose()
})
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
})
await b.runtime.dispose()
})
it('bash summary clicks do not open details or host paths', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = b.runtime.renderRoot()
view.getByText('Build').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
await b.runtime.dispose()
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
const view = b.runtime.renderRoot()
expect(view.getByText('Tool call')).toBeTruthy()
let dispose = (): void => {}
dispose = b.slots.register(
{ name: 'conversation.chat.toolview', key: 'mystery' },
() => <div data-testid="mystery-row" />)
await b.runtime.flush()
// Per-key version tick: the row flipped without a remount of the view.
expect(view.getByTestId('mystery-row')).toBeTruthy()
expect(view.queryByText('Tool call')).toBeNull()
dispose()
await b.runtime.flush()
expect(view.queryByTestId('mystery-row')).toBeNull()
expect(view.getByText('Tool call')).toBeTruthy()
await b.runtime.dispose()
})
it('a duplicate key registration fails loud at load', async () => {
const b = await bench([])
expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' },
() => null,
)).toThrow(/key "bash"/)
await b.runtime.dispose()
})
it('the inject channel feeds (sessionId) => I into the row component', async () => {
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
const poked: string[] = []
b.slots.register({
name: 'conversation.chat.toolview',
key: 'probe',
// Two-way business face: data derived from the session id out, a
// callback closing over it back in — the askuser-pattern inject shape.
inject: (sessionId: SessionId) => ({
mark: `for:${sessionId}`,
poke: () => { poked.push(sessionId) },
}),
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
<button data-testid="probe-row" onClick={poke}>{mark}</button>
))
const view = b.runtime.renderRoot()
const row = view.getByTestId('probe-row')
expect(row.textContent).toBe(`for:${SID}`)
row.click()
expect(poked).toEqual([SID])
await b.runtime.dispose()
})
})
describe('registrant declaration injection', () => {
it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation. Plugin apply runs,
// while slots.inject waits for the declaration itself.
let applyRuns = 0
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
applyRuns += 1
registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null))
}
const late = runtime.ctx.plugin({
name: 'late-registrant',
inject: ['slots'],
apply: registrantApply,
})
await Promise.resolve()
await late.await()
expect(applyRuns).toBe(1)
expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0)
// Mounting the package declares the slot and activates the waiting entry.
await runtime.mount({ inject: [...inject], apply })
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
.toEqual(expect.arrayContaining(['bash', 'late']))
await runtime.dispose()
})
})

View File

@@ -1,27 +1,36 @@
// @vitest-environment jsdom
// ChatView behavior: flow derivation, streaming isolation (Profiler counts),
// toolview dispatch and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire.
// Tool seat ownership and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire or Tool presentation plugin.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { useEffect } from 'react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
afterEach(() => {
cleanup()
@@ -34,10 +43,11 @@ beforeEach(() => {
})
const SID = 's1' as SessionId
type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -45,11 +55,21 @@ function snapshotBase(): ConversationSnapshot {
/** Scripted snapshot source: set() swaps the top-level object like the real Session. */
function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const initial = { ...snapshotBase(), ...init }
let snap: ConversationSnapshot = {
...initial,
chat: init?.chat ?? chatSnapshotFixture(initial),
}
const subs = new Set<() => void>()
return {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
const merged = { ...snap, ...next }
snap = {
...merged,
chat: Object.hasOwn(next, 'chat') && next.chat !== undefined
? next.chat
: chatSnapshotFixture(merged, snap.chat),
}
for (const fn of [...subs]) fn()
},
source: {
@@ -73,7 +93,8 @@ const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode =>
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
const retry = (seq: number): ModelRetryNode => ({
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
seq, time: seq * 1_000, turn: 1, step: 0,
retryState: 'scheduled',
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
retry: 1, maxRetries: 2, delayMs: 450,
@@ -88,10 +109,23 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
})
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummaryNode => ({
kind: 'compaction', seq: 8, time: 8_000,
summary: '## 压缩摘要\n\n保留的事实。',
summaryEventSeq: 7,
shadowedItemCount: 16,
shadowedTokenCount: 11_309,
...over,
})
/** Empty sessions-list hook for the global standard-kit seat. */
@@ -124,12 +158,99 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
// every tool lands on GenericToolCard); keyed dispatch to registered rows
// is the slot machinery's behavior, covered by its own specs.
const chat = createChatStore().create()
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
const t = makeTranslate(zh, commonZh)
const toolOwners: Array<{
callId: string
toolName: string
block: ToolCallBlock
selectedCallId: string | undefined
openFile: ChatNodeOwnerProps['openFile']
inspectCall: ChatNodeOwnerProps['inspectCall']
}> = []
const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
const renderTurnTailSlot = (() => null) as unknown as
React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
const renderSlot = ((key: string, owner: object, opts?: {
fallback?: React.ReactNode
hookContext?: unknown
}) => {
if (key !== 'conversation.chat.node') return opts?.fallback ?? null
const nodeOwner = owner as RoutedChatNodeOwner
const nodeKey = opts?.hookContext as string | undefined
const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => {
const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
return location?.kind === 'turn' || location?.kind === 'step'
? location.turn.data.get(dataKey)
: undefined
})
const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
{ ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
)
switch (nodeOwner.node.kind) {
case 'user':
case 'steering':
return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
case 'context':
return <ContextMessageNodeView {...nodeProps<'context'>()} />
case 'assistant-step':
return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
case 'command':
return (
<CommandNodeView
{...nodeProps<'command'>()}
renderSlot={renderCommandSlot}
SessionProvider={props.SessionProvider}
/>
)
case 'manual-compaction':
return <ManualCompactionNodeView {...nodeProps<'manual-compaction'>()} />
case 'compaction':
return <CompactionNodeView {...nodeProps<'compaction'>()} />
case 'model-retry':
return <RetryNodeView {...nodeProps<'model-retry'>()} />
case 'turn-error':
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
case 'turn-tail':
return (
<TurnTailNodeView
{...nodeProps<'turn-tail'>()}
renderSlot={renderTurnTailSlot}
renderSlotChain={renderTurnTail}
SessionProvider={props.SessionProvider}
/>
)
case 'unknown':
return <UnknownNodeView {...nodeProps<'unknown'>()} />
case 'tool-call': {
const block = nodeOwner.node.data.root
const toolName = 'kind' in block ? block.call?.name ?? '' : block.name
const tool = {
callId: block.callId,
toolName,
block,
selectedCallId: nodeOwner.selectedCallId,
openFile: nodeOwner.openFile,
inspectCall: nodeOwner.inspectCall,
}
toolOwners.push(tool)
return (
<div
data-testid={`tool-seat-${tool.callId}`}
data-chat-anchor-key={`call:${tool.callId}`}
data-chat-call-id={tool.callId}
>
{tool.toolName || '(unnamed)'}:{tool.callId}
</div>
)
}
default:
return opts?.fallback ?? null
}
}) as unknown as ChatViewSlotProps['renderSlot']
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (render-prop pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
@@ -151,16 +272,21 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
inspectCall,
chatScroll,
forkAt,
// Absent-service default; mention tests override with a real resolver.
fileMentions: () => undefined,
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
t,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
return {
set, ChatView, props, openDetails, openFile, loadOlder, inspectCall,
chatScroll, forkAt, setSelection, toolOwners,
}
}
/** Simulate reader input before the browser delivers the host scroll event. */
/** Simulate reader input (any device): a delivered position that deviates
* from the observed-top ledger of programmatic writes. */
function readerScroll(element: HTMLElement, top: number): void {
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
element.scrollTop = top
fireEvent.scroll(element)
}
@@ -184,76 +310,45 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
}
}
describe('chat-flow derivation', () => {
it('groups consecutive tool results and keeps stable keys', () => {
const nodes: ConversationNode[] = [
user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'),
assistant(5, 'found'), toolResult(6, 'c'),
]
const items = deriveChatFlow(nodes)
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
const group = items[2]!
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
describe('Chat node rendering', () => {
it('reuses one stable row for consecutive retry turns', () => {
const first = retry(2)
const second = { ...retry(3), turn: 2, retry: 2 }
const initial = deriveChatFlow([user(1, 'try'), first])
const updated = deriveChatFlow([user(1, 'try'), first, second])
expect(flowKeys(initial)).toBe('n1|n2')
expect(flowKeys(updated)).toBe('n1|n2')
expect(updated).toHaveLength(2)
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
})
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
const headsOnly: AssistantMessageNode = {
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
}
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
expect(flowKeys(items)).toBe('g3')
const group = items[0]!
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
// Interrupted and visible-content nodes still render (已停止 marker / prose).
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const nodes: ConversationNode[] = [
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
toolResult(4, 'a'),
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
]
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
// Turn 2 is still producing steps: its latest narration owns nothing, and
// the settled turn 1 keeps its seat.
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
})
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
expect(runningTurnStartTime(new Map([
[1, { startTime: 1_000, endTime: 5_000 }],
[2, { startTime: 6_000 }],
]))).toBe(6_000)
expect(runningTurnStartTime(new Map([
[1, { startTime: 1_000, endTime: 5_000 }],
[2, { startTime: 6_000, endTime: 9_000 }],
]))).toBeNull()
it('threads the injected file-mention vocabulary into the closing prose only', () => {
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
},
})
const h = makeHarness({
nodes: [
user(1, 'build it'),
assistant(2, 'writing `report.html` now', 1),
wrote(3, 'w', 'site/report.html'),
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
],
turnEnds: new Map([[1, 4]]),
})
// Stub provider mirroring the real service: only produced files resolve.
h.props.fileMentions = owner => ({
resolve: (value) => {
if (value !== 'report.html') return undefined
return {
open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
label: '打开 site/report.html',
title: 'site/report.html',
}
},
})
const view = render(<h.ChatView {...h.props} />)
// Exactly one live mention: the closing message links, the mid-turn
// narration stays inert code, and the unknown file resolves to nothing.
const mentions = view.container.querySelectorAll('code button')
expect(mentions).toHaveLength(1)
const mention = view.getByRole('button', { name: '打开 site/report.html' })
expect(mention.getAttribute('title')).toBe('site/report.html')
fireEvent.click(mention)
// The vocabulary was built from the closing message's own owner currency.
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
})
it('formatRunDuration localizes units and floors partial seconds', () => {
@@ -264,43 +359,24 @@ describe('chat-flow derivation', () => {
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
})
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const nodes: ConversationNode[] = [
user(1, 'first'),
assistant(2, 'answer before tools'),
toolResult(3, 'a'),
interruptedThink,
user(6, 'second'),
assistant(7, 'clean tail', 2),
user(10, 'user-only tail'),
user(13, 'steering tail'),
]
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7])
})
})
describe('ChatView', () => {
it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
const h = makeHarness({
nodes: [{ ...toolResult(3, 'w1'), call: null }],
})
const view = render(<h.ChatView {...h.props} />)
// classifyTool('') → others; the summary slot falls back to the callId.
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
expect(view.getByText('w1')).toBeTruthy()
expect(view.getByTestId('tool-seat-w1')).toBeTruthy()
expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
})
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
let firstTop = 100
let nextTop = 300
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
@@ -327,27 +403,31 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
})
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
it('renders the fixture main line as independently keyed business nodes', () => {
const h = makeHarness({
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('do the thing')).toBeTruthy()
expect(view.getByText('running tools')).toBeTruthy()
expect(view.getAllByText('Bash')).toHaveLength(2)
expect(view.getByText('run a')).toBeTruthy()
expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a')
expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b')
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
key: row.getAttribute('data-chat-flow-key'),
kind: row.getAttribute('data-chat-flow-kind'),
}))).toEqual([
{ key: 'n1', kind: 'user' },
{ key: 'n2', kind: 'assistant' },
{ key: 'g3', kind: 'tool-group' },
{ key: 'fixture:user:1', kind: 'user' },
{ key: 'fixture:assistant:2', kind: 'assistant-step' },
{ key: 'fixture:tool:a', kind: 'tool-call' },
{ key: 'fixture:tool:b', kind: 'tool-call' },
])
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
.toEqual(['a', 'b'])
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
.toEqual([
'fixture:user:1', 'fixture:assistant:2',
'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
])
})
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
@@ -414,14 +494,13 @@ describe('ChatView', () => {
act(() => {
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
})
// The completed turn's transcript tail is the steering bubble, not the
// narration, so the assistant's branch action stays unavailable and the
// steering bubble still offers none.
// The Turn Tail belongs to the closed Turn, independently of a later
// steering bubble's placement in the Chat list.
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(1)
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(branchButtons[0]!)
expect(h.forkAt).not.toHaveBeenCalled()
expect(h.forkAt).toHaveBeenCalledWith(1)
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
@@ -462,7 +541,7 @@ describe('ChatView', () => {
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
h.set({ nodes: [user(1, 'try'), nextRetry] })
})
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
expect(view.container.querySelector('details')).toBe(disclosure)
@@ -472,7 +551,6 @@ describe('ChatView', () => {
h.set({
nodes: [
user(1, 'try'),
retryNode,
{ ...nextRetry, retryState: 'started' },
context,
assistant(5, 'done'),
@@ -501,14 +579,12 @@ describe('ChatView', () => {
])
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
it('hands the trajectory callback to the Tool seat', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(h.inspectCall).toHaveBeenCalledWith('a')
render(<h.ChatView {...h.props} />)
expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall)
})
it('shows assistant IconActions only on the last content message of each turn', () => {
@@ -617,7 +693,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 2]]),
})
const view = render(<h.ChatView {...h.props} />)
// One scope per message row; the CSS reveal keys off this attribute.
// The user row and the settled assistant's Turn Tail each own one clock scope.
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
})
@@ -644,7 +720,29 @@ describe('ChatView', () => {
expect(h.forkAt.mock.calls).toEqual([[2]])
})
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
it('disables fork when the indexed Turn has a later steering Node', () => {
const base = chatSnapshotFixture({
nodes: [user(1, 'question'), assistant(2, 'answer')],
turnEnds: new Map([[1, 4]]),
})
const chat = {
...base,
locations: {
getTurn: (turn: number) => turn === 1
? [...base.locations.getTurn(turn), 'fixture:steering:later']
: base.locations.getTurn(turn),
getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
},
}
const h = makeHarness({ chat })
const view = render(<h.ChatView {...h.props} />)
const branch = view.getByRole('button', { name: '在新对话中分支' })
expect(branch.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(branch)
expect(h.forkAt).not.toHaveBeenCalled()
})
it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
@@ -700,19 +798,13 @@ describe('ChatView', () => {
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
})
it('streaming partial frames re-render only the tail (Profiler count)', () => {
it('streaming partial frames update the tail without replacing a sibling Tool row', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
})
let renders = 0
const counting = (
<Profiler id="chat" onRender={() => { renders += 1 }}>
<h.ChatView {...h.props} />
</Profiler>
)
const view = render(counting)
const before = renders
const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
const view = render(<h.ChatView {...h.props} />)
const tool = view.getByTestId('tool-seat-a')
const beforeHtml = tool.innerHTML
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
})
@@ -720,9 +812,8 @@ describe('ChatView', () => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
})
expect(view.getByText('streaming… more')).toBeTruthy()
// Each chunk commits exactly one profiler pass (the tail), never a full-tree storm.
expect(renders - before).toBe(2)
expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml)
expect(view.getByTestId('tool-seat-a')).toBe(tool)
expect(tool.innerHTML).toBe(beforeHtml)
})
it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
@@ -732,7 +823,9 @@ describe('ChatView', () => {
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.props.renderSlot = ((_key: string, _owner: object) => {
h.props.renderSlot = ((key: string, owner: object) => {
if (key !== 'conversation.chat.node'
|| (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null
rowRenders += 1
return <div data-testid="counting-row" />
})
@@ -748,47 +841,70 @@ describe('ChatView', () => {
expect(rowRenders).toBe(afterMount)
})
it('tool row expands to the args body via the whole-row toggle', () => {
it('updates the selected call id handed to the Tool seat', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).not.toHaveBeenCalled()
expect(h.openFile).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-selected]')).toBeNull()
render(<h.ChatView {...h.props} />)
expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined()
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a')
})
it('clicking a file-tool path summary opens the host file, not details', () => {
const h = makeHarness({
nodes: [{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
}],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('src/a.ts'))
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
expect(h.openDetails).not.toHaveBeenCalled()
})
it('running calls render as a live tool group with the running state', () => {
it('hands running calls to a live Tool group', () => {
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(view.getByText('cmd-r1')).toBeTruthy()
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
expect(view.getByRole('status').textContent).toBe('Deep diving...')
})
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
const mounted = vi.fn()
const unmounted = vi.fn()
function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) {
useEffect(() => {
mounted()
return () => { unmounted() }
}, [])
const root = node.data.root
return (
<div data-testid="stateful-tool" data-state={'kind' in root ? 'settled' : 'running'}>
{root.callId}
</div>
)
}
const h = makeHarness({
nodes: [user(1, 'q'), assistant(4, 'later')],
runningCalls: [runningCall('r1')],
running: true,
})
h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
const routed = owner as RoutedChatNodeOwner
return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
? <StatefulToolNode node={routed.node} />
: opts?.fallback ?? null
}) as ChatViewSlotProps['renderSlot']
const view = render(<h.ChatView {...h.props} />)
const tool = view.getByTestId('stateful-tool')
const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')
expect(tool.dataset.state).toBe('running')
expect(mounted).toHaveBeenCalledTimes(1)
act(() => {
h.set({
nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
runningCalls: [],
running: false,
})
})
expect(view.getByTestId('stateful-tool')).toBe(tool)
expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row)
expect(tool.dataset.state).toBe('settled')
expect(mounted).toHaveBeenCalledTimes(1)
expect(unmounted).not.toHaveBeenCalled()
})
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
const startTime = Date.now() - 125_000
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
@@ -813,19 +929,25 @@ describe('ChatView', () => {
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
})
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
it('hands each ordered root call to the keyed business-node slot', () => {
const block = toolResult(3, 'a')
const h = makeHarness({ nodes: [block] })
const calls: { key: string; owner: object; entryKey?: string }[] = []
h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
})
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
// (Registered-row takeover and live unload are slot machinery behavior,
// owned by the slot system's own specs.)
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
key: 'conversation.chat.node',
owner: { node: { kind: 'tool-call' }, selectedCallId: undefined },
entryKey: 'tool-call',
})
const owner = calls[0]?.owner as RoutedChatNodeOwner
expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block)
expect(owner.openFile).toBe(h.openFile)
expect(owner.inspectCall).toBe(h.inspectCall)
})
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
@@ -835,7 +957,7 @@ describe('ChatView', () => {
// jsdom has no layout: fake the metrics the anchor math reads.
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement
let anchoredTop = 100
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
@@ -852,60 +974,6 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(1600)
})
it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
let prepended = false
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'call:late') {
const top = prepended ? 400 : 100
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 200 } as DOMRect
})
try {
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 80)
fireEvent.click(view.getByText('加载更早'))
// Total height grows by 500, but only 300 belongs before the call row.
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
prepended = true
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
expect(scroller.scrollTop).toBe(380)
} finally {
rect.mockRestore()
}
})
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
let prepended = false
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:5') {
const top = prepended ? 400 : 100
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 200 } as DOMRect
})
try {
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 80)
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
prepended = true
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
expect(scroller.scrollTop).toBe(380)
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
} finally {
rect.mockRestore()
}
})
it('back-to-bottom cancels an in-flight paging anchor', () => {
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
@@ -939,7 +1007,7 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -947,12 +1015,12 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// The wheel cannot move farther down. A stream-finalization shrink clamps
// the old position, then reflow grows the layout before scroll delivery.
fireEvent.wheel(scroller, { deltaY: 120 })
metrics.setLayout(1_040, 500)
// Stream finalization shrinks the column: the browser clamps the pinned
// position onto the new floor and delivers a scroll event. The clamp
// lands exactly on the ledger's floor min, so it is not reader input.
metrics.setLayout(800, 700)
fireEvent.scroll(scroller)
expect(scroller.scrollTop).toBe(740)
expect(scroller.scrollTop).toBe(500)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
@@ -961,7 +1029,7 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(900)
})
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -969,8 +1037,10 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// Chromium advances compositor geometry before delivering the event:
// attribution must compare against the observed-top ledger, never a
// baseline sampled from already-moved raw geometry.
scroller.scrollTop = 500
fireEvent.wheel(scroller, { deltaY: -200 })
fireEvent.scroll(scroller)
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
@@ -1050,7 +1120,7 @@ describe('ChatView', () => {
() => ({ top: 0, bottom: 500 } as DOMRect),
)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:1') {
if (this.dataset.chatAnchorKey === 'fixture:user:1') {
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
}
return { top: 0, bottom: 40 } as DOMRect
@@ -1089,12 +1159,12 @@ describe('ChatView', () => {
})
document.body.appendChild(host)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect
return { top: 0, bottom: 500 } as DOMRect
})
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 })
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(1_500)
expect(h.chatScroll.read()).toBeNull()
@@ -1166,11 +1236,6 @@ describe('ChatView', () => {
})
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
const command = (over: Partial<CommandNode>): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the bare command name is the title, the outcome text
// the summary — neither the dispatched `/` nor its arguments reach the row
// (the settlement text already says what the command did).
@@ -1188,6 +1253,7 @@ describe('ChatView', () => {
const fv = render(<failed.ChatView {...failed.props} />)
expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(fv.getByText('命令失败')).toBeTruthy()
expect(fv.getByText('失败')).toBeTruthy()
// Still executing: running state with the executing copy.
const executing = makeHarness({
@@ -1196,6 +1262,7 @@ describe('ChatView', () => {
const xv = render(<executing.ChatView {...executing.props} />)
expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(xv.getByText('执行中…')).toBeTruthy()
expect(xv.getByText('运行中')).toBeTruthy()
// Cross-window soft-fall (run page truncated): generic title, outcome preserved.
const orphan = makeHarness({
@@ -1205,4 +1272,65 @@ describe('ChatView', () => {
expect(ov.getByText('命令')).toBeTruthy()
expect(ov.getByText('已完成')).toBeTruthy()
})
it('renders /compact as one stateful disclosure from running through completion', () => {
const running = command({
commandId: 'cmd-compact' as CommandNode['commandId'],
name: 'compact',
outcome: null,
})
const h = makeHarness({ nodes: [running] })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('正在压缩…')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
act(() => {
h.set({
nodes: [{
...running,
outcome: {
kind: 'success',
text: 'Compacted 16 history items (~11309 tokens).',
sourceEventSeq: 7,
},
}, compaction()],
})
})
expect(view.queryByText('正在压缩…')).toBeNull()
expect(view.queryByText('上下文已压缩')).toBeNull()
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens')).toBeTruthy()
const row = view.getByRole('button', { name: /compact/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull()
expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull()
expect(view.queryByText('保留的事实。')).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull()
expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy()
})
it('keeps /compact no-history and error settlements on the generic command row', () => {
const noHistory = makeHarness({
nodes: [command({
name: 'compact',
outcome: { kind: 'success', text: 'No compactable history yet.' },
})],
})
const noHistoryView = render(<noHistory.ChatView {...noHistory.props} />)
expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy()
expect(noHistoryView.queryByRole('button')).toBeNull()
const failed = makeHarness({
nodes: [command({
commandId: 'cmd-compact-failed' as CommandNode['commandId'],
name: 'compact',
outcome: { kind: 'error', text: 'Compaction cancelled.' },
})],
})
const failedView = render(<failed.ChatView {...failed.props} />)
expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
})
})

View File

@@ -0,0 +1,862 @@
import { describe, expect, it } from 'vitest'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
import type {
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
} from '../src/client/contract/chat-nodes.ts'
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
nextTurnInboxDefinition,
nextStepInboxDefinition,
messageDefinition,
assistantDefinition,
toolDefinition,
commandDefinition,
compactionDefinition,
retryDefinition,
turnErrorDefinition,
turnTailDefinition,
]
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] {
return DEFINITIONS
}
fallbackEntry(): ConversationNodeDefinition {
return unknownFallbackDefinition
}
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] {
return [chatViewDefinition]
}
}
function at(
seq: number,
type: string,
data: unknown,
extra: Record<string, unknown> = {},
): ConversationEventInput {
return {
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
data,
...extra,
} as unknown as ConversationEventInput['event'],
view: undefined,
}
}
function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
value.flush()
return value
}
function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
const current = value.snapshot('chat') as ChatSnapshot | undefined
if (current === undefined) throw new Error('chat view was not registered')
return current
}
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
return value.nodes.values().find(candidate => candidate.kind === kind)
}
function textMessage(id: string, text: string) {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind: 'user' },
}
}
function assistantMessage(id: string, text: string) {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}
}
function toolResult(callId: string, text: string) {
return {
id: `result-${callId}`,
role: 'user',
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
toolCallId: callId,
content: [{ type: 'text', text }],
isError: false,
}],
}
}
describe('built-in conversation node Definitions', () => {
it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'streaming' },
}),
])
const runningSnapshot = snapshot(value)
const running = node(runningSnapshot, 'assistant-step')
expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
const order = runningSnapshot.order
value.append(at(4, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-1', 'settled'),
}, { surfaceOp: 'append' }))
value.flush()
const settledSnapshot = snapshot(value)
const settled = node(settledSnapshot, 'assistant-step')
expect(settled?.key).toBe(running?.key)
expect(settledSnapshot.order).toBe(order)
expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
const interruptedValue = assembler([
at(10, 'turn/start', { turn: 2 }),
at(11, 'step/start', { turn: 2, step: 1 }),
at(12, 'assistant/chunk', {
turn: 2,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'partial' },
}),
at(13, 'step/end', { turn: 2, step: 1 }),
])
const interrupted = node(snapshot(interruptedValue), 'assistant-step')
expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
const hiddenValue = assembler([
at(20, 'turn/start', { turn: 3 }),
at(21, 'step/start', { turn: 3, step: 1 }),
at(22, 'llm/retry', {
retryId: 'retry-hidden',
turn: 3,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 1,
maxRetries: 2,
delayMs: 10,
failure: { code: 'TRANSPORT', message: 'temporary' },
}),
])
expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
const toolOnlyValue = assembler([
at(30, 'turn/start', { turn: 4 }),
at(31, 'step/start', { turn: 4, step: 1 }),
at(32, 'assistant/chunk', {
turn: 4,
step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
}),
at(33, 'assistant/message', {
turn: 4,
step: 1,
message: {
...assistantMessage('assistant-tool-only', ''),
content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
},
}, { surfaceOp: 'append' }),
])
const toolOnlySnapshot = snapshot(toolOnlyValue)
expect(toolOnlySnapshot.order).toEqual([])
expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
kind: 'assistant',
seq: 33,
timing: { firstTokenTime: 1_700_000_000_032 },
}])
const interruptedToolOnlyValue = assembler([
at(35, 'turn/start', { turn: 5 }),
at(36, 'step/start', { turn: 5, step: 1 }),
at(37, 'assistant/chunk', {
turn: 5,
step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
}),
at(38, 'step/end', { turn: 5, step: 1 }),
])
const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
expect(interruptedToolOnly?.visibility).toBe('visible')
expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
const retryTimingValue = assembler([
at(50, 'turn/start', { turn: 6 }),
at(51, 'step/start', { turn: 6, step: 1 }),
at(52, 'assistant/chunk', {
turn: 6,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
}),
at(53, 'llm/retry', {
retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
failure: { code: 'TRANSPORT', message: 'temporary' },
}),
at(54, 'assistant/chunk', {
turn: 6,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
}),
at(55, 'assistant/message', {
turn: 6,
step: 1,
message: assistantMessage('assistant-retried', 'done'),
}, { surfaceOp: 'append' }),
])
const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
const partialWindow = assembler([
at(40, 'assistant/chunk', {
turn: 5,
step: 2,
chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
}),
at(41, 'step/end', { turn: 5, step: 2 }),
], true)
const recovered = node(snapshot(partialWindow), 'assistant-step')
expect(recovered?.data).toMatchObject({
status: 'interrupted',
blocks: [{ kind: 'text', text: 'loaded partial' }],
})
})
it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
])
const runningSnapshot = snapshot(value)
const running = node(runningSnapshot, 'tool-call')
expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
const order = runningSnapshot.order
value.append(at(4, 'tool/result', {
turn: 1,
step: 1,
message: toolResult('root', 'done'),
}, { surfaceOp: 'append' }))
value.flush()
const settledSnapshot = snapshot(value)
const settled = node(settledSnapshot, 'tool-call')
expect(settled?.key).toBe(running?.key)
expect(settledSnapshot.order).toBe(order)
expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' })
const history = assembler([
at(14, 'tool/code-dispatch-start', {
rootCallId: 'history-root',
parentCallId: 'history-root',
subCallId: 'child',
name: 'read',
arguments: { path: 'README.md' },
}),
at(15, 'tool/code-dispatch', {
rootCallId: 'history-root',
parentCallId: 'history-root',
subCallId: 'child',
name: 'read',
arguments: { path: 'README.md' },
isError: false,
content: [{ type: 'text', text: 'contents' }],
}),
at(16, 'tool/result', {
turn: 2,
step: 1,
message: toolResult('history-root', 'root done'),
}, { surfaceOp: 'append' }),
], true)
const before = node(snapshot(history), 'tool-call')
expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
])
history.prepend([
at(10, 'turn/start', { turn: 2 }),
at(11, 'step/start', { turn: 2, step: 1 }),
at(13, 'tool/call', {
turn: 2,
step: 1,
callId: 'history-root',
name: 'code',
arguments: '{}',
}),
], false)
history.flush()
const after = node(snapshot(history), 'tool-call')
expect(after?.key).toBe(before?.key)
expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
])
const firstChild = (after?.data as ToolChatData).root.subCalls[0]
history.append(at(17, 'tool/code-dispatch-start', {
rootCallId: 'history-root',
parentCallId: 'history-root',
subCallId: 'second-child',
name: 'write',
arguments: { path: 'out.txt' },
}))
history.flush()
const withSecondChild = node(snapshot(history), 'tool-call')
expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
})
it('prepends an older turn without replacing already materialized nodes', () => {
const value = assembler([
at(20, 'turn/start', { turn: 2 }),
at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
at(22, 'step/start', { turn: 2, step: 1 }),
at(23, 'assistant/message', {
turn: 2,
step: 1,
message: assistantMessage('newer-assistant', 'newer answer'),
}, { surfaceOp: 'append' }),
at(24, 'step/end', { turn: 2, step: 1 }),
at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
], true)
const before = snapshot(value)
const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
const store = before.nodes
value.prepend([
at(10, 'turn/start', { turn: 1 }),
at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
at(12, 'step/start', { turn: 1, step: 1 }),
at(13, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('older-assistant', 'older answer'),
}, { surfaceOp: 'append' }),
at(14, 'step/end', { turn: 1, step: 1 }),
at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
], false)
value.flush()
const after = snapshot(value)
expect(after.nodes).toBe(store)
expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
expect(after.order).toHaveLength(before.order.length + 3)
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
'user', 'assistant-step', 'turn-tail',
'user', 'assistant-step', 'turn-tail',
])
})
it('appends a later turn without replacing nodes from the completed turn', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
at(3, 'step/start', { turn: 1, step: 1 }),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('first-assistant', 'first answer'),
}, { surfaceOp: 'append' }),
at(5, 'step/end', { turn: 1, step: 1 }),
at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
const before = snapshot(value)
const oldOrder = before.order
const oldNodes = oldOrder.map(key => before.nodes.get(key))
value.append(at(7, 'turn/start', { turn: 2 }))
value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
value.flush()
const after = snapshot(value)
expect(after.nodes).toBe(before.nodes)
expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
'user', 'assistant-step', 'turn-tail', 'user',
])
})
it('keeps branching unavailable when a tool result follows the closing Assistant', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-before-tool', 'running a tool'),
}, { surfaceOp: 'append' }),
at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }),
at(5, 'tool/result', {
turn: 1,
step: 1,
message: toolResult('late-tool', 'done'),
}, { surfaceOp: 'append' }),
at(6, 'step/end', { turn: 1, step: 1 }),
at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData
expect(tail.closing?.finalNode.seq).toBe(3)
expect(tail.branchUnavailable).toBe(true)
})
it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
const value = assembler([
at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
], true)
const before = node(snapshot(value), 'user')
expect(before).toBeDefined()
value.prepend([
at(1, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
inserted: [textMessage('steer-1', 'change direction')],
}),
at(2, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
removedCount: 1,
inserted: [],
}),
], false)
value.flush()
const after = node(snapshot(value), 'steering')
expect(after?.key).toBe(before?.key)
expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
expect(node(snapshot(value), 'user')).toBeUndefined()
})
it('orders claimed steering after the finalized Turn tail', () => {
const steering = textMessage('steer-after-answer', 'change direction')
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-before-steering', 'initial answer'),
}, { surfaceOp: 'append' }),
at(4, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
inserted: [steering],
}),
at(5, 'agent/inbox/spliced', {
target: 'next-step',
start: 0,
removedCount: 1,
inserted: [],
}),
at(6, 'user/message', steering, { surfaceOp: 'append' }),
at(7, 'step/end', { turn: 1, step: 1 }),
at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
const current = snapshot(value)
const steeringNode = node(current, 'steering')
expect(steeringNode).toBeDefined()
expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
})
it('classifies appended producer context from durable source metadata', () => {
const value = assembler([
at(1, 'user/message', {
...textMessage('skill-context', 'follow these instructions'),
source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
}, { surfaceOp: 'append' }),
])
expect(node(snapshot(value), 'context')?.data).toMatchObject({
kind: 'context',
provenance: { role: 'inject', label: 'demo-skill' },
form: 'instructions',
})
})
it('keeps replacement copies out of Chat business nodes', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'user/message', {
...textMessage('replacement-user', 'model-only context'),
source: { kind: 'plugin', plugin: 'foreign' },
}, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('replacement-assistant', 'rewritten answer'),
}, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
at(6, 'tool/result', {
turn: 1,
step: 1,
message: toolResult('root', 'pruned result'),
}, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
])
const current = snapshot(value)
expect(node(current, 'user')).toBeUndefined()
expect(node(current, 'context')).toBeUndefined()
expect(node(current, 'assistant-step')).toBeUndefined()
expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
})
it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
const retry = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'llm/retry', {
retryId: 'retry-1',
turn: 1,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 1,
maxRetries: 2,
delayMs: 10,
failure: { code: 'TRANSPORT', message: 'first' },
}),
at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
at(5, 'llm/retry', {
retryId: 'retry-1',
turn: 1,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 2,
maxRetries: 2,
delayMs: 20,
failure: { code: 'TRANSPORT', message: 'second' },
}),
at(6, 'step/end', { turn: 1, step: 1 }),
at(7, 'turn/end', {
turn: 1,
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
}),
])
const retryNode = node(snapshot(retry), 'model-retry')
const retryData = retryNode?.data as RetryChatData
expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
expect(node(snapshot(retry), 'turn-error')).toBeUndefined()
const compactions = assembler([
at(10, 'command/run', {
commandId: 'command-1',
name: 'compact',
source: { kind: 'user' },
}),
at(11, 'compact/start', {
compactionId: 'manual-1',
sourceCommandId: 'command-1',
turn: null,
}),
at(12, 'compact/summary', {
compactionId: 'manual-1',
sourceCommandId: 'command-1',
summary: [{ type: 'text', text: 'manual summary' }],
shadowedSeqs: [1, 2],
shadowedTokenCount: 100,
}),
at(13, 'user/message', {
...textMessage('manual-checkpoint', 'checkpoint'),
source: {
kind: 'plugin',
plugin: 'compact',
compactionId: 'manual-1',
sourceCommandId: 'command-1',
},
}, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
at(14, 'compact/end', {
compactionId: 'manual-1',
sourceCommandId: 'command-1',
turn: null,
}),
at(15, 'command/done', {
commandId: 'command-1',
kind: 'success',
sourceEventSeq: 12,
}),
at(20, 'compact/start', { compactionId: 'automatic-1', turn: null }),
at(21, 'compact/summary', {
compactionId: 'automatic-1',
summary: [{ type: 'text', text: 'automatic summary' }],
shadowedSeqs: [3, 4],
shadowedTokenCount: 200,
}),
at(22, 'user/message', {
...textMessage('automatic-checkpoint', 'checkpoint'),
source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
}, { surfaceOp: { op: 'replace', start: 3, end: 4 } }),
at(23, 'compact/end', { compactionId: 'automatic-1', turn: null }),
])
const manual = node(snapshot(compactions), 'manual-compaction')
expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
summary: 'manual summary',
summaryEventSeq: 12,
})
const automatic = node(snapshot(compactions), 'compaction')
expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
})
it('fills a landed compaction marker when an older page supplies its summary', () => {
const value = assembler([
at(13, 'user/message', {
...textMessage('checkpoint', 'checkpoint'),
source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
}, { surfaceOp: { op: 'replace', start: 1, end: 8 } }),
], true)
const before = node(snapshot(value), 'compaction')
expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
value.prepend([
at(9, 'compact/start', { compactionId: 'compact-1', turn: null }),
at(10, 'compact/summary', {
compactionId: 'compact-1',
summary: [
{ type: 'text', text: 'older ' },
{ type: 'image', data: 'ignored' },
{ type: 'text', text: 'summary' },
],
shadowedSeqs: [1, 2, 3],
shadowedTokenCount: 42,
}),
], false)
value.flush()
const after = node(snapshot(value), 'compaction')
expect(after?.key).toBe(before?.key)
expect(after?.data).toMatchObject({
summary: 'older summary',
summaryEventSeq: 10,
shadowedItemCount: 3,
shadowedTokenCount: 42,
})
})
it('renders a historical compaction when its start remains outside the loaded window', () => {
const value = assembler([
at(10, 'compact/summary', {
compactionId: 'compact-windowed',
summary: [{ type: 'text', text: 'loaded summary' }],
shadowedSeqs: [1, 2, 3],
shadowedTokenCount: 42,
}),
at(11, 'user/message', {
...textMessage('checkpoint-windowed', 'checkpoint'),
source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
}, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
], true)
expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
summary: 'loaded summary',
summaryEventSeq: 10,
shadowedItemCount: 3,
shadowedTokenCount: 42,
})
})
it('ignores legacy compaction transactions without correlation ids', () => {
const value = assembler([
at(10, 'compact/start', { turn: null }),
at(11, 'compact/end', { turn: null, error: 'This operation was aborted' }),
at(20, 'compact/start', { turn: null }),
at(21, 'compact/summary', {
summary: [{ type: 'text', text: 'legacy summary' }],
shadowedSeqs: [1, 2, 3],
shadowedTokenCount: 42,
}),
at(22, 'user/message', {
...textMessage('legacy-checkpoint', 'checkpoint'),
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
at(23, 'compact/end', { turn: null }),
], true)
expect(node(snapshot(value), 'compaction')).toBeUndefined()
})
it('ignores legacy retry and code-dispatch events without correlation ids', () => {
const value = assembler([
at(10, 'llm/retry', {
turn: 1,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 1,
maxRetries: 2,
delayMs: 10,
failure: { code: 'TRANSPORT', message: 'first legacy retry' },
}),
at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
at(20, 'llm/retry', {
turn: 2,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 1,
maxRetries: 2,
delayMs: 10,
failure: { code: 'TRANSPORT', message: 'second legacy retry' },
}),
at(30, 'tool/code-dispatch-start', {
parentCallId: 'root',
subCallId: 'child',
name: 'legacy-subcall',
arguments: {},
}),
at(31, 'tool/code-dispatch', {
parentCallId: 'root',
subCallId: 'child',
name: 'legacy-subcall',
arguments: {},
content: [],
}),
], true)
expect(node(snapshot(value), 'model-retry')).toBeUndefined()
expect(node(snapshot(value), 'tool-call')).toBeUndefined()
})
it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => {
const value = assembler([
at(5, 'llm/retry', {
retryId: 'retry-paged',
turn: 1,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 2,
maxRetries: 2,
delayMs: 20,
failure: { code: 'TRANSPORT', message: 'second' },
}),
at(6, 'step/end', { turn: 1, step: 1 }),
at(7, 'turn/end', {
turn: 1,
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
}),
], true)
expect(node(snapshot(value), 'model-retry')).toBeUndefined()
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
value.prepend([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'llm/retry', {
retryId: 'retry-paged',
turn: 1,
step: 1,
provider: 'fake',
mode: 'normal',
policyKey: 'fake-normal',
retry: 1,
maxRetries: 2,
delayMs: 10,
failure: { code: 'TRANSPORT', message: 'first' },
}),
at(4, 'llm/retry-started', {
retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
}),
], false)
value.flush()
const retry = node(snapshot(value), 'model-retry')
expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
})
it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
const value = assembler([
at(12, 'tool/code-dispatch-start', {
rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
}),
at(13, 'tool/code-dispatch', {
rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
isError: false, content: [{ type: 'text', text: 'child result' }],
}),
at(14, 'tool/result', {
turn: 1,
step: 1,
message: toolResult('root', 'root result'),
}, { surfaceOp: 'append' }),
at(20, 'compact/summary', {
compactionId: 'manual-1',
sourceCommandId: 'command-1',
summary: [{ type: 'text', text: 'manual summary' }],
shadowedSeqs: [1, 2],
shadowedTokenCount: 100,
}),
at(21, 'user/message', {
...textMessage('manual-checkpoint', 'checkpoint'),
source: {
kind: 'plugin',
plugin: 'compact',
compactionId: 'manual-1',
sourceCommandId: 'command-1',
},
}, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
at(22, 'command/done', {
commandId: 'command-1',
kind: 'success',
sourceEventSeq: 20,
}),
], true)
const tool = node(snapshot(value), 'tool-call')
const root = (tool?.data as ToolChatData).root
expect(root.subCalls).toHaveLength(1)
expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
const manual = node(snapshot(value), 'manual-compaction')
expect((manual?.data as ManualCompactionChatData)).toMatchObject({
command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
compaction: { summary: 'manual summary', summaryEventSeq: 20 },
})
})
})

View File

@@ -1,26 +1,17 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// bash sample state dots, the node-half empty apply, and AssistantMarkdown
// reasoning/unknown block arms.
// Branch tails the acceptance specs do not reach: the node-half empty apply
// and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -29,14 +20,6 @@ describe('tails', () => {
expect(() => { nodeApply() }).not.toThrow()
})
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
const view = render(
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
)
expect(view.queryByTestId('icon')).toBeNull()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown
@@ -73,67 +56,4 @@ describe('tails', () => {
expect(blank.container.firstChild).toBeNull()
})
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
const settled: ToolResultNode = {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null,
}
const props: GenericToolCardProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
t,
} as unknown as BashRowProps)
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null,
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
const stoppedResult: ToolResultNode = {
...errorResult,
error: { name: 'E', code: 'interrupted' },
}
const runningView = render(<BashRow {...props(running)} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(runningView.getByText('Bash')).toBeTruthy()
expect(runningView.getByText('List')).toBeTruthy()
runningView.unmount()
const errorView = render(<BashRow {...props(errorResult)} />)
expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.getByText('失败')).toBeTruthy()
errorView.unmount()
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stoppedView.getByText('已停止')).toBeTruthy()
})
})

View File

@@ -1,375 +0,0 @@
// @vitest-environment jsdom
// The diff render intent on the web side: the pure diffCardModel derivation
// over callView/resultView, and both conversation render sites that consume it
// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and
// the details panel's Output section.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
/** FileMutationRow's full prop shape (ToolRow runtime share + conversation locale seat). */
type FileMutationRowProps = Parameters<typeof FileMutationRow>[0]
const SID = 's1' as SessionId
const t = makeTranslate(zh, commonZh)
const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}'
/** The edit tool's own call view (a call-time diff derived from the arguments). */
const callDiff = (over?: Partial<Extract<ToolCallView, { card: 'diff' }>>): ToolCallView => ({
card: 'diff', title: 'Edit notes/demo.txt',
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
})
/** The edit tool's own result view (the applied hunk diff). */
const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>): ToolResultView => ({
card: 'diff', title: 'Edit notes/demo.txt',
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
callView: callDiff(), resultView: resultDiff(), ...over,
})
describe('diffCardModel', () => {
it('derives a running card from the call view alone', () => {
expect(diffCardModel(running())).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
})
})
it('derives a settled card from the result view, which replaces the call-time diff', () => {
// The applied hunks (result) win over the args-derived call diff.
expect(diffCardModel(settled({
resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }),
}))).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
})
})
it('renders a settled diff even when the window dropped the call head', () => {
// A truncated call carries only the result view, which holds the whole change.
expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1)
})
it('returns null for every non-diff call: no views, generic views, unknown cards', () => {
expect(diffCardModel(running({ callView: null }))).toBeNull()
expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull()
expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
// A generic result settles a diff call on the generic path (write/edit's
// own execution-error arm).
expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
expect(diffCardModel(running({ callView: future }))).toBeNull()
expect(diffCardModel(settled({
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
}))).toBeNull()
})
it('falls back to null for a malformed diff payload off the wire', () => {
// toolEventViewSchema validates only the `card` string, so a version
// mismatch can deliver a diff card with an unusable diffs field. Each shape
// routes to the generic path instead of throwing inside DiffBlock.
const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView)
expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull()
// The running side narrows identically.
expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull()
})
})
describe('chat row diff body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t,
})
it('the expanded body is the applied diff, capped tighter than the panel', () => {
expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16)
const view = render(<GenericToolCard {...ownerProps(settled())} />)
// Collapsed: the summary row (path) only, no diff body.
expect(view.queryByText('hello fixture')).toBeNull()
// The path link is not the expand control; the leading toggle is.
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
})
it('a running diff call expands to its intended change', () => {
const view = render(<GenericToolCard {...ownerProps(running())} />)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
})
it('a non-diff call keeps the args-JSON text body', () => {
// A non-file tool name so the row is not single-file (no path link), and its
// args body is the fallback the diff card must not have replaced.
const view = render(<GenericToolCard {...{
callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t,
block: settled({
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
callView: null, resultView: null,
}),
}} />)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.getByText(/"foo"/)).toBeTruthy()
})
})
describe('FileMutationRow diff card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as unknown as FileMutationRowProps)
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('collapses to the summary row; expanding reveals the applied diff card', () => {
const view = render(<FileMutationRow {...rowProps(settled())} />)
// The diff card is collapsed by default — not in the DOM until expanded.
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.queryByText('hello fixture')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
})
it('the summary is a path link that opens the tool path through the host', () => {
const openFile = vi.fn()
const view = render(<FileMutationRow {...{ ...rowProps(settled()), openFile }} />)
// The path link rides the collapsed summary, so it opens without expanding.
fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' }))
// The row passes the tool's own path; the injected openFile resolves it
// against the session cwd (apply.ts), so the row must not resolve twice.
expect(openFile).toHaveBeenCalledWith('notes/demo.txt')
})
it('registers under write too, rendering a create as an added-only diff', () => {
const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}'
const view = render(<FileMutationRow {...rowProps(settled({
call: { name: 'write', argsRaw: writeArgs },
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
}), 'write')} />)
// The footer counts live inside the collapsed diff card.
toggleRow(view)
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('reflects the run state on its leading slot', () => {
const runningView = render(<FileMutationRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
cleanup()
const errorView = render(<FileMutationRow {...rowProps(settled({ isError: true, resultView: null, callView: null }))} />)
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('a mutation call with no diff view renders the summary row alone', () => {
const view = render(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
// No diff material: expanding shows the args-JSON body, never a diff card.
expect(view.container.querySelector('[data-diff]')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).toBeNull()
})
it('surfaces the result text when an errored mutation has no diff card', () => {
// write/edit return undefined from presentResult on isError, so the failure
// has no diff — ToolRow shows the model-facing error text as the collapsed
// summary's first line (errorSummary) instead of a bare red dot.
const view = render(<FileMutationRow {...rowProps(settled({
isError: true, callView: null, resultView: null,
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
}))} />)
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy()
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<FileMutationRow {...rowProps(settled({
isError: true, callView: null, resultView: null, content: [],
error: { name: 'ToolError', code: 'sandbox_denied' },
}))} />)
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
})
it('shows no error summary for a successful diff or a running call', () => {
// ToolRow's error-color summary line is set only on the error state.
const ok = render(<FileMutationRow {...rowProps(settled())} />)
expect(ok.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
cleanup()
const run = render(<FileMutationRow {...rowProps(running())} />)
expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
})
it('shows the stopped state when the call was interrupted', () => {
const view = render(<FileMutationRow {...rowProps(settled({
callView: null, resultView: null, isError: true,
error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
// The amber StateDot is aria-hidden, so ToolRow carries the state to AT as
// visually-hidden text; without it a stopped row is a colour-only signal.
expect(view.getByText('已停止')).toBeTruthy()
})
it('renders a plain summary span when the call carries no file path', () => {
// Empty args leave deriveFilePath undefined, so the summary is not a link.
const view = render(<FileMutationRow {...rowProps(settled({
call: { name: 'edit', argsRaw: '' }, callView: null, resultView: null,
}))} />)
expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull()
expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull()
})
})
describe('fileMutationToolview registration', () => {
it('registers one component under both edit and write, and each disposes', () => {
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
const disposers: (() => void)[] = []
let disposeInjection = (): void => {}
const ctx = {
slots: {
inject: (_name: string, callback: () => Iterable<() => void>) => {
const active = [...callback()]
disposeInjection = () => { for (const dispose of active.reverse()) dispose() }
return disposeInjection
},
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
const entry = { key, locale, disposed: false }
registered.push(entry)
const dispose = () => { entry.disposed = true }
disposers.push(dispose)
return dispose
},
},
}
fileMutationToolview.apply(ctx as never)
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
expect(fileMutationToolview.inject).toEqual(['slots'])
// Disposal removes each contribution (packages/AGENTS.md registry contract).
disposeInjection()
expect(registered.every(r => r.disposed)).toBe(true)
})
})
describe('DetailsPanel diff Output section', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' }
it('renders the applied diff at full height, keeping the JSON Input section', () => {
const view = mount(snapshot({ nodes: [settled()] }), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
})
it('a running diff call renders its intended change, not the 运行中… placeholder', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.queryByText('运行中…')).toBeNull()
})
it('a non-diff result keeps the flattened pre', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'permission denied' }],
})],
}), target)
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied')
})
})

View File

@@ -3,10 +3,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
import type { DetailsSlotProps, DetailsToolOwnerProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
@@ -14,6 +15,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { zh } from '../src/client/locales.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
@@ -33,9 +35,21 @@ afterEach(() => {
const SID = 's1' as SessionId
/** Minimal framework seat for direct DetailsPanel host tests. */
const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID)
/** Observe the owner currency without importing the Tool details renderer. */
function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
return (_key, owner) => {
owners?.push(owner as unknown as DetailsToolOwnerProps)
return <div data-testid="tool-details-seat" />
}
}
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -57,11 +71,16 @@ describe('render branch tails', () => {
it('StatsLine counts window nodes but drops every token group without a projection', () => {
// Node `usage` is deliberately ignored: billing rides the durable
// tokenUsage projection, so an absent projection leaves counts only.
const nodes = [
{ kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
{ kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
] as const
const snap = {
...snapshotBase(),
chat: chatSnapshotFixture({ nodes }),
nodes: [
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
...nodes,
],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
@@ -95,6 +114,8 @@ describe('render branch tails', () => {
})
const view = render(
<DetailsPanel
SessionProvider={SessionProviderStub}
renderSlot={renderToolDetailsProbe()}
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)}
@@ -112,26 +133,40 @@ describe('render branch tails', () => {
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
localStorage.clear()
const snap = snapshotBase()
const longText = 'x'.repeat(1_000)
snap.codeDispatches = new Map([['p1', [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_000,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
}]]])
snap.runningCalls = [{
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 7_000, callView: null, subCalls: [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
callTime: 8_000,
content: [], isError: false, callView: null, resultView: null,
subCalls: [{
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_500,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
subCalls: [],
}],
}],
}]
snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls })
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const owners: DetailsToolOwnerProps[] = []
const view = render(
<DetailsPanel
SessionProvider={SessionProviderStub}
renderSlot={renderToolDetailsProbe(owners)}
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)}
@@ -145,10 +180,15 @@ describe('render branch tails', () => {
t={t}
/>,
)
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
// and the COMPLETE logged output renders (no truncation anywhere).
// Conversation resolves the selected sub-call and hands its complete
// frozen block to the Tool-owned details seat.
expect(view.getByText('read')).toBeTruthy()
expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
expect(view.getByText(longText)).toBeTruthy()
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
expect(owners).toHaveLength(1)
expect(owners[0]?.block).toMatchObject({
callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
content: [{ type: 'text', text: longText }],
})
})
})

View File

@@ -1,13 +1,13 @@
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
// semantics (input stays free; primary turns stop), the machine pending lock,
// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
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 { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -35,7 +35,8 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -161,11 +162,15 @@ function bench(over?: BenchOptions) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const stopping = over?.running === true && over.subagent === undefined
const primaryStops = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, steerQueue: over?.steerQueue }
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
steerQueue: over?.steerQueue,
}
}
describe('Enter semantics', () => {
@@ -363,10 +368,10 @@ describe('Enter semantics', () => {
})
})
describe('running and lock semantics (queue cut 1)', () => {
describe('running and lock semantics', () => {
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
expect(textarea.disabled).toBe(false) // running no longer locks
expect(textarea.disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
@@ -391,8 +396,8 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
it('running continuable subagent keeps Send beside an independent Stop', () => {
const { button, interruptButton, textarea, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
@@ -405,22 +410,53 @@ describe('running and lock semantics (queue cut 1)', () => {
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(interruptButton).not.toBeNull()
expect(textarea.disabled).toBe(false)
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(stop).not.toHaveBeenCalled()
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
const empty = bench({
it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
const { button, interruptButton, textarea, stop, view } = bench({
running: true,
draft: '',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: false,
},
})
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(button.disabled).toBe(true)
expect(interruptButton?.disabled).toBe(false)
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
it('running one-shot subagent never exposes Stop', () => {
const { button, interruptButton, stop } = bench({
running: true,
draft: '不可停止',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'one-shot',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(interruptButton).toBeNull()
expect(stop).not.toHaveBeenCalled()
})
it('keeps both running subagent Enter gestures on Queue transport', () => {
@@ -535,8 +571,8 @@ describe('running and lock semantics (queue cut 1)', () => {
// scrollport element holds both layers.
expect(scroll.contains(textarea)).toBe(true)
expect(scroll.contains(backdrop)).toBe(true)
// The glyph layer carries the draft and nothing else: with one scrollport
// it no longer pads its own height to match a second box's scroll extent.
// The glyph layer carries the draft and nothing else — no height padding
// to a second box's scroll extent.
expect(backdrop.textContent).toBe('line\n'.repeat(40))
})
@@ -765,7 +801,7 @@ describe('decorations', () => {
expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
})
it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
it('a lexicon-matched plain token renders the text-ref mark', () => {
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
const { view, shell } = bench({ lexicon })
act(() => { shell.setDraft('use /fixture-demo now') })
@@ -777,7 +813,7 @@ describe('decorations', () => {
})
})
describe('insertText (decision 21 scoped event body)', () => {
describe('insertText (scoped event body)', () => {
it('splices plain text over the span and reports success as true', () => {
const { shell } = bench({ draft: '/fix' })
const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
@@ -827,7 +863,7 @@ describe('strips and variants', () => {
})
describe('command launcher chrome and control seats', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('命令')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.

View File

@@ -1,6 +1,6 @@
/**
* InputMachine unit account (design §9.1, eng. plan §3.9-3.12): the submit
* plane carried over from the InputCore era (adjudication, span CAS, drift
* InputMachine unit account: the submit
* plane (adjudication, span CAS, drift
* guard, anti-backwash), plus the occurrence table (shift / whole-chip
* deletion / same-name independence), the self-managed undo log (typing
* coalescing, paste two-stage undo, redo chain), consume-token guards, the
@@ -625,7 +625,7 @@ describe('input-machine: projectClipboard', () => {
})
})
describe('decorations: scanTextRefs (decision 21)', () => {
describe('decorations: scanTextRefs', () => {
const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
['/', ['commit-helper', 'fixture-demo']],
['@', ['worker-1']],

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* Impact-matrix projection tests (design §5.2 影响矩阵, row by row): what each
* Impact-matrix projection tests (row by row): what each
* phase projects onto the InputBar — enter routing, visuals (token color /
* hint / pending), edit freedom, and the published currency's claim seat.
* React over jsdom per the client testing discipline; the machine is real.
@@ -8,7 +8,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -26,7 +26,8 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -183,7 +184,7 @@ describe('matrix row: locked (session disabled)', () => {
expect(shell.snapshot.phase).toBe('plain')
})
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
it('running does NOT lock: typing and enter-queue stay live', () => {
const { textarea, sink } = bench({ running: true })
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })

View File

@@ -1,17 +1,17 @@
// @vitest-environment jsdom
/**
* Scenario-chain integration (design §8 A/C/D/H/I): the real per-session
* Scenario-chain integration (scenarios A/C/D/H/I): the real per-session
* SlashController pipeline over a real session scope (SessionsService over
* a listed host session) + a command source implementing the decision
* table's relevant cells + the real SessionInput machine (scoped-event
* listeners wired the way the hub does) + the real InputBar. ui-command
* itself is not a dependency of this package; the source below is the
* decision-table contract at the SlashSource seam.
* decision-table contract at the `SlashSource` boundary.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
@@ -34,7 +34,7 @@ interface FakeCommand {
input?: { hint: string }
}
/** T6 decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */
/** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */
function commandSource(commands: FakeCommand[], execute: (line: string) => Promise<SubmitOutcome>) {
const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name)
const leadingClaim = (desc: FakeCommand): CommandClaim => ({
@@ -112,7 +112,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -6,6 +6,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -32,7 +33,8 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -1,326 +0,0 @@
// @vitest-environment jsdom
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
// each composing ToolRow with the read card as its collapsed-by-default expanded
// body) and the details panel's Output section (resident, full height). Also
// pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Context } from 'cordis'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
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 {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** The chat-view locale seat: this package's namespace over the common fallback. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
// The read tool's real schema key is `file_path`; the top-level read samples
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
const ARGS = '{"file_path":"src/a.ts","offset":41}'
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
/** The read block's rendered content cells, one string per row (highlighting
* breaks a line across token spans, so match on the row's textContent). */
function contentTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '')
}
/** Three windowed lines starting at file line 41 (a read past an offset). */
const sampleLines = [
{ number: 41, text: 'export const a = 1' },
{ number: 42, text: 'export const b = 2' },
{ number: 43, text: 'export const c = 3' },
]
/** The read tool's own result view for a settled file read. */
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
})
describe('readCardModel', () => {
it('derives the card from a settled read result view', () => {
expect(readCardModel(settled())).toEqual({
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
})
})
it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => {
const model = readCardModel(settled())
expect(model?.lines).toEqual(sampleLines)
expect(model?.lines).not.toBe(sampleLines)
expect(model?.lines[0]).not.toBe(sampleLines[0])
})
it('takes the result view\'s replacement title over the relativized path', () => {
// The presentation contract defines a result title as REPLACING the pending
// one, so a tool that supplies a label wins over the path here.
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
.toBe('Read (head) src/a.ts')
})
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
// A workspace-rooted absolute path shows its short form.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
.toBe('src/a.ts')
// A path outside the workspace stays as authored.
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
.toBe('/srv/other.ts')
// With no session cwd there is nothing to relativize against.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
.toBe('/w/app/src/a.ts')
})
it('carries an omitted language through as undefined', () => {
const noLang = resultRead()
delete (noLang as { lang?: string }).lang
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
})
it('returns null for a running read: the read intent is result-side only', () => {
// A read carries no content until execute returns, so the pending call is a
// generic card and there is no read card to draw yet.
expect(readCardModel(running())).toBeNull()
})
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
expect(readCardModel(settled({ resultView: null }))).toBeNull()
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(readCardModel(settled({ resultView: future }))).toBeNull()
})
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('expands to the read card, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
// fallback card owns the read block once expanded.
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
// Collapsed: no read card in the DOM yet.
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
expect(view.getByText('41')).toBeTruthy()
})
it('a non-read tool renders the bare row with no read card', () => {
const view = render(<GenericToolCard {...({
callId: 'c1', toolName: 'echo', block: settled({
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('a running read renders the summary row alone (no result view yet)', () => {
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as unknown as Parameters<typeof ReadRow>[0])
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('collapses to the path summary; the whole row toggles the read card', () => {
const view = render(<ReadRow {...rowProps(settled())} />)
expect(view.getByText('Read')).toBeTruthy()
// Collapsed: the path is the summary link alone, and the card is absent.
expect(view.getAllByText('src/a.ts').length).toBe(1)
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
// Expanded: the summary link stays inline and the card's banner label adds a
// second occurrence of the path.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
// Collapse back in place: the card unmounts, the summary link returns.
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getAllByText('src/a.ts').length).toBe(1)
})
it('the path summary opens the file through the host', () => {
const openFile = vi.fn()
const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
// The row derives the file path from args; the chat view resolves it against
// the cwd before this callback opens it, so the arg path is what arrives.
expect(openFile).toHaveBeenCalledWith('src/a.ts')
})
it('a running read renders the summary row alone, and its state', () => {
const view = render(<ReadRow {...rowProps(running())} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an error read result shows the error state and no read card', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: { card: 'generic' }, isError: true,
content: [{ type: 'text', text: 'ENOENT' }],
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an interrupted read shows the stopped state', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
})
it('registers under the read key of the keyed toolview slot', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: {
inject: (_name: string, callback: () => () => void) => callback(),
register: (options: { name: unknown; key?: unknown }) => { registered.push(options); return () => undefined },
} } as unknown as Context
readToolview.apply(ctx)
// The row composes ToolRow, so it declares its locale namespace at the seat.
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
expect(readToolview.inject).toEqual(['slots'])
})
})
describe('DetailsPanel Output section (read)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
t={t}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
it('renders the read card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
const view = mount(snapshot({
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
}), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-read]')).not.toBeNull()
// The panel takes the primitive's own default cap (16), not the row's.
expect(view.getByText(`… 其余 ${20 - 16}`)).toBeTruthy()
expect(contentTexts(view.container)).toContain('row-0')
})
it('a non-read result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'plain result' }],
})],
}), target)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
})
it('a running read keeps the 运行中… placeholder (no result view)', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})

View File

@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/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 { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const t = makeTranslate(zh, commonZh)
describe('ReasoningRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
/>,
)
expect(view.getByText('运行中')).toBeTruthy()
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(view.queryByText('运行中')).toBeNull()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
fireEvent.click(view.getByText('Think'))
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
})
})

View File

@@ -1,445 +0,0 @@
// @vitest-environment jsdom
// The search render intent on the web side: the pure searchCardModel derivation
// over resultView, and the conversation render sites that consume it — the chat
// tool row (GenericToolCard's fallback body and SearchRow, both composing the
// shared ToolRow with the search card collapsed by default) and the details
// panel's Output section (resident, full height). The keyed registration under
// both grep and glob is pinned here too.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
import { zh } from '../src/client/locales.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
type SearchRowProps = Parameters<typeof SearchRow>[0]
afterEach(cleanup)
/** Conversation-locale translate stub for the render sites' `t` seat. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
/** The rendered search card's kind attribute, so a render site cannot silently drop it. */
function searchKindOf(container: HTMLElement): string | null {
return container.querySelector('[data-search]')?.getAttribute('data-search') ?? null
}
/** The rendered result rows of the search card, one string per visible row. */
function searchRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-search] [class^="_line_"]')].map(row => row.textContent ?? '')
}
const SID = 's1' as SessionId
const GREP_ARGS = '{"pattern":"foo","path":"src"}'
const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}'
/** A grep result view: matches grouped by file. */
const resultMatches = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'matches' }>>): ToolResultView => ({
card: 'search', shape: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
],
truncated: false, total: 3, ...over,
})
/** A glob result view: a flat path list. */
const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; shape: 'paths' }>>): ToolResultView => ({
card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over,
})
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over,
})
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over,
})
describe('searchCardModel', () => {
it('derives a matches card from the grep result view', () => {
expect(searchCardModel(settledGrep())).toEqual({
title: undefined,
recovery: undefined,
card: {
kind: 'matches',
files: [
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] },
],
truncated: false, total: 3,
},
})
})
it('derives a paths card from the glob result view, carrying the truncation signal', () => {
// Empty block content isolates the truncation signal from the recovery arm.
expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({
title: undefined,
recovery: undefined,
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
})
})
it('carries the result view\'s replacement title when the presenter sets one', () => {
expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches')
// Without one it is absent, so the row keeps its args-derived summary.
expect(searchCardModel(settledGrep())?.title).toBeUndefined()
})
it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => {
// A search card is result-time only: a running call has no result view yet.
expect(searchCardModel(runningGrep())).toBeNull()
expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull()
// A generic result settles a search call as a generic card (grep/glob failure
// or a nested run_code dispatch), which keeps the generic path.
expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull()
// A terminal result view is a different card entirely.
expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull()
})
it('returns null for a card:search view whose shape this version does not compile', () => {
// `shape` rides the same untrusted wire frame as `card`; a subtype this client
// does not know must fall to the generic path, never render as a paths card
// that would crash SearchBlock on an absent `paths`.
const futureShape = {
card: 'search', shape: 'future', truncated: false, total: 0,
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull()
})
it('returns null for a known shape whose structured shape is missing or malformed', () => {
// The host wire schema checks the `card`/`shape` strings but not the grouped
// shape, so a version mismatch could deliver shape:'matches' with no `files`
// (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at
// `.reduce`/`.map`; the derivation drops to the generic path instead.
const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
const badFile = {
card: 'search', shape: 'matches', truncated: false, total: 1,
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
const badPaths = {
card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42],
} as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
})
it('surfaces the recovery text only when the result was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
// The recovery locator lives in the raw tool/result content (the view carries
// no text), surfaced only when the card capped the result.
const capped = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}))
expect(capped?.recovery).toBe(recovery)
// Not capped: the card holds every match, so the raw content adds nothing and
// is dropped.
const whole = searchCardModel(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: false }),
}))
expect(whole?.recovery).toBeUndefined()
// Capped but the block carries no text: nothing to surface.
const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) }))
expect(noText?.recovery).toBeUndefined()
})
})
describe('chat row search body (GenericToolCard fallback)', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the expanded body is the grouped matches, capped tighter than the panel', () => {
expect(CHAT_SEARCH_MAX_LINES).toBeLessThan(16)
const view = render(<GenericToolCard {...ownerProps(settledGrep(), 'grep')} />)
// Collapsed: the one-line summary row only, no card.
expect(view.queryByText(/const foo = 1/)).toBeNull()
toggleRow(view)
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(view.getByText('a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('matches')
// The args JSON body the generic path would have shown is gone.
expect(view.queryByText(/"pattern"/)).toBeNull()
})
it('the glob fallback expands to the flat path card', () => {
const view = render(<GenericToolCard {...ownerProps(settledGlob(), 'glob')} />)
toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
it('a non-search result keeps the args-JSON text body', () => {
const view = render(<GenericToolCard {...ownerProps(settledGrep({
resultView: { card: 'generic' },
}), 'grep')} />)
toggleRow(view)
expect(view.getByText(/"pattern"/)).toBeTruthy()
expect(searchKindOf(view.container)).toBeNull()
})
it('the expanded body shows the recovery footer below a capped card', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<GenericToolCard {...ownerProps(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
})
describe('SearchRow keyed card', () => {
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t,
} as unknown as SearchRowProps)
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('collapses to the summary row; expanding reveals the grep card', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('Search')).toBeTruthy()
// Collapsed: the card is not in the DOM until the row is expanded.
expect(searchKindOf(view.container)).toBeNull()
expect(view.queryByText(/const foo = 1/)).toBeNull()
toggleRow(view)
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(searchKindOf(view.container)).toBe('matches')
// The card's copy control lives inside the expanded body.
expect(view.getByText('复制')).toBeTruthy()
})
it('expands to the glob path card', () => {
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
expect(searchKindOf(view.container)).toBeNull()
toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
it('agrees with the summary row about the run state', () => {
const runningView = render(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
// No result view yet, so no card even once material could expand.
expect(searchKindOf(runningView.container)).toBeNull()
cleanup()
const errorView = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: { card: 'generic' },
}), 'grep')} />)
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
})
it('surfaces the result text through the Output section when an errored search has no card', () => {
// grep/glob return no presentResult on error → no card; the row shows the
// first error line as the collapsed summary and the full text once expanded.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null,
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
}), 'grep')} />)
expect(searchKindOf(view.container)).toBeNull()
// Error state: the first line is the collapsed summary.
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
toggleRow(view)
// Now in ToolRow's Output section too (the kept summary makes it appear twice).
expect(view.container.querySelector('[data-error]')?.textContent).toBe('grep: invalid regular expression')
})
it('surfaces the result text for a settled non-error call with no card once expanded', () => {
// A successful nested run_code sub-dispatch (backend computes no
// presentationMeta, so resultView is null) or a legacy generic result settles
// with search === null and state ok. The keyed SearchRow owns the slot, so
// ToolRow's Output section carries the text; it is only visible expanded.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: false, resultView: null,
content: [{ type: 'text', text: 'nested run_code output line' }],
}), 'grep')} />)
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
expect(searchKindOf(view.container)).toBeNull()
// Collapsed: the ok row shows its args summary, not the output text.
expect(view.queryByText('nested run_code output line')).toBeNull()
toggleRow(view)
expect(view.getByText('nested run_code output line')).toBeTruthy()
})
it('renders the recovery footer below the card when the search was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<SearchRow {...rowProps(settledGrep({
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
it('shows no recovery footer for an uncapped search', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.container.textContent).not.toMatch(/stored at/)
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null, content: [],
error: { name: 'ToolError', code: 'timeout' },
}), 'grep')} />)
// Error state: the derived name/code line is the collapsed summary.
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
it('shows the result view\'s replacement title instead of the args summary', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
resultView: resultMatches({ title: '3 matches in 2 files' }),
}), 'grep')} />)
expect(view.getByText('3 matches in 2 files')).toBeTruthy()
})
it('keeps the args-derived summary when the result view has no title', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('foo')).toBeTruthy()
})
it('registers the one row component under both grep and glob keys', () => {
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
inject: (_name: string, callback: () => Iterable<() => void>) => {
for (const _dispose of callback()) { /* exhaust transactional setup */ }
return () => undefined
},
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => undefined
},
},
} as never
searchToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['grep', 'glob'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component, two keys.
expect(registered[0]!.component).toBe(SearchRow)
expect(registered[1]!.component).toBe(SearchRow)
expect(searchToolview.inject).toEqual(['slots'])
})
})
describe('DetailsPanel Output section (search)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' }
const globTarget: SelectionTarget = { turnSeq: 11, callId: 'c2', toolName: 'glob' }
it('renders the grep matches card at full height, keeping the JSON Input section', () => {
const view = mount(snapshot({ nodes: [settledGrep()] }), grepTarget)
expect(view.getByText(/"pattern"/)).toBeTruthy()
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(searchKindOf(view.container)).toBe('matches')
})
it('renders the glob path card', () => {
const view = mount(snapshot({ nodes: [settledGlob()] }), globTarget)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
it('renders the recovery footer below the card for a capped search', () => {
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
const view = mount(snapshot({
nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })],
}), globTarget)
expect(searchKindOf(view.container)).toBe('paths')
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
})
it('a non-search result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settledGrep({ callView: null, resultView: null })],
}), grepTarget)
expect(searchKindOf(view.container)).toBeNull()
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1')
})
})

View File

@@ -95,7 +95,7 @@ describe('selection survives on the store seat', () => {
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// TestSessions.remove drives the same public slot lifecycle seam the
// TestSessions.remove drives the same public slot lifecycle contract the
// production SessionsService calls when the scope dies (pruneStoreScope).
await b.runtime.sessions.remove('s1')

View File

@@ -9,6 +9,7 @@ import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { zh } from '../src/client/locales.ts'
@@ -25,7 +26,10 @@ async function bench() {
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
const fiber = runtime.ctx.plugin(ConversationService, { input: hub })
const fiber = runtime.ctx.plugin(ConversationService, {
input: hub,
blocks: new ComposerBlockRegistry(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
@@ -89,6 +93,7 @@ describe('ConversationService', () => {
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare, makeTranslate(zh, {})),
blocks: new ComposerBlockRegistry(),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)

View File

@@ -5,7 +5,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -70,7 +70,8 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -91,6 +92,8 @@ function mount(
omitSummaryRow?: boolean
/** Classify the selected child as a subagent instead of an ordinary fork. */
summaryOrigin?: 'subagent'
/** A composer block another plugin raised for this session. */
composerBlock?: { reason: string }
} = {},
) {
const root = sid('root')
@@ -118,9 +121,14 @@ function mount(
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
/** Owner share handed to the two composer tool-row seats, per render. */
const seatOwners: { key: string; owner: unknown }[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
seatOwners.push({ key, owner })
}
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session.header') {
return (
@@ -198,7 +206,12 @@ function mount(
stop={stop}
command={() => Promise.resolve(true)}
t={t}
renderSlot={(() => null) as InputBarProps['renderSlot']}
renderSlot={((key: string, seatOwner: object) => {
// The bar's own seats: recorded so a case can assert what share
// each tool-row control received.
seatOwners.push({ key, owner: seatOwner })
return null
}) as InputBarProps['renderSlot']}
{...bar}
/>
)
@@ -224,6 +237,7 @@ function mount(
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
useComposerBlock: select => select(options.composerBlock),
useInput,
inputActions,
renderSlot,
@@ -233,7 +247,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, retargetWorkspace, session, slotCalls, open,
view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -242,12 +256,44 @@ function mount(
describe('Hero chrome', () => {
it('renders the English preview badge through the hero locale seat', () => {
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
expect(view.getByText('Let\'s start building')).toBeTruthy()
expect(view.getByText('Into the Unknown')).toBeTruthy()
expect(view.getByText('Preview')).toBeTruthy()
})
})
describe('ConversationRoot resident composer', () => {
it('renders the composer inert with the blocker\u2019s own reason', () => {
const b = mount(conversationSnapshot(), undefined, undefined, {
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
// One disabled textarea with the blocker's placeholder, never a second
// tree: the DOM survives the block being raised and cleared.
expect(box.disabled).toBe(true)
expect(box.placeholder).toBe('select a model first')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).not.toHaveBeenCalled()
// The model seat stays live. Locking it too would leave the composer
// asking for the one thing it prevents — every block this contract has is
// cleared by choosing a model.
const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
expect(seat('conversation.input.model')).toEqual({ locked: false })
expect(seat('conversation.input.plan')).toEqual({ locked: true })
})
it('lets the no-workspace posture win over a block', () => {
// Picking a workspace is the earlier prerequisite; naming a model first
// would send the user somewhere they cannot act yet.
const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, {
summaryBlank: true,
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(box.disabled).toBe(true)
expect(box.placeholder).not.toBe('select a model first')
})
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
const b = mount(conversationSnapshot())
const box = b.view.getByRole('textbox')
@@ -306,7 +352,7 @@ describe('ConversationRoot resident composer', () => {
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('探索未知之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
@@ -330,7 +376,7 @@ describe('ConversationRoot resident composer', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.queryByText('探索未知之境')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -355,7 +401,7 @@ describe('ConversationRoot resident composer', () => {
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('探索未知之境')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
@@ -373,7 +419,7 @@ describe('ConversationRoot resident composer', () => {
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.queryByText('探索未知之境')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})

View File

@@ -1,675 +0,0 @@
// @vitest-environment jsdom
// The terminal render intent on the web side: the pure terminalCardModel
// derivation over callView/resultView, and both conversation render sites that
// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
// and the details panel's Output section.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { zh } from '../src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
/**
* Match an output line with its interior whitespace intact: the column
* alignment this card exists to preserve is exactly what the default
* whitespace-collapsing matcher would hide.
*/
const RAW = { normalizer: (text: string) => text }
/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
function runStateOf(container: HTMLElement): string | null {
return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
}
const SID = 's1' as SessionId
const ARGS = '{"command":"ls -la","description":"List files"}'
/** The bash tool's own call view for a foreground command. */
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
})
/** The bash tool's own result view for a settled foreground command. */
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
callView: callTerminal(), resultView: resultTerminal(), ...over,
})
describe('terminalCardModel', () => {
it('derives a running card from the call view alone', () => {
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
description: 'List files',
card: {
command: 'ls -la', cwd: '/projects/app', output: undefined,
exitCode: undefined, signal: undefined, running: true,
},
})
})
it('derives a settled card from both sides, carrying the exit status', () => {
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '/projects/app' }),
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
}))).toEqual({
description: 'List files',
card: {
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
exitCode: 2, signal: undefined, running: false,
},
})
expect(terminalCardModel(settled({
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
}))?.card.signal).toBe('SIGTERM')
})
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
// isError stays false on a failing command (the exit status is result
// data), so this predicate is the row's only failure signal.
expect(terminalFailed(terminalCardModel(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled({
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
})
it('takes the result view\'s replacement title over the pending one', () => {
// The presentation contract defines a result title as REPLACING the pending
// title, so a tool that rewrites it at settle time must win here.
expect(terminalCardModel(settled({
callView: callTerminal({ title: 'pnpm run check' }),
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
}))?.card.command).toBe('pnpm run check --filter web')
// Without one, the call's title is what the card keeps.
expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
})
it('resolves the cwd against the session workspace the way the bridge must', () => {
// Omitted workdir — the common bash call — IS the session workspace.
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
// A relative workdir joins under it.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'packages/ui' }),
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
// An absolute one is used as-is.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '/srv/other' }),
}), '/w/app')?.card.cwd).toBe('/srv/other')
// With no session cwd there is nothing to resolve against: a relative path
// stays as authored and an omitted one stays absent (a bare `$` prompt).
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'packages/ui' }),
}))?.card.cwd).toBe('packages/ui')
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
// The running arm resolves identically.
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
})
it('normalizes a relative workdir so the label names the directory actually used', () => {
// The bash executor resolves the workdir before running, so `..` against
// /w/app runs in /w — the card must say `w`, not `..`.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '..' }),
}), '/w/app')?.card.cwd).toBe('/w')
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '.' }),
}), '/w/app')?.card.cwd).toBe('/w/app')
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../sibling' }),
}), '/w/app')?.card.cwd).toBe('/w/sibling')
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: './nested/../other' }),
}), '/w/app')?.card.cwd).toBe('/w/app/other')
// A `..` that would climb past the root is dropped, as a filesystem does.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../../..' }),
}), '/w')?.card.cwd).toBe('/')
// An absolute path carrying segments normalizes too.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '/srv/./app/../other' }),
}), '/w/app')?.card.cwd).toBe('/srv/other')
// A Windows path keeps its separators.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
}), '/w')?.card.cwd).toBe('C:\\ws')
// Without a session cwd a relative `..` has nothing to resolve against, so
// it survives as authored rather than being silently dropped.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../elsewhere' }),
}))?.card.cwd).toBe('../elsewhere')
})
it('keeps a UNC server and share as an unpoppable root', () => {
// Windows cannot climb above a share, so `..` from the share root stays put.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '..' }),
}), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
// Below the share it pops normally, keeping the UNC separators.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '..' }),
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
// Several `..` cannot escape the root either.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../../..' }),
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
})
it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
// A truncated call carries no cwd anywhere: the result view has none, and
// the original call may have used an explicit workdir. Falling back to the
// session workspace here would name a directory the card cannot know.
expect(terminalCardModel(settled({
call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
}), '/w/app')?.card.cwd).toBeUndefined()
// A present call view that omits its cwd still means the workspace.
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
})
it('carries the call view\'s description, which the contract renders above the card', () => {
expect(terminalCardModel(settled())?.description).toBe('List files')
expect(terminalCardModel(running())?.description).toBe('List files')
// A presenter that supplies none, and a window-truncated call side, both
// leave it absent so the row keeps its args-derived summary.
expect(terminalCardModel(settled({
callView: { card: 'terminal', title: 'ls' },
}))?.description).toBeUndefined()
expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
})
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
// Truncation drops both the call head and its view (conversation.ts).
const truncated = { call: null, callView: null }
expect(terminalCardModel(settled({
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
}))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
})
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
expect(terminalCardModel(running({ callView: null }))).toBeNull()
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
// A generic result settles a terminal call as a generic card (the bash
// tool's own execution-error and background paths).
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
expect(terminalCardModel(running({ callView: future }))).toBeNull()
expect(terminalCardModel(settled({
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
}))).toBeNull()
})
})
describe('chat row terminal body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the expanded body is the command output inside the row scroll container', () => {
const view = render(<GenericToolCard {...ownerProps(settled())} />)
// Collapsed: the one-line summary row only, no output.
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
toggleRow(view)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
expect(view.getByText('ls -la')).toBeTruthy()
// The args JSON body the generic path would have shown is gone.
expect(view.queryByText(/"command"/)).toBeNull()
})
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
const view = render(<GenericToolCard {...ownerProps(settled({
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
}))} />)
toggleRow(view)
expect(view.getByText('line-5')).toBeTruthy()
expect(view.getByText('line-19')).toBeTruthy()
expect(view.queryByText(/其余/)).toBeNull()
})
it('renders a multi-line command as one prompt row per line', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ title: 'ls -la\necho done' }),
}))} />)
toggleRow(view)
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
// Still one dot for the call, on the first row.
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
})
it('the fallback row shows the presenter description, not the args summary', () => {
// Any terminal-declaring tool without its own keyed row lands here, so the
// contract's above-card description has to win at this render site as well.
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
})
it('keeps the presenter description visible once the terminal card is expanded', () => {
// The contract puts the description ABOVE the card. The collapsed summary is
// hidden while a row is open, so an expanded terminal row has to draw it
// itself or the description would only ever be visible collapsed.
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
toggleRow(view)
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
expect(view.getByText('Terminal 3')).toBeTruthy()
})
it('a running terminal call expands to the prompt line with no output yet', () => {
const view = render(<GenericToolCard {...ownerProps(running())} />)
toggleRow(view)
expect(view.getByText('ls -la')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
// The card states its own run state: a running command reads as running
// even though it has no output yet to distinguish it from an empty settle.
expect(runStateOf(view.container)).toBe('ongoing')
})
it('a non-terminal call keeps the args-JSON text body', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: null, resultView: null,
}))} />)
toggleRow(view)
expect(view.getByText(/"command"/)).toBeTruthy()
})
it('a terminal call with no args still expands, through its terminal body alone', () => {
// Empty args make the text body null; the terminal material carries the row.
const view = render(<GenericToolCard {...ownerProps(settled({
call: { name: 'bash', argsRaw: '' },
}))} />)
toggleRow(view)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))} />)
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
})
})
describe('BashRow terminal card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as unknown as BashRowProps)
it('collapses to the summary row; the whole row toggles the command output', () => {
const view = render(<BashRow {...rowProps(settled())} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
// Collapse back in place: the summary row returns, the card unmounts.
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
// The row's leading StateDot and the card's run-state dot describe the same
// command, so a running row whose card claimed 'done' would be a contradiction
// the reader sees on one line.
it('agrees with the summary row about the run state', () => {
const runningView = render(<BashRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
fireEvent.click(runningView.container.querySelector('[data-expandable]')!)
expect(runStateOf(runningView.container)).toBe('ongoing')
cleanup()
const settledView = render(<BashRow {...rowProps(settled())} />)
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
fireEvent.click(settledView.container.querySelector('[data-expandable]')!)
expect(runStateOf(settledView.container)).toBe('done')
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render(<BashRow {...rowProps(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))} />)
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
})
it('shows the terminal presenter\'s description instead of the args summary', () => {
// `terminal_send`-style presenters author a description the args do not
// repeat; the contract puts it above the card, which is this row's summary.
const view = render(<BashRow {...rowProps(settled({
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
})
it('keeps the args-derived summary when the presenter authored no description', () => {
const view = render(<BashRow {...rowProps(settled({
callView: { card: 'terminal', title: 'ls -la' },
}))} />)
expect(view.getByText('List files')).toBeTruthy()
})
it('a non-terminal bash call (background start) renders the summary row alone', () => {
const view = render(<BashRow {...rowProps(settled({
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
resultView: { card: 'generic' },
}))} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
})
it('expands a generic execution error to its original args and full output', () => {
const view = render(<BashRow {...rowProps(settled({
content: [{ type: 'text', text: 'Error: command aborted' }],
isError: true,
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
resultView: { card: 'generic' },
}))} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.getAttribute('role')).toBe('button')
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/"command": "ls -la"/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('IN')).toBeTruthy()
expect(view.getByText('OUT')).toBeTruthy()
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
})
})
describe('DetailsPanel Output section', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
// The panel never unmounts between selections, so per-call view state has to
// be keyed off the selected call or it leaks into the next one.
it('resets the card\'s expand state when the selected call changes', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
}), target)
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
// A second call, selected without unmounting the panel, starts collapsed.
cleanup()
const second = mount(snapshot({
nodes: [settled({
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
})],
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
})
it('renders the presenter description above the card', () => {
const view = mount(snapshot({
nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
}), target)
const description = view.getByText('Terminal 3')
const card = view.container.querySelector('[data-terminal]')
expect(card).not.toBeNull()
// Above, not below: document order is what places it as the card's heading.
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('resolves the prompt cwd against the session workspace', () => {
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
// No workdir in the call view: the prompt label is the workspace basename.
expect(view.getByText('app')).toBeTruthy()
})
it('renders the terminal card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
}), target)
expect(view.getByText(/"command"/)).toBeTruthy()
expect(view.getByText('ls -la')).toBeTruthy()
// The panel takes the primitive's own default cap (16), not the row's.
expect(view.getByText(`… 其余 ${20 - 16}`)).toBeTruthy()
expect(view.getByText('row-0')).toBeTruthy()
})
it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('ls -la')).toBeTruthy()
expect(view.queryByText('运行中…')).toBeNull()
expect(runStateOf(view.container)).toBe('ongoing')
})
it('a running non-terminal call keeps the 运行中… placeholder', () => {
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
})
it('a non-terminal result keeps the flattened pre with its error styling', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null, isError: true,
content: [{ type: 'text', text: 'permission denied' }],
})],
}), target)
const pre = view.container.querySelector('pre[data-error]')
expect(pre?.textContent).toBe('permission denied')
})
// The panel resolves a sub-dispatch through the same material as a native
// call, so a sub-call that DID carry terminal views would render the card.
// The shipped wire cannot produce that yet: `session.ts` folds
// `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
// the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
// pins the resolution path with views injected directly, and the arm below
// pins what the shipped path actually shows today.
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
}), target)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
}), target)
// No terminal card: the generic path renders the result text in the Output
// section's <pre> (the Input section has its own, hence the scoping).
expect(view.container.querySelector('[data-terminal]')).toBeNull()
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
})
it('a running run_code sub-dispatch resolves through the running material', () => {
const view = mount(snapshot({
// The leading non-matching sub-call exercises the scan's skip.
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
}), target)
expect(view.getByText('ls -la')).toBeTruthy()
})
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
const view = mount(snapshot({
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
}), target)
expect(view.getByText('c1')).toBeTruthy()
expect(view.queryByText('输入')).toBeNull()
expect(view.getByText('输出')).toBeTruthy()
})
it('scans past other nodes and other calls before reporting the call out of window', () => {
const view = mount(snapshot({
nodes: [
{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
settled({ callId: 'elsewhere' }),
],
runningCalls: [running({ callId: 'also-elsewhere' })],
}), target)
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('no selection at all renders the guidance line and the default title', () => {
const view = mount(snapshot(), null)
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
})
it('a step selection without a callId renders the guidance line too', () => {
const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
})
it('the close button reaches closeDetails', () => {
localStorage.clear()
const chat = createChatStore().create()
const closeDetails = vi.fn()
const snap = snapshot()
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
{
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
}))}
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}))}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
t={t}
/>,
)
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
expect(closeDetails).toHaveBeenCalledTimes(1)
})
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
const nonText = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'reasoning', text: 'why' }],
})],
}), target)
// Scope to the Output section: the Input section's CodeBlock renders a
// <pre> of its own, and it comes first in document order.
expect(nonText.getByText('输出').closest('section')?.querySelector('pre')?.textContent)
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
cleanup()
const empty = mount(snapshot({
nodes: [settled({
callView: null, resultView: null, content: [], isError: true,
error: { name: 'ToolError', code: 'interrupted' },
})],
}), target)
expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
})
})

View File

@@ -1,30 +1,20 @@
// @vitest-environment jsdom
/**
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
* including several `in_progress` at once, collapse), its TodoDock adapter
* (selects the plan off the session snapshot and follows changes), the row's
* plan summary (counts plus the two halves of the active summary — the named
* task and the `+N` count that parallel work adds, kept apart so the row never
* ellipsizes the count away), and the todo_write toolview row (progress summary
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
* leading expansion).
* including several `in_progress` at once, collapse), and its TodoDock
* adapter (selects the plan off the session snapshot and follows changes).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// Export discipline: packages/client/AGENTS.md.
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
import { NS, zh } from '../src/client/locales.ts'
type TodoRowProps = Parameters<typeof TodoRow>[0]
// Mirrors the real lookup chain (conversation namespace, then common).
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
@@ -45,40 +35,6 @@ const PARALLEL: TodoItem[] = [
{ content: '补测试', status: 'pending' },
]
describe('planSummary', () => {
it('counts done/total and names the single active item with no extra count', () => {
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
})
it('reports the extra active count separately when several items are in progress', () => {
// Parallel work marks several: naming one and hiding the rest would lose
// them, and the count stays unjoined so the row cannot ellipsize it.
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
})
it('has no hint when nothing is in progress', () => {
expect(planSummary([{ content: '都完了', status: 'completed' }]))
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
})
it('has no hint when the first active item carries no usable content (model JSON)', () => {
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
// yields no hint — and no orphan count, even with a second active item to
// count. Whitespace-only is the tool's own rejection rule (trimmed
// non-empty), and a rejected call keeps its args verbatim.
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 })
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 })
})
it('is empty-safe', () => {
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
})
})
describe('TodoPanel', () => {
it('renders nothing while the list is empty', () => {
const { container } = render(<TodoPanel todos={[]} t={t} />)
@@ -130,8 +86,8 @@ describe('TodoPanel', () => {
it('marks every parallel active item, and counts them all in the header', () => {
render(<TodoPanel todos={PARALLEL} t={t} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
// The old unconditional cap made this list unreachable: three items carry
// the in-progress glyph at once, and the header counts all three.
// An unconditional in-progress cap would make this list unreachable: three
// items carry the in-progress glyph at once, and the header counts all three.
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
expect(screen.getByText('跑后台构建')).toBeTruthy()
@@ -178,110 +134,3 @@ describe('TodoDock', () => {
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
})
})
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw },
content: [], isError: false, callView: null, resultView: null, ...over,
})
function rowProps(block: unknown): TodoRowProps {
return {
callId: 'c1', toolName: 'todo_write', block,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
t,
} as unknown as TodoRowProps
}
describe('TodoRow', () => {
const ARGS = JSON.stringify({ todos: LIST })
it('summarizes counts and the active item from the call args', () => {
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
expect(screen.getByText('更新任务清单')).toBeTruthy()
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
})
it('reports the extra active count outside the ellipsized summary text', () => {
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
const text = screen.getByText('1/5 已完成 · 写组件')
const extra = screen.getByText('+2')
// Separate spans: .summary truncates, the count must not travel inside it.
expect(text.contains(extra)).toBe(false)
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
})
it('omits the active clause when no item is in progress and reads running-call args', () => {
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
// planSummary yields activeContent null here, but the counts are known good,
// so the row drops only the active clause — `?? model.summary` never runs.
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
expect(screen.getByText('1/2 已完成')).toBeTruthy()
expect(container.textContent).not.toContain('+')
})
it('keeps the non-ok execution states visible through the shared row states', () => {
// A running call (no result yet) carries the running state (row sweep).
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()
// A cancelled call wrote no todo/write: the row must not read as a completed update.
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('falls back to the generic summary on malformed args and marks the error state', () => {
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
// Generic others summary: "<tool> · <raw>".
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
it('falls back when parsed args carry no todos array', () => {
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
})
it('leading toggle expands the raw args body', () => {
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
// The expanded body is the pretty-printed args, not the tool output.
expect(screen.getByText(/搭骨架/)).toBeTruthy()
})
it.each([
{ label: 'null root', argsRaw: 'null' },
{ label: 'non-object root', argsRaw: '42' },
{ label: 'null items', argsRaw: '{"todos":[null]}' },
])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
// No throw, and the generic others summary carries the raw args verbatim.
expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
})
it('window-truncated result (call head lost) falls back to the callId summary', () => {
render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
expect(screen.getByText('todo_write · c1')).toBeTruthy()
})
it('todoToolview injects the toolview declaration directly', () => {
expect(todoToolview.name).toBe('todo-toolview')
expect(todoToolview.inject).toEqual(['slots'])
const register = vi.fn(() => () => undefined)
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
todoToolview.apply({ slots: { inject, register } } as never)
expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
})
})

View File

@@ -1,45 +0,0 @@
/**
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
* exist but not whether a narrow row still fits on one line; these read the
* declarations the layout depends on.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
/** Declarations only: the sheet's prose names the properties it explains. */
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
function declarations(selector: string): string[] {
// Anchored at a rule boundary: an unanchored match would silently read a
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
// if one ever lands above the base rule.
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
}
describe('ToolRow.module.css summary line', () => {
it('keeps the summary suffix on one line and unshrunk', () => {
// `flex: none` stops the box shrinking, not the text wrapping: without
// `nowrap`, a row too narrow for title + separator + suffix wraps the `+n`
// onto a second line — the exact case the slot exists to survive.
expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([
'flex: none',
'white-space: nowrap',
]))
})
it('leaves the truncation to the summary text alone', () => {
// The suffix must never ellipsize: a clipped count reads as a smaller
// number rather than as missing information.
expect(declarations('.summary')).toEqual(expect.arrayContaining([
'overflow: hidden',
'text-overflow: ellipsis',
'white-space: nowrap',
]))
expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis']))
})
})

View File

@@ -1,16 +1,11 @@
// View-ring + toolview-hole type-chain samples, slot form: both are declared
// slots, so the register→inject→render chain and its compile-time locks are
// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
// duals). This spec pins the package-specific surface: the SlotMap rows
// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
// and tool-row composed-props contracts, and the runtime dual — a real
// SlotsService ledger driving registration/order/disposal the way
// ConversationRoot's tab projection consumes it.
// View-ring type-chain samples. This spec pins the conversation-owned SlotMap
// row, list-kind registration shape, composed view props, and the runtime
// ledger projection consumed by ConversationRoot.
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ReactNode } from 'react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts'
import type { ChatViewSlotProps, ConvViewProps } from '../src/client/contract/slots.ts'
describe('view-ring type negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
@@ -54,30 +49,6 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
return null
}
void chatProps
// 7. Keyed hole registration requires the key shape field.
// @ts-expect-error missing `key` on a keyed-slot registration
slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
// 8. A list-kind shape field is rejected on the keyed hole.
slots.register(
// @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
{ name: 'conversation.chat.toolview', key: 'k', order: 1 },
(_p: ToolRowProps) => null)
// 9. Tool-row components stay within their composed contract: the
// owner share + standard kit supply no chat-view members.
const overreaching = (props: ToolRowProps): ReactNode => {
// @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
void props.loadOlder
return null
}
void overreaching
// 10. Owner-share drift is red at the row component seam: block is the
// call union, not arbitrary payload.
const drifted = (props: ToolRowProps): ReactNode => {
// @ts-expect-error the block union has no `argsParsed` member
void props.block.argsParsed
return null
}
void drifted
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')

View File

@@ -1,294 +0,0 @@
// @vitest-environment jsdom
// The web render intent on the web side: the pure webCardModel derivation over
// resultView, and the conversation render sites that consume it — the keyed
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
// render-site fallback, and the details panel's Output section. Mirrors
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
// row's collapsed-by-default ToolRow card, the panel arm, and the keyed
// registration. WebRow now composes the shared ToolRow, so its web card is
// collapsed by default and appears only once the whole row is expanded.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */
const t = makeTranslate(zh, commonZh)
const SEARCH_ARGS = '{"query":"deepseek harness"}'
const FETCH_ARGS = '{"url":"https://example.com/page"}'
/** A web_search result view; overrides tune the sources / answer / truncation. */
const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'search' }>>): ToolResultView => ({
card: 'web', kind: 'search', truncated: false,
answer: 'A short answer.',
sources: [
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://plain.example.org/b' },
],
...over,
})
/** A web_fetch result view. */
const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>>): ToolResultView => ({
card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
})
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
})
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'search text' }], isError: false,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
})
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'fetch body' }], isError: false,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
})
describe('webCardModel', () => {
it('derives a search card from the result view, projecting every source field', () => {
expect(webCardModel(settledSearch())).toEqual({
kind: 'search',
answer: 'A short answer.',
truncated: false,
sources: [
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
],
})
})
it('carries the search truncation flag and an absent answer', () => {
const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
})
it('derives a fetch card from the result view', () => {
expect(webCardModel(settledFetch())).toEqual({
kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
})
expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
.toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
})
it('returns null for a running call, since the web card is result-only', () => {
expect(webCardModel(runningSearch())).toBeNull()
// Even a running call that somehow carried a web call view stays generic:
// the derivation reads resultView only.
expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
})
it('returns null for a settled call whose result view is not a web card', () => {
expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView
expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
// A web card whose kind this UI version does not know (a newer host's
// value) also takes the generic path, not a malformed fetch.
const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull()
})
})
describe('chat row web body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
callId: block.callId, toolName, block, openFile: vi.fn(),
})
// WebRow reads only toolName/block off the full runtime share plus the locale
// seat; the standard kit is unused, so the cast supplies the owner slice and
// `t` alone (as BashRow's tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters<typeof WebRow>[0] =>
({ ...ownerProps(block, toolName), t } as unknown as Parameters<typeof WebRow>[0])
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the WebRow collapses to the summary row, expanding to the full search card', () => {
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
// Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
// Expanded: the resident search card with every source field.
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// hostname fallback for the source with no title
expect(view.getByText('plain.example.org')).toBeTruthy()
})
it('the WebRow expands to the fetch card, titled Fetch', () => {
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
expect(view.getByText('Fetch')).toBeTruthy()
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
// The url shows as the card's link; scope to the card.
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('a running web call is the summary row alone, with nothing to expand', () => {
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
// No card material and no expandable body: clicking the row reveals nothing.
expect(view.container.querySelector('[data-expandable]')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
})
it('a failed web call keeps the summary row without the card', () => {
const view = render(<WebRow {...rowProps(settledSearch({
isError: true, resultView: { card: 'generic' },
}), 'web_search')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.container.querySelector('[data-web]')).toBeNull()
// The row reflects the error state so the summary line still reads as failed.
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
// A web-declaring tool without its own keyed row lands on the fallback; its
// card routes through the same collapsed-by-default ToolRow.
const view = render(<GenericToolCard {...ownerProps(settledSearch({
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
}), 'fx-web')} t={t} />)
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
expect(view.getByText('Titled')).toBeTruthy()
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
})
it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
const view = render(<GenericToolCard {...ownerProps(settledSearch({
call: { name: 'echo', argsRaw: '{}' }, callView: null, resultView: null,
}), 'echo')} t={t} />)
expect(view.container.querySelector('[data-web]')).toBeNull()
})
})
describe('DetailsPanel web Output section', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
}
}
it('renders the search card at full source allowance', () => {
const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// The Input JSON section survives beside it.
expect(view.getByText(/"query"/)).toBeTruthy()
})
it('renders the fetch card and keeps the fetched body below it', () => {
const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
// The card is a summary (URL + status only); the panel is the single-call
// reading surface, so the fetched body still renders below the card.
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
})
it('a non-web result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settledSearch({ callView: null, resultView: null })],
}), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
expect(view.container.querySelector('[data-web]')).toBeNull()
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('search text')
})
})
describe('web toolview registration', () => {
it('registers one WebRow under both web_search and web_fetch', () => {
const registered: { key: string; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
inject: (_name: string, callback: () => Iterable<() => void>) => {
for (const _dispose of callback()) { /* exhaust transactional setup */ }
return () => undefined
},
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => {}
},
},
} as unknown as import('cordis').Context
webToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component under both keys, not two thin rows.
expect(registered[0]?.component).toBe(WebRow)
expect(registered[1]?.component).toBe(WebRow)
expect(webToolview.inject).toEqual(['slots'])
})
})