workflow-run.client.spec.tsx 40 KB

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