@migor/cli 0.10.9 → 0.10.10

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/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [0.10.10] - 2026-11-12
9
+
10
+ ### 变更
11
+
12
+ 调试面板从「**看得见一棵树**」推进到「**看得到这一趟 run 的账、找得到那一条、带得走**」。
13
+ 三批改动(#304 布局 / #305 说真话 / #306 补账与检索),无破坏性变更、无迁移动作。
14
+ 两包同步 0.10.9 → 0.10.10;`src/` 一行未动(`git diff v0.10.9..HEAD -- src/` 为空),
15
+ 框架包此次只有版本常量变化,实际改动全在 CLI 面板与 trace-view 渲染件。
16
+ 评审依据:`docs/reviews/2026-11-12/inspector-board-gap.md`。
17
+
18
+ - **#304 · 右栏三块都能折叠**:调用树 / 能力排行 / 回复原先高度写死(树 `flex: 1`、下面两块
19
+ 各 `38vh`),三块同开时谁都不够读;现在标题栏可点,`data-open` 与 `aria-expanded` 由
20
+ `applyPane` **一处**写入,收起后剩下的块按份分同一个剩余空间(树 1、回复 1.5、排行按内容
21
+ 上限 45%)。收起不等于失明:标题栏仍留规模读数。折叠态是布局偏好,进 localStorage。
22
+ ⚠️ 第一版踩过一处**状态全对、画面全错**:`#pane-trace > .pane-body` 的 `display: flex`
23
+ 挂在 id 选择器上,特异性压过折叠规则 ⇒ 折叠后树照样铺满;无头 Chrome 截图才看得见。
24
+ - **#305 · 修掉五处在说谎的地方**(只治这五处,不新增视图):
25
+
26
+ | 症状 | 根因 |
27
+ |---|---|
28
+ | 回复切走 / 刷新后就消失 | `finalText` 只接在 `run-done` 那一帧 SSE 上,trace 上根本没有它 |
29
+ | 失败 / 中止轮进不了对话视图 | `mergeConversation` 读 `r.prompt`,生产形状是 `note.prompt`;**单测喂的正是那个不存在的形状** |
30
+ | 根 span 上的事件整类丢失 | `reset()` 把根登记成 `'root'`,事件按真 `spanId` 投递 ⇒ 静默 return |
31
+ | 每条 run 都叫 `agent.run` | dev 环不传 `runName`,而 `note.prompt` 一直有货、一直没被用 |
32
+ | 14 种收尾原因只呈现 3 种(`suspended` 显示成「正常跑完」) | 只分辨中止 / 失败 / 正常,而挂起的 trace 状态是 **ok** |
33
+
34
+ 改法:`RunNote` 落 `finalText` 且 `/api/runs/:id` 带顶层 `note`(空回复**不写这个键** ——
35
+ 面板要能区分「本来没回复」与「回复丢了」);`reset(rootName, rootId)` 收下框架真 `spanId`;
36
+ 列表首行给 prompt;`stopReasonClass` 四类终态走「**白名单 + 未知落被拦住**」,框架新增成员时
37
+ 宁可显示成被拦住并露出原文,也不谎报正常收尾。
38
+ - **#306 · 先修地基,再补八件事**:`renderTrace()` 此前「每个动作 = 一次全树重建」,
39
+ 600 span 实测 **1801 次**重建(在飞时每帧还再来一次);拆成调度层 + `drawTree`,
40
+ `playTrace` 把 reset + 回放 + finish **整块**包进 `view.batch()` ⇒ 50 span 只重建 1 次。
41
+ 在此基础上补:run 级总账(轮次 / 工具 / 失败 / in-out / cache / 成本带预算上限 / 模型)、
42
+ span attributes 可见**含根**(`model` / `system.version` / `tools.names` 只写在根上)、
43
+ 时间轴(按 run 总时长归一化)、树内检索(只高亮跳转、不改结构)、列表检索(关键词 +
44
+ 只看异常,异常**含挂起**)、深链 `?run=`(`replaceState` 不堆历史)、重跑(**回填四个输入,
45
+ 不自动跑** —— 自动跑会在误触时花钱)、导出(复制这条 trace 的 JSONL,去掉 CLI 侧 `note`)。
46
+ - **三条「不给假读数」的口径**:① 总账缺字段的那一项**整项不出现**,不显示 `0%` / `$0.0000`
47
+ ——「成本是 0」与「没有单价表」是两件事;② cache 分母**含缓存写入**,否则刚写了一大块缓存
48
+ 的那次 run 会显示成 100% 命中,正是最容易误读的时刻;③ 时间轴**缺时间戳就整棵不画** ——
49
+ 手写 trace / 官网模拟回放画一条人人占满的条,比不画更误导。
50
+ - **顺手修的实拍发现**:三块同开时调用树只分到 134px ≈ 三行(夹具 23 行只看得见 3 行),
51
+ `flex-grow: 2` ⇒ 191px。取 2 不是更极端:回复仍要比它大,结论优先。
52
+
53
+ ### 新增:面板从「看树」到「看账」的三批收口
54
+
55
+ - 决策见 `docs/spec.md` §10 2026-11-12 ref-panel-panes、2026-11-12 ref-inspector-truth、
56
+ 2026-11-13 ref-inspector-board;守卫登记见 `docs/guards.md` §1。
57
+ - **新增守卫**:CLI `structure.test.mjs` 的 **W6**(折叠态只许在 `applyPane` 里写,
58
+ `data-open` 与 `aria-expanded` 必须一起改);`stop-reason.test.mjs` **跨包口径**
59
+ (框架每加一个 `AgentStopReason`,面板都得给出中文说法;只按行首 `|` 摘成员,
60
+ 注释里散落的例子不算);trace-view `render-batch.test.js`(批量 / 属性含根 / 时间轴 /
61
+ 树内检索);`panel-logic.test.mjs` + `inspector.test.mjs`(总账 / 检索 / 深链 / 回填接线)。
62
+ - **验证**:CLI **257 → 278**、trace-view **96 → 113**(两侧读数都在本机各跑一遍核过)。
63
+ 三批共 25 条变异各自咬住;#305 / #306 另用无头 Chrome 实拍四条 run 覆盖四态
64
+ (列表四个圆点、根行七项属性、`?run=demo-2` 直达、回填后**没有**提交、
65
+ **不推** `run-done` 时切走再切回来回复仍在)。
66
+ - ⚠️ `/api/runs/:id` 的响应多一个顶层 `note`(additive,`playTrace` 只读 `spans` / `traceId`)。
67
+
8
68
  ## [0.10.9] - 2026-11-12
