@modesto_ma/dsh-channels 0.2.0 → 0.4.0

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 (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -47,8 +47,15 @@ import type {
47
47
  SaveCredentialsResponse,
48
48
  } from './contract.ts'
49
49
  import {
50
+ configValueOf,
51
+ deleteOverlayKey,
52
+ mergeOverlay,
53
+ mergeOverlayPatch,
54
+ overlayValueOf,
55
+ overlayValues,
50
56
  parseOverlay,
51
57
  pickEditable,
58
+ setOverlayValue,
52
59
  } from './overlay.ts'
53
60
  import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
54
61
  import type { SettingsCatalog } from './catalog.ts'
@@ -79,7 +86,7 @@ export interface SettingsTarget {
79
86
  configSnapshot(): FeishuConfig
80
87
  baseConfigSnapshot(): FeishuConfig
81
88
  runningConfigSnapshot(): FeishuConfig | undefined
82
- applyOverlay(overlay: Partial<FeishuConfig>): void
89
+ applyOverlay(overlay: OverlayPatch): void
83
90
  restart(): Promise<void>
84
91
  credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
85
92
  setCredential(field: CredentialField, value: string): Promise<void>
@@ -125,10 +132,17 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
125
132
  'welcomeOnboarding',
126
133
  'allowPermissionSwitch',
127
134
  'acceptAttachments',
135
+ 'prefetchImages',
128
136
  'workspaceRetentionAuto',
137
+ 'feishuTools.attachmentFetch',
138
+ 'feishuTools.topicMessages',
129
139
  ])
130
140
 
131
141
  const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
142
+ attachmentStore: [
143
+ { value: 'native', label: 'DSH 原生附件库(默认)' },
144
+ { value: 'archive', label: '工作区 attachments/(旧版)' },
145
+ ],
132
146
  dmMode: [
133
147
  { value: 'open', label: '开放私聊' },
134
148
  { value: 'disabled', label: '禁用私聊' },
@@ -160,7 +174,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
160
174
  const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
161
175
  baseDir: {
162
176
  label: '工作区根目录(文件落点)',
163
- help: '每个飞书话题拥有独立工作区:私聊 <baseDir>/topics/p2p/<open_id>/<topic>/,群聊 <baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/(topic 来自话题 id)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的工作区 —— 已有话题即使 /renew 也沿用原工作区,旧文件不会迁移。',
177
+ help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
164
178
  placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
165
179
  wide: true,
166
180
  },
@@ -185,9 +199,104 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
185
199
  max: 30,
186
200
  step: 1,
187
201
  },
