dsh-activity-pane 0.7.0 → 0.9.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.
package/src/core.mjs CHANGED
@@ -435,6 +435,23 @@ function chatNodeAt(nodes, key) {
435
435
  }
436
436
  }
437
437
 
438
+ /** 子代理模型读取的触发信号(R-01-012/AC-17):快照最新一个助手节点已定案
439
+ * (status === "settled",即其 assistant/message 事件已落宿主日志,尾页读取必命中)
440
+ * 时为 true。只检查最新一个助手节点——它随流式推送翻转为 settled 的那一刻即产生
441
+ * 触发,尾扫即停、近 O(1),完全挂在既有订阅推送上(R-02-004);工具密集期 4 行折叠窗口
442
+ * 可能不含助手行,故不看折叠时间线而直接读快照。流式未定案/中断/无快照为 false。 */
443
+ export function chatLatestAssistantSettled(snapshot) {
444
+ const chat = snapshot?.chat;
445
+ const order = Array.isArray(chat?.order) ? chat.order : [];
446
+ const nodes = chat?.nodes;
447
+ for (let i = order.length - 1; i >= 0; i -= 1) {
448
+ const node = chatNodeAt(nodes, order[i]);
449
+ if (!isRecord(node) || node.visibility === "hidden" || node.kind !== "assistant-step") continue;
450
+ return isRecord(node.data) && node.data.status === "settled";
451
+ }
452
+ return false;
453
+ }
454
+
438
455
  /** 尾部反向收集原始工作项(不含 live 合并),取够 want 个可转换项或耗尽 order 即停。
439
456
  * continueToUser:取够后以廉价结构检查(isUserChatNode:非 hidden 的 user/steering 且含非空文本块)继续前走至最近一个未收集的用户节点(含
440
457
  * steering),命中才转换并入队首——供指令锚行派生(R-01-012/AC-12),不为找锚做全序转换。 */
@@ -813,12 +830,54 @@ export function foldedConversationTimeline(snapshot, limit = 4, cwd = "", descen
813
830
  return [];
814
831
  }
815
832
 
833
+ /** user/message 非用户 source 的 provenance 投影(对齐宿主 dsh-client-ui-chat
834
+ * contextProvenance):recall = 跨会话召回,其余为注入;label 取各 source 形态的
835
+ * 稳定标识,未知 kind 直接以 kind 呈现。 */
836
+ function contextProvenanceOf(source) {
837
+ if (!isRecord(source)) return { role: "inject", label: null };
838
+ const kind = typeof source.kind === "string" ? source.kind : null;
839
+ if (kind === null) return { role: "inject", label: null };
840
+ if (kind === "session-reference") return { role: "recall", label: joinedSourceLabels(source.references, "label") ?? kind };
841
+ if (kind === "agent-instructions") return { role: "inject", label: joinedSourceLabels(source.changes, "path") ?? kind };
842
+ if (kind === "plugin") return { role: "inject", label: typeof source.plugin === "string" ? source.plugin : kind };
843
+ if (kind === "skill-invocation") return { role: "inject", label: typeof source.name === "string" ? source.name : kind };
844
+ return { role: "inject", label: kind };
845
+ }
846
+
847
+ /** source 数组成员的字段去重收集(首见顺序),逗号拼接为单行标签。 */
848
+ function joinedSourceLabels(list, field) {
849
+ if (!Array.isArray(list)) return null;
850
+ const seen = [];
851
+ for (const entry of list) {
852
+ const value = isRecord(entry) && typeof entry[field] === "string" ? entry[field] : null;
853
+ if (value !== null && !seen.includes(value)) seen.push(value);
854
+ }
855
+ return seen.length > 0 ? seen.join(", ") : null;
856
+ }
857
+
816
858
  function timelineItemFromEvent(entry, cwd = "") {
817
859
  const event = isRecord(entry?.event) ? entry.event : entry;
818
860
  const data = isRecord(event?.data) ? event.data : {};
819
861
  if (!event || typeof event.type !== "string") return null;
820
- if (event.type === "user/message" && data.source?.kind === "user") {
821
- return { id: `user:${event.seq}`, kind: "user", icon: "user", label: "用户", text: contentText(data.content), detail: null, status: "done" };
862
+ if (event.type === "user/message") {
863
+ if (data.source?.kind === "user") {
864
+ return { id: `user:${event.seq}`, kind: "user", icon: "user", label: "用户", text: contentText(data.content), detail: null, status: "done" };
865
+ }
866
+ // V3 log 路径的注入上下文行:非用户 source 的 user/message 即宿主 ContextMessageNode
867
+ // (0.1.5 无独立 context 事件),镜像原生 ContextInjectionRow 的角色文案。
868
+ const text = contentText(data.content);
869
+ if (text === "") return null;
870
+ const provenance = contextProvenanceOf(data.source);
871
+ return {
872
+ id: `context:${event.seq}`,
873
+ kind: "context",
874
+ icon: "context",
875
+ label: provenance.role === "recall" ? "跨会话召回" : "上下文注入",
876
+ text,
877
+ summary: provenance.label ?? "",
878
+ detail: null,
879
+ status: "done",
880
+ };
822
881
  }
823
882
  if (event.type === "assistant/message") {
824
883
  const text = contentText(data.message?.content);
@@ -860,20 +919,15 @@ function historyToolResultRoot(data, resultView, callInfo = null) {
860
919
  };
861
920
  }
862
921
 
863
- /** 判断 session window 是否尚未 hydrate,需用 native history 补齐。 */
864
- export function needsHistorySnapshot(snapshot) {
865
- return !snapshot || !Array.isArray(snapshot.chat?.order) || snapshot.chat.order.length === 0;
866
- }
867
-
868
922
  /** 冷会话 history 回溯深翻:自尾页起按 beforeSeq 向前翻页,直至命中最近一条用户消息
869
- * (messagePreviews 的 userPreview 非空,R-01-013/AC-03)、或翻尽
870
- * (hasMore=false/无更多事件/业务错误 null);requireOpenTurnStart 为 true 时
871
- * (运行会话开放回合起点兜底,R-01-009/AC-06)命中用户消息后开放回合起点未命中
872
- * 仍继续深翻直至起点命中或翻尽。maxPages 仅作显式护栏(默认 Infinity 即不设页数
873
- * 上限——用户消息必然存在于会话最早段,翻尽必终止,无需预置页数界)。fetchPage
874
- * (beforeSeq) 注入实际读取(返回 `{events, hasMore}` 或 null),便于纯函数单测;
875
- * 中途异常保留已得事件并以 error 返回。返回 `{ events, error }`(events 按时间
876
- * 正序,新页在后)。 */
923
+ * (messagePreviews 的 userPreview 非空,R-01-013/AC-03)、或翻尽
924
+ * (hasMore=false/无更多事件/业务错误 null);requireOpenTurnStart 为 true 时
925
+ * (运行会话开放回合起点兜底,R-01-009/AC-06)命中用户消息后开放回合起点未命中
926
+ * 仍继续深翻直至起点命中或翻尽。maxPages 仅作显式护栏(默认 Infinity 即不设页数
927
+ * 上限——用户消息必然存在于会话最早段,翻尽必终止,无需预置页数界)。fetchPage
928
+ * (beforeSeq) 注入实际读取(返回 `{events, hasMore}` 或 null),便于纯函数单测;
929
+ * 中途异常保留已得事件并以 error 返回。返回 `{ events, error }`(events 按时间
930
+ * 正序,新页在后)。 */
877
931
  export async function pagedHistoryEvents({ fetchPage, maxPages = Infinity, requireOpenTurnStart = false }) {
878
932
  const allEvents = [];
879
933
  let beforeSeq;
@@ -902,17 +956,45 @@ export async function pagedHistoryEvents({ fetchPage, maxPages = Infinity, requi
902
956
  return { events: allEvents, error };
903
957
  }
904
958
 
905
- /** 冷会话 history 的扁平工作项映射:供没有 ChatSnapshot 的活动/历史会话折叠分组使用。
906
- * native `sessions.history` 响应只含 `{events, hasMore, projections?}`(in-flight
907
- * partial 以 chunk 事件携带,不做逐 chunk 折叠),故只从事件流取尾部工作项。
959
+ /** V3 log 窗口的扁平工作项映射:供没有 ChatSnapshot 的活动/历史会话折叠分组使用。
960
+ * eventSource 快照含 type === "transient" 的 `assistant/live-chunk` 条目(agent-stream
961
+ * 增量帧,seq 为插值小数,仅存在于流式期间):按 attemptId 原位累积为单个 running
962
+ * assistant 行(对齐快照路径 mergeLiveItems 的 partial 行语义);回合落定后 transient
963
+ * 条目随 attempt 出窗,durable assistant/message 自然接管。tool-call-delta 不折叠——
964
+ * tool/call 以 durable 事件落定后经既有 call/result 配对路径呈现。
908
965
  * tool/result 落定同 callId 的 call 项(原位替换,name/arguments/callView 由 call 事件补齐):
909
966
  * history 是冻结过去,call 事件单独留存会成为永久 running 幽灵行(R-01-016/AC-01)。 */
910
967
  export function conversationTimelineFromHistory(history, limit = 4, cwd = "") {
911
968
  const items = [];
912
969
  const inflightCalls = new Map(); // callId → { index, data, callView }:等待结果落定的 tool/call 事件
970
+ const liveAttempts = new Map(); // attemptId → { index, text, reasoning }:流式期间累积的 live 行
913
971
  for (const entry of Array.isArray(history) ? history : []) {
914
972
  const event = eventOf(entry);
915
973
  const data = isRecord(event?.data) ? event.data : {};
974
+ if (event?.type === "assistant/live-chunk") {
975
+ const attemptId = typeof data.attemptId === "string" ? data.attemptId : "";
976
+ const text = typeof data.chunk?.text === "string" ? data.chunk.text : "";
977
+ if (attemptId === "" || text === "" || !isRecord(data.chunk)) continue;
978
+ const attempt = liveAttempts.get(attemptId) ?? (() => {
979
+ const created = { index: items.length, text: "", reasoning: "" };
980
+ liveAttempts.set(attemptId, created);
981
+ items.push({ id: `live:${attemptId}`, kind: "assistant", icon: "assistant", text: "", detail: null, status: "running", live: true });
982
+ return created;
983
+ })();
984
+ if (data.chunk.type === "reasoning-delta") attempt.reasoning += text;
985
+ else if (data.chunk.type === "text-delta") attempt.text += text;
986
+ else continue;
987
+ items[attempt.index] = {
988
+ ...items[attempt.index],
989
+ text: attempt.text,
990
+ detail: attempt.reasoning || null,
991
+ // 镜像原生 ReasoningRow:流式思考显示尾部最新行,避免与已定案首行摘要漂移。
992
+ summary: attempt.reasoning ? latestLineOf(attempt.reasoning) : attempt.text,
993
+ status: "running",
994
+ live: true,
995
+ };
996
+ continue;
997
+ }
916
998
  if (event?.type === "tool/result") {
917
999
  const callId = toolResultCallId(data);
918
1000
  const pending = callId !== undefined ? inflightCalls.get(callId) : undefined;
@@ -942,12 +1024,15 @@ export function conversationTimelineFromHistory(history, limit = 4, cwd = "") {
942
1024
 
943
1025
  /** 冷 history 折叠分组时间线(R-01-017、R-01-012/AC-12~AC-15):页内全部事件映射折叠后
944
1026
  * 套用与快照路径同一窗口/锚行选择(selectTimelineRows),最近用户消息滚动触顶后停留为
945
- * 首行锚行。 */
946
- export function foldedHistoryTimeline(history, limit = 4, cwd = "") {
1027
+ * 首行锚行。settleIdle:阻塞等待呈现(pendingText 存在)下折叠前把残留 running 行落定,
1028
+ * 组标题/状态由已定案成员派生(「运行了命令」而非「正在运行」蓝闪),与快照路径的
1029
+ * settleWhenIdle 前置语义一致。 */
1030
+ export function foldedHistoryTimeline(history, limit = 4, cwd = "", settleIdle = false) {
947
1031
  const max = Math.max(0, limit);
948
1032
  if (max === 0) return [];
949
1033
  const items = conversationTimelineFromHistory(history, Number.MAX_SAFE_INTEGER, cwd);
950
- return selectTimelineRows(foldWorkGroups(items, Number.MAX_SAFE_INTEGER), max);
1034
+ const settled = settleIdle ? settleWhenIdle(items, true) : items;
1035
+ return selectTimelineRows(foldWorkGroups(settled, Number.MAX_SAFE_INTEGER), max);
951
1036
  }
952
1037
 
953
1038
  /** history 指令锚行提取(R-01-012/AC-12 快照窗口外兜底):尾部反向取最近一条非空文本的
@@ -965,14 +1050,6 @@ export function historyInstructionAnchor(history) {
965
1050
  }
966
1051
 
967
1052
 
968
- /** 开放回合起点缺口判定(R-01-009/AC-06 冷窗口兜底触发口径):快照就绪、宿主判定运行中、
969
- * 轮内订阅已建立,但快照 turnTimings 无开放回合起点(liveStartTime 为 null)——超长回合
970
- * 的 turn/start 在尾页窗口之外。等待/空闲会话(非运行或无 liveness 记录)不算缺口,
971
- * 不触发 history 补读。 */
972
- export function openTurnStartMissing({ snapshotReady = false, running = false, hasLiveness = false, liveStartTime = null } = {}) {
973
- return snapshotReady === true && running === true && hasLiveness === true && liveStartTime == null;
974
- }
975
-
976
1053
  /** 开放回合起点兜底提取(R-01-009/AC-06):history 事件尾部反向扫描,最近一条边界事件
977
1054
  * 为 turn/start 即存在开放回合、返回其时刻;为 turn/end 则无开放回合返回 null。
978
1055
  * minTurn:快照已知的最晚回合号——history 开放回合落后于此(拉取后已切换新回合)时
@@ -1045,6 +1122,64 @@ export function modelMetadata(models) {
1045
1122
  };
1046
1123
  }
1047
1124
 
1125
+ /** 子代理模型溯源(R-01-012/AC-17):从 history 事件流尾扫最近一条携带模型溯源的
1126
+ * `assistant/message` 事件,取 `message.source.model`。返回值 reasoning 恒为空串,
1127
+ * effort 由调用方以 `reasoningEffortFromHistoryEvents` 同页折叠;无命中(空事件、
1128
+ * 无助手消息或溯源缺失)返回 null,调用方保持模型区空白、不以 preset 或母会话
1129
+ * 模型冒充(R-01-012/AC-18)。 */
1130
+ export function modelFromHistoryEvents(history) {
1131
+ const entries = Array.isArray(history) ? history : [];
1132
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1133
+ const event = eventOf(entries[i]);
1134
+ if (event?.type !== "assistant/message") continue;
1135
+ const source = isRecord(event.data?.message?.source) ? event.data.message.source : null;
1136
+ if (source !== null && typeof source.model === "string" && source.model !== "") {
1137
+ return { model: source.model, reasoning: "" };
1138
+ }
1139
+ }
1140
+ return null;
1141
+ }
1142
+
1143
+ /** 子代理 reasoning effort(R-01-012/AC-17):history 尾扫最新一条 `request/header`
1144
+ * 事件,取 `config.reasoningEffort`——宿主的会话选择即从该折叠读取。命中最新请求头
1145
+ * 即停:更早请求头属已废弃纪元,即使声明过 effort 也不再回扫。最新请求头未声明
1146
+ * effort 或页内无请求头(超长子会话的 header 在日志开头、可能落在尾页窗口之外)
1147
+ * 返回 null,调用方回落目录条目 `reasoning` 后保持空值(R-01-012/AC-18)。 */
1148
+ export function reasoningEffortFromHistoryEvents(history) {
1149
+ const entries = Array.isArray(history) ? history : [];
1150
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1151
+ const event = eventOf(entries[i]);
1152
+ if (event?.type !== "request/header") continue;
1153
+ const config = isRecord(event.data?.header?.config) ? event.data.header.config : null;
1154
+ if (config !== null && typeof config.reasoningEffort === "string" && config.reasoningEffort !== "") {
1155
+ return config.reasoningEffort;
1156
+ }
1157
+ return null;
1158
+ }
1159
+ return null;
1160
+ }
1161
+
1162
+ /** 模型目录分组的 modelId → {name, reasoning} 索引(R-01-012/AC-17):溯源
1163
+ * `source.model` 是 provider 侧 id,显示名需经目录分组解析;同一部署的目录分组在
1164
+ * 主/子会话间共享,条目 `reasoning` 是请求头 effort 不可得时的回退来源。畸形条目
1165
+ * 跳过;目录缺失返回空索引,调用方回退显示原始溯源 id(R-01-012/AC-18)。 */
1166
+ export function catalogModelEntries(groups) {
1167
+ const entries = {};
1168
+ for (const group of Array.isArray(groups) ? groups : []) {
1169
+ if (!isRecord(group)) continue;
1170
+ for (const model of Array.isArray(group.models) ? group.models : []) {
1171
+ if (!isRecord(model)) continue;
1172
+ if (typeof model.id === "string" && model.id !== "" && typeof model.name === "string" && model.name !== "") {
1173
+ entries[model.id] = {
1174
+ name: model.name,
1175
+ reasoning: typeof model.reasoning === "string" && model.reasoning !== "" ? model.reasoning : "",
1176
+ };
1177
+ }
1178
+ }
1179
+ }
1180
+ return entries;
1181
+ }
1182
+
1048
1183
  /** 只提供卡片底部所需的原始统计字段,不拼接当前动作文案。 */
1049
1184
  export function runtimeStats({ elapsedMs = null, outputTokens = null, rateTokS = null } = {}) {
1050
1185
  return {
@@ -1187,36 +1322,6 @@ export function escapeCssString(value) {
1187
1322
  .replace(/\0/g, "�");
1188
1323
  }
1189
1324
 
1190
- /** 冷会话补充数据读取决策(单次渲染内是否发起 models/history 读取)。
1191
- * 失败路径会写入空 model/history 使决策转为「不读」(可见期内不热重试);
1192
- * 详情与记账随可见性清理(pruneInvisibleEntries)一起移除后,决策自然恢复为「读取」。
1193
- * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1194
- * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1195
- * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1196
- * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡或最近卡
1197
- * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12、R-01-013/AC-12)。 */
1198
- export function detailLoadPlan({
1199
- detail = {},
1200
- isSubagent = false,
1201
- snapshotReady = false,
1202
- historyNeeded = false,
1203
- previewFallbackNeeded = false,
1204
- durationFallbackNeeded = false,
1205
- windowComplete = true,
1206
- modelInflight = false,
1207
- historyInflight = false,
1208
- } = {}) {
1209
- return {
1210
- subagent: isSubagent === true,
1211
- model: !isSubagent && !detail.model && !modelInflight,
1212
- history:
1213
- !historyInflight &&
1214
- ((durationFallbackNeeded && detail.durationFallbackLoaded !== true) ||
1215
- (previewFallbackNeeded && detail.previewFallbackLoaded !== true) ||
1216
- (!detail.history && ((!snapshotReady && historyNeeded) || (snapshotReady === true && windowComplete === false)))),
1217
- };
1218
- }
1219
-
1220
1325
  /** 打开重试链是否应取消:目标已成为当前会话(已到达),或用户已激活其它卡片(被新意图取代)。 */
1221
1326
  export function shouldCancelOpenRetry({ targetId, currentId = null, activatedId = null } = {}) {
1222
1327
  if (targetId === undefined || targetId === null) return true;
@@ -1687,6 +1792,8 @@ export function cardSignature(entries) {
1687
1792
  entry.loadingTimeline ?? null,
1688
1793
  entry.loadingPreviews ?? null,
1689
1794
  entry.tokenStats ?? [entry.outputTokens ?? null, entry.inputTokens ?? null, entry.cacheHitPct ?? null, entry.rateTokS ?? null, entry.elapsedMs ?? null],
1795
+ // 累计运行时长参与签名(R-01-020):回填/SSE 推送与逐秒推进都要驱动重绘。
1796
+ entry.totalBusyMs ?? null,
1690
1797
  ]),
1691
1798
  );
1692
1799
  }
@@ -1872,58 +1979,256 @@ function durationTime(value) {
1872
1979
  return Number.isFinite(time) ? time : null;
1873
1980
  }
1874
1981
 
1875
- /** 从 history 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1876
- function lastTurnDurationCandidateFromEvents(events) {
1877
- const starts = new Map();
1982
+ /**
1983
+ * 从 history 重放回合/等待边界事件,提取最近一个已结束回合的运行过程耗时(busy 口径:
1984
+ * 起止差值扣除回合内阻塞等待,与 R-01-020 累计口径一致,恒不大于该回合墙钟时长)。
1985
+ * 回合起止不完整、时间逆序或回合运行段不为正时忽略该回合;无可得回合返回 null。
1986
+ */
1987
+ export function lastTurnBusyFromEvents(events) {
1988
+ let state = emptyTurnStats();
1878
1989
  let latest = null;
1879
1990
  for (const entry of Array.isArray(events) ? events : []) {
1880
- const event = eventOf(entry);
1881
- const turn = Number(event?.data?.turn);
1882
- const time = durationTime(event?.time);
1883
- if (!Number.isFinite(turn) || time === null) continue;
1884
- if (event.type === "turn/start") {
1885
- starts.set(turn, time);
1886
- continue;
1887
- }
1888
- if (event.type !== "turn/end") continue;
1889
- const start = starts.get(turn);
1890
- if (start === undefined || time < start) continue;
1891
- if (latest === null || time > latest.end) latest = { end: time, duration: time - start };
1991
+ const time = durationTime(eventOf(entry)?.time);
1992
+ const prev = state;
1993
+ state = applyTurnEventToStats(state, entry);
1994
+ if (prev.openTurnStart === null || state.openTurnStart !== null) continue;
1995
+ // 能走到回合闭合的必为时刻有效的 turn/end(applyTurnEventToStats 对无效时刻无效果)。
1996
+ const activeMs = (state.busyMs ?? 0) - (prev.busyMs ?? 0);
1997
+ if (activeMs <= 0) continue;
1998
+ latest = { end: time, duration: activeMs };
1892
1999
  }
1893
- return latest;
2000
+ return latest?.duration ?? null;
1894
2001
  }
1895
2002
 
1896
- /** 从 turnTimings 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1897
- function lastTurnDurationCandidateFromTimings(turnTimings) {
1898
- if (!(turnTimings instanceof Map)) return null;
1899
- let latest = null;
1900
- for (const timing of turnTimings.values()) {
1901
- const start = durationTime(timing?.startTime);
1902
- const end = durationTime(timing?.endTime);
1903
- if (start === null || end === null || end < start) continue;
1904
- if (latest === null || end > latest.end) latest = { end, duration: end - start };
2003
+ /** 最近完整回合的固定耗时(busy 口径):从 history 事件重放派生。 */
2004
+ export function lastTurnDuration({ history = [] } = {}) {
2005
+ return lastTurnBusyFromEvents(history);
2006
+ }
2007
+
2008
+ /** ask_user_question 工具名:提问/计划审查等待的开启边界(tool/call)与配对结算(tool/result)。 */
2009
+ const QUESTION_TOOL_NAME = "ask_user_question";
2010
+
2011
+ /** 回合/等待边界事件类型全集:busy 记账只消费这些事件,其余事件对记账无效果。 */
2012
+ const BOUNDARY_EVENT_TYPES = new Set(["turn/start", "turn/end", "approval/asked", "approval/decided", "tool/call", "tool/result"]);
2013
+
2014
+ /** 边界事件判定(宿主实时登记入口与记账转移共用,新增边界只改 BOUNDARY_EVENT_TYPES 一处)。 */
2015
+ export function isBusyBoundaryEvent(type) {
2016
+ return BOUNDARY_EVENT_TYPES.has(type);
2017
+ }
2018
+
2019
+ /** 空记账状态(无任何有效计时)。 */
2020
+ export function emptyTurnStats() {
2021
+ return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null, watermarkTime: null };
2022
+ }
2023
+
2024
+ /** 从任意来源拷贝记账状态:缺失字段归一为 null(旧记录/部分记录兼容)。 */
2025
+ export function turnStatsFrom(state) {
2026
+ return {
2027
+ busyMs: state?.busyMs ?? null,
2028
+ openTurnStart: state?.openTurnStart ?? null,
2029
+ openWaitStart: state?.openWaitStart ?? null,
2030
+ openWaitKind: state?.openWaitKind ?? null,
2031
+ openWaitId: state?.openWaitId ?? null,
2032
+ waitedMs: state?.waitedMs ?? null,
2033
+ watermarkTime: state?.watermarkTime ?? null,
2034
+ };
2035
+ }
2036
+
2037
+ /**
2038
+ * 记账状态逐字段相等(不含 watermarkSeq——它不是状态字段,由宿主侧随写入一并落盘;
2039
+ * watermarkTime 为水位处已覆盖事件的时刻,参与相等判定以让补写该字段的收敛写入生效)。
2040
+ */
2041
+ export function turnStatsEqual(a, b) {
2042
+ return (
2043
+ a === b ||
2044
+ ((a?.busyMs ?? null) === (b?.busyMs ?? null) &&
2045
+ (a?.openTurnStart ?? null) === (b?.openTurnStart ?? null) &&
2046
+ (a?.openWaitStart ?? null) === (b?.openWaitStart ?? null) &&
2047
+ (a?.openWaitKind ?? null) === (b?.openWaitKind ?? null) &&
2048
+ (a?.openWaitId ?? null) === (b?.openWaitId ?? null) &&
2049
+ (a?.waitedMs ?? null) === (b?.waitedMs ?? null) &&
2050
+ (a?.watermarkTime ?? null) === (b?.watermarkTime ?? null))
2051
+ );
2052
+ }
2053
+
2054
+ /** 结算当前未配对等待区间:时刻有效则累加 waitedMs,随后清空等待态。 */
2055
+ function settleOpenWait(next, time) {
2056
+ if (next.openWaitStart !== null && time >= next.openWaitStart) {
2057
+ next.waitedMs = (next.waitedMs ?? 0) + (time - next.openWaitStart);
2058
+ }
2059
+ next.openWaitStart = null;
2060
+ next.openWaitKind = null;
2061
+ next.openWaitId = null;
2062
+ }
2063
+
2064
+ /** 强制关闭开放回合(turn/end 与启动扫描共用):先按时刻结算未配对等待(等待尾部不
2065
+ * 计入运行时长),再把 `time − openTurnStart − waitedMs` 的运行部分累加进 busyMs,
2066
+ * 最后清空回合态;时间逆序时跳过累加、仅清空。 */
2067
+ function settleTurnClose(next, time) {
2068
+ if (next.openWaitStart !== null) settleOpenWait(next, time);
2069
+ if (next.openTurnStart !== null && time >= next.openTurnStart) {
2070
+ const activeMs = time - next.openTurnStart - (next.waitedMs ?? 0);
2071
+ if (activeMs > 0) next.busyMs = (next.busyMs ?? 0) + activeMs;
1905
2072
  }
1906
- return latest;
2073
+ next.openTurnStart = null;
2074
+ next.waitedMs = null;
1907
2075
  }
1908
2076
 
1909
- /** 从 history 提取最近完整回合的固定耗时;回合起止不完整或逆序时忽略该回合。 */
1910
- export function lastTurnDurationFromEvents(events) {
1911
- return lastTurnDurationCandidateFromEvents(events)?.duration ?? null;
2077
+ /**
2078
+ * 会话回合统计记账的单步转移(R-01-020):对单个回合/等待边界事件应用后返回新状态。
2079
+ * 状态 `{ busyMs, openTurnStart, openWaitStart, openWaitKind, openWaitId, waitedMs }`——
2080
+ * busyMs 为运行过程时长的累计(null 表示尚无任何有效回合计时),openTurnStart 为当前
2081
+ * 开放回合起点,openWaitStart/openWaitKind/openWaitId 为当前未配对等待区间(回合内
2082
+ * 阻塞等待:'approval' 审批 / 'question' 提问,id 为审批 id 或提问 callId),waitedMs
2083
+ * 为本回合内已配对等待时长的累计;completed/blocked/max-tokens/aborted/error 全部结束
2084
+ * 原因均计入,回合间空闲与回合内等待不计入。等待边界:`approval/asked` 与
2085
+ * `ask_user_question` 的 `tool/call` 开启(记录 id/callId),`approval/decided` 与同
2086
+ * id/callId 的 `tool/result` 结算(id 不匹配的结算事件忽略);等待串行不嵌套(已有
2087
+ * 未配对等待时新边界忽略),`turn/end` 先按事件时刻强制结算未配对等待再以
2088
+ * `time − openTurnStart − waitedMs` 结算回合。start 覆盖式登记(串行回合下最后一个
2089
+ * start 为当前回合),end 配对最近 start;起点缺失或时间逆序的回合跳过累加、仅清空
2090
+ * 状态。event 兼容 history 条目包装与裸事件(eventOf 解包),宿主实时登记与回填共用
2091
+ * 同一转移,保证口径一致(R-01-020/AC-02)。
2092
+ */
2093
+ export function applyTurnEventToStats(state, entry) {
2094
+ const event = eventOf(entry);
2095
+ const time = durationTime(event?.time);
2096
+ const type = event?.type;
2097
+ if (time === null || !isBusyBoundaryEvent(type)) return state;
2098
+ const next = turnStatsFrom(state);
2099
+ if (type === "turn/start") {
2100
+ next.openTurnStart = time;
2101
+ next.openWaitStart = null;
2102
+ next.openWaitKind = null;
2103
+ next.openWaitId = null;
2104
+ next.waitedMs = null;
2105
+ return next;
2106
+ }
2107
+ if (type === "turn/end") {
2108
+ settleTurnClose(next, time);
2109
+ return next;
2110
+ }
2111
+ if (type === "approval/asked") {
2112
+ if (next.openTurnStart === null || next.openWaitStart !== null) return state;
2113
+ next.openWaitStart = time;
2114
+ next.openWaitKind = "approval";
2115
+ next.openWaitId = typeof event?.data?.id === "string" ? event.data.id : null;
2116
+ return next;
2117
+ }
2118
+ if (type === "approval/decided") {
2119
+ if (next.openWaitStart === null || next.openWaitKind !== "approval") return state;
2120
+ if (next.openWaitId !== null && event?.data?.id !== next.openWaitId) return state;
2121
+ settleOpenWait(next, time);
2122
+ return next;
2123
+ }
2124
+ if (type === "tool/call") {
2125
+ if (event?.data?.name !== QUESTION_TOOL_NAME) return state;
2126
+ if (next.openTurnStart === null || next.openWaitStart !== null) return state;
2127
+ next.openWaitStart = time;
2128
+ next.openWaitKind = "question";
2129
+ next.openWaitId = typeof event?.data?.callId === "string" ? event.data.callId : null;
2130
+ return next;
2131
+ }
2132
+ // tool/result:仅结算开启中的提问等待,且 callId 与开启边界一致——其余 result 对
2133
+ // 记账无效果(等待串行,同 callId 的下一个 result 即配对边界)。
2134
+ if (next.openWaitStart === null || next.openWaitKind !== "question") return state;
2135
+ const resultCallId = event?.data?.message?.source?.callId;
2136
+ if (next.openWaitId !== null && resultCallId !== next.openWaitId) return state;
2137
+ settleOpenWait(next, time);
2138
+ return next;
1912
2139
  }
1913
2140
 
1914
- /** 从 turnTimings 提取最近完整回合的固定耗时;全部回合未结束或无效时返回 null。 */
1915
- export function lastTurnDurationFromTimings(turnTimings) {
1916
- return lastTurnDurationCandidateFromTimings(turnTimings)?.duration ?? null;
2141
+ /**
2142
+ * busy 记账字段归一:null/undefined 直通为 null(「无数据」语义),仅真实有限数字
2143
+ * 保留。必须先判空再走 Number()——Number(null) === 0 会把「无开放回合」误归一为
2144
+ * epoch 0,令合成值膨胀为 now−0(R-01-020/AC-06 回归)。
2145
+ */
2146
+ export function normalizeBusyMs(value) {
2147
+ if (value === null || value === undefined) return null;
2148
+ const n = Number(value);
2149
+ return Number.isFinite(n) ? n : null;
1917
2150
  }
1918
2151
 
1919
- /** 在快照与 history 中按最近结束时刻选择同一最新完整回合的固定耗时。 */
1920
- export function lastTurnDuration({ turnTimings = null, history = [] } = {}) {
1921
- const candidates = [lastTurnDurationCandidateFromTimings(turnTimings), lastTurnDurationCandidateFromEvents(history)].filter(Boolean);
1922
- let latest = null;
1923
- for (const candidate of candidates) {
1924
- if (latest === null || candidate.end > latest.end) latest = candidate;
2152
+ /**
2153
+ * 标题行累计运行时长的显示合成(R-01-020/AC-01、AC-03、AC-06、AC-07):已完成回合
2154
+ * 累计加开放回合的实时已耗时(扣除回合内已配对等待与进行中的未配对等待);两者皆
2155
+ * 不可得时返回 null(调用方不显示,不以 0 冒充)。now 缺失或无效时不推进实时增量,
2156
+ * 只返回已完成累计。等待期间 `(now − openTurnStart)` 与 `(now − openWaitStart)` 两项
2157
+ * 随 now 同步增长相互抵消,显示值自冻结;等待结算时 waitedMs 接管同一增量,恢复
2158
+ * 运行后从冻结值继续,回合结算处连续无跳变。
2159
+ */
2160
+ export function totalBusyDisplayMs({ busyMs = null, openTurnStart = null, waitedMs = null, openWaitStart = null, now = null } = {}) {
2161
+ let total = typeof busyMs === "number" && Number.isFinite(busyMs) && busyMs >= 0 ? busyMs : null;
2162
+ if (openTurnStart !== null && Number.isFinite(openTurnStart) && Number.isFinite(now) && now > openTurnStart) {
2163
+ const waited = typeof waitedMs === "number" && Number.isFinite(waitedMs) && waitedMs >= 0 ? waitedMs : 0;
2164
+ const waitingNow = openWaitStart !== null && Number.isFinite(openWaitStart) && now > openWaitStart ? now - openWaitStart : 0;
2165
+ total = (total ?? 0) + Math.max(0, now - openTurnStart - waited - waitingNow);
1925
2166
  }
1926
- return latest?.duration ?? null;
2167
+ return total;
2168
+ }
2169
+
2170
+ /**
2171
+ * 回合统计记账的统一收敛(R-01-020/AC-04、AC-05):对全会话事件列表重放出下一份
2172
+ * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、强制重放、持久化水位非法、
2173
+ * 或水位超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态
2174
+ * 全量重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、
2175
+ * 总耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2176
+ * 水位推进处同步记录 `watermarkTime`(水位处已覆盖事件的时刻,与实时登记按效果事件
2177
+ * 写入的口径互为保守——检测只要求该值不超过真实边界事件时刻)——宿主实时登记据此
2178
+ * 识别 seq 空间重编(低 seq 事件携带比水位更新的时刻,增量口径已失效)。
2179
+ * `closeOpenTurn`(宿主启动扫描):重放后仍存在的开放回合按日志最后事件时刻强制
2180
+ * 结算关闭——宿主重启后不存在仍在运行的回合,残留起点只会令总耗时无限增长;尾部
2181
+ * 未配对等待一并按同刻结算(不落到运行时长里)。
2182
+ */
2183
+ export function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFresh = false } = {}) {
2184
+ const list = Array.isArray(records) ? records : [];
2185
+ let maxSeq = null;
2186
+ let lastTime = null;
2187
+ for (const record of list) {
2188
+ const seq = Number(record?.seq);
2189
+ if (Number.isFinite(seq) && (maxSeq === null || seq > maxSeq)) maxSeq = seq;
2190
+ const time = durationTime(eventOf(record)?.time);
2191
+ if (time !== null && (lastTime === null || time > lastTime)) lastTime = time;
2192
+ }
2193
+ const fresh = forceFresh || !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2194
+ let state = emptyTurnStats();
2195
+ let watermarkSeq = null;
2196
+ let watermarkTime = null;
2197
+ // 水位推进:seq 更大即前推水位;事件时刻有效才更新 watermarkTime(无效不前推)。
2198
+ const advance = (record) => {
2199
+ const seq = Number(record?.seq);
2200
+ if (!Number.isFinite(seq) || (watermarkSeq !== null && seq <= watermarkSeq)) return;
2201
+ watermarkSeq = seq;
2202
+ const time = durationTime(eventOf(record)?.time);
2203
+ if (time !== null) watermarkTime = time;
2204
+ };
2205
+ if (fresh) {
2206
+ for (const record of list) {
2207
+ state = applyTurnEventToStats(state, record);
2208
+ advance(record);
2209
+ }
2210
+ } else {
2211
+ state = turnStatsFrom(current);
2212
+ watermarkTime = state.watermarkTime;
2213
+ watermarkSeq = Number(current.watermarkSeq);
2214
+ for (const record of list) {
2215
+ const seq = Number(record?.seq);
2216
+ if (!Number.isFinite(seq) || seq <= watermarkSeq) continue;
2217
+ state = applyTurnEventToStats(state, record);
2218
+ advance(record);
2219
+ }
2220
+ }
2221
+ if (closeOpenTurn && state.openTurnStart !== null) {
2222
+ // 启动扫描:宿主重启后不存在仍在运行的回合——按日志最后事件时刻强制结算关闭;
2223
+ // 日志不可得(空/缺失)时仅清除回合态,不制造虚假运行时长。
2224
+ if (lastTime !== null) settleTurnClose(state, lastTime);
2225
+ state.openTurnStart = null;
2226
+ state.openWaitStart = null;
2227
+ state.openWaitKind = null;
2228
+ state.openWaitId = null;
2229
+ state.waitedMs = null;
2230
+ }
2231
+ return { ...state, watermarkSeq, watermarkTime };
1927
2232
  }
1928
2233
 
1929
2234
  /**