workflow-run.client.spec.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  1. // @vitest-environment jsdom
  2. import { Context, Service } from '@deepseek-ai/cordis'
  3. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import {
  6. ConversationNodeAssembler, UiConversation,
  7. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  8. import type {
  9. ConversationEventInput, ConversationMatch, ConversationNodeDefinition, ConversationViewDefinition,
  10. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  11. import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client'
  12. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  13. import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
  14. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  15. import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
  16. import {
  17. chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot,
  18. stubSettingsScope, workspaceSnapshot,
  19. } from '@deepseek-ai/dsh-client-test-runtime'
  20. import {
  21. WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps,
  22. } from '../src/client/WorkflowRunPanel.tsx'
  23. import { apply, inject } from '../src/client/index.ts'
  24. import { zh } from '../src/client/locales.ts'
  25. import {
  26. workflowRunDefinition, type WorkflowRunChatData,
  27. } from '../src/client/workflow-definition.ts'
  28. import { apply as applyNode } from '../src/index.ts'
  29. import { apply as applyInvariant } from '../src/invariant.ts'
  30. import type {} from '../src/client/index.ts'
  31. afterEach(cleanup)
  32. const PARENT_ID = 'parent' as SessionId
  33. const CHILD_ID = 'child-1' as SessionId
  34. const SECOND_ID = 'child-2' as SessionId
  35. type TrajectoryState = Parameters<Parameters<WorkflowRunPanelProps['useTrajectory']>[0]>[0]
  36. const panelSession = sessionSnapshot(PARENT_ID)
  37. const panelAttention = new Map<SessionId, never>()
  38. const panelWorkspace = workspaceSnapshot()
  39. const panelConversation = conversationSnapshot()
  40. const panelChat = emptyChatSnapshot()
  41. const panelTrajectory: TrajectoryState = {
  42. eventNodes: [],
  43. eventLocations: new Map(),
  44. requests: [],
  45. callSchemas: new Map(),
  46. partial: null,
  47. runningCalls: [],
  48. }
  49. interface ChatSnapshot {
  50. readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
  51. }
  52. class TestEventDefinitions {
  53. entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] }
  54. fallbackEntry(): undefined { return undefined }
  55. }
  56. class TestViewDefinitions {
  57. entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] }
  58. }
  59. const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
  60. target: 'chat',
  61. create: () => {
  62. let nodes = new Map<string, ChatConversationViewNode>()
  63. const snapshot = (): ChatSnapshot => ({ nodes })
  64. return {
  65. empty: snapshot(),
  66. replace: ({ nodes: values }) => {
  67. nodes = new Map(values.map(node => [node.key, node]))
  68. return snapshot()
  69. },
  70. apply: ({ upserts }) => {
  71. nodes = new Map(nodes)
  72. for (const node of upserts) nodes.set(node.key, node)
  73. return snapshot()
  74. },
  75. }
  76. },
  77. }
  78. function at(seq: number, type: string, data: unknown): ConversationEventInput {
  79. return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'] }
  80. }
  81. function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
  82. return { ...input, role, location: { kind: 'unresolved' } }
  83. }
  84. function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
  85. const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
  86. value.replaceWindow(entries, hasMore)
  87. value.flush()
  88. return value
  89. }
  90. function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined {
  91. const snapshot = value.snapshot('chat') as ChatSnapshot
  92. return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined
  93. }
  94. function completeEvents(): ConversationEventInput[] {
  95. return [
  96. at(1, 'turn/start', { turn: 1 }),
  97. at(2, 'step/start', { turn: 1, step: 1 }),
  98. at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
  99. at(4, 'tool-workflow/agent-start', {
  100. runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1',
  101. }),
  102. at(5, 'tool-workflow/agent-start', {
  103. runId: 'run-1', seq: 2, label: 'second', childId: 'child-2',
  104. }),
  105. at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }),
  106. at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }),
  107. at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }),
  108. at(9, 'step/end', { turn: 1, step: 1 }),
  109. at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  110. ]
  111. }
  112. describe('workflow-run Conversation Definition', () => {
  113. it('groups exact phase identities in first-member order and preserves terminal members', () => {
  114. const value = assembler(completeEvents())
  115. const data = workflowData(value)
  116. expect(data).toEqual({
  117. name: 'audit',
  118. status: 'failed',
  119. phases: [
  120. {
  121. key: 'value:0:', phase: '',
  122. members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }],
  123. },
  124. {
  125. key: 'missing', phase: null,
  126. members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }],
  127. },
  128. ],
  129. })
  130. const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]!
  131. expect(node.anchorSeq).toBe(3)
  132. expect(node.kind).toBe('workflow-run')
  133. })
  134. it('keeps an update-only tail pending until prepend supplies the unique start', () => {
  135. const tail = completeEvents().slice(3)
  136. const value = assembler(tail, true)
  137. expect(workflowData(value)).toBeUndefined()
  138. value.prepend(completeEvents().slice(0, 3), false)
  139. value.flush()
  140. expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents())))
  141. })
  142. it('produces the same final data through live append as complete replay', () => {
  143. const events = completeEvents()
  144. const value = assembler(events.slice(0, 3))
  145. for (const event of events.slice(3)) value.append(event)
  146. value.flush()
  147. expect(workflowData(value)).toEqual(workflowData(assembler(events)))
  148. })
  149. it('shows missing terminal facts as interrupted only after the owning Location closes', () => {
  150. const value = assembler([
  151. at(1, 'turn/start', { turn: 1 }),
  152. at(2, 'step/start', { turn: 1, step: 1 }),
  153. at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
  154. at(4, 'tool-workflow/agent-start', {
  155. runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1',
  156. }),
  157. ])
  158. expect(workflowData(value)?.status).toBe('running')
  159. value.append(at(5, 'step/end', { turn: 1, step: 1 }))
  160. value.flush()
  161. expect(workflowData(value)).toMatchObject({
  162. status: 'interrupted',
  163. phases: [{ members: [{ status: 'interrupted' }] }],
  164. })
  165. })
  166. it('retains a zero-member run as its own completed node', () => {
  167. const value = assembler([
  168. at(1, 'turn/start', { turn: 1 }),
  169. at(2, 'step/start', { turn: 1, step: 1 }),
  170. at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }),
  171. at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }),
  172. ])
  173. expect(workflowData(value)).toEqual({
  174. name: 'empty', status: 'completed', phases: [],
  175. })
  176. })
  177. it('folds same-phase cancellation and a turn-level interruption', () => {
  178. const cancelled = assembler([
  179. at(1, 'turn/start', { turn: 1 }),
  180. at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }),
  181. at(3, 'tool-workflow/agent-start', {
  182. runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1',
  183. }),
  184. at(4, 'tool-workflow/agent-start', {
  185. runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2',
  186. }),
  187. at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }),
  188. at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }),
  189. at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }),
  190. ])
  191. expect(workflowData(cancelled)).toMatchObject({
  192. status: 'cancelled',
  193. phases: [{ phase: 'Research', members: [{ status: 'cancelled' }, { status: 'completed' }] }],
  194. })
  195. const interruptedTurn = assembler([
  196. at(1, 'turn/start', { turn: 1 }),
  197. at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }),
  198. at(3, 'tool-workflow/agent-start', {
  199. runId: 'turn', seq: 1, label: 'open', childId: 'child-1',
  200. }),
  201. at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  202. ])
  203. expect(workflowData(interruptedTurn)?.status).toBe('interrupted')
  204. })
  205. it('handles session/unresolved placement and defensive Definition calls', () => {
  206. const sessionLevel = assembler([
  207. at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }),
  208. at(2, 'tool-workflow/agent-start', {
  209. runId: 'session', seq: 1, label: 'open', childId: 'child-1',
  210. }),
  211. ])
  212. expect(workflowData(sessionLevel)?.status).toBe('running')
  213. const invalidStart = matched(at(1, 'tool-workflow/agent-start', {
  214. runId: 'direct', seq: 1, label: 'member', childId: 'child-1',
  215. }), 'start')
  216. const emptyContext: Parameters<typeof workflowRunDefinition.start>[0] = {
  217. key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct',
  218. matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(),
  219. }
  220. const reader: Parameters<typeof workflowRunDefinition.start>[2] = { previous: () => undefined }
  221. expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader))
  222. .toThrow('workflow-run start requires tool-workflow/run-start')
  223. const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start')
  224. const startedContext = { ...emptyContext, matches: [start], start }
  225. const state = workflowRunDefinition.start(startedContext, start, reader)
  226. const updateContext: Parameters<typeof workflowRunDefinition.update>[0] = { ...startedContext, state }
  227. const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update')
  228. expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state)
  229. expect(workflowRunDefinition.target).toBe('chat')
  230. expect(workflowRunDefinition.buildViewNode?.({
  231. ...updateContext, matches: [], start: undefined,
  232. })).toBeNull()
  233. const directNode = workflowRunDefinition.buildViewNode?.(updateContext) as ChatConversationViewNode | null | undefined
  234. if (directNode === null) throw new Error('expected direct workflow Chat node')
  235. if (directNode === undefined) throw new Error('expected workflow Chat view builder')
  236. expect(directNode.kind).toBe('workflow-run')
  237. expect((directNode.data as WorkflowRunChatData).status).toBe('running')
  238. })
  239. })
  240. function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] {
  241. return {
  242. key: '12:workflow-runrun-1',
  243. kind: 'workflow-run',
  244. id: 'run-1',
  245. target: 'chat',
  246. anchorSeq: 3,
  247. location: { kind: 'unresolved' },
  248. visibility: 'visible',
  249. data,
  250. }
  251. }
  252. const phase = (overrides: Partial<WorkflowRunChatData['phases'][number]> = {}): WorkflowRunChatData['phases'][number] => ({
  253. key: 'missing',
  254. phase: null,
  255. members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }],
  256. ...overrides,
  257. })
  258. const listState = (overrides: Partial<SessionListState> = {}): SessionListState => ({
  259. ids: [PARENT_ID, CHILD_ID],
  260. byId: {
  261. [PARENT_ID]: {
  262. id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0,
  263. },
  264. [CHILD_ID]: {
  265. id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent',
  266. running: true, blank: false, updatedAt: 0,
  267. },
  268. },
  269. current: PARENT_ID,
  270. phase: 'ready',
  271. subagentsByParent: {},
  272. jobsBySession: {},
  273. currentAddress: undefined,
  274. ...overrides,
  275. })
  276. function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps {
  277. return {
  278. node: node(data),
  279. sessionId: PARENT_ID,
  280. useSessions: selector => selector(sessions),
  281. useSessionPendingInteraction: selector => selector(panelAttention),
  282. useSession: selector => selector(panelSession),
  283. useProjection: () => undefined,
  284. useConversation: selector => selector(panelConversation),
  285. useChat: selector => selector(panelChat),
  286. useTrajectory: selector => selector(panelTrajectory),
  287. useInput: () => { throw new Error('unused') },
  288. inputActions: {
  289. setDraft: () => {},
  290. addImages: () => false,
  291. removeImage: () => {},
  292. pruneImages: () => {},
  293. submit: () => {},
  294. },
  295. useWorkspaces: selector => selector(panelWorkspace),
  296. useTurnData: () => undefined,
  297. openFile: () => {},
  298. inspectCall: () => {},
  299. forkAt: () => {},
  300. renderMessageImages: () => null,
  301. fileMentions: () => undefined,
  302. openSession,
  303. t: makeTranslate(zh),
  304. }
  305. }
  306. describe('WorkflowRunPanel', () => {
  307. it('keeps live run and phase controls manual across ordinary updates and outer hiding', () => {
  308. const running: WorkflowRunChatData = {
  309. name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
  310. }
  311. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  312. const runHeader = screen.getByRole('button', { name: /^audit/ })
  313. const phaseHeader = screen.getByRole('button', { name: /Research/ })
  314. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  315. expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
  316. expect(screen.getByText('worker')).toBeTruthy()
  317. fireEvent.click(phaseHeader)
  318. expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
  319. expect(screen.queryByText('worker')).toBeNull()
  320. fireEvent.click(runHeader)
  321. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  322. view.rerender(<WorkflowRunPanel {...panelProps({
  323. ...running,
  324. phases: [phase({
  325. key: 'research', phase: 'Research',
  326. members: [
  327. { seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' },
  328. { seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running' },
  329. ],
  330. })],
  331. })} />)
  332. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  333. expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
  334. fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
  335. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  336. fireEvent.keyDown(runHeader, { key: ' ' })
  337. const updatedPhase = screen.getByRole('button', { name: /Research/ })
  338. expect(updatedPhase.getAttribute('aria-expanded')).toBe('false')
  339. expect(screen.getByText('运行中 2')).toBeTruthy()
  340. fireEvent.keyDown(updatedPhase, { key: 'Enter' })
  341. expect(screen.getByText('worker')).toBeTruthy()
  342. expect(screen.getByText('second')).toBeTruthy()
  343. fireEvent.click(runHeader)
  344. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  345. expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
  346. fireEvent.keyDown(runHeader, { key: ' ' })
  347. expect(screen.getByRole('button', { name: /Research/ }).getAttribute('aria-expanded')).toBe('true')
  348. })
  349. it('folds each normal completion once and opens a new same-key activity cycle', () => {
  350. const running: WorkflowRunChatData = {
  351. name: 'audit', status: 'running', phases: [phase()],
  352. }
  353. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  354. const runningPhase = screen.getByRole('button', { name: /未分阶段/ })
  355. fireEvent.click(runningPhase)
  356. fireEvent.keyDown(runningPhase, { key: 'Enter' })
  357. expect(screen.getByText('worker')).toBeTruthy()
  358. const phaseCompleted: WorkflowRunChatData = {
  359. ...running,
  360. phases: [phase({
  361. members: [{
  362. seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed',
  363. }],
  364. })],
  365. }
  366. view.rerender(<WorkflowRunPanel {...panelProps(phaseCompleted)} />)
  367. const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
  368. expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
  369. expect(screen.queryByText('done')).toBeNull()
  370. fireEvent.click(phaseHeader)
  371. expect(screen.getByText('done')).toBeTruthy()
  372. const cleanUpdate: WorkflowRunChatData = {
  373. ...phaseCompleted,
  374. phases: [phase({
  375. members: [{
  376. seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
  377. }],
  378. })],
  379. }
  380. view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
  381. expect(screen.getByText('reviewed')).toBeTruthy()
  382. fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
  383. const runHeader = screen.getByRole('button', { name: /^audit/ })
  384. fireEvent.click(runHeader)
  385. const renewed: WorkflowRunChatData = {
  386. name: 'audit', status: 'running',
  387. phases: [phase({
  388. members: [
  389. { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' },
  390. { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'running' },
  391. ],
  392. })],
  393. }
  394. view.rerender(<WorkflowRunPanel {...panelProps(renewed)} />)
  395. expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
  396. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
  397. expect(screen.getByText('new')).toBeTruthy()
  398. const renewedPhaseCompleted: WorkflowRunChatData = {
  399. ...renewed,
  400. phases: [phase({
  401. members: [
  402. { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' },
  403. { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'completed' },
  404. ],
  405. })],
  406. }
  407. view.rerender(<WorkflowRunPanel {...panelProps(renewedPhaseCompleted)} />)
  408. expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
  409. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
  410. fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
  411. view.rerender(<WorkflowRunPanel {...panelProps({
  412. ...renewedPhaseCompleted,
  413. status: 'completed',
  414. })} />)
  415. expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
  416. fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
  417. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
  418. })
  419. it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
  420. const firstMember = {
  421. seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const,
  422. }
  423. const phaseClean: WorkflowRunChatData = {
  424. name: 'phase-cycle', status: 'running',
  425. phases: [phase({ members: [firstMember] })],
  426. }
  427. const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
  428. fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
  429. expect(screen.getByText('first')).toBeTruthy()
  430. const runHeader = screen.getByRole('button', { name: /^phase-cycle/ })
  431. fireEvent.click(runHeader)
  432. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  433. phaseView.rerender(<WorkflowRunPanel {...panelProps({
  434. ...phaseClean,
  435. phases: [phase({ members: [firstMember, {
  436. seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
  437. }] })],
  438. })} />)
  439. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  440. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
  441. expect(screen.queryByText('first')).toBeNull()
  442. expect(screen.queryByText('second')).toBeNull()
  443. phaseView.rerender(<WorkflowRunPanel {...panelProps({
  444. ...phaseClean,
  445. status: 'completed',
  446. phases: [phase({ members: [firstMember, {
  447. seq: 2, label: 'second', childId: SECOND_ID, status: 'completed',
  448. }, {
  449. seq: 3, label: 'final', childId: 'child-3' as SessionId, status: 'completed',
  450. }] })],
  451. })} />)
  452. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  453. expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
  454. })
  455. it('initializes a newly observed phase before it becomes interactive', () => {
  456. const running: WorkflowRunChatData = {
  457. name: 'dynamic-phase', status: 'running',
  458. phases: [phase({ key: 'research', phase: 'Research' })],
  459. }
  460. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  461. view.rerender(<WorkflowRunPanel {...panelProps({
  462. ...running,
  463. phases: [
  464. ...running.phases,
  465. phase({
  466. key: 'build', phase: 'Build',
  467. members: [{ seq: 2, label: 'builder', childId: SECOND_ID, status: 'running' }],
  468. }),
  469. ],
  470. })} />)
  471. const build = screen.getByRole('button', { name: /Build/ })
  472. expect(build.getAttribute('aria-expanded')).toBe('true')
  473. fireEvent.click(build)
  474. expect(build.getAttribute('aria-expanded')).toBe('false')
  475. })
  476. it('derives the zero-member running and completed states from the current run status', () => {
  477. const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
  478. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  479. expect(screen.getByRole('button', { name: /^empty/ }).getAttribute('aria-expanded')).toBe('true')
  480. expect(screen.getByText('没有启动成员')).toBeTruthy()
  481. view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
  482. const header = screen.getByRole('button', { name: /^empty/ })
  483. expect(header.getAttribute('aria-expanded')).toBe('false')
  484. expect(screen.queryByText('没有启动成员')).toBeNull()
  485. fireEvent.click(header)
  486. expect(screen.getByText('没有启动成员')).toBeTruthy()
  487. })
  488. it.each(['failed', 'cancelled', 'interrupted'] as const)(
  489. 'initializes %s attention as an expanded disclosure that remains manually collapsible',
  490. (status) => {
  491. render(<WorkflowRunPanel {...panelProps({
  492. name: 'member-outcome', status,
  493. phases: [phase({
  494. members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
  495. })],
  496. })} />)
  497. const runHeader = screen.getByRole('button', { name: /^member-outcome/ })
  498. const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
  499. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  500. expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
  501. expect(screen.getByText(status)).toBeTruthy()
  502. fireEvent.click(phaseHeader)
  503. fireEvent.click(runHeader)
  504. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  505. },
  506. )
  507. it('opens the first abnormal edge once and preserves later abnormal choices', () => {
  508. const running: WorkflowRunChatData = {
  509. name: 'audit', status: 'running', phases: [phase()],
  510. }
  511. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  512. fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
  513. fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
  514. const failed: WorkflowRunChatData = {
  515. name: 'audit', status: 'running',
  516. phases: [phase({
  517. members: [{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' }],
  518. })],
  519. }
  520. view.rerender(<WorkflowRunPanel {...panelProps(failed)} />)
  521. expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
  522. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
  523. fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
  524. fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
  525. view.rerender(<WorkflowRunPanel {...panelProps({
  526. ...failed,
  527. phases: [phase({
  528. members: [
  529. { seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' },
  530. { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
  531. ],
  532. })],
  533. })} />)
  534. expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
  535. fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
  536. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
  537. expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
  538. })
  539. it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
  540. render(<WorkflowRunPanel {...panelProps({
  541. name: 'audit', status: 'completed',
  542. phases: [
  543. phase({ key: 'value:0:', phase: '', members: [{
  544. seq: 1, label: '', childId: 'child-1' as SessionId, status: 'completed',
  545. }] }),
  546. phase({ key: 'missing', phase: null, members: [{
  547. seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
  548. }] }),
  549. ],
  550. })} />)
  551. const runHeader = screen.getByRole('button', { name: /^audit/ })
  552. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  553. const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
  554. expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
  555. const activePhase = screen.getByRole('button', { name: /未分阶段/ })
  556. expect(activePhase.getAttribute('aria-expanded')).toBe('true')
  557. expect(screen.queryByText('空成员名')).toBeNull()
  558. expect(screen.getByText('second')).toBeTruthy()
  559. fireEvent.click(cleanPhase)
  560. expect(screen.getByText('空成员名')).toBeTruthy()
  561. expect(screen.getByText('second')).toBeTruthy()
  562. fireEvent.click(activePhase)
  563. expect(screen.queryByText('second')).toBeNull()
  564. expect(screen.getByText('空成员名')).toBeTruthy()
  565. fireEvent.click(runHeader)
  566. fireEvent.click(runHeader)
  567. expect(screen.getByRole('button', { name: /空阶段名/ }).getAttribute('aria-expanded')).toBe('true')
  568. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
  569. fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
  570. expect(screen.queryByText('空成员名')).toBeNull()
  571. })
  572. it('renders mixed and interrupted aggregate status while attention stays visible', () => {
  573. const mixed: WorkflowRunChatData = {
  574. name: 'repo-audit', status: 'failed',
  575. phases: [phase({
  576. members: [
  577. { seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' },
  578. { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
  579. ],
  580. })],
  581. }
  582. const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
  583. expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
  584. expect([...mixedView.container.querySelectorAll('[data-member-status]')]
  585. .map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
  586. expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2)
  587. expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
  588. mixedView.unmount()
  589. const interruptedView = render(<WorkflowRunPanel {...panelProps({
  590. name: 'repo-audit', status: 'interrupted',
  591. phases: [phase({
  592. members: [
  593. { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
  594. { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
  595. ],
  596. })],
  597. })} />)
  598. expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
  599. expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
  600. expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
  601. })
  602. it('defers normal completion collapse until focused member content loses focus', () => {
  603. const sessions = listState({
  604. ids: [PARENT_ID, CHILD_ID, SECOND_ID],
  605. byId: {
  606. ...listState().byId,
  607. [SECOND_ID]: {
  608. id: SECOND_ID, displayTitle: 'second', parentId: PARENT_ID, origin: 'subagent',
  609. running: true, blank: false, updatedAt: 0,
  610. },
  611. },
  612. })
  613. const running: WorkflowRunChatData = {
  614. name: 'audit', status: 'running', phases: [phase({
  615. members: [
  616. { seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' },
  617. { seq: 2, label: 'second', childId: SECOND_ID, status: 'running' },
  618. ],
  619. })],
  620. }
  621. const view = render(<WorkflowRunPanel {...panelProps(running, sessions)} />)
  622. const member = screen.getByRole('button', { name: '打开 worker' })
  623. const second = screen.getByRole('button', { name: '打开 second' })
  624. const runHeader = screen.getByRole('button', { name: /^audit/ })
  625. const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
  626. member.focus()
  627. expect(document.activeElement).toBe(member)
  628. fireEvent.blur(member, { relatedTarget: second })
  629. second.focus()
  630. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  631. expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
  632. const outside = document.createElement('button')
  633. document.body.append(outside)
  634. fireEvent.blur(second, { relatedTarget: outside })
  635. outside.focus()
  636. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  637. expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
  638. member.focus()
  639. view.rerender(<WorkflowRunPanel {...panelProps({
  640. name: 'audit', status: 'completed',
  641. phases: [phase({
  642. members: [
  643. { seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' },
  644. { seq: 2, label: 'second', childId: SECOND_ID, status: 'completed' },
  645. ],
  646. })],
  647. }, sessions)} />)
  648. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  649. expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
  650. const retained = screen.getByRole('button', { name: 'worker' })
  651. expect(retained.getAttribute('aria-disabled')).toBe('true')
  652. expect(document.activeElement).toBe(retained)
  653. fireEvent.blur(retained, { relatedTarget: outside })
  654. outside.focus()
  655. expect(document.activeElement).toBe(outside)
  656. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  657. fireEvent.click(runHeader)
  658. const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
  659. expect(completedPhase.getAttribute('aria-expanded')).toBe('false')
  660. fireEvent.click(completedPhase)
  661. expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
  662. expect(screen.getByText('worker')).toBeTruthy()
  663. outside.remove()
  664. })
  665. it('handles a pointer blur and header click as one pending-completion close', () => {
  666. const running: WorkflowRunChatData = {
  667. name: 'audit', status: 'running', phases: [phase()],
  668. }
  669. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  670. const member = screen.getByRole('button', { name: '打开 worker' })
  671. member.focus()
  672. view.rerender(<WorkflowRunPanel {...panelProps({
  673. name: 'audit', status: 'completed',
  674. phases: [phase({
  675. members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
  676. })],
  677. })} />)
  678. const retained = screen.getByRole('button', { name: 'worker' })
  679. const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
  680. const runHeader = screen.getByRole('button', { name: /^audit/ })
  681. expect(fireEvent.mouseDown(retained)).toBe(true)
  682. expect(document.activeElement).toBe(retained)
  683. expect(fireEvent.mouseDown(phaseHeader)).toBe(false)
  684. fireEvent.click(phaseHeader)
  685. expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
  686. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  687. expect(fireEvent.mouseDown(runHeader)).toBe(false)
  688. fireEvent.click(runHeader)
  689. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  690. })
  691. it('settles pending completion when keyboard focus moves from content to its header', () => {
  692. const running: WorkflowRunChatData = {
  693. name: 'audit', status: 'running', phases: [phase()],
  694. }
  695. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  696. const member = screen.getByRole('button', { name: '打开 worker' })
  697. member.focus()
  698. view.rerender(<WorkflowRunPanel {...panelProps({
  699. ...running,
  700. phases: [phase({
  701. members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
  702. })],
  703. })} />)
  704. const retained = screen.getByRole('button', { name: 'worker' })
  705. const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
  706. const runHeader = screen.getByRole('button', { name: /^audit/ })
  707. fireEvent.blur(retained, { relatedTarget: phaseHeader })
  708. phaseHeader.focus()
  709. expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
  710. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  711. view.rerender(<WorkflowRunPanel {...panelProps({
  712. ...running,
  713. status: 'completed',
  714. phases: [phase({
  715. members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
  716. })],
  717. })} />)
  718. expect(runHeader.getAttribute('aria-expanded')).toBe('true')
  719. fireEvent.blur(phaseHeader, { relatedTarget: runHeader })
  720. runHeader.focus()
  721. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  722. })
  723. it('settles a deferred phase close when the user hides the outer run', () => {
  724. const running: WorkflowRunChatData = {
  725. name: 'audit', status: 'running', phases: [phase()],
  726. }
  727. const view = render(<WorkflowRunPanel {...panelProps(running)} />)
  728. const member = screen.getByRole('button', { name: '打开 worker' })
  729. member.focus()
  730. view.rerender(<WorkflowRunPanel {...panelProps({
  731. ...running,
  732. phases: [phase({
  733. members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
  734. })],
  735. })} />)
  736. const runHeader = screen.getByRole('button', { name: /^audit/ })
  737. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
  738. fireEvent.click(runHeader)
  739. expect(runHeader.getAttribute('aria-expanded')).toBe('false')
  740. fireEvent.click(runHeader)
  741. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
  742. })
  743. it('reinitializes manual choices from durable facts after a renderer remount', () => {
  744. const data: WorkflowRunChatData = {
  745. name: 'audit', status: 'running', phases: [phase()],
  746. }
  747. const view = render(<WorkflowRunPanel {...panelProps(data)} />)
  748. fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
  749. fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
  750. view.unmount()
  751. render(<WorkflowRunPanel {...panelProps(data)} />)
  752. expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
  753. expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
  754. })
  755. it('opens only a running ordinary-list subagent proven to have this parent', () => {
  756. const data: WorkflowRunChatData = {
  757. name: 'audit', status: 'running', phases: [phase()],
  758. }
  759. const openSession = vi.fn()
  760. render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
  761. fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
  762. expect(openSession).toHaveBeenCalledWith('child-1')
  763. })
  764. it('promotes a running member when its ordinary Session row arrives', () => {
  765. const data: WorkflowRunChatData = {
  766. name: 'audit', status: 'running', phases: [phase()],
  767. }
  768. const view = render(<WorkflowRunPanel {...panelProps(data, listState({ ids: [PARENT_ID] }))} />)
  769. expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
  770. view.rerender(<WorkflowRunPanel {...panelProps(data, listState())} />)
  771. expect(screen.getByRole('button', { name: '打开 worker' })).toBeTruthy()
  772. })
  773. it.each([
  774. ['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
  775. ['remote row', listState({ byId: {
  776. ...listState().byId,
  777. [CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined },
  778. } }), 'running'],
  779. ['wrong parent', listState({ byId: {
  780. ...listState().byId,
  781. [CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId },
  782. } }), 'running'],
  783. ['list terminal', listState({ byId: {
  784. ...listState().byId,
  785. [CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false },
  786. } }), 'running'],
  787. ['member terminal', listState(), 'completed'],
  788. ] as const)('does not navigate when %s', (_name, sessions, memberStatus) => {
  789. const data: WorkflowRunChatData = {
  790. name: 'audit', status: 'running',
  791. phases: [phase({
  792. members: [{
  793. seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus,
  794. }],
  795. })],
  796. }
  797. render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
  798. expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
  799. cleanup()
  800. })
  801. })
  802. class TestSessions extends Service {
  803. readonly opened: SessionId[] = []
  804. constructor(ctx: Context) { super(ctx, 'sessions') }
  805. open(id: SessionId): void { this.opened.push(id) }
  806. }
  807. describe('plugin lifecycle', () => {
  808. it('registers and removes the Definition and keyed renderer with its fiber', async () => {
  809. const ctx = new Context()
  810. await ctx.plugin(SlotRegistry).await()
  811. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
  812. ctx.provide('remote', { $on: () => () => {} } as never)
  813. ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
  814. await ctx.plugin(TestSessions).await()
  815. const conversationEvents = new UiConversation(ctx, ctx.sessions).events
  816. ctx.slots.register({
  817. name: 'root',
  818. children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } },
  819. } as never, () => null)
  820. await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
  821. const fiber = ctx.plugin({ inject: [...inject], apply })
  822. await fiber.await()
  823. expect(conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
  824. expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
  825. const entry = ctx.slots.entries('conversation.chat.node')[0]!
  826. const face = entry.inject?.() as unknown as WorkflowRunInjected
  827. face.openSession(CHILD_ID)
  828. expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID])
  829. await fiber.dispose()
  830. expect(conversationEvents.entries()).toEqual([])
  831. expect(ctx.slots.entries('conversation.chat.node')).toEqual([])
  832. const replacement = ctx.plugin({ inject: [...inject], apply })
  833. await replacement.await()
  834. expect(conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
  835. expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
  836. await replacement.dispose()
  837. })
  838. it('keeps the node half inert and registers invariant ownership', async () => {
  839. applyNode()
  840. const registered: string[] = []
  841. const ctx = new Context()
  842. ctx.provide('invariants')
  843. ctx.set('invariants', {
  844. register: (pkg: string) => { registered.push(pkg); return () => {} },
  845. } as never)
  846. await applyInvariant(ctx)
  847. expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run'])
  848. })
  849. })