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:
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
298
packages/client/ui-conversation/tests/chat-snapshot-fixture.ts
Normal file
298
packages/client/ui-conversation/tests/chat-snapshot-fixture.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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: '排队' } })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
117
packages/client/ui-conversation/tests/reasoning-row.spec.tsx
Normal file
117
packages/client/ui-conversation/tests/reasoning-row.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user