188
- acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
189
- workspaceRetention: { label: '过期工作区保留期', help: '超过所选天数没有活动的飞书工作区会进入下方「工作区清理」列表;「关闭」表示永不删除。DSH 会话日志不受影响。' },
190
- workspaceRetentionAuto: { label: '自动执行工作区清理', help: '开启后插件在启动时及每天执行一次过期工作区清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
202
+ acceptAttachments: {
203
+ label: '接收入站附件',
204
+ help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息静默登记元数据;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不登记。',
205
+ },
206
+ attachmentStore: {
207
+ label: '入站附件存储后端',
208
+ help: 'native(默认):图片与文件存入 DSH 原生附件库(不可变、内容寻址去重、读取校验、随 Session 回放;不自动清理)。archive:旧版下载到话题工作区 attachments/(可清理但会阻塞会话)。宿主未挂载附件服务时 native 自动降级 archive。',
209
+ },
210
+ prefetchImages: {
211
+ label: '预取触发消息的图片',
212
+ help: 'native 下把触发消息的图片预取为首轮 ImageBlock,会话/话题/卡片不等下载;失败、超限或超时的图片降级为清单中的懒取项。关闭后图片也需用 feishu_attachment_fetch 获取。',
213
+ },
214
+ prefetchTimeoutMs: {
215
+ label: '图片预取整段超时(ms)',
216
+ help: '一次触发消息图片预取的总超时(默认 10000 ms,100–120000);到期后已取回的图片保留,其余降级为清单,不阻塞回合。',
217
+ min: 100,
218
+ max: 120_000,
219
+ step: 100,
220
+ },
221
+ prefetchImageMaxBytes: {
222
+ label: '单张预取图片上限(字节)',
223
+ help: '单张图片超过该字节数即不入首轮图片、留在清单懒取(默认 20971520 = 20 MiB)。',
224
+ min: 1,
225
+ max: 200 * 1024 * 1024,
226
+ step: 1,
227
+ },
228
+ prefetchTotalMaxBytes: {
229
+ label: '单次预取总量上限(字节)',
230
+ help: '一次触发消息图片预取的总字节预算(默认 52428800 = 50 MiB);用尽后剩余图片降级为清单懒取。',
231
+ min: 1,
232
+ max: 1024 * 1024 * 1024,
233
+ step: 1,
234
+ },
235
+ historyBootstrapLimit: {
236
+ label: '首轮话题历史条数',
237
+ help: '新 Session 首轮自动附带本话题最近 N 条消息摘要(0 = 关闭,上限 20);被动读取使用短预算,缺权限/失败时静默省略。更早的消息用 feishu_topic_messages 工具翻页。',
238
+ min: 0,
239
+ max: 20,
240
+ step: 1,
241
+ },
242
+ pendingAttachmentTtlMs: {
243
+ label: '静默附件登记有效期(ms)',
244
+ help: '纯附件/未触发消息只登记元数据(不下载)。超过该时长的登记由周期 GC 清除;0 = 不按时间清理(每个 scope 仍有容量上限)。默认 604800000 = 7 天。',
245
+ min: 0,
246
+ step: 1,
247
+ },
248
+ pendingAttachmentLimit: {
249
+ label: '静默附件登记容量(每个 scope)',
250
+ help: '每个 (会话, 话题, 发送者) scope 最多保留的登记条数(1–100);超出时淘汰该 scope 最旧的一条。',
251
+ min: 1,
252
+ max: 100,
253
+ step: 1,
254
+ },
255
+ promptContextTimeoutMs: {
256
+ label: '被动读取预算(ms)',
257
+ help: '引用摘录与首轮话题历史这类被动 prompt 增强的整段预算(默认 3000 ms,100–10000);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 feishu_tools 超时控制。',
258
+ min: 100,
259
+ max: 10_000,
260
+ step: 100,
261
+ },
262
+ 'feishuTools.attachmentFetch': {
263
+ label: '启用 feishu_attachment_fetch 工具',
264
+ help: '让模型按 message_id/file_key 懒取已授权消息的图片/文件。关闭后文件无法懒取,插件整体回退 archive 后端(入站文件直接下载到工作区)。对新会话生效。',
265
+ },
266
+ 'feishuTools.attachmentFetchMaxResources': {
267
+ label: '单次取回资源数上限',
268
+ help: '一次工具调用最多取回的附件数量(默认 10,1–50);超出会返回可读原因,模型可按 file_key 拆分重试。',
269
+ min: 1,
270
+ max: 50,
271
+ step: 1,
272
+ },
273
+ 'feishuTools.attachmentFetchMaxBytes': {
274
+ label: '单次取回字节预算(字节)',
275
+ help: '一次工具调用的总下载字节预算(默认 104857600 = 100 MiB);文件流式写入原生库,不整文件缓冲。',
276
+ min: 1,
277
+ max: 1024 * 1024 * 1024,
278
+ step: 1,
279
+ },
280
+ 'feishuTools.attachmentFetchTimeoutMs': {
281
+ label: '单次取回超时(ms)',
282
+ help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,1000–600000);到期取消下载并逐项报告可读原因。',
283
+ min: 1_000,
284
+ max: 600_000,
285
+ step: 1_000,
286
+ },
287
+ 'feishuTools.topicMessages': {
288
+ label: '启用 feishu_topic_messages 工具',
289
+ help: '让模型按需读取引用消息与话题内更早的消息/附件。关闭只影响模型工具;被动的引用摘录与首轮历史仍由 historyBootstrapLimit 控制。',
290
+ },
291
+ 'feishuTools.topicMessagesTimeoutMs': {
292
+ label: '单次话题读取超时(ms)',
293
+ help: '一次 feishu_topic_messages 的整段超时(默认 20000 ms,1000–120000);被动摘录使用独立的短预算。',
294
+ min: 1_000,
295
+ max: 120_000,
296
+ step: 1_000,
297
+ },
298
+ workspaceRetention: { label: '过期目录保留期', help: '只清理工作区与 Agent 产物目录,不影响 Session 附件:默认 native 存储下附件在 DSH 原生附件库,不参与目录清理;仅当显式使用 archive 后端时,附件副本位于话题目录、会随目录清理。用户工作区本身永不删除,DSH 会话日志不受影响。' },
299
+ workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置;清理只清理工作区与 Agent 产物目录,不影响 Session 附件。' },
191
300
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
192
301
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
193
302
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -210,6 +319,21 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
210
319
  accessMode: 'access',
