@meiyukichan/dsh-sandore 0.1.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/LICENSE +201 -0
  2. package/cordis.patch.yml +39 -0
  3. package/dsh.plugin.json +16 -0
  4. package/lib/client/CommandEditor.d.ts +19 -0
  5. package/lib/client/DagEditor.d.ts +22 -0
  6. package/lib/client/DefinitionDetail.d.ts +13 -0
  7. package/lib/client/DefinitionList.d.ts +19 -0
  8. package/lib/client/DiscussionInput.d.ts +63 -0
  9. package/lib/client/DragHandle.d.ts +16 -0
  10. package/lib/client/EditorPane.d.ts +13 -0
  11. package/lib/client/FlowEditor.d.ts +31 -0
  12. package/lib/client/InstanceList.d.ts +24 -0
  13. package/lib/client/NewInstanceDialog.d.ts +19 -0
  14. package/lib/client/PreviewPanel.d.ts +13 -0
  15. package/lib/client/RunView.d.ts +79 -0
  16. package/lib/client/StepChatPanel.d.ts +34 -0
  17. package/lib/client/TreeNav.d.ts +27 -0
  18. package/lib/client/UnifiedTimeline.d.ts +11 -0
  19. package/lib/client/WorkflowButton.d.ts +11 -0
  20. package/lib/client/WorkflowList.d.ts +18 -0
  21. package/lib/client/WorkflowOverlay.d.ts +6 -0
  22. package/lib/client/chat/AssistantMarkdown.d.ts +22 -0
  23. package/lib/client/chat/CompactionItem.d.ts +24 -0
  24. package/lib/client/chat/ContextBody.d.ts +136 -0
  25. package/lib/client/chat/ContextInjectionRow.d.ts +25 -0
  26. package/lib/client/chat/GenericCommandCard.d.ts +10 -0
  27. package/lib/client/chat/MessageIconActions.d.ts +41 -0
  28. package/lib/client/chat/MessageItem.d.ts +65 -0
  29. package/lib/client/chat/ReasoningRow.d.ts +13 -0
  30. package/lib/client/chat/TurnTailNodeView.d.ts +29 -0
  31. package/lib/client/chat/UserMessageRow.d.ts +18 -0
  32. package/lib/client/chat/attachment/ImageLightbox.d.ts +26 -0
  33. package/lib/client/chat/attachment/MessageImage.d.ts +56 -0
  34. package/lib/client/chat/attachment/image-attachment.d.ts +41 -0
  35. package/lib/client/chat/attachment/image-labels.d.ts +21 -0
  36. package/lib/client/chat/message-chrome.d.ts +51 -0
  37. package/lib/client/chat/reference/ReferenceIcon.d.ts +20 -0
  38. package/lib/client/chat/turn-assistant.d.ts +12 -0
  39. package/lib/client/chat/turn-metrics.d.ts +42 -0
  40. package/lib/client/chat/use-calendar-day.d.ts +5 -0
  41. package/lib/client/chat/use-throttled-visual-update.d.ts +7 -0
  42. package/lib/client/chat-flow.d.ts +110 -0
  43. package/lib/client/composer-selects.d.ts +17 -0
  44. package/lib/client/dag-editor-model.d.ts +141 -0
  45. package/lib/client/describe-error.d.ts +9 -0
  46. package/lib/client/index.d.ts +6 -0
  47. package/lib/client/locales.d.ts +777 -0
  48. package/lib/client/model-faces.d.ts +156 -0
  49. package/lib/client/open-discussion.d.ts +24 -0
  50. package/lib/client/panel-model.d.ts +34 -0
  51. package/lib/client/remote.d.ts +34 -0
  52. package/lib/client/run-view-model.d.ts +304 -0
  53. package/lib/client/sandore-save.d.ts +41 -0
  54. package/lib/client/sandore-sse.d.ts +15 -0
  55. package/lib/client/step-chat-retry.d.ts +44 -0
  56. package/lib/client/tool/GenericToolCard.d.ts +28 -0
  57. package/lib/client/tool/ToolRow.d.ts +95 -0
  58. package/lib/client/tool/diff-card-model.d.ts +63 -0
  59. package/lib/client/tool/read-card-model.d.ts +67 -0
  60. package/lib/client/tool/search-card-model.d.ts +94 -0
  61. package/lib/client/tool/terminal-card-model.d.ts +86 -0
  62. package/lib/client/tool/tool-call-model.d.ts +74 -0
  63. package/lib/client/tool/web-card-model.d.ts +44 -0
  64. package/lib/client/use-focus-refresh.d.ts +1 -0
  65. package/lib/client/workflow-face.d.ts +68 -0
  66. package/lib/client/workflow-store.d.ts +80 -0
  67. package/lib/client/workspace-path.d.ts +15 -0
  68. package/lib/client.js +38389 -0
  69. package/lib/index.js +8854 -0
  70. package/lib/microflow/branch-evaluator.d.ts +290 -0
  71. package/lib/microflow/concurrency-limiter.d.ts +39 -0
  72. package/lib/microflow/errors.d.ts +39 -0
  73. package/lib/microflow/file-utils.d.ts +210 -0
  74. package/lib/microflow/finalize-manager.d.ts +414 -0
  75. package/lib/microflow/fork-session.d.ts +151 -0
  76. package/lib/microflow/index.d.ts +17 -0
  77. package/lib/microflow/internal.d.ts +56 -0
  78. package/lib/microflow/loop-manager.d.ts +127 -0
  79. package/lib/microflow/rules-resolver.d.ts +28 -0
  80. package/lib/microflow/stage-executor.d.ts +362 -0
  81. package/lib/microflow/step-navigator.d.ts +108 -0
  82. package/lib/microflow/step-runner.d.ts +190 -0
  83. package/lib/microflow/subagents.d.ts +380 -0
  84. package/lib/tool/index.d.ts +65 -0
  85. package/lib/typert.host.d.ts +16 -0
  86. package/lib/typert.host.js +25 -0
  87. package/lib/typert.remote-client.d.ts +114 -0
  88. package/lib/typert.remote-client.js +399 -0
  89. package/lib/workflow/ask-tool.d.ts +78 -0
  90. package/lib/workflow/dag-navigator.d.ts +136 -0
  91. package/lib/workflow/domain.d.ts +304 -0
  92. package/lib/workflow/engine-surface.d.ts +70 -0
  93. package/lib/workflow/errors.d.ts +13 -0
  94. package/lib/workflow/host-agent.d.ts +53 -0
  95. package/lib/workflow/index.d.ts +23 -0
  96. package/lib/workflow/interaction-manager.d.ts +170 -0
  97. package/lib/workflow/lifecycle-manager.d.ts +111 -0
  98. package/lib/workflow/persistence.d.ts +35 -0
  99. package/lib/workflow/plugin.d.ts +27 -0
  100. package/lib/workflow/reactive-state.d.ts +27 -0
  101. package/lib/workflow/recovery-manager.d.ts +158 -0
  102. package/lib/workflow/remote.d.ts +139 -0
  103. package/lib/workflow/schema.d.ts +1917 -0
  104. package/lib/workflow/seam.d.ts +254 -0
  105. package/lib/workflow/shell.d.ts +127 -0
  106. package/lib/workflow/sse.d.ts +16 -0
  107. package/lib/workflow/typert/typert.host.d.ts +16 -0
  108. package/lib/workflow/typert/typert.remote-client.d.ts +114 -0
  109. package/lib/workflow/types.d.ts +187 -0
  110. package/lib/workflow/wire.d.ts +256 -0
  111. package/lib/workflow/workflow-engine.d.ts +373 -0
  112. package/lib/workflow/yaml-error.d.ts +22 -0
  113. package/lib/workflow/yaml-parser.d.ts +23 -0
  114. package/package.json +140 -0
  115. package/packages/client/CommandEditor.tsx +73 -0
  116. package/packages/client/DagEditor.module.css +711 -0
  117. package/packages/client/DagEditor.tsx +1203 -0
  118. package/packages/client/DefinitionDetail.module.css +110 -0
  119. package/packages/client/DefinitionDetail.tsx +126 -0
  120. package/packages/client/DefinitionList.tsx +173 -0
  121. package/packages/client/DiscussionInput.tsx +763 -0
  122. package/packages/client/DragHandle.tsx +92 -0
  123. package/packages/client/EditorPane.tsx +70 -0
  124. package/packages/client/FlowEditor.tsx +227 -0
  125. package/packages/client/InstanceList.tsx +250 -0
  126. package/packages/client/NewInstanceDialog.module.css +244 -0
  127. package/packages/client/NewInstanceDialog.tsx +478 -0
  128. package/packages/client/PreviewPanel.tsx +55 -0
  129. package/packages/client/RunView.module.css +246 -0
  130. package/packages/client/RunView.tsx +314 -0
  131. package/packages/client/StepChatPanel.tsx +486 -0
  132. package/packages/client/TreeNav.tsx +294 -0
  133. package/packages/client/UnifiedTimeline.tsx +484 -0
  134. package/packages/client/WorkflowButton.module.css +72 -0
  135. package/packages/client/WorkflowButton.tsx +50 -0
  136. package/packages/client/WorkflowList.tsx +158 -0
  137. package/packages/client/WorkflowOverlay.module.css +390 -0
  138. package/packages/client/WorkflowOverlay.tsx +136 -0
  139. package/packages/client/WorkflowPanel.module.css +247 -0
  140. package/packages/client/chat/AssistantMarkdown.module.css +38 -0
  141. package/packages/client/chat/AssistantMarkdown.tsx +120 -0
  142. package/packages/client/chat/ChatView.module.css +198 -0
  143. package/packages/client/chat/CompactionItem.tsx +85 -0
  144. package/packages/client/chat/ContextBody.module.css +162 -0
  145. package/packages/client/chat/ContextBody.tsx +598 -0
  146. package/packages/client/chat/ContextInjectionRow.module.css +63 -0
  147. package/packages/client/chat/ContextInjectionRow.tsx +78 -0
  148. package/packages/client/chat/GenericCommandCard.module.css +87 -0
  149. package/packages/client/chat/GenericCommandCard.tsx +76 -0
  150. package/packages/client/chat/MessageIconActions.module.css +84 -0
  151. package/packages/client/chat/MessageIconActions.tsx +147 -0
  152. package/packages/client/chat/MessageItem.module.css +293 -0
  153. package/packages/client/chat/MessageItem.tsx +384 -0
  154. package/packages/client/chat/ReasoningRow.module.css +82 -0
  155. package/packages/client/chat/ReasoningRow.tsx +70 -0
  156. package/packages/client/chat/TurnTailNodeView.module.css +10 -0
  157. package/packages/client/chat/TurnTailNodeView.tsx +63 -0
  158. package/packages/client/chat/UserMessageRow.module.css +75 -0
  159. package/packages/client/chat/UserMessageRow.tsx +92 -0
  160. package/packages/client/chat/accessibility.module.css +9 -0
  161. package/packages/client/chat/attachment/ImageLightbox.module.css +45 -0
  162. package/packages/client/chat/attachment/ImageLightbox.tsx +70 -0
  163. package/packages/client/chat/attachment/MessageImage.module.css +70 -0
  164. package/packages/client/chat/attachment/MessageImage.tsx +136 -0
  165. package/packages/client/chat/attachment/image-attachment.ts +98 -0
  166. package/packages/client/chat/attachment/image-labels.ts +34 -0
  167. package/packages/client/chat/message-chrome.ts +96 -0
  168. package/packages/client/chat/reference/ReferenceIcon.tsx +40 -0
  169. package/packages/client/chat/turn-assistant.ts +15 -0
  170. package/packages/client/chat/turn-metrics.ts +102 -0
  171. package/packages/client/chat/use-calendar-day.ts +27 -0
  172. package/packages/client/chat/use-throttled-visual-update.ts +44 -0
  173. package/packages/client/chat-flow.spec.ts +357 -0
  174. package/packages/client/chat-flow.ts +278 -0
  175. package/packages/client/composer-selects.module.css +376 -0
  176. package/packages/client/composer-selects.tsx +531 -0
  177. package/packages/client/dag-editor-model.spec.ts +173 -0
  178. package/packages/client/dag-editor-model.ts +359 -0
  179. package/packages/client/describe-error.spec.ts +18 -0
  180. package/packages/client/describe-error.ts +12 -0
  181. package/packages/client/index.ts +144 -0
  182. package/packages/client/input.module.css +854 -0
  183. package/packages/client/is-conditional-check.spec.ts +34 -0
  184. package/packages/client/locales.ts +789 -0
  185. package/packages/client/model-faces.ts +225 -0
  186. package/packages/client/open-discussion.spec.ts +39 -0
  187. package/packages/client/open-discussion.ts +32 -0
  188. package/packages/client/panel-model.spec.ts +152 -0
  189. package/packages/client/panel-model.ts +72 -0
  190. package/packages/client/preview.module.css +95 -0
  191. package/packages/client/react-dom.d.ts +11 -0
  192. package/packages/client/reactflow-theme.css +91 -0
  193. package/packages/client/remote.spec.ts +41 -0
  194. package/packages/client/remote.ts +46 -0
  195. package/packages/client/run-view-model.spec.ts +641 -0
  196. package/packages/client/run-view-model.ts +636 -0
  197. package/packages/client/sandore-save.spec.ts +146 -0
  198. package/packages/client/sandore-save.ts +97 -0
  199. package/packages/client/sandore-sse.ts +59 -0
  200. package/packages/client/step-chat-retry.spec.ts +70 -0
  201. package/packages/client/step-chat-retry.ts +65 -0
  202. package/packages/client/timeline.module.css +503 -0
  203. package/packages/client/tool/GenericToolCard.tsx +99 -0
  204. package/packages/client/tool/ToolRow.module.css +308 -0
  205. package/packages/client/tool/ToolRow.tsx +328 -0
  206. package/packages/client/tool/diff-card-model.ts +103 -0
  207. package/packages/client/tool/read-card-model.ts +86 -0
  208. package/packages/client/tool/search-card-model.ts +166 -0
  209. package/packages/client/tool/terminal-card-model.ts +225 -0
  210. package/packages/client/tool/tool-call-model.ts +267 -0
  211. package/packages/client/tool/web-card-model.ts +80 -0
  212. package/packages/client/tree-nav.module.css +282 -0
  213. package/packages/client/use-focus-refresh.ts +23 -0
  214. package/packages/client/workflow-face.ts +72 -0
  215. package/packages/client/workflow-store.spec.ts +49 -0
  216. package/packages/client/workflow-store.ts +161 -0
  217. package/packages/client/workspace-path.ts +27 -0
  218. package/packages/css-modules.d.ts +6 -0
  219. package/packages/microflow/branch-evaluator.spec.ts +745 -0
  220. package/packages/microflow/branch-evaluator.ts +1037 -0
  221. package/packages/microflow/concurrency-limiter.ts +70 -0
  222. package/packages/microflow/errors.ts +64 -0
  223. package/packages/microflow/file-utils.spec.ts +604 -0
  224. package/packages/microflow/file-utils.ts +521 -0
  225. package/packages/microflow/finalize-manager.spec.ts +1977 -0
  226. package/packages/microflow/finalize-manager.ts +1464 -0
  227. package/packages/microflow/fork-session.spec.ts +184 -0
  228. package/packages/microflow/fork-session.ts +316 -0
  229. package/packages/microflow/index.ts +17 -0
  230. package/packages/microflow/internal.ts +166 -0
  231. package/packages/microflow/loop-manager.spec.ts +294 -0
  232. package/packages/microflow/loop-manager.ts +255 -0
  233. package/packages/microflow/rules-resolver.spec.ts +147 -0
  234. package/packages/microflow/rules-resolver.ts +58 -0
  235. package/packages/microflow/stage-executor.spec.ts +1577 -0
  236. package/packages/microflow/stage-executor.ts +1478 -0
  237. package/packages/microflow/step-navigator.spec.ts +301 -0
  238. package/packages/microflow/step-navigator.ts +300 -0
  239. package/packages/microflow/step-runner.spec.ts +1132 -0
  240. package/packages/microflow/step-runner.ts +752 -0
  241. package/packages/microflow/subagents.spec.ts +215 -0
  242. package/packages/microflow/subagents.ts +422 -0
  243. package/packages/tests/fixtures/test-workflow.yaml +128 -0
  244. package/packages/tests/mocks/agents.ts +206 -0
  245. package/packages/tests/mocks/llm.ts +46 -0
  246. package/packages/tests/mocks/session-title.ts +31 -0
  247. package/packages/tests/mocks/sessions.ts +131 -0
  248. package/packages/tests/mocks/shell.ts +60 -0
  249. package/packages/tests/mocks/storage-domain.ts +101 -0
  250. package/packages/tests/mocks/subagents.ts +197 -0
  251. package/packages/tests/mocks/subprocess.ts +59 -0
  252. package/packages/tests/mocks/user-questions.ts +33 -0
  253. package/packages/tests/mocks/workspace.ts +52 -0
  254. package/packages/tool/index.spec.ts +287 -0
  255. package/packages/tool/index.ts +348 -0
  256. package/packages/workflow/ask-tool.ts +173 -0
  257. package/packages/workflow/dag-navigator.spec.ts +236 -0
  258. package/packages/workflow/dag-navigator.ts +425 -0
  259. package/packages/workflow/domain.ts +26 -0
  260. package/packages/workflow/engine-surface.ts +71 -0
  261. package/packages/workflow/errors.ts +13 -0
  262. package/packages/workflow/host-agent.spec.ts +92 -0
  263. package/packages/workflow/host-agent.ts +62 -0
  264. package/packages/workflow/index.ts +25 -0
  265. package/packages/workflow/interaction-manager.spec.ts +390 -0
  266. package/packages/workflow/interaction-manager.ts +397 -0
  267. package/packages/workflow/lifecycle-manager.spec.ts +683 -0
  268. package/packages/workflow/lifecycle-manager.ts +288 -0
  269. package/packages/workflow/persistence.spec.ts +109 -0
  270. package/packages/workflow/persistence.ts +64 -0
  271. package/packages/workflow/plugin.ts +39 -0
  272. package/packages/workflow/reactive-state.spec.ts +69 -0
  273. package/packages/workflow/reactive-state.ts +52 -0
  274. package/packages/workflow/recovery-manager.spec.ts +606 -0
  275. package/packages/workflow/recovery-manager.ts +471 -0
  276. package/packages/workflow/remote.spec.ts +1045 -0
  277. package/packages/workflow/remote.ts +479 -0
  278. package/packages/workflow/schema.spec.ts +303 -0
  279. package/packages/workflow/schema.ts +562 -0
  280. package/packages/workflow/seam.ts +293 -0
  281. package/packages/workflow/shell.ts +142 -0
  282. package/packages/workflow/sse.ts +110 -0
  283. package/packages/workflow/typert/typert.host.d.ts +16 -0
  284. package/packages/workflow/typert/typert.host.js +25 -0
  285. package/packages/workflow/typert/typert.remote-client.d.ts +114 -0
  286. package/packages/workflow/typert/typert.remote-client.js +399 -0
  287. package/packages/workflow/types.ts +258 -0
  288. package/packages/workflow/wire.ts +267 -0
  289. package/packages/workflow/workflow-engine-e2e.spec.ts +262 -0
  290. package/packages/workflow/workflow-engine-real-e2e.spec.ts +922 -0
  291. package/packages/workflow/workflow-engine.spec.ts +4389 -0
  292. package/packages/workflow/workflow-engine.ts +2133 -0
  293. package/packages/workflow/yaml-error.ts +29 -0
  294. package/packages/workflow/yaml-parser.spec.ts +757 -0
  295. package/packages/workflow/yaml-parser.ts +287 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * chat-flow 纯逻辑单测(node 环境,无 React / 无 DSH 运行时依赖)。
