ask-question-row.client.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. // @vitest-environment jsdom
  2. /**
  3. * ask_user_question toolview acceptance: `waiting` summary while running,
  4. * answered-count from the result JSON once settled (skipped answers
  5. * excluded), readable question lists for ASK_CANCELLED and ASK_ABORTED,
  6. * shared ToolRow state semantics for interrupted/failed calls, and generic
  7. * fallbacks on malformed results.
  8. */
  9. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
  12. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  13. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  14. // Export discipline: packages/client/AGENTS.md.
  15. import { AskQuestionRow, askQuestionToolview } from '../src/client/tool/toolviews/ask-question-row.tsx'
  16. import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
  17. afterEach(cleanup)
  18. const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
  19. const READABLE_ARGS = JSON.stringify({ questions: [
  20. { id: 'goal', question: 'What do you want to accomplish?' },
  21. { id: 'scope', question: 'Which project should this apply to?' },
  22. { id: 'notes', question: 'Anything else?' },
  23. ] })
  24. const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
  25. kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
  26. call: { name: 'ask_user_question', argsRaw },
  27. content: resultText === null ? [] : [{ type: 'text', text: resultText }],
  28. isError: false, subCalls: [], ...over,
  29. })
  30. const runningCall = (argsRaw: string) =>
  31. ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
  32. const t = makeTranslate(zh, commonZh)
  33. function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
  34. return {
  35. callId: 'c1', toolName: 'ask_user_question', block, t,
  36. openFile: vi.fn(),
  37. sessionId: 's1',
  38. useSessions: () => undefined,
  39. } as unknown as Parameters<typeof AskQuestionRow>[0]
  40. }
  41. const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
  42. describe('AskQuestionRow', () => {
  43. it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
  44. const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
  45. expect(screen.getByText('提问')).toBeTruthy()
  46. expect(screen.getByText('等待回答')).toBeTruthy()
  47. expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
  48. })
  49. it('settled result counts answered entries (selected choices or custom text)', () => {
  50. render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
  51. { id: 'a', selected: ['x'] },
  52. { id: 'b', selected: [], custom: 'freeform' },
  53. { id: 'c', selected: ['y', 'z'], custom: '' },
  54. ])))} />)
  55. expect(screen.getByText('3/3 已回答')).toBeTruthy()
  56. })
  57. it('expands a successful result as paired questions and readable answer lines', () => {
  58. render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, answers([
  59. { id: 'scope', selected: ['deepseek-harness'] },
  60. { id: 'goal', selected: ['Develop a feature'], custom: 'Keep the API small' },
  61. { id: 'notes', selected: [] },
  62. ])))} />)
  63. fireEvent.click(screen.getByRole('button', { expanded: false }))
  64. expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
  65. expect(screen.getByText('Develop a feature')).toBeTruthy()
  66. expect(screen.getByText('Keep the API small')).toBeTruthy()
  67. expect(screen.getByText('Which project should this apply to?')).toBeTruthy()
  68. expect(screen.getByText('deepseek-harness')).toBeTruthy()
  69. expect(screen.getByText('Anything else?')).toBeTruthy()
  70. expect(screen.getByText('未回答')).toBeTruthy()
  71. expect(screen.queryByText(/"questions"/)).toBeNull()
  72. expect(screen.queryByText(/"answers"/)).toBeNull()
  73. })
  74. it('keeps generic diagnostics when a valid answer result includes a non-text block', () => {
  75. const resultText = answers([
  76. { id: 'goal', selected: ['Develop a feature'] },
  77. { id: 'scope', selected: ['deepseek-harness'] },
  78. { id: 'notes', selected: [] },
  79. ])
  80. const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, resultText, {
  81. content: [
  82. { type: 'text', text: resultText },
  83. { type: 'reasoning', text: 'unexpected diagnostic' },
  84. ],
  85. }))} />)
  86. expect(screen.getByText(`ask_user_question · ${READABLE_ARGS}`)).toBeTruthy()
  87. fireEvent.click(screen.getByRole('button', { expanded: false }))
  88. expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
  89. expect(view.container.textContent).toContain('"type": "reasoning"')
  90. expect(view.container.textContent).toContain('"text": "unexpected diagnostic"')
  91. })
  92. it('skipped questions (no selection, no custom) stay out of the answered count', () => {
  93. const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
  94. { id: 'a', selected: ['x'] },
  95. { id: 'b', selected: [], custom: '' },
  96. { id: 'c' },
  97. ])))} />)
  98. expect(screen.getByText('1/3 已回答')).toBeTruthy()
  99. expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
  100. })
  101. it.each([
  102. { label: 'non-JSON result text', text: 'oops' },
  103. { label: 'non-object result root', text: '"str"' },
  104. { label: 'null result root', text: 'null' },
  105. { label: 'missing answers array', text: '{"other":1}' },
  106. { label: 'null answer entries', text: '{"answers":[null]}' },
  107. { label: 'empty result content', text: null },
  108. ])('settled result falls back to the generic summary on $label', ({ text }) => {
  109. render(<AskQuestionRow {...rowProps(resultNode(ARGS, text))} />)
  110. expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
  111. })
  112. it.each([
  113. { label: 'missing id', value: { selected: ['x'] } },
  114. { label: 'non-array selection', value: { id: 'a', selected: 'x' } },
  115. { label: 'non-string selection', value: { id: 'a', selected: [1] } },
  116. { label: 'non-string custom text', value: { id: 'a', selected: [], custom: 1 } },
  117. ])('falls back to generic JSON for a result with $label', ({ value }) => {
  118. const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, answers([value])))} />)
  119. fireEvent.click(screen.getByRole('button', { expanded: false }))
  120. expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
  121. })
  122. it.each([
  123. { label: 'non-JSON args', args: 'oops' },
  124. { label: 'non-object args', args: '[]' },
  125. { label: 'missing question array', args: '{}' },
  126. { label: 'non-object question', args: '{"questions":[null]}' },
  127. { label: 'non-string question id', args: '{"questions":[{"id":1,"question":"Q"}]}' },
  128. { label: 'non-string question text', args: '{"questions":[{"id":"a","question":1}]}' },
  129. { label: 'duplicate question ids', args: '{"questions":[{"id":"a","question":"Q1"},{"id":"a","question":"Q2"}]}' },
  130. { label: 'different question count', args: '{"questions":[]}' },
  131. ])('keeps raw details when answer pairing sees $label', ({ args }) => {
  132. const view = render(<AskQuestionRow {...rowProps(resultNode(args, answers([
  133. { id: 'a', selected: ['x'] },
  134. ])))} />)
  135. expect(screen.getByText('1/1 已回答')).toBeTruthy()
  136. fireEvent.click(screen.getByRole('button', { expanded: false }))
  137. expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
  138. })
  139. it.each([
  140. { label: 'duplicate answer ids', values: [{ id: 'a', selected: ['x'] }, { id: 'a', selected: ['y'] }] },
  141. { label: 'unknown answer id', values: [{ id: 'other', selected: ['x'] }] },
  142. ])('keeps raw details for $label', ({ values }) => {
  143. const args = JSON.stringify({ questions: values.map((_, index) => ({
  144. id: String.fromCharCode(97 + index), question: `Question ${String(index + 1)}`,
  145. })) })
  146. const view = render(<AskQuestionRow {...rowProps(resultNode(args, answers(values)))} />)
  147. fireEvent.click(screen.getByRole('button', { expanded: false }))
  148. expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
  149. })
  150. it('user cancellation shows the original questions without raw JSON or an error body', () => {
  151. // ASK_CANCELLED: the ask_user_question handler's cancel error.
  152. const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
  153. { isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
  154. expect(screen.getByText('已取消')).toBeTruthy()
  155. expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
  156. fireEvent.click(screen.getByRole('button', { expanded: false }))
  157. expect(screen.getByText('本轮已取消,未提交回答')).toBeTruthy()
  158. expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
  159. expect(screen.getByText('Which project should this apply to?')).toBeTruthy()
  160. expect(screen.getByText('Anything else?')).toBeTruthy()
  161. expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
  162. expect(screen.queryByText(/"questions"/)).toBeNull()
  163. expect(screen.queryByText(/the user cancelled ask_user_question/)).toBeNull()
  164. })
  165. it('a turn abort shows the original questions with stopped semantics', () => {
  166. // ASK_ABORTED: the ask handler's turn-abort settlement.
  167. const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
  168. { isError: true, error: { name: 'UserQuestionError', code: 'ASK_ABORTED' } }))} />)
  169. expect(screen.getByText('已中断')).toBeTruthy()
  170. expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
  171. fireEvent.click(screen.getByRole('button', { expanded: false }))
  172. expect(screen.getByText('本轮已中断,未提交回答')).toBeTruthy()
  173. expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
  174. expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
  175. })
  176. it.each([
  177. { label: 'non-JSON args', args: 'oops' },
  178. { label: 'an empty question set', args: '{"questions":[]}' },
  179. { label: 'a question without visible text', args: '{"questions":[{"id":"a"}]}' },
  180. ])('cancelled result keeps raw diagnostics for $label', ({ args }) => {
  181. const view = render(<AskQuestionRow {...rowProps(resultNode(args, null,
  182. { isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
  183. expect(screen.getByText('已取消')).toBeTruthy()
  184. fireEvent.click(screen.getByRole('button', { expanded: false }))
  185. expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
  186. })
  187. it('an interrupted turn reads as stopped, not cancelled', () => {
  188. const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
  189. { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
  190. expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
  191. expect(screen.queryByText('已取消')).toBeNull()
  192. expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
  193. })
  194. it('other tool errors keep the generic summary with the error state', () => {
  195. const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null, { isError: true }))} />)
  196. expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
  197. expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
  198. })
  199. it('window-truncated result (call head lost) falls back to the callId summary', () => {
  200. render(<AskQuestionRow {...rowProps(resultNode('', null, { call: null }))} />)
  201. expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
  202. })
  203. it('leading toggle expands the raw args body', () => {
  204. render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])))} />)
  205. fireEvent.click(screen.getByRole('button', { expanded: false }))
  206. expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
  207. })
  208. it('askQuestionToolview injects the toolview declaration directly', () => {
  209. expect(askQuestionToolview.name).toBe('ask-question-toolview')
  210. expect(askQuestionToolview.inject).toEqual(['slots'])
  211. const register = vi.fn(() => () => undefined)
  212. const inject = vi.fn((_name: string, callback: () => () => void) => callback())
  213. askQuestionToolview.apply({ slots: { inject, register } } as never)
  214. expect(inject).toHaveBeenCalledWith('tool.call.toolview', expect.any(Function))
  215. expect(register).toHaveBeenCalledWith(
  216. { name: 'tool.call.toolview', key: 'ask_user_question', locale: 'conversation' },
  217. AskQuestionRow,
  218. )
  219. })
  220. })