211
320
  authRequestTtlDays: 'access',
212
321
  acceptAttachments: 'content',
322
+ attachmentStore: 'content',
323
+ prefetchImages: 'content',
324
+ prefetchTimeoutMs: 'content',
325
+ prefetchImageMaxBytes: 'content',
326
+ prefetchTotalMaxBytes: 'content',
327
+ historyBootstrapLimit: 'content',
328
+ pendingAttachmentTtlMs: 'content',
329
+ pendingAttachmentLimit: 'content',
330
+ promptContextTimeoutMs: 'tools',
331
+ 'feishuTools.attachmentFetch': 'tools',
332
+ 'feishuTools.attachmentFetchMaxResources': 'tools',
333
+ 'feishuTools.attachmentFetchMaxBytes': 'tools',
334
+ 'feishuTools.attachmentFetchTimeoutMs': 'tools',
335
+ 'feishuTools.topicMessages': 'tools',
336
+ 'feishuTools.topicMessagesTimeoutMs': 'tools',
213
337
  provider: 'model',
214
338
  model: 'model',
215
339
  reasoningEffort: 'model',
@@ -403,6 +527,14 @@ export function normalizeAccessName(
403
527
  /** Build the read-only advanced card from the effective config. */
404
528
  export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
405
529
  return [
530
+ {
531
+ key: 'topicIsolation',
532
+ label: '话题目录隔离',
533
+ value: config.topicIsolation === 'auto'
534
+ ? '自动(Git 仓库用 worktree,否则普通子目录)'
535
+ : config.topicIsolation === 'worktree' ? 'Git worktree(失败回退子目录)' : '普通子目录',
536
+ help: '每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。',
537
+ },
406
538
  { key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
407
539
  { key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
408
540
  { key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
@@ -528,9 +660,11 @@ export class SettingsSurface {
528
660
  const effective = this.target.configSnapshot()
529
661
  const overlay = await this.store.load()
530
662
  const running = this.target.runningConfigSnapshot()
531
- const overriddenKeys = EDITABLE_KEYS.filter(key => overlay[key] !== undefined)
663
+ const overriddenKeys = EDITABLE_KEYS.filter(
664
+ key => overlayValueOf(overlay as Record<string, unknown>, key) !== undefined,
665
+ )
532
666
  const restartPendingKeys = this.target.isRunning() && running !== undefined
533
- ? RESTART_KEYS.filter(key => effective[key] !== running[key])
667
+ ? RESTART_KEYS.filter(key => configValueOf(effective, key) !== configValueOf(running, key))
534
668
  : []
535
669
  const catalog = await this.catalogFor(effective.provider, effective.model)
536
670
  const fields = withDirectoryBrowse(
@@ -569,7 +703,7 @@ export class SettingsSurface {
569
703
  try {
570
704
  cleanup = await this.target.cleanupPreview()
571
705
  } catch (error) {
572
- this.logger?.warn?.('dsh-channels: 工作区清理预览失败:%s', messageOf(error))
706
+ this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
573
707
  cleanup = {
574
708
  retention: effective.workspaceRetention,
575
709
  auto: effective.workspaceRetentionAuto,
@@ -603,7 +737,7 @@ export class SettingsSurface {
603
737
  config: {
604
738
  base: pickEditable(base) as Record<string, ConfigValue | undefined>,
605
739
  effective: pickEditable(effective) as Record<string, ConfigValue | undefined>,
606
- overlay: overlay as EditableValues,
740
+ overlay: overlayValues(overlay),
607
741
  overriddenKeys,
608
742
  restartPendingKeys,
609
743
  fields,
@@ -633,7 +767,7 @@ export class SettingsSurface {
633
767
  clearedKeys.push(key)
634
768
  continue
635
769
  }
636
- raw[key] = value
770
+ setOverlayValue(raw, key, value)
637
771
  }
638
772
 
639
773
  const parsed = parseOverlay(raw)
@@ -662,9 +796,8 @@ export class SettingsSurface {
662
796
 
663
797
  let merged: OverlayPatch
664
798
  try {
665
- merged = { ...await this.store.load() }
666
- for (const key of clearedKeys) delete merged[key]
667
- Object.assign(merged, parsed.patch)
799
+ merged = mergeOverlayPatch(await this.store.load(), parsed.patch)
800
+ for (const key of clearedKeys) deleteOverlayKey(merged, key)
668
801
  } catch (error) {
669
802
  return { ok: false, error: `写入覆盖配置失败:${messageOf(error)}` }
670
803
  }
@@ -673,7 +806,7 @@ export class SettingsSurface {
673
806
  // provider (or lone model) leaves the session without a complete route,
674
807
  // so an edit to either side must end with both set or both cleared.
675
808
  if (values.provider !== undefined || values.model !== undefined) {
676
- const prospective = { ...this.target.baseConfigSnapshot(), ...merged }
809
+ const prospective = mergeOverlay(this.target.baseConfigSnapshot(), merged)
677
810
  const hasProvider = (prospective.provider ?? '') !== ''
678
811
  const hasModel = (prospective.model ?? '') !== ''
679
812
  if (hasProvider !== hasModel) {
package/src/streamer.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  * lines: the delegation status line (子代理 / 工作流 progress, settled
12
12
  * workflow outcome, heartbeat) and the tool line (the parent's in-flight
13
13
  * tool call). The lines are INDEPENDENT — a later tool call never
14
- * supersedes the settled workflow status. Subagent children live in their
14
+ * supersedes the settled workflow status. The lines render only on the
15
+ * card that is currently streaming, so frozen segment cards never repeat
16
+ * a stale status. Subagent children live in their
15
17
  * own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
16
18
  * events are folded into the parent turn's status line through
17
19
  * `session.header.parentSession` (status only — the child's text output
@@ -19,10 +21,11 @@
19
21
  * 3. When the body outgrows one card's budget, the overflow is split at a
20
22
  * clean breakpoint and sent as a continuation segment card (segment 2, 3,
21
23
  * …) into the same topic; the stream then keeps flowing into the newest
22
- * card. Reasoning is never segmented — it stays on card 1 and is clipped
23
- * to the leftover budget (body has priority).
24
- * 4. `turnEnded(turn)` — finalize every segment card (`回复结束` / `回复失败`)
25
- * and request the Typing → DONE/No reaction swap on the triggering message.
24
+ * card. Reasoning is never segmented — it stays on card 1 inside the
25
+ * fold panel's own fixed window, independent of the body length.
26
+ * 4. `finalize(turn)` — flip every segment card to its terminal state
27
+ * (`回复结束` / `回复失败`), render the settled footer on the main card and
28
+ * request the Typing → DONE/No reaction swap on the triggering message.
26
29
  *
27
30
  * The first visible text delta flips the panel title 推理中 → 回复中; the
28
31
  * final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
@@ -38,7 +41,7 @@
38
41
  */
39
42
 
40
43
  import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
41
- import { buildCard, segmentCardContent } from './cards.ts'
44
+ import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
42
45
  import type { CardState, PanelPhase } from './cards.ts'
43
46
  import type { FeishuChannel } from './feishu-channel.ts'
44
47
  import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
@@ -49,7 +52,7 @@ import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
49
52
  * console can confirm which build of the activity status line is loaded.
50
53
  * Bump on every behavior change to this feature.
51
54
  */
52
- export const ACTIVITY_BUILD_MARKER = 'activity-status-v6'
55
+ export const ACTIVITY_BUILD_MARKER = 'activity-status-v7'
53
56
 
54
57
  /** How a segment card reached Feishu. */
55
58
  export interface SegmentDelivery {
@@ -149,8 +152,6 @@ interface TurnState {
149
152
  header: HeaderSample | undefined
150
153
  /** Provider usage produced during THIS turn; never inherited from history. */
151
154
  lastUsage: UsageSample | undefined
152
- /** Structured terminal failure for this turn, when `turn/end.kind=error`. */
153
- failure: { message: string; code?: string } | undefined
154
155
  /** Segment cards in order; `cards[0]` is the attached reply card. */
155
156
  cards: SegmentCard[]
156
157
  dirty: boolean
@@ -161,6 +162,12 @@ interface TurnState {
161
162
  disposed: boolean
162
163
  /** Unix ms when the turn was attached (heartbeat elapsed base). */
163
164
  turnStartedAt: number
165
+ /** Wall-clock start of the currently open reasoning phase (undefined = not reasoning). */
166
+ reasoningSince: number | undefined
167
+ /** Accumulated wall-clock time the turn spent reasoning, in ms. A phase
168
+ * opens at the first reasoning delta and closes when the step's output ends
169
+ * (first answer text or step end), so tool execution is never counted. */
170
+ reasoningMs: number
164
171
  /** The in-flight tool call, if any (cleared by its `tool/result`). */
165
172
  toolActivity: { name: string; label: string; since: number } | undefined
166
173
  /** Live workflow-run folds projected by the workflow tool into this session. */
@@ -202,6 +209,8 @@ interface AttemptMark {
202
209
  cardCount: number
203
210
  bodyLengths: number[]
204
211
  reasoningLength: number
212
+ reasoningMs: number
213
+ reasoningSince: number | undefined
205
214
  }
206
215
 
207
216
  /** One live `tool-workflow/run-*` fold: run identity and member progress. */
@@ -321,7 +330,6 @@ export class TurnStreamer {
321
330
  reasoning: '',
322
331
  header: undefined,
323
332
  lastUsage: undefined,
324
- failure: undefined,
325
333
  cards: [{
326
334
  messageId: cardMessageId,
327
335
  body: '',
@@ -339,6 +347,8 @@ export class TurnStreamer {
339
347
  finalized: false,
340
348
  disposed: false,
341
349
  turnStartedAt: Date.now(),
350
+ reasoningSince: undefined,
351
+ reasoningMs: 0,
342
352
  toolActivity: undefined,
343
353
  workflowRuns: new Map(),
344
354
  workflowEndStatus: undefined,
@@ -531,6 +541,9 @@ export class TurnStreamer {
531
541
  if (state === undefined || state.disposed) return
532
542
  switch (frame.type) {
533
543
  case 'start': {
544
+ // A new step starts: close any reasoning phase the previous step left
545
+ // open (defensive — its `end` frame normally does this first).
546
+ this.closeReasoning(state)
534
547
  state.attempts.set(frame.attemptId, this.attemptMark(state))
535
548
  if (state.turn === undefined) state.turn = frame.turn
536
549
  break
@@ -538,6 +551,7 @@ export class TurnStreamer {
538
551
  case 'chunk': {
539
552
  const chunk = frame.chunk
540
553
  if (chunk.type === 'text-delta') {
554
+ if (chunk.text !== '') this.closeReasoning(state)
541
555
  const last = state.cards[state.cards.length - 1]
542
556
  if (last !== undefined && !last.frozen && chunk.text !== '') {
543
557
  last.body += chunk.text
@@ -545,12 +559,17 @@ export class TurnStreamer {
545
559
  this.markDirty(session.id, state)
546
560
  }
547
561
  } else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
562
+ if (state.reasoningSince === undefined) state.reasoningSince = Date.now()
548
563
  state.reasoning += chunk.text
549
564
  this.markDirty(session.id, state)
550
565
  }
551
566
  break
552
567
  }
553
568
  case 'end': {
569
+ // The step's output is complete: a reasoning phase that ended with a
570
+ // tool call (no answer text) stops counting here, so the tool run that
571
+ // follows is not miscounted as reasoning time.
572
+ this.closeReasoning(state)
554
573
  const mark = state.attempts.get(frame.attemptId)
555
574
  state.attempts.delete(frame.attemptId)
556
575
  if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
@@ -568,9 +587,18 @@ export class TurnStreamer {
568
587
  cardCount: state.cards.length,
569
588
  bodyLengths: state.cards.map(card => card.body.length),
570
589
  reasoningLength: state.reasoning.length,
590
+ reasoningMs: state.reasoningMs,
591
+ reasoningSince: state.reasoningSince,
571
592
  }
572
593
  }
573
594
 
595
+ /** Close an open reasoning phase, accumulating its wall-clock time. */
596
+ private closeReasoning(state: TurnState, now = Date.now()): void {
597
+ if (state.reasoningSince === undefined) return
598
+ state.reasoningMs += Math.max(0, now - state.reasoningSince)
599
+ state.reasoningSince = undefined
600
+ }
601
+
574
602
  /**
575
603
  * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
576
604
  * suffix of the logical body: segments created during the attempt are
@@ -594,6 +622,9 @@ export class TurnStreamer {
594
622
  tail.dirty = true
595
623
  }
596
624
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
625
+ // The abandoned attempt's reasoning time is not part of the visible turn.
626
+ state.reasoningMs = mark.reasoningMs
627
+ state.reasoningSince = mark.reasoningSince
597
628
  }
598
629
 
599
630
  private markDirty(sessionId: string, state: TurnState): void {
@@ -641,6 +672,9 @@ export class TurnStreamer {
641
672
  : context?.contextWindow ?? projected?.contextWindow,
642
673
  model: header?.model,
643
674
  effort: header?.effort,
675
+ // Only a turn that actually reasoned reports a duration; a non-reasoning
676
+ // model leaves the field undefined (rendered as `—`).
677
+ reasoningMs: state.reasoning === '' ? undefined : state.reasoningMs,
644
678
  }
645
679
  }
646
680
 
@@ -717,13 +751,10 @@ export class TurnStreamer {
717
751
  * an elapsed counter so the card never sits silently.
718
752
  * Once the turn is finalized, the line switches to the SETTLED form
719
753
  * (workflow conclusion + finished-subagent summary) — the final card keeps
720
- * the delegation status; only the tool line disappears.
754
+ * the delegation status; only the tool line disappears. A failed turn shows
755
+ * its diagnostic once, in the body: the status line never repeats it.
721
756
  */
722
757
  private delegationLine(state: TurnState, now: number): string {
723
- if (state.failure !== undefined) {
724
- const suffix = state.failure.code === undefined ? '' : ` · ${state.failure.code}`
725
- return clipActivity(`🔴 本轮运行失败${suffix}`)
726
- }
727
758
  if (state.finalized) return this.settledLine(state)
728
759
 
729
760
  let latest: WorkflowRun | undefined
@@ -832,10 +863,14 @@ export class TurnStreamer {
832
863
  }, this.heartbeatMs)
833
864
  }
834
865
 
835
- private cardState(sessionId: string, state: TurnState, body: string): CardState {
836
- // The snapshot point recomputes (and remembers) both activity lines so
837
- // every patch — including heartbeat-driven ones — carries the current
838
- // status and tool call.
866
+ /**
867
+ * Snapshot one card. `active` is true for the card currently streaming:
868
+ * only that card renders the transient activity lines, so frozen segment
869
+ * cards never repeat a stale tool/delegation line. Both lines are still
870
+ * recomputed (and remembered) on every snapshot so heartbeat/transition
871
+ * logging stays accurate.
872
+ */
873
+ private cardState(sessionId: string, state: TurnState, body: string, active = true): CardState {
839
874
  const now = Date.now()
840
875
  const status = this.delegationLine(state, now)
841
876
  if (status !== state.status) {
@@ -853,8 +888,8 @@ export class TurnStreamer {
853
888
  phase: state.phase,
854
889
  reasoning: state.reasoning,
855
890
  body,
856
- status: state.status,
857
- tool: state.tool,
891
+ status: active ? state.status : '',
892
+ tool: active ? state.tool : '',
858
893
  stats: this.statsFor(sessionId, state),
859
894
  }
860
895
  }
@@ -882,7 +917,7 @@ export class TurnStreamer {
882
917
  const card = state.cards[index]
883
918
  if (card === undefined || card.frozen || card.mode === 'text') return
884
919
 
885
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
920
+ const options = segmentCardOptions(index)
886
921
  const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
887
922
  if (split.head === '') {
888
923
  // Degenerate budget (cannot happen with the real fixed overhead):
@@ -944,10 +979,10 @@ export class TurnStreamer {
944
979
  let lastError: unknown
945
980
  for (let attempt = 0; attempt < 3; attempt++) {
946
981
  try {
947
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), {
948
- panel: false,
949
- segment: index + 1,
950
- })
982
+ const snapshot = buildCard(
983
+ this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
984
+ segmentCardOptions(index),
985
+ )
951
986
  const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
952
987
  card.sending = false
953
988
  card.messageId = delivery.messageId
@@ -996,8 +1031,11 @@ export class TurnStreamer {
996
1031
  if (!force && !card.dirty && !turnDirty) return
997
1032
  if (!force && card.nextRetryAt > now) return
998
1033
  card.dirty = false
999
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
1000
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), options)
1034
+ const options = segmentCardOptions(index)
1035
+ const snapshot = buildCard(
1036
+ this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
1037
+ options,
1038
+ )
1001
1039
  this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
1002
1040
  card.dirty = true
1003
1041
  card.failures++
@@ -1027,8 +1065,10 @@ export class TurnStreamer {
1027
1065
  ): void {
1028
1066
  if (state.finalized) return
1029
1067
  state.finalized = true
1030
- state.failure = failure
1031
1068
  state.phase = failure === undefined ? 'done' : 'error'
1069
+ // A turn that ends inside a reasoning phase (e.g. no answer text) still
1070
+ // reports the time it spent reasoning.
1071
+ this.closeReasoning(state)
1032
1072
  if (failure !== undefined) {
1033
1073
  const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
1034
1074
  const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
@@ -1040,7 +1080,7 @@ export class TurnStreamer {
1040
1080
  // terminal diagnostic in its own continuation card instead.
1041
1081
  state.cards.push(this.freshSegment(notice))
1042
1082
  } else {
1043
- tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n---\n\n${notice}`
1083
+ tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n${notice}`
1044
1084
  tail.dirty = true
1045
1085
  }
1046
1086
  }