Merge remote-tracking branch 'origin/feat/search-presenter' into feat/web-search-card
# Conflicts: # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/tests/chat-apply.spec.tsx
This commit is contained in:
@@ -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()
|
||||
|
||||
129
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
129
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
@@ -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 search row (grep + glob), and the todo row as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample, the search row (grep + glob), and the product rows as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// All registrant plugins' 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
|
||||
// one search row registers under both grep and glob.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'todo_write'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', '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([
|
||||
|
||||
Reference in New Issue
Block a user