Merge remote-tracking branch 'origin/master' into feat/web-diff-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/tests/chat-apply.spec.tsx
This commit is contained in:
Chinesezjc
2026-07-30 19:04:33 +08:00
449 changed files with 10484 additions and 4267 deletions
@@ -163,7 +163,7 @@ describe('conversation slot inject surface', () => {
unbind()
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
b.composerSurface(ROOT).stop()
b.composerSurface(ROOT).stop!()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
@@ -172,12 +172,19 @@ describe('conversation slot inject surface', () => {
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
const b = await bench()
const entry = b.entryOf('conversation.composer.bar')
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
// Unknown session: the keyboard face's binding resolution answers nothing.
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
// No session (session-maybe absent side): machine faces absent, static
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop
const stop = injectFn(ROOT).stop!
await b.feature.dispose()
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
await b.runtime.dispose()
@@ -0,0 +1,129 @@
// @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 type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
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 })
function rowProps(block: unknown): ToolRowProps {
return {
callId: 'c1', toolName: 'ask_user_question', block,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
}
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('Ask question')).toBeTruthy()
expect(screen.getByText('waiting')).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 answered')).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 answered')).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('cancelled')).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('interrupted')).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('cancelled')).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 is a plain registrant riding the conversation load-order seam', () => {
expect(askQuestionToolview.name).toBe('ask-question-toolview')
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
askQuestionToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
})
})
@@ -108,7 +108,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
const view = runtime.renderRoot()
// Keyed toolview registration took the row (summary derived from args).
const row = view.container.querySelector('[data-sample="todo-row"]')
const row = view.container.querySelector('[data-tool="todo_write"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
@@ -128,7 +128,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
await waitFor(() => {
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
})
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
await runtime.dispose()
})
})
@@ -80,13 +80,13 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the file-mutation rows, and the todo row as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the file-mutation rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Each registrant plugin's inject: ['slots', 'conversation'] resolved — the
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// file-mutation registrant claims both write and edit for the diff card.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'todo_write'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'todo_write', 'ask_user_question'])
// 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()
@@ -1,7 +1,7 @@
// @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.
// bash sample state dots, the node-half empty apply, and AssistantMarkdown
// reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
@@ -197,9 +197,14 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('ships the session-scoped registrant plugin shape', () => {
it('registers as the terminal composer-context entry', () => {
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
expect(typeof queueDockEntry.apply).toBe('function')
const register = vi.fn()
queueDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith(
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
QueueDock,
)
})
})
@@ -3,8 +3,8 @@
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
* rows, collapse), its TodoDock adapter (selects the plan off the session
* snapshot and follows changes), and the todo_write toolview row (progress
* summary from args, generic fallback on malformed JSON, error badge,
* keyboard activation).
* summary from args, generic fallback on malformed JSON, shared ToolRow
* state dots and leading expansion).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -92,12 +92,12 @@ describe('TodoDock', () => {
expect(screen.queryByTestId('todo-panel')).toBeNull()
})
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
it('registers between the goal and queue entries', () => {
expect(todoDockEntry.name).toBe('conversation-todo-dock')
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
})
})
@@ -131,8 +131,8 @@ describe('TodoRow', () => {
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
// A running call (no result yet) shows the ongoing dot, never the ok badge.
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()
@@ -141,20 +141,26 @@ describe('TodoRow', () => {
// 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()
expect(stopped.getByText('已中断')).toBeTruthy()
})
it('falls back to the generic summary on malformed args and flags errors', () => {
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(screen.getByText('failed')).toBeTruthy()
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 and stays non-interactive', () => {
it('falls back when parsed args carry no todos array', () => {
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
})
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([