3
+ * @module @deepseek-ai/dsh-client-ui-sandore/src/client/chat-flow.spec
4
+ */
5
+ import { describe, expect, it } from 'vitest'
6
+ import type {
7
+ CommandNode, ConversationNode, PartialAssistant, RunningToolCall,
8
+ } from '@deepseek-ai/dsh-client-runtime/client'
9
+ import { buildStepChatFlow, STEP_CHAT_MAX_NODES, type StepChatFlowRow } from './chat-flow.ts'
10
+
11
+ // ── mock 工厂:构造结构合法的 ConversationNode / snapshot 输入 ─────────────
12
+
13
+ function userNode(seq: number, text: string): ConversationNode {
14
+ return { kind: 'user', seq, time: seq * 1000, content: [{ type: 'text', text }], source: {} }
15
+ }
16
+
17
+ function steeringNode(seq: number, text: string): ConversationNode {
18
+ return { kind: 'steering', seq, time: seq * 1000, messageId: `m-${seq}` as never, content: [{ type: 'text', text }], source: {} } as unknown as ConversationNode
19
+ }
20
+
21
+ function assistantNode(
22
+ seq: number,
23
+ turn: number,
24
+ step: number,
25
+ text: string,
26
+ toolCalls: ReadonlyArray<{ callId: string; name: string; argsRaw: string }> = [],
27
+ ): ConversationNode {
28
+ return {
29
+ kind: 'assistant',
30
+ seq,
31
+ time: seq * 1000,
32
+ turn,
33
+ step,
34
+ blocks: [
35
+ ...(text === '' ? [] : [{ kind: 'text' as const, text }]),
36
+ ...toolCalls.map((call) => ({ kind: 'tool-call' as const, ...call })),
37
+ ],
38
+ }
39
+ }
40
+
41
+ function toolResultNode(
42
+ seq: number,
43
+ callId: string,
44
+ text: string,
45
+ opts: { isError?: boolean; call?: { name: string; argsRaw: string } | null } = {},
46
+ ): ConversationNode {
47
+ return {
48
+ kind: 'tool-result',
49
+ seq,
50
+ time: seq * 1000,
51
+ callId,
52
+ call: opts.call ?? null,
53
+ callTime: null,
54
+ content: [{ type: 'text', text }],
55
+ isError: opts.isError ?? false,
56
+ callView: null,
57
+ resultView: null,
58
+ subCalls: [],
59
+ }
60
+ }
61
+
62
+ function commandNode(
63
+ seq: number,
64
+ opts: {
65
+ name?: string | null
66
+ args?: string | null
67
+ outcome?: CommandNode['outcome']
68
+ } = {},
69
+ ): ConversationNode {
70
+ return {
71
+ kind: 'command',
72
+ seq,
73
+ time: seq * 1000,
74
+ commandId: `cmd-${seq}` as never,
75
+ name: opts.name ?? 'ls',
76
+ args: opts.args ?? '',
77
+ outcome: opts.outcome ?? null,
78
+ } as unknown as ConversationNode
79
+ }
80
+
81
+ /** 边缘节点工厂:compaction / unknown / context / model-retry / turn-error / turn-max-tokens。 */
82
+ function edgeNode(
83
+ kind: 'compaction' | 'unknown' | 'context' | 'model-retry' | 'turn-error' | 'turn-max-tokens',
84
+ seq: number,
85
+ ): ConversationNode {
86
+ const base = { kind, seq, time: seq * 1000 } as object
87
+ switch (kind) {
88
+ case 'compaction':
89
+ return { ...base, summary: null, summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null } as unknown as ConversationNode
90
+ case 'unknown':
91
+ return { ...base, type: 'surface/whatever', data: {} } as unknown as ConversationNode
92
+ case 'context':
93
+ return {
94
+ ...base, content: [], source: {},
95
+ provenance: { kind: 'system', producer: 'x' },
96
+ form: null,
97
+ } as unknown as ConversationNode
98
+ case 'model-retry':
99
+ return {
100
+ ...base, turn: 1, step: 0, retry: 1, maxRetries: 2, mode: 'normal', delayMs: 1_000,
101
+ retryState: 'scheduled', failure: { message: 'boom' },
102
+ } as unknown as ConversationNode
103
+ case 'turn-error':
104
+ return { ...base, turn: 1, step: 0, message: 'failed' } as unknown as ConversationNode
105
+ case 'turn-max-tokens':
106
+ return { ...base, turn: 1, step: 0 } as unknown as ConversationNode
107
+ }
108
+ }
109
+
110
+ function snapshot(
111
+ nodes: readonly ConversationNode[],
112
+ opts: {
113
+ partial?: PartialAssistant | null
114
+ runningCalls?: readonly RunningToolCall[]
115
+ turnTimings?: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
116
+ } = {},
117
+ ): { nodes: readonly ConversationNode[]; partial: PartialAssistant | null; runningCalls: readonly RunningToolCall[]; turnTimings?: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }> } {
118
+ return { nodes, partial: opts.partial ?? null, runningCalls: opts.runningCalls ?? [], ...(opts.turnTimings === undefined ? {} : { turnTimings: opts.turnTimings }) }
119
+ }
120
+
121
+ /** 断言辅助:把 readonly 展示行数组收窄为可索引读的行类型。 */
122
+ function asRows(result: readonly StepChatFlowRow[]): readonly StepChatFlowRow[] {
123
+ return result
124
+ }
125
+
126
+ // ── 用户/助手消息映射 ──────────────────────────────────────────────────────
127
+
128
+ describe('buildStepChatFlow:用户/助手消息映射', () => {
129
+ it('user → 用户行(携带 node);assistant → agent 行(blocks/非流式)', () => {
130
+ const rows = asRows(buildStepChatFlow(snapshot([
131
+ userNode(1, '你好'),
132
+ assistantNode(2, 1, 0, '收到'),
133
+ ])))
134
+ expect(rows).toEqual([
135
+ { kind: 'user', key: 'user-1', node: expect.objectContaining({ kind: 'user', content: [{ type: 'text', text: '你好' }], time: 1000 }) },
136
+ { kind: 'agent', key: 'assistant-2', blocks: [{ kind: 'text', text: '收到' }], streaming: false, interrupted: false },
137
+ ])
138
+ })
139
+
140
+ it('steering 节点按用户行渲染(右气泡语义)', () => {
141
+ const rows = asRows(buildStepChatFlow(snapshot([steeringNode(1, '插话')])))
142
+ expect(rows[0]).toMatchObject({ kind: 'user', key: 'steering-1' })
143
+ expect(rows[0]?.kind === 'user' ? rows[0].node : null).toMatchObject({ kind: 'steering', time: 1000 })
144
+ })
145
+ })
146
+
147
+ // ── 工具行映射与配对 ───────────────────────────────────────────────────────
148
+
149
+ describe('buildStepChatFlow:工具行映射与配对', () => {
150
+ it('assistant 的 tool-call 块展开为工具行(RunningToolCall 数据面)', () => {
151
+ const rows = asRows(buildStepChatFlow(snapshot([
152
+ assistantNode(2, 1, 0, '', [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd":"ls"}' }]),
153
+ ])))
154
+ expect(rows).toHaveLength(2)
155
+ expect(rows[1]).toMatchObject({ kind: 'tool', key: 'tool-c1', toolName: 'bash' })
156
+ expect(rows[1]?.kind === 'tool' ? rows[1].block : null).toMatchObject({ callId: 'c1', name: 'bash', argsRaw: '{"cmd":"ls"}' })
157
+ })
158
+
159
+ it('tool-result 与同 callId 工具行配对:同一行替换为 settled(block 变 ToolResultNode)', () => {
160
+ const rows = asRows(buildStepChatFlow(snapshot([
161
+ assistantNode(2, 1, 0, '', [{ callId: 'c1', name: 'bash', argsRaw: '{}' }]),
162
+ toolResultNode(3, 'c1', 'a.ts\nb.ts'),
163
+ ])))
164
+ expect(rows).toHaveLength(2)
165
+ expect(rows[1]).toMatchObject({ kind: 'tool', key: 'tool-c1' })
166
+ const block = rows[1]?.kind === 'tool' ? rows[1].block : null
167
+ expect(block).toMatchObject({ kind: 'tool-result', callId: 'c1', isError: false })
168
+ })
169
+
170
+ it('窗口截断导致 call 头不可见时 tool-result 单列(name 回退 call 回填 / callId)', () => {
171
+ const rows = asRows(buildStepChatFlow(snapshot([
172
+ toolResultNode(3, 'c1', 'ok', { call: { name: 'read', argsRaw: '{"path":"a"}' } }),
173
+ ])))
174
+ expect(rows[0]).toMatchObject({ kind: 'tool', key: 'tool-result-3', toolName: 'read' })
175
+ })
176
+
177
+ it('命令节点 → 命令行', () => {
178
+ const rows = asRows(buildStepChatFlow(snapshot([
179
+ commandNode(4, { name: 'permission', args: 'read', outcome: null }),
180
+ ])))
181
+ expect(rows[0]).toMatchObject({ kind: 'command', key: 'command-4' })
182
+ expect(rows[0]?.kind === 'command' ? rows[0].node : null).toMatchObject({ name: 'permission', outcome: null })
183
+ })
184
+ })
185
+
186
+ // ── 边缘节点映射(DSH conversation.chat.node 各 keyed 渲染器对应行) ───────
187
+
188
+ describe('buildStepChatFlow:边缘节点映射', () => {
189
+ it('context/compaction/unknown/model-retry/turn-error/turn-max-tokens → 对应展示行', () => {
190
+ const rows = asRows(buildStepChatFlow(snapshot([
191
+ userNode(1, '你好'),
192
+ edgeNode('context', 2),
193
+ edgeNode('compaction', 3),
194
+ edgeNode('unknown', 4),
195
+ edgeNode('model-retry', 5),
196
+ edgeNode('turn-error', 6),
197
+ edgeNode('turn-max-tokens', 7),
198
+ assistantNode(8, 1, 0, '收到'),
199
+ ])))
200
+ expect(rows.map((row) => row.kind)).toEqual([
201
+ 'user', 'context', 'compaction', 'unknown', 'retry', 'turn-error', 'turn-max-tokens', 'agent',
202
+ ])
203
+ expect(rows[1]).toMatchObject({ kind: 'context', key: 'context-2' })
204
+ expect(rows[2]).toMatchObject({ kind: 'compaction', key: 'compaction-3' })
205
+ expect(rows[3]).toMatchObject({ kind: 'unknown', key: 'unknown-4' })
206
+ expect(rows[4]).toMatchObject({ kind: 'retry', key: 'retry-5' })
207
+ expect(rows[5]).toMatchObject({ kind: 'turn-error', key: 'turn-error-6' })
208
+ expect(rows[6]).toMatchObject({ kind: 'turn-max-tokens', key: 'turn-max-tokens-7' })
209
+ // 行携带公开节点数据(渲染器直接消费)
210
+ expect(rows[1]?.kind === 'context' ? rows[1].node : null).toMatchObject({ kind: 'context', seq: 2 })
211
+ expect(rows[2]?.kind === 'compaction' ? rows[2].node : null).toMatchObject({ kind: 'compaction', seq: 3 })
212
+ expect(rows[3]?.kind === 'unknown' ? rows[3].node : null).toMatchObject({ kind: 'unknown', type: 'surface/whatever' })
213
+ expect(rows[4]?.kind === 'retry' ? rows[4].node : null).toMatchObject({ kind: 'model-retry', retryState: 'scheduled' })
214
+ expect(rows[5]?.kind === 'turn-error' ? rows[5].node : null).toMatchObject({ kind: 'turn-error', message: 'failed' })
215
+ })
216
+ })
217
+
218
+ // ── partial 流式 ───────────────────────────────────────────────────────────
219
+
220
+ describe('buildStepChatFlow:partial 流式', () => {
221
+ it('partial 追加为流式 agent 行 + 运行中工具行', () => {
222
+ const rows = asRows(buildStepChatFlow(snapshot([userNode(1, '开始')], {
223
+ partial: {
224
+ turn: 1,
225
+ step: 0,
226
+ blocks: [
227
+ { kind: 'text', text: '正在写代码' },
228
+ { kind: 'tool-call', callId: 'c1', name: 'write', argsRaw: '{"path":"a.ts"}' },
229
+ ],
230
+ },
231
+ })))
232
+ expect(rows).toEqual([
233
+ { kind: 'user', key: 'user-1', node: expect.objectContaining({ kind: 'user', time: 1000 }) },
234
+ {
235
+ kind: 'agent', key: 'partial-agent-1-0',
236
+ blocks: [
237
+ { kind: 'text', text: '正在写代码' },
238
+ { kind: 'tool-call', callId: 'c1', name: 'write', argsRaw: '{"path":"a.ts"}' },
239
+ ],
240
+ streaming: true, interrupted: false,
241
+ },
242
+ { kind: 'tool', key: 'partial-tool-c1', toolName: 'write', block: expect.objectContaining({ callId: 'c1', name: 'write' }) },
243
+ ])
244
+ })
245
+
246
+ it('partial 与已结算节点共存(历史节点 + 流式尾巴)', () => {
247
+ const rows = asRows(buildStepChatFlow(snapshot([
248
+ userNode(1, 'q'),
249
+ assistantNode(2, 1, 0, '已完结'),
250
+ ], {
251
+ partial: { turn: 2, step: 0, blocks: [{ kind: 'text', text: '新一轮' }] },
252
+ })))
253
+ const agentRows = rows.filter((row) => row.kind === 'agent')
254
+ expect(agentRows).toHaveLength(2)
255
+ expect(agentRows[1]).toMatchObject({ key: 'partial-agent-2-0', streaming: true })
256
+ })
257
+ })
258
+
259
+ // ── turn-tail 派生(DSH 'turn-tail' ChatNode 语义) ────────────────────────
260
+
261
+ describe('buildStepChatFlow:turn-tail 派生', () => {
262
+ it('已完结 turn(turnTimings 有 endTime)→ 关闭行后插入 tail 行(文本/时长/指标)', () => {
263
+ const nodes: ConversationNode[] = [
264
+ userNode(1, 'q'),
265
+ assistantNode(2, 1, 0, '答', undefined),
266
+ ]
267
+ const rows = asRows(buildStepChatFlow(snapshot(nodes, {
268
+ turnTimings: new Map([[1, { startTime: 10_000, endTime: 15_000 }]]),
269
+ })))
270
+ expect(rows).toHaveLength(3)
271
+ expect(rows[1]).toMatchObject({ kind: 'agent', key: 'assistant-2' })
272
+ expect(rows[2]).toMatchObject({ kind: 'tail', key: 'tail-1' })
273
+ const tail = rows[2]?.kind === 'tail' ? rows[2].data : null
274
+ expect(tail).toMatchObject({ turn: 1, seq: 2, time: 2000, text: '答', runMs: 5_000, branchUnavailable: false })
275
+ })
276
+
277
+ it('无 endTime(turn 未完结 / end 在窗口外)→ 不产出 tail', () => {
278
+ const nodes: ConversationNode[] = [
279
+ userNode(1, 'q'),
280
+ assistantNode(2, 1, 0, '答'),
281
+ ]
282
+ const rows = asRows(buildStepChatFlow(snapshot(nodes, {
283
+ turnTimings: new Map([[1, { startTime: 10_000 }]]),
284
+ })))
285
+ expect(rows.filter((row) => row.kind === 'tail')).toHaveLength(0)
286
+ })
287
+
288
+ it('不提供 turnTimings(旧输入面)→ 不产出 tail', () => {
289
+ const rows = asRows(buildStepChatFlow(snapshot([
290
+ userNode(1, 'q'),
291
+ assistantNode(2, 1, 0, '答'),
292
+ ])))
293
+ expect(rows.filter((row) => row.kind === 'tail')).toHaveLength(0)
294
+ })
295
+
296
+ it('关闭行后有工具结算(turn 尾部非文本)→ branchUnavailable=true', () => {
297
+ const nodes: ConversationNode[] = [
298
+ userNode(1, 'q'),
299
+ assistantNode(2, 1, 0, '正在处理', [{ callId: 'c1', name: 'bash', argsRaw: '{}' }]),
300
+ toolResultNode(3, 'c1', 'ok'),
301
+ ]
302
+ const rows = asRows(buildStepChatFlow(snapshot(nodes, {
303
+ turnTimings: new Map([[1, { startTime: 10_000, endTime: 20_000 }]]),
304
+ })))
305
+ const tail = rows.find((row) => row.kind === 'tail')
306
+ expect(tail).toBeDefined()
307
+ expect(tail?.kind === 'tail' ? tail.data.branchUnavailable : null).toBe(true)
308
+ })
309
+
310
+ it('多个已完结 turn → 每个 turn 的关闭行后各插一条 tail,顺序稳定', () => {
311
+ const nodes: ConversationNode[] = [
312
+ userNode(1, 'q1'),
313
+ assistantNode(2, 1, 0, '答1'),
314
+ userNode(3, 'q2'),
315
+ assistantNode(4, 2, 0, '答2'),
316
+ ]
317
+ const rows = asRows(buildStepChatFlow(snapshot(nodes, {
318
+ turnTimings: new Map([
319
+ [1, { startTime: 10_000, endTime: 11_000 }],
320
+ [2, { startTime: 20_000, endTime: 22_000 }],
321
+ ]),
322
+ })))
323
+ expect(rows.map((row) => row.kind)).toEqual(['user', 'agent', 'tail', 'user', 'agent', 'tail'])
324
+ expect(rows[2]).toMatchObject({ key: 'tail-1' })
325
+ expect(rows[5]).toMatchObject({ key: 'tail-2' })
326
+ })
327
+ })
328
+
329
+ // ── 截断 ───────────────────────────────────────────────────────────────────
330
+
331
+ describe('buildStepChatFlow:最新 10 条节点截断', () => {
332
+ it(`超过 ${STEP_CHAT_MAX_NODES} 条节点时只保留最新 ${STEP_CHAT_MAX_NODES} 条`, () => {
333
+ const nodes: ConversationNode[] = []
334
+ for (let i = 1; i <= 8; i++) {
335
+ nodes.push(userNode(i * 2 - 1, `q${i}`))
336
+ nodes.push(assistantNode(i * 2, i, 0, `a${i}`))
337
+ }
338
+ const rows = asRows(buildStepChatFlow(snapshot(nodes)))
339
+ // 16 节点截尾 10 → 首行 user q4(seq 7)。
340
+ expect(rows).toHaveLength(STEP_CHAT_MAX_NODES)
341
+ expect(rows[0]).toMatchObject({ kind: 'user', key: 'user-7' })
342
+ expect(rows[rows.length - 1]).toMatchObject({ kind: 'agent', key: 'assistant-16' })
343
+ })
344
+
345
+ it('截断发生在节点层(assistant 展开的工具行不挤出窗口外的节点)', () => {
346
+ const nodes: ConversationNode[] = []
347
+ for (let i = 1; i <= 6; i++) {
348
+ nodes.push(userNode(i * 2 - 1, `q${i}`))
349
+ nodes.push(assistantNode(i * 2, i, 0, `a${i}`, [{ callId: `c${i}`, name: 'bash', argsRaw: '{}' }]))
350
+ }
351
+ const rows = asRows(buildStepChatFlow(snapshot(nodes)))
352
+ // 12 节点(q1..q6 + a1..a6)截尾 10 → q2..a6(seq 3..12);每个 assistant
353
+ // 展开 1 工具行 → 5 user + 5×2 = 15 行。
354
+ expect(rows[0]).toMatchObject({ kind: 'user', key: 'user-3' })
355
+ expect(rows[rows.length - 1]).toMatchObject({ kind: 'tool', key: 'tool-c6' })
356
+ })
357
+ })
@@ -0,0 +1,278 @@
1
+ /**
2
+ * chat-flow:轻量子代理面板的纯逻辑层(无 React / 无 DSH 运行时依赖,vitest node
3
+ * 环境可跑)。对应 DSH ChatView 的「组装层→渲染行」语义:
4
+ *
5
+ * - 从 `snapshot.nodes`(公开 `ConversationNode[]`,已排序)取**最新 10 条节点**;
6
+ * - assistant 节点的 tool-call 块展开为独立工具行(与 DSH 把 tool-call 组装为独立
7
+ * 'tool-call' 行的行为一致),tool-result 节点与同 callId 的未结算工具行**配对**
8
+ * (替换该行——即 DSH 的「同一行从 running 更新为 settled」),窗口截断导致 call
9
+ * 头不可见时单列(与 DSH 的 soft-fall 一致);
10
+ * - `partial`(流式 assistant 增量)追加为流式 agent 行 + 运行中工具行;
11
+ * - context/compaction/model-retry/turn-error/turn-max-tokens/unknown 等边缘节点映射为
12
+ * 展示行(对应 DSH `conversation.chat.node` 各 keyed 渲染器);
13
+ * - 已完结 turn(`turnTimings`/`turnEnds` 提供时)在其结算 assistant 行后派生
14
+ * **turn-tail 行**(对应 DSH 的 'turn-tail' ChatNode:复制 + 运行时长/TTFT/吞吐 +
15
+ * branch;branchUnavailable 近似为「结算行不是面板最后一行」)。
16
+ *
17
+ * 组件层(StepChatPanel)把 `StepChatFlowRow` 逐一映射为抄自 DSH 的渲染组件。
18
+ * @module @deepseek-ai/dsh-client-ui-sandore/src/client/chat-flow
19
+ */
20
+ import type {
21
+ AssistantBlock, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, ModelRetryNode,
22
+ PartialAssistant, RunningToolCall, ToolCallBlock, TurnErrorNode, TurnMaxTokensNode,
23
+ UnknownSurfaceNode,
24
+ } from '@deepseek-ai/dsh-client-runtime/client'
25
+ import { assistantText } from './chat/turn-assistant.ts'
26
+ import { deriveTurnMetrics } from './chat/turn-metrics.ts'
27
+
28
+ /** 面板展示的最大节点数(轻量面板:仅最新 10 条节点)。 */
29
+ export const STEP_CHAT_MAX_NODES = 10
30
+
31
+ /**
32
+ * `buildStepChatFlow` 的最小快照输入面(只读 nodes/partial/runningCalls;
33
+ * `ConversationSnapshot` 结构上满足它,单测可直接构造小对象)。
34
+ */
35
+ export interface StepChatFlowInput {
36
+ readonly nodes: readonly ConversationNode[]
37
+ readonly partial: PartialAssistant | null
38
+ readonly runningCalls: readonly RunningToolCall[]
39
+ /** 已完结 turn 的时间边界(`ConversationSnapshot.turnTimings`);缺省不派生 turn-tail。 */
40
+ readonly turnTimings?: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
41
+ }
42
+
43
+ /** turn-tail 展示数据(对齐 DSH `TurnTailChatData` 的展示面;组件层消费)。 */
44
+ export interface StepChatTailData {
45
+ /** 已完结 turn 号。 */
46
+ turn: number
47
+ /** 结算 assistant 节点 seq(分支锚点)。 */
48
+ seq: number
49
+ /** 结算时间(Unix epoch ms;时钟用)。 */
50
+ time: number
51
+ /** 结算 assistant 的拼接文本(复制按钮取文本)。 */
52
+ text: string
53
+ /** Turn 壁钟时长 ms;turn/start|end 均在窗口内时可用。 */
54
+ runMs?: number | undefined
55
+ /** Turn 首步 TTFT ms;未记录时省略。 */
56
+ ttftMs?: number | undefined
57
+ /** Turn 解码吞吐;未记录时省略。 */
58
+ tokensPerSecond?: number | undefined
59
+ /** 分支不可用(结算 assistant 非窗口尾部等)。 */
60
+ branchUnavailable: boolean
61
+ }
62
+
63
+ /** 一条展示行(kind 判别;key 为 React key,跨快照稳定)。 */
64
+ export type StepChatFlowRow =
65
+ | {
66
+ readonly kind: 'user'
67
+ readonly key: string
68
+ readonly node: Extract<ConversationNode, { kind: 'user' | 'steering' }>
69
+ }
70
+ | {
71
+ readonly kind: 'agent'
72
+ readonly key: string
73
+ readonly blocks: readonly AssistantBlock[]
74
+ readonly streaming: boolean
75
+ readonly interrupted: boolean
76
+ }
77
+ | { readonly kind: 'tool'; readonly key: string; readonly toolName: string; readonly block: ToolCallBlock }
78
+ | { readonly kind: 'command'; readonly key: string; readonly node: CommandNode }
79
+ | { readonly kind: 'context'; readonly key: string; readonly node: ContextMessageNode }
80
+ | { readonly kind: 'compaction'; readonly key: string; readonly node: CompactionSummaryNode }
81
+ | { readonly kind: 'retry'; readonly key: string; readonly node: ModelRetryNode }
82
+ | { readonly kind: 'turn-error'; readonly key: string; readonly node: TurnErrorNode }
83
+ | { readonly kind: 'turn-max-tokens'; readonly key: string }
84
+ | { readonly kind: 'unknown'; readonly key: string; readonly node: UnknownSurfaceNode }
85
+ | { readonly kind: 'tail'; readonly key: string; readonly data: StepChatTailData }
86
+
87
+ /** assistant 节点的 tool-call 块 → RunningToolCall(工具行 running 态数据面)。 */
88
+ function runningCallOf(
89
+ block: Extract<AssistantBlock, { kind: 'tool-call' }>,
90
+ turn: number,
91
+ step: number,
92
+ time: number,
93
+ ): RunningToolCall {
94
+ return {
95
+ callId: block.callId,
96
+ name: block.name,
97
+ argsRaw: block.argsRaw,
98
+ turn,
99
+ step,
100
+ time,
101
+ callView: null,
102
+ subCalls: [],
103
+ }
104
+ }
105
+
106
+ /** 命令节点 → 展示行。 */
107
+ function commandRow(node: CommandNode): StepChatFlowRow {
108
+ return { kind: 'command', key: `command-${node.seq}`, node }
109
+ }
110
+
111
+ /**
112
+ * tool-result 节点 → 工具行:优先与同 callId 的最后一条未结算工具行配对(原地替换,
113
+ * 即 DSH 的 running→settled 更新);窗口截断导致 call 头不可见时单列(name 回退
114
+ * `call?.name ?? callId`)。
115
+ * @returns 配对成功返回 null(已原地替换);否则返回新的单列行。
116
+ */
117
+ function toolResultRow(
118
+ node: Extract<ConversationNode, { kind: 'tool-result' }>,
119
+ rows: StepChatFlowRow[],
120
+ toolIndexByCallId: Map<string, number>,
121
+ ): StepChatFlowRow | null {
122
+ const index = toolIndexByCallId.get(node.callId)
123
+ if (index !== undefined) {
124
+ const row = rows[index]
125
+ if (row !== undefined && row.kind === 'tool') {
126
+ // 配对:同一 callId 行从 running 更新为 settled(ToolResultNode ⊂ ToolCallBlock)。
127
+ rows[index] = { ...row, block: node }
128
+ return null
129
+ }
130
+ }
131
+ // 窗口截断:call 头不可见,退化为独立结果行(DSH soft-fall)。
132
+ return {
133
+ kind: 'tool',
134
+ key: `tool-result-${node.seq}`,
135
+ toolName: node.call?.name ?? node.callId,
136
+ block: node,
137
+ }
138
+ }
139
+
140
+ /** 关闭行文本判定(DSH `tailData.hasText`:finalNode 携带非空文本块)。 */
141
+ function hasText(node: Extract<ConversationNode, { kind: 'assistant' }>): boolean {
142
+ return node.blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
143
+ }
144
+
145
+ /** 已完结 turn 的关闭行 + tail 数据(在 agent 行之后插入)。 */
146
+ interface PendingTail {
147
+ readonly rowIndex: number
148
+ readonly data: StepChatTailData
149
+ }
150
+
151
+ /**
152
+ * 快照 → 展示行(最新 10 条节点;assistant 展开为文本行 + 工具行;partial 追加在尾部;
153
+ * 已完结 turn 派生 turn-tail 行)。
154
+ */
155
+ export function buildStepChatFlow(snapshot: StepChatFlowInput): readonly StepChatFlowRow[] {
156
+ const rows: StepChatFlowRow[] = []
157
+ const toolIndexByCallId = new Map<string, number>()
158
+ // turn → 最后一个带文本的 assistant 行索引 + 节点(tail 关闭行候选)。
159
+ const closingByTurn = new Map<number, { rowIndex: number; node: Extract<ConversationNode, { kind: 'assistant' }> }>()
160
+
161
+ const pushTool = (row: StepChatFlowRow): void => {
162
+ rows.push(row)
163
+ if (row.kind === 'tool') toolIndexByCallId.set(row.block.callId, rows.length - 1)
164
+ }
165
+
166
+ for (const node of snapshot.nodes.slice(-STEP_CHAT_MAX_NODES)) {
167
+ switch (node.kind) {
168
+ case 'user':
169
+ rows.push({ kind: 'user', key: `user-${node.seq}`, node })
170
+ break
171
+ case 'steering':
172
+ rows.push({ kind: 'user', key: `steering-${node.seq}`, node })
173
+ break
174
+ case 'assistant': {
175
+ rows.push({
176
+ kind: 'agent',
177
+ key: `assistant-${node.seq}`,
178
+ blocks: node.blocks,
179
+ streaming: false,
180
+ interrupted: node.interrupted === true,
181
+ })
182
+ if (hasText(node)) closingByTurn.set(node.turn, { rowIndex: rows.length - 1, node })
183
+ for (const block of node.blocks) {
184
+ if (block.kind !== 'tool-call') continue
185
+ pushTool({
186
+ kind: 'tool',
187
+ key: `tool-${block.callId}`,
188
+ toolName: block.name,
189
+ block: runningCallOf(block, node.turn, node.step, node.time),
190
+ })
191
+ }
192
+ break
193
+ }
194
+ case 'tool-result': {
195
+ const row = toolResultRow(node, rows, toolIndexByCallId)
196
+ if (row !== null) pushTool(row)
197
+ break
198
+ }
199
+ case 'command':
200
+ rows.push(commandRow(node))
201
+ break
202
+ case 'context':
203
+ rows.push({ kind: 'context', key: `context-${node.seq}`, node })
204
+ break
205
+ case 'compaction':
206
+ rows.push({ kind: 'compaction', key: `compaction-${node.seq}`, node })
207
+ break
208
+ case 'model-retry':
209
+ rows.push({ kind: 'retry', key: `retry-${node.seq}`, node })
210
+ break
211
+ case 'turn-error':
212
+ rows.push({ kind: 'turn-error', key: `turn-error-${node.seq}`, node })
213
+ break
214
+ case 'turn-max-tokens':
215
+ rows.push({ kind: 'turn-max-tokens', key: `turn-max-tokens-${node.seq}` })
216
+ break
217
+ case 'unknown':
218
+ rows.push({ kind: 'unknown', key: `unknown-${node.seq}`, node })
219
+ break
220
+ }
221
+ }
222
+
223
+ // 流式增量:assistant partial 追加为流式 agent 行 + 运行中工具行。
224
+ if (snapshot.partial !== null) {
225
+ const { turn, step, blocks } = snapshot.partial
226
+ rows.push({
227
+ kind: 'agent',
228
+ key: `partial-agent-${turn}-${step}`,
229
+ blocks,
230
+ streaming: true,
231
+ interrupted: false,
232
+ })
233
+ for (const block of blocks) {
234
+ if (block.kind !== 'tool-call') continue
235
+ pushTool({
236
+ kind: 'tool',
237
+ key: `partial-tool-${block.callId}`,
238
+ toolName: block.name,
239
+ block: runningCallOf(block, turn, step, Date.now()),
240
+ })
241
+ }
242
+ }
243
+
244
+ // turn-tail 派生:已完结 turn 的关闭行后插入 tail 行(DSH 'turn-tail' ChatNode 语义)。
245
+ if (snapshot.turnTimings !== undefined && closingByTurn.size > 0) {
246
+ const sliced = snapshot.nodes.slice(-STEP_CHAT_MAX_NODES)
247
+ const metrics = deriveTurnMetrics(sliced)
248
+ const tails: PendingTail[] = []
249
+ for (const [turn, closing] of closingByTurn) {
250
+ const timing = snapshot.turnTimings.get(turn)
251
+ // 无 turn/end:turn 未完结(或 end 在窗口外),DSH 同样不产出 tail。
252
+ if (timing === undefined || timing.endTime === undefined) continue
253
+ const turnMetrics = metrics.get(turn)
254
+ const data: StepChatTailData = {
255
+ turn,
256
+ seq: closing.node.seq,
257
+ time: closing.node.time,
258
+ text: assistantText(closing.node.blocks),
259
+ runMs: Math.max(0, timing.endTime - timing.startTime),
260
+ ...turnMetrics?.ttftMs === undefined ? {} : { ttftMs: turnMetrics.ttftMs },
261
+ ...turnMetrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: turnMetrics.tokensPerSecond },
262
+ // 近似(DSH 用 turn 内 tool/call|tool/result|error-end|retry 的 latest seq 比较):
263
+ // 窗口内该 turn 的关闭行之后还有任何行(工具结算等)→ 分支不可用。
264
+ branchUnavailable: closing.rowIndex !== rows.length - 1,
265
+ }
266
+ tails.push({ rowIndex: closing.rowIndex, data })
267
+ }
268
+ // 按关闭行索引升序插入(前面已插入的 tail 使后续目标索引后移),保证多 turn 顺序稳定。
269
+ tails.sort((a, b) => a.rowIndex - b.rowIndex)
270
+ let inserted = 0
271
+ for (const tail of tails) {
272
+ rows.splice(tail.rowIndex + 1 + inserted, 0, { kind: 'tail', key: `tail-${tail.data.turn}`, data: tail.data })
273
+ inserted += 1
274
+ }
275
+ }
276
+
277
+ return rows
278
+ }