9
69
 
10
70
  ### 变更
@@ -4561,7 +4621,8 @@ toolkit 142 / runtime 55 / engine 403 / container 10 / store 55 —— 全绿。
4561
4621
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
4562
4622
  框架本体单包;CLI 独立成包(workspaces)。
4563
4623
 
4564
- [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.9...HEAD
4624
+ [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.10...HEAD
4625
+ [0.10.10]: https://github.com/migoric/agentia/releases/tag/v0.10.10
4565
4626
  [0.10.9]: https://github.com/migoric/agentia/releases/tag/v0.10.9
4566
4627
  [0.10.8]: https://github.com/migoric/agentia/releases/tag/v0.10.8
4567
4628
  [0.10.7]: https://github.com/migoric/agentia/releases/tag/v0.10.7
@@ -234,7 +234,14 @@ export function update(state, ev) {
234
234
  lastError = null;
235
235
  const effects = [{ kind: 'disarm-abort-timer' }];
236
236
  if (ev.traceId !== null && note !== null) {
237
- effects.push({ kind: 'note-run', traceId: ev.traceId, note });
237
+ // 回复(`finalText`)只有这一帧带 ⇒ 必须与 note 一起落账,否则面板切走再切回来
238
+ // 就再也取不到了(它不在 trace 上)。新建对象而不是改 `note`:pendingNote 是
239
+ // 上一事件的 state,就地改会让「同一份 note 被两条 run 共享」成为可能。
240
+ effects.push({
241
+ kind: 'note-run',
242
+ traceId: ev.traceId,
243
+ note: ev.finalText ? { ...note, finalText: ev.finalText } : note,
244
+ });
238
245
  }
239
246
  effects.push({
240
247
  kind: 'emit',
@@ -147,44 +147,71 @@ export function playTrace(view, trace) {
147
147
  }
148
148
  items.sort((a, b) => a.t - b.t || a.ord - b.ord || a.d - b.d || a.seq - b.seq);
149
149
 
150
- const rootRaw = String(root.name || 'run');
151
- // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀
152
- view.reset(rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw);
153
-
154
- for (const it of items) {
155
- if (it.kind === 'start') {
156
- if (it.s === root) continue; // 根由 reset 建,不重复开
157
- view.start({
158
- id: it.s.spanId,
159
- parent: it.s.parentSpanId,
160
- kind: it.s.kind,
161
- name: displayName(it.s),
162
- arg: '',
163
- });
164
- } else if (it.kind === 'event') {
165
- view.event(
166
- it.s.spanId,
167
- it.ev.name,
168
- eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
169
- eventText(it.ev),
170
- it.ev.body?.ok !== false,
171
- eventFull(it.ev),
172
- );
173
- } else {
174
- view.end({
175
- id: it.s.spanId,
176
- ms: it.s.endedAt - it.s.startedAt,
177
- usage: usageOf(it.s),
178
- status: it.s.status,
179
- error: it.s.error,
180
- arg: '',
181
- });
150
+ /** 把归一好的动作逐条喂给视图(不含 reset / finish —— 那两个由外层一起包进批里) */
151
+ const replay = () => {
152
+ for (const it of items) {
153
+ if (it.kind === 'start') {
154
+ if (it.s === root) continue; // 根由 reset 建,不重复开
155
+ view.start({
156
+ id: it.s.spanId,
157
+ parent: it.s.parentSpanId,
158
+ kind: it.s.kind,
159
+ name: displayName(it.s),
160
+ arg: '',
161
+ startedAt: it.s.startedAt,
162
+ // 属性(model / system.version / tools.names / retry.attempt / handoff.* / …):
163
+ // 树上一格都不显示的话,「这次是不是提示词版本没换」只能靠猜(2026-11-13)
164
+ attributes: it.s.attributes,
165
+ });
166
+ } else if (it.kind === 'event') {
167
+ view.event(
168
+ it.s.spanId,
169
+ it.ev.name,
170
+ eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
171
+ eventText(it.ev),
172
+ it.ev.body?.ok !== false,
173
+ eventFull(it.ev),
174
+ );
175
+ } else {
176
+ view.end({
177
+ id: it.s.spanId,
178
+ ms: it.s.endedAt - it.s.startedAt,
179
+ endedAt: it.s.endedAt,
180
+ usage: usageOf(it.s),
181
+ status: it.s.status,
182
+ error: it.s.error,
183
+ arg: '',
184
+ });
185
+ }
182
186
  }
183
- }
187
+ };
184
188
 
185
- // 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
186
- // (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
187
- // 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。
188
- if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
189
+ const rootRaw = String(root.name || 'run');
190
+ /* 整个回放(含 reset 与收尾)包在**一次**重画里(2026-11-13):此前每个动作都触发整树
191
+ 重建 ⇒ O(N²)(实测 600 span = 1801 次全树重建、假 DOM 就 172ms)。语义不变 ——
192
+ 渲染仍是全量重建(那是折叠态必须外部持有的原因),只是把 N 次压成 1 次。
193
+ ⚠️ `reset` 也必须在批内:它在批外会先白画一遍空树,收尾再画一遍 ⇒ 2 次。
194
+ 没有 `batch` 的宿主(自定义 view 对象 / 老版本)退回逐条画,行为不变。 */
195
+ const replayWithFinish = () => {
196
+ // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀。
197
+ // 第二个参数是**真 spanId**:根上的事件(score / trace.truncated / approval.decided)
198
+ // 按 spanId 投递,只登记字面量 'root' 会让它们全部静默丢失(见 view.js 的 reset)。
199
+ // 第三个是**根起点**:时间轴按它归一化(缺了就整棵树不画条)。
200
+ // 第四个是**根的 attributes**:`model` / `system.version` / `tools.names` 只写在根上,
201
+ // 而根行由 reset 建、不走 `view.start()` —— 不在这里递进去,这些键树上一格都没有。
202
+ view.reset(
203
+ rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw,
204
+ String(root.spanId || ''),
205
+ typeof root.startedAt === 'number' ? root.startedAt : undefined,
206
+ root.attributes,
207
+ );
208
+ replay();
209
+ /* 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
210
+ (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
211
+ 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。 */
212
+ if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
213
+ };
214
+ if (typeof view.batch === 'function') view.batch(replayWithFinish);
215
+ else replayWithFinish();
189
216
  return true;
190
217
  }
@@ -586,6 +586,24 @@ function createTraceView(rootEl, opts = {}) {
586
586
  let traceRoot = null;
587
587
  const spanMap = new Map();
588
588
  const usageAcc = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
589
+ /* 批量渲染(2026-11-13)。此前每来一条 span 动作就整树重建一次 ⇒ `playTrace` 是 O(N²):
590
+ 实测 50 span 触发 151 次全树重建、600 span 触发 1801 次(假 DOM 就 172ms),而在飞时
591
+ `renderLive` 每个 rAF 还会再调一次 `playTrace`。这不是「慢一点」,是长跑时面板直接卡死。
592
+ 语义不变:**渲染仍是全量重建**(那是折叠态必须外部持有的原因),只是把「一批动作之间
593
+ 的 N 次重建」压成「结束后一次」。
594
+ ⚠️ 嵌套是安全的(计数器),且批内抛错也会在 finally 里补画 —— 否则异常路径之后整棵树
595
+ 停在中间状态,而下一条事件又会被 `dirty` 吞掉。 */
596
+ let batchDepth = 0;
597
+ let batchDirty = false;
598
+ /* 树内检索(2026-11-13):**不改结构**,只给命中的行加类 + 提供「跳到下一处」。
599
+ 为什么不裁剪节点:树形是「谁挂在谁下面」的事实,隐藏父节点会让子节点看起来属于别人;
600
+ 而裁剪后「有多少条被藏起来了」又要另一处交代。高亮 + 跳转在不改事实的前提下解决
601
+ 「一百个 span 里找那一条」。 */
602
+ let query = '';
603
+ let hitIndex = -1;
604
+ /* 时间轴开关(2026-11-13):轨道占 72px,窄面板里是从名字里抢的 ⇒ 允许关掉。
605
+ 默认开 —— 它是「谁和谁并行 / 8 秒被谁吃掉」唯一的落点,而那条信息此前完全没有。 */
606
+ let timeline = true;
589
607
  /* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
590
608
  渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
591
609
  const expanded = new Set();
@@ -620,8 +638,55 @@ function createTraceView(rootEl, opts = {}) {
620
638
  }
621
639
  }
622
640
 
641
+ /** 把一批渲染动作压成一次重画(见 `batchDepth` 的头注)。没在批里就直接画。 */
623
642
  function renderTrace() {
643
+ if (batchDepth > 0) {
644
+ batchDirty = true;
645
+ return;
646
+ }
647
+ drawTree();
648
+ }
649
+
650
+ function batch(fn) {
651
+ batchDepth += 1;
652
+ try {
653
+ fn();
654
+ } finally {
655
+ batchDepth -= 1;
656
+ // 批内一次都没标脏(例如只读了状态)⇒ 不白画一遍
657
+ if (batchDepth === 0 && batchDirty) {
658
+ batchDirty = false;
659
+ drawTree();
660
+ }
661
+ }
662
+ }
663
+
664
+ /** 检索:这一行的文本是否命中。空查询恒 false(「没在搜」不等于「全都命中」) */
665
+ function hits(texts) {
666
+ if (!query) return false;
667
+ const q = query.toLowerCase();
668
+ for (const t of texts) if (t && String(t).toLowerCase().includes(q)) return true;
669
+ return false;
670
+ }
671
+
672
+ function drawTree() {
624
673
  rootEl.innerHTML = '';
674
+ hitIndex = -1;
675
+ // 还没 reset 过(视图刚建出来):清空即返回。setTimeline / setQuery 可能先于第一棵树
676
+ // 被调用 —— 没有这一条,`traceRoot.order` 会抛,而调用方只是在设一个开关。
677
+ if (!traceRoot) return;
678
+ /* 时间轴的归一化基准:根 span 的起点与整棵树的终点。两者缺一就**不画条**
679
+ (手写 trace / 官网模拟回放没有时间戳 —— 画一条人人占满的条比不画更误导)。 */
680
+ const t0 = traceRoot && typeof traceRoot.startedAt === 'number' ? traceRoot.startedAt : null;
681
+ let tMax = t0;
682
+ if (t0 !== null) {
683
+ for (const n of spanMap.values()) {
684
+ const e = typeof n.endAt === 'number' ? n.endAt : n.startedAt;
685
+ if (typeof e === 'number' && e > tMax) tMax = e;
686
+ }
687
+ }
688
+ const total = t0 !== null && tMax !== null ? Math.max(0, tMax - t0) : 0;
689
+
625
690
  const rows = [];
626
691
  /* 每个节点下【事件】与【子 span】按发生顺序混排:tool.input 先于它触发的 capability span、
627
692
  tool.output 后于它,这个先后本身就是语义,不能拍平成一类。 */
@@ -644,6 +709,11 @@ function createTraceView(rootEl, opts = {}) {
644
709
  const open = expanded.has(ev.key);
645
710
  const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
646
711
  row.dataset.ev = ev.type;
712
+ /* 检索只扫摘要与类型/工具名,**不扫完整正文**:`full` 常见 10KB 级,每帧每行
713
+ indexOf 一遍会让「搜索」本身成为掉帧的原因(600 行 × 10KB ≈ 6MB/帧)。
714
+ 2000 字符以内才扫 —— 那个尺度上全文搜索才有意义(再长就是文档,不是一行)。 */
715
+ const fullShort = typeof ev.full === 'string' && ev.full.length <= 2000 ? ev.full : '';
716
+ if (hits([ev.type, ev.tool, ev.text, fullShort])) row.className += ' tr-hit';
647
717
  row.dataset.capability = capabilityTypeOf(ev.tool);
648
718
  row.appendChild(el('span', 'tr-pre', branch));
649
719
  row.appendChild(el('span', 'tr-evv', ev.type === 'tool.output' ? '◂' : '▸'));
@@ -693,6 +763,18 @@ function createTraceView(rootEl, opts = {}) {
693
763
  row.dataset.kind = node.kind;
694
764
  const ut = node.kind === 'capability' ? capabilityTypeOf(node.name) : '';
695
765
  if (ut) row.dataset.capability = ut;
766
+ const attrKeys = node.attributes ? Object.keys(node.attributes) : [];
767
+ const attrKey = 'sp:' + node.id;
768
+ const attrOpen = attrKeys.length > 0 && expanded.has(attrKey);
769
+ /* 命中面:名字 / 入参摘要 / 错误原文 / **已展开的**属性值。
770
+ 属性只在展开时才算:折叠态那些值本来就没显示 —— 高亮一个看不见的东西只会让
771
+ 「N 处匹配」对不上眼睛看到的。 */
772
+ const attrVals = attrOpen
773
+ ? Object.values(node.attributes).map((v) => (typeof v === 'string' ? v : String(v)))
774
+ : [];
775
+ if (hits([node.name, node.arg, bad ? node.error?.message : '', ...attrVals])) {
776
+ row.className += ' tr-hit';
777
+ }
696
778
  row.appendChild(el('span', 'tr-pre', branch));
697
779
  row.appendChild(el('span', 'tr-dot', node.done ? (bad ? '✕' : '●') : '◌'));
698
780
  if (ut) row.appendChild(el('span', 'tr-ico', CAP_ICO[ut]));
@@ -714,10 +796,46 @@ function createTraceView(rootEl, opts = {}) {
714
796
  if (bad) parts.push(node.error?.type || 'error');
715
797
  meta = parts.join(' · ');
716
798
  } else meta = '…';
799
+ /* 时间轴条(2026-11-13):按 run 的总时长归一化 —— 「8 秒被谁吃掉 / 谁和谁并行」
800
+ 只有结构视角是看不见的。放在 meta **之前**并自己吃掉 `margin-left: auto`,
801
+ 让「耗时读数 — 轨道」贴在一起(两者说的是同一件事)。根行不画:它恒占满全宽。 */
802
+ if (!isRoot && timeline && t0 !== null && total > 0 && typeof node.startedAt === 'number') {
803
+ const offset = node.startedAt - t0;
804
+ const left = Math.min(1, Math.max(0, offset / total));
805
+ const dur = node.done && node.ms > 0 ? node.ms : Math.max(total - offset, 0);
806
+ const width = Math.min(1 - left, dur / total);
807
+ const track = el('span', 'tr-tl');
808
+ const bar = el('span', 'tr-tl-bar');
809
+ // 几何只能走 style(宽度是**数据**,不是样式选择);两端都 clamp 过 ⇒ 不会溢出轨道
810
+ bar.style.cssText = `left:${(left * 100).toFixed(2)}%;width:${Math.max(width * 100, 0.6).toFixed(2)}%`;
811
+ track.appendChild(bar);
812
+ track.title = `起点 ${Math.round(offset)}ms · 时长 ${Math.round(dur)}ms`;
813
+ row.dataset.tl = '1';
814
+ row.appendChild(track);
815
+ }
717
816
  const metaEl = el('span', 'tr-meta', meta);
718
817
  if (bad && node.error?.message) metaEl.title = node.error.message;
719
818
  row.appendChild(metaEl);
819
+ /* span 属性(2026-11-13):`model` / `system.version` / `tools.names` / `retry.attempt` /
820
+ `handoff.*` / `guardrail.*` 全在 attributes 里,此前树上一格都不显示 ——
821
+ 「这次是不是提示词版本没换」「是不是重试了三次」只能靠猜。
822
+ 块体复用 `full-view.js`(纯 DOM 构造,不再写第二套渲染)。 */
823
+ if (attrKeys.length > 0) {
824
+ row.dataset.expandable = '1';
825
+ row.appendChild(el('span', 'tr-caret', attrOpen ? '▾' : '▸'));
826
+ row.addEventListener('click', () => {
827
+ if (!hasSelection()) toggleExpand(attrKey);
828
+ });
829
+ }
720
830
  rootEl.appendChild(row);
831
+ if (attrOpen) {
832
+ let fold = folds.get(attrKey);
833
+ if (!fold) {
834
+ fold = new Map();
835
+ folds.set(attrKey, fold);
836
+ }
837
+ rootEl.appendChild(createFullView(node.attributes, { fold }));
838
+ }
721
839
  // 失败原文必须**看得见** —— 只塞进 title(hover 才显)等于没显示。首次运行最常见的失败
722
840
  // (没配 API key)就靠这一行定位;单起一行,不去挤占 nowrap 的 span 行。
723
841
  if (bad && node.error?.message) {
@@ -743,10 +861,19 @@ function createTraceView(rootEl, opts = {}) {
743
861
  usageEls.cost.textContent = '$' + cost.toFixed(4);
744
862
  }
745
863
 
746
- /** 开树。rootName 为 run 根显示名(如 'run · 审查一份文档') */
747
- function reset(rootName) {
864
+ /**
865
+ * 开树。rootName 为 run 根显示名(如 'run · 审查一份文档')。
866
+ *
867
+ * `rootId` = **框架给这个根 span 的真 `spanId`**(`fromTrace.js` 的 `playTrace` 会传)。
868
+ * 为什么必须传:根节点上的事件(`score` / `trace.truncated` / `approval.decided`)由
869
+ * `event(span.spanId, …)` 投递,而旧实现只把根登记在字面量 `'root'` 上 ⇒ `spanMap.get`
870
+ * 落空 ⇒ `event()` 静默 return。症状不是报错,是**评估结论与截断警告整类消失**,而子 span
871
+ * 的事件一切正常(排查时最容易被判成「框架没记」)。字面量 `'root'` 仍登记:官网
872
+ * playground 的模拟脚本用它当根 id,两个键指向同一节点,零成本。
873
+ */
874
+ function reset(rootName, rootId, startedAt, attributes) {
748
875
  traceRoot = {
749
- id: 'root',
876
+ id: rootId || 'root',
750
877
  kind: 'run',
751
878
  name: rootName || 'run',
752
879
  children: [],
@@ -754,10 +881,23 @@ function createTraceView(rootEl, opts = {}) {
754
881
  order: [],
755
882
  done: false,
756
883
  ms: 0,
884
+ /* 时间轴的基准(2026-11-13):根 span 的起点。缺省 null ⇒ 整棵树不画条
885
+ (手写 trace / 官网模拟回放没有时间戳,画一条人人占满的条比不画更误导)。 */
886
+ startedAt: typeof startedAt === 'number' ? startedAt : null,
887
+ endAt: null,
888
+ /* 根 span 的属性(2026-11-13):`model` / `system.version` / `tools.names` /
889
+ `prompts.versions` / `stop_reason` **只写在根上**。根行不经过 `start()`
890
+ (它由 reset 建),所以不在这里接一手,这些键在面板上就一格都看不到 ——
891
+ 「这次是不是提示词版本没换」只能去翻 JSON。空对象当没有。 */
892
+ attributes:
893
+ attributes && typeof attributes === 'object' && Object.keys(attributes).length > 0
894
+ ? attributes
895
+ : null,
757
896
  usage: { input: 0, output: 0 },
758
897
  };
759
898
  spanMap.clear();
760
899
  spanMap.set('root', traceRoot);
900
+ if (rootId && rootId !== 'root') spanMap.set(rootId, traceRoot);
761
901
  expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
762
902
  folds.clear(); // 块内折叠同理:留着只会在下一次点开时出现"莫名已折叠"的状态
763
903
  usageAcc.input = 0;
@@ -778,6 +918,9 @@ function createTraceView(rootEl, opts = {}) {
778
918
  order: [],
779
919
  done: false,
780
920
  ms: 0,
921
+ startedAt: typeof s.startedAt === 'number' ? s.startedAt : null,
922
+ endAt: null,
923
+ attributes: s.attributes && typeof s.attributes === 'object' ? s.attributes : null,
781
924
  status: 'ok',
782
925
  error: null,
783
926
  usage: null,
@@ -817,6 +960,14 @@ function createTraceView(rootEl, opts = {}) {
817
960
  if (!node) return;
818
961
  node.done = true;
819
962
  node.ms = s.ms || 0;
963
+ /* 终点优先取框架的真 `endedAt`(时间轴的右端);没有就按起点 + 时长推。
964
+ 两者都没有(手写 trace)⇒ 留 null,那一段不画条。 */
965
+ node.endAt =
966
+ typeof s.endedAt === 'number'
967
+ ? s.endedAt
968
+ : typeof node.startedAt === 'number'
969
+ ? node.startedAt + node.ms
970
+ : null;
820
971
  node.status = s.status || 'ok';
821
972
  node.error = s.error || null;
822
973
  if (s.arg) node.arg = s.arg;
@@ -843,6 +994,9 @@ function createTraceView(rootEl, opts = {}) {
843
994
  if (!traceRoot) return;
844
995
  traceRoot.done = true;
845
996
  traceRoot.ms = ms;
997
+ if (typeof traceRoot.startedAt === 'number' && typeof ms === 'number') {
998
+ traceRoot.endAt = traceRoot.startedAt + ms;
999
+ }
846
1000
  // run 失败时根 span 也要标红:原先 status 恒为 ok,错误只体现在子 span 上
847
1001
  traceRoot.status = status || 'ok';
848
1002
  traceRoot.error = error || null;
@@ -850,14 +1004,76 @@ function createTraceView(rootEl, opts = {}) {
850
1004
  renderTrace();
851
1005
  }
852
1006
 
1007
+ /** 当前命中行(`.tr-hit`)。宿主没有 `querySelectorAll`(stub / 极简 DOM)时返回空集 */
1008
+ function hitRows() {
1009
+ try {
1010
+ if (typeof rootEl.querySelectorAll !== 'function') return [];
1011
+ return Array.from(rootEl.querySelectorAll('.tr-hit'));
1012
+ } catch {
1013
+ return [];
1014
+ }
1015
+ }
1016
+
1017
+ /** 加/去一个类名(不依赖 classList:stub DOM 未必实现它) */
1018
+ function setFlag(node, cls, on) {
1019
+ const parts = String(node.className || '')
1020
+ .split(/\s+/)
1021
+ .filter((c) => c && c !== cls);
1022
+ if (on) parts.push(cls);
1023
+ node.className = parts.join(' ');
1024
+ }
1025
+
853
1026
  return {
854
1027
  reset,
855
1028
  start,
856
1029
  event,
857
1030
  end,
858
1031
  finish,
1032
+ /** 把一批动作压成一次重画(`playTrace` 必用;宿主自己批量喂时也可以用) */
1033
+ batch,
859
1034
  render: renderTrace,
1035
+ /**
1036
+ * 树内检索:**只高亮 + 跳转,不改结构**(理由见 `query` 的头注)。返回命中行数。
1037
+ * 空串 = 结束检索(清掉所有高亮)。
1038
+ */
1039
+ setQuery(q) {
1040
+ const next = typeof q === 'string' ? q.trim() : '';
1041
+ if (next === query) return hitRows().length;
1042
+ query = next;
1043
+ renderTrace();
1044
+ return hitRows().length;
1045
+ },
1046
+ get hitCount() {
1047
+ return hitRows().length;
1048
+ },
1049
+ /**
1050
+ * 跳到下一处命中(`dir < 0` 向上),返回 **1 基序号**;没有命中返回 0。
1051
+ * 越界绕回:搜索框里按 Enter 一路按到底再回到第一处,比「按不动了」更符合预期。
1052
+ */
1053
+ nextHit(dir = 1) {
1054
+ const list = hitRows();
1055
+ if (list.length === 0) return 0;
1056
+ hitIndex = (hitIndex + (dir > 0 ? 1 : -1) + list.length) % list.length;
1057
+ const cur = list[hitIndex];
1058
+ for (const r of list) setFlag(r, 'tr-hit-cur', r === cur);
1059
+ try {
1060
+ cur.scrollIntoView?.({ block: 'center' });
1061
+ } catch {
1062
+ /* 没有滚动能力(stub / 老浏览器):序号仍然对,只是没把行带进视野 */
1063
+ }
1064
+ return hitIndex + 1;
1065
+ },
860
1066
  setUsage: renderUsage,
1067
+ /** 时间轴开关(默认开)。关掉只是不画轨道,数据与命中都不受影响 */
1068
+ setTimeline(on) {
1069
+ const next = on !== false;
1070
+ if (next === timeline) return;
1071
+ timeline = next;
1072
+ renderTrace();
1073
+ },
1074
+ get timeline() {
1075
+ return timeline;
1076
+ },
861
1077
  /** 换单价(如真实模式切服务商):null 表示无公开单价 → 成本显示为 — */
862
1078
  setPrice(p) {
863
1079
  price = p ?? null;
@@ -1019,45 +1235,72 @@ function playTrace(view, trace) {
1019
1235
  }
1020
1236
  items.sort((a, b) => a.t - b.t || a.ord - b.ord || a.d - b.d || a.seq - b.seq);
1021
1237
 
1022
- const rootRaw = String(root.name || 'run');
1023
- // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀
1024
- view.reset(rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw);
1025
-
1026
- for (const it of items) {
1027
- if (it.kind === 'start') {
1028
- if (it.s === root) continue; // 根由 reset 建,不重复开
1029
- view.start({
1030
- id: it.s.spanId,
1031
- parent: it.s.parentSpanId,
1032
- kind: it.s.kind,
1033
- name: displayName(it.s),
1034
- arg: '',
1035
- });
1036
- } else if (it.kind === 'event') {
1037
- view.event(
1038
- it.s.spanId,
1039
- it.ev.name,
1040
- eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
1041
- eventText(it.ev),
1042
- it.ev.body?.ok !== false,
1043
- eventFull(it.ev),
1044
- );
1045
- } else {
1046
- view.end({
1047
- id: it.s.spanId,
1048
- ms: it.s.endedAt - it.s.startedAt,
1049
- usage: usageOf(it.s),
1050
- status: it.s.status,
1051
- error: it.s.error,
1052
- arg: '',
1053
- });
1238
+ /** 把归一好的动作逐条喂给视图(不含 reset / finish —— 那两个由外层一起包进批里) */
1239
+ const replay = () => {
1240
+ for (const it of items) {
1241
+ if (it.kind === 'start') {
1242
+ if (it.s === root) continue; // 根由 reset 建,不重复开
1243
+ view.start({
1244
+ id: it.s.spanId,
1245
+ parent: it.s.parentSpanId,
1246
+ kind: it.s.kind,
1247
+ name: displayName(it.s),
1248
+ arg: '',
1249
+ startedAt: it.s.startedAt,
1250
+ // 属性(model / system.version / tools.names / retry.attempt / handoff.* / …):
1251
+ // 树上一格都不显示的话,「这次是不是提示词版本没换」只能靠猜(2026-11-13)
1252
+ attributes: it.s.attributes,
1253
+ });
1254
+ } else if (it.kind === 'event') {
1255
+ view.event(
1256
+ it.s.spanId,
1257
+ it.ev.name,
1258
+ eventToolName(it.ev.name, it.ev.body?.tool, typeMap),
1259
+ eventText(it.ev),
1260
+ it.ev.body?.ok !== false,
1261
+ eventFull(it.ev),
1262
+ );
1263
+ } else {
1264
+ view.end({
1265
+ id: it.s.spanId,
1266
+ ms: it.s.endedAt - it.s.startedAt,
1267
+ endedAt: it.s.endedAt,
1268
+ usage: usageOf(it.s),
1269
+ status: it.s.status,
1270
+ error: it.s.error,
1271
+ arg: '',
1272
+ });
1273
+ }
1054
1274
  }
1055
- }
1275
+ };
1056
1276
 
1057
- // 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
1058
- // (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
1059
- // 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。
1060
- if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
1277
+ const rootRaw = String(root.name || 'run');
1278
+ /* 整个回放(含 reset 与收尾)包在**一次**重画里(2026-11-13):此前每个动作都触发整树
1279
+ 重建 ⇒ O(N²)(实测 600 span = 1801 次全树重建、假 DOM 就 172ms)。语义不变 ——
1280
+ 渲染仍是全量重建(那是折叠态必须外部持有的原因),只是把 N 次压成 1 次。
1281
+ ⚠️ `reset` 也必须在批内:它在批外会先白画一遍空树,收尾再画一遍 ⇒ 2 次。
1282
+ 没有 `batch` 的宿主(自定义 view 对象 / 老版本)退回逐条画,行为不变。 */
1283
+ const replayWithFinish = () => {
1284
+ // 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀。
1285
+ // 第二个参数是**真 spanId**:根上的事件(score / trace.truncated / approval.decided)
1286
+ // 按 spanId 投递,只登记字面量 'root' 会让它们全部静默丢失(见 view.js 的 reset)。
1287
+ // 第三个是**根起点**:时间轴按它归一化(缺了就整棵树不画条)。
1288
+ // 第四个是**根的 attributes**:`model` / `system.version` / `tools.names` 只写在根上,
1289
+ // 而根行由 reset 建、不走 `view.start()` —— 不在这里递进去,这些键树上一格都没有。
1290
+ view.reset(
1291
+ rootRaw.startsWith('run') ? rootRaw : 'run · ' + rootRaw,
1292
+ String(root.spanId || ''),
1293
+ typeof root.startedAt === 'number' ? root.startedAt : undefined,
1294
+ root.attributes,
1295
+ );
1296
+ replay();
1297
+ /* 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
1298
+ (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
1299
+ 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。 */
1300
+ if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
1301
+ };
1302
+ if (typeof view.batch === 'function') view.batch(replayWithFinish);
1303
+ else replayWithFinish();
1061
1304
  return true;
1062
1305
  }
1063
1306