@sema-agent/client-core 0.62.1 → 0.63.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.
@@ -196,6 +196,22 @@ function appendBytes(prev, chunk) {
196
196
  const joinsPair = lastPrev >= 0xd800 && lastPrev <= 0xdbff && firstNew >= 0xdc00 && firstNew <= 0xdfff;
197
197
  return joinsPair ? add - 2 : add;
198
198
  }
199
+ /**
200
+ * 工具参数(开集 `unknown`)的**规范序列化** —— 字节账({@link itemBytesOf})与「这一发到底改了
201
+ * 没有」({@link startContentKey})共用这**一口**,两处对同一份参数的看法因此不可能分叉。
202
+ * 不可序列化(循环引用等)⇒ 空串:它进不了展示面,也就占不了这本账的预算、也不算内容变化。
203
+ */
204
+ function serializeToolInput(input) {
205
+ if (input === undefined)
206
+ return '';
207
+ try {
208
+ return JSON.stringify(input) ?? '';
209
+ }
210
+ catch {
211
+ // 循环引用/不可序列化 ⇒ 这一位按空串计(见本函数头注)
212
+ return '';
213
+ }
214
+ }
199
215
  /** 一条 item 的字节账(`truncated` 是关于预算的元信息,自身不计)。 */
200
216
  function itemBytesOf(it) {
201
217
  switch (it.kind) {
@@ -203,18 +219,9 @@ function itemBytesOf(it) {
203
219
  case 'thinking':
204
220
  case 'echo':
205
221
  return utf8Len(it.text);
206
- case 'tool': {
207
- let n = utf8Len(it.name) + utf8Len(it.output ?? '');
208
- // 工具参数是**开集** `unknown`:序列化一次算体量(每张卡只算一次,不在增量热路径上)
209
- try {
210
- if (it.input !== undefined)
211
- n += utf8Len(JSON.stringify(it.input) ?? '');
212
- }
213
- catch {
214
- // 循环引用/不可序列化 ⇒ 这一位按 0 计(它进不了展示面,也就占不了这本账的预算)
215
- }
216
- return n;
217
- }
222
+ case 'tool':
223
+ // 工具参数序列化一次算体量(每张卡只算一次,不在增量热路径上)
224
+ return utf8Len(it.name) + utf8Len(it.output ?? '') + utf8Len(serializeToolInput(it.input));
218
225
  case 'truncated':
219
226
  return 0;
220
227
  }
@@ -256,27 +263,39 @@ function noteDropped(s, dropped) {
256
263
  }
257
264
  s.items.unshift({ kind: 'truncated', droppedBytes: s.droppedBytes, keptFrom: s.droppedBytes });
258
265
  s.itemBytes.unshift(0);
259
- rebuildOpenTools(s);
266
+ pruneCardPhases(s);
260
267
  }
261
- /** items 被增删之后重建「开着的工具卡 → 下标」表(下标随 splice/unshift 整体漂移)。
262
- * 🔴 同批把**卡已不在 items 上**的阶段水位清掉:水位是「这张卡现在承载的是哪一阶段」,卡没了它
263
- * 就没有指涉对象,而这张表按 `toolCallId` 一条一条长 —— 不跟着卡清 = 一条长 turn 上的无界增长
264
- * (本账本的立身之本就是有界)。守的是内存面,不是行为面,理由见 `toolPhase` 头注的那一笔。 */
265
- function rebuildOpenTools(s) {
266
- s.openTools.clear();
268
+ /**
269
+ * items 被增删之后,把**卡已不在 items 上**的阶段账清掉。
270
+ *
271
+ * 🔴 这里此前还维护第二张表(`toolCallId` 开着的卡下标)。删掉它是本批「按类修」的一部分:
272
+ * 一次工具调用的卡**在不在、在哪里**只有 `items` 说了算(本文件 {@link toolCardIndexOf} 头注
273
+ * 逐字写过这条),而下标表是同一件事的第二处真源 —— 它与 items 脱节的方式是可预期的
274
+ * (splice/unshift 之后下标整体漂移),而本批四条的病根正是「同一件事记了两本各自记的账」。
275
+ * 收口那一支改成每次按 `toolCallId` 现查(至多 200 条,一次线性扫),两处真源就只剩一处。
276
+ */
277
+ function pruneCardPhases(s) {
278
+ // 🔴 只清**卡的**阶段账。段边界不在这里 —— 它压在缓冲上(TaskContentState.pendingCuts),卡被丢掉
279
+ // 时缓冲里那截正文一个字都没少,边界跟着卡消失就等于把「这里有过一次工具」这件事丢了。
267
280
  const live = new Set();
268
- s.items.forEach((it, i) => {
269
- if (it.kind === 'tool') {
281
+ for (const it of s.items) {
282
+ if (it.kind === 'tool')
270
283
  live.add(it.id);
271
- if (it.output === undefined)
272
- s.openTools.set(it.id, i);
273
- }
274
- });
284
+ }
275
285
  for (const id of s.toolPhase.keys()) {
276
286
  if (!live.has(id))
277
287
  s.toolPhase.delete(id);
278
288
  }
279
289
  }
290
+ /** 取(必要时新建)一张卡的阶段账 —— 卡与账同生:建卡/改卡的每一支都经这一口。 */
291
+ function phaseSlot(s, id) {
292
+ const cur = s.toolPhase.get(id);
293
+ if (cur !== undefined)
294
+ return cur;
295
+ const next = { boundary: false };
296
+ s.toolPhase.set(id, next);
297
+ return next;
298
+ }
280
299
  /**
281
300
  * 每子代帽:超出就**从最早的内容开始丢**,丢不动了再从缓冲头部裁。
282
301
  *
@@ -289,6 +308,13 @@ function enforceTaskCap(s) {
289
308
  if (s.bytes <= cap)
290
309
  return false;
291
310
  let dropped = 0;
311
+ // 「有没有卡离开 items」按**条数**量,不拿 `dropped`(字节)代答。
312
+ // 📋 如实(变异自证):这一改**今天零行为差异** —— 每子代帽恒 ≥ 1(configureSubagentContentStore
313
+ // 的 fail-loud 挡住 0 与负数),而循环只在「还装不下」时继续,所以有卡离开就一定有字节离开,
314
+ // 两个条件当下等价;门对它没有判别力是**正常**的,别拿门当它的证明。这样写的理由是:
315
+ // 「卡走了、它的阶段账同批清」是一条**行为面**不变量(陈旧的 `end` 会把重建那一轮的收口整帧
316
+ // 拦下 —— 卡建回来却永远显示在飞,G9 那一格量的就是它),它不该靠一条关于**字节**的推论维持。
317
+ let removedItems = 0;
292
318
  const first = () => (s.items[0]?.kind === 'truncated' ? 1 : 0);
293
319
  while (s.bytes > cap && s.items.length > first()) {
294
320
  const idx = first();
@@ -297,25 +323,32 @@ function enforceTaskCap(s) {
297
323
  s.itemBytes.splice(idx, 1);
298
324
  addBytes(s, -b);
299
325
  dropped += b;
326
+ removedItems += 1;
300
327
  }
328
+ // 🔴 裁的是缓冲**头部** ⇒ 压在这条缓冲上的段边界同拍左移(边界是位置,前缀短了它就得跟着短;
329
+ // 不移的话那一刀会切在错的字上)。段边界活得比卡长,所以这一步是真会走到的:上面那个循环
330
+ // 可以只丢掉卡而缓冲一个字没少,反过来这里裁了缓冲而边界还压着。
301
331
  if (s.bytes > cap) {
302
332
  const cut = trimHeadBytes(s.thinkBuf, s.bytes - cap);
333
+ shiftPendingCuts(s, 'think', s.thinkBuf.length - cut.rest.length);
303
334
  s.thinkBuf = cut.rest;
304
335
  addBytes(s, -cut.removed);
305
336
  dropped += cut.removed;
306
337
  }
307
338
  if (s.bytes > cap) {
308
339
  const cut = trimHeadBytes(s.textBuf, s.bytes - cap);
340
+ shiftPendingCuts(s, 'text', s.textBuf.length - cut.rest.length);
309
341
  s.textBuf = cut.rest;
310
342
  addBytes(s, -cut.removed);
311
343
  dropped += cut.removed;
312
344
  }
345
+ if (removedItems > 0)
346
+ pruneCardPhases(s);
313
347
  if (dropped > 0) {
314
- rebuildOpenTools(s);
315
348
  noteDropped(s, dropped);
316
349
  return true;
317
350
  }
318
- return false;
351
+ return removedItems > 0;
319
352
  }
320
353
  /**
321
354
  * 总帽:整条**最久未用**的子代账本被清掉(`tasks` 的迭代序就是 LRU 序,活跃项每次访问都会重插)。
@@ -375,12 +408,12 @@ function stateFor(taskId, parentToolCallId) {
375
408
  itemBytes: [],
376
409
  textBuf: '',
377
410
  thinkBuf: '',
378
- openTools: new Map(),
379
411
  parentToolCallId,
380
412
  bytes: 0,
381
413
  droppedBytes: 0,
382
414
  seenAggregateIds: new Set(),
383
415
  recordedSegments: new Set(),
416
+ pendingCuts: [],
384
417
  toolPhase: new Map(),
385
418
  };
386
419
  tasks.set(taskId, s);
@@ -405,29 +438,82 @@ function pushItem(s, item) {
405
438
  addBytes(s, -freed);
406
439
  // 条数帽丢掉的同样是**内容**,同样要留痕(此前这条路是无声的)。
407
440
  noteDropped(s, freed);
408
- // open-tool indexes shifted — rebuild from the surviving items
409
- rebuildOpenTools(s);
441
+ // 卡被裁掉了 它的阶段账同批作废(卡没了,那本账就没有指涉对象)
442
+ pruneCardPhases(s);
410
443
  }
411
444
  }
412
- /** Close out streaming buffers into items (segment boundary: a tool starts, or the run settles). */
445
+ /**
446
+ * 段闭合:把两条缓冲收成 item(段边界 = 工具起头,或这一轮 settle)。
447
+ *
448
+ * 🔴 **在所有还没兑现的段边界处一起切**({@link TaskContentState.pendingCuts};收口先于开始帧
449
+ * 到达时记下的位置)。
450
+ * 为什么不是「只切触发这一次闭合的那张卡的那一条」:边界记的是**缓冲上的位置**,不是某张卡的
451
+ * 私产 —— 一条缓冲上可以同时压着好几条(两张 orphan 卡交错),而闭合一旦发生缓冲就整只清空,
452
+ * 没被兑现的那些位置从此无处可切。异源复审 R5 的两个反例都是这一形:①兑现者是 `settle` 或
453
+ * 别的工具的 `tool_start`(它们此前根本不看这些位置)⇒ 两段黏成一段;②两张 orphan 交错时第一发
454
+ * 迟到的 start 只兑现自己那条、把另一条丢掉 ⇒ 后两段黏成一段。黏成一段的代价是整轮重放时那一段
455
+ * 的内容闸不命中,历史与缓冲各留一份。
456
+ */
413
457
  function flushBuffers(s) {
458
+ const asc = (a, b) => a - b;
459
+ const textCuts = [...new Set(s.pendingCuts.map((c) => c.text))].sort(asc);
460
+ const thinkCuts = [...new Set(s.pendingCuts.map((c) => c.think))].sort(asc);
414
461
  // 🔴 缓冲的字节此前已经计过账;搬进 item 时先扣掉缓冲那一份,再由 pushItem 计 item 那一份
415
462
  // (纯空白段被丢弃时同样要扣 —— 少扣一次,总账就会一路虚高到把别人的账本淘汰掉)。
416
463
  addBytes(s, -utf8Len(s.thinkBuf));
417
464
  const think = s.thinkBuf;
418
465
  s.thinkBuf = '';
419
- if (think.trim()) {
420
- pushItem(s, { kind: 'thinking', text: think });
421
- rememberSegment(s, think);
422
- }
466
+ closeSegments(s, 'thinking', think, thinkCuts);
423
467
  addBytes(s, -utf8Len(s.textBuf));
424
468
  const text = s.textBuf;
425
469
  s.textBuf = '';
426
- if (text.trim()) {
427
- pushItem(s, { kind: 'text', text });
428
- rememberSegment(s, text);
470
+ closeSegments(s, 'text', text, textCuts);
471
+ // 缓冲已空 压在它上面的位置一条不剩地兑现完了(指涉对象没有了)
472
+ s.pendingCuts.length = 0;
473
+ // 🔴 段闭合发生过了 ⇒ 在场每张卡都不再欠一次段闭合。这一位问的是「自这张卡出现以来发生过段闭合
474
+ // 没有」而不是「它自己切过没有」:兑现的可以是别人,而那一发迟到的 `tool_start` 若还当自己欠着,
475
+ // 就会在别人切完之后**再切一刀** —— 切掉的是当前那条与它无关的活体尾段。
476
+ for (const p of s.toolPhase.values())
477
+ p.boundary = true;
478
+ }
479
+ /** 一条缓冲收成 item:在每一条**落在中间**的边界上切一刀(空白段照旧丢弃、不铸空 item)。 */
480
+ function closeSegments(s, kind, buf, cuts) {
481
+ const push = (part) => {
482
+ if (!part.trim())
483
+ return;
484
+ pushItem(s, kind === 'text' ? { kind: 'text', text: part } : { kind: 'thinking', text: part });
485
+ rememberSegment(s, part);
486
+ };
487
+ let from = 0;
488
+ for (const at of cuts) {
489
+ const n = safeCut(buf, at);
490
+ if (n <= from || n >= buf.length)
491
+ continue;
492
+ push(buf.slice(from, n));
493
+ from = n;
429
494
  }
495
+ push(buf.slice(from));
496
+ }
497
+ /**
498
+ * 把段边界夹进 `[0, buf.length]`,并且**绝不劈开合法代理对**。
499
+ *
500
+ * 🔴 为什么会劈到:边界是「记下它那一刻的缓冲长度」,而上游真的会把一对代理对拆成**两帧**送
501
+ * (本文件 {@link appendBytes} 头注就是为这一形写的)—— 记边界那一刻缓冲尾部可能正停在高位
502
+ * 代理项上。从那里切会切出两个孤代理项:屏上两段各挂半个字符,两段的字节账合计也比整条多 2。
503
+ * ⇒ 撞上就把边界**退**到那一对之前(宁可把这半个字符归给后一段)。
504
+ */
505
+ function safeCut(buf, at) {
506
+ let n = at < 0 ? 0 : at > buf.length ? buf.length : at;
507
+ if (n > 0 && n < buf.length) {
508
+ const prev = buf.charCodeAt(n - 1);
509
+ const here = buf.charCodeAt(n);
510
+ if (prev >= 0xd800 && prev <= 0xdbff && here >= 0xdc00 && here <= 0xdfff)
511
+ n -= 1;
512
+ }
513
+ return n;
430
514
  }
515
+ /** 两次段闭合之间最多记多少条段边界(见 {@link TaskContentState.pendingCuts} 的有界那一笔)。 */
516
+ const MAX_PENDING_CUTS = 64;
431
517
  /** 有界 FIFO 记号(两本小账共用;只用来做幂等判定,不参与预算)。 */
432
518
  const MAX_REPLAY_KEYS = 64;
433
519
  function remember(set, key) {
@@ -555,20 +641,66 @@ function notNewer(eventId, mark) {
555
641
  return false;
556
642
  return eventId <= mark;
557
643
  }
558
- /** 读一张卡的阶段水位(没有 ⇒ 这张卡还没落过对应那一半的帧)。 */
644
+ /** 读一张卡的阶段账(没有 ⇒ 这张卡还没落过任何一半的帧)。 */
559
645
  function phaseOf(s, id) {
560
646
  return id === undefined ? undefined : s.toolPhase.get(id);
561
647
  }
562
- /** 抬一张卡的阶段水位(身份缺席 ⇒ 不抬:一个抬不动的水位比一个抬错的水位诚实)。 */
563
- function markPhase(s, id, half, eventId) {
564
- if (id === undefined || typeof eventId !== 'string' || eventId.length === 0)
648
+ /**
649
+ * 内容落账之后给这张卡盖章:**水位与卡上现在这份内容同源**。三形:
650
+ * · 身份在场 抬到这一发;
651
+ * · 身份缺席**且这一发真的改了卡上那一半的内容** ⇒ 把这一半**清掉**。此前缺席时一律「不抬」,
652
+ * 于是无身份的那一发把内容换掉了、水位还停在**换掉之前**那一阶段上 —— 随后正确的那一发原样
653
+ * 重放被自己的水位 `notNewer` 整帧拦掉,卡**永久**停在旧内容上;
654
+ * · 身份缺席**而内容一个字节没变**(重放送来的是同一份值)⇒ **原样保留**。水位仍然如实描述卡上
655
+ * 这份内容,清掉它等于顺手把「旧一阶段不许盖新一阶段」那道闸一起卸了 —— 异源复审 R5 的反例:
656
+ * 一发无身份的**同值**重放之后,旧一阶段的 start 就能把改后参数盖回原参,而更新的结果还留在
657
+ * 卡上,屏上成了「这个参数产生了那个结果」。喂点真的会混来源(tail 腿的工具帧不带 `eventId`)。
658
+ *
659
+ * 一句话:**这一半的水位,永远描述卡上这一半现在这份内容**——内容没动它就没理由动。
660
+ */
661
+ function stampPhase(s, id, half, eventId, changed) {
662
+ if (id === undefined)
565
663
  return;
566
- const cur = s.toolPhase.get(id);
567
- if (cur === undefined) {
568
- s.toolPhase.set(id, { [half]: eventId });
664
+ const p = phaseSlot(s, id);
665
+ if (typeof eventId === 'string' && eventId.length > 0)
666
+ p[half] = eventId;
667
+ else if (changed)
668
+ delete p[half];
669
+ }
670
+ /** 卡上「开始那一半」的内容指纹(名 + 参数的规范序列化)—— 只用来回答「这一发真的改了吗」。 */
671
+ function startContentKey(it) {
672
+ return `${it.name}\u0000${serializeToolInput(it.input)}`;
673
+ }
674
+ /** 这张卡不欠段闭合了(建卡那一支刚刚无条件闭合过一次)。 */
675
+ function markBoundary(s, id) {
676
+ phaseSlot(s, id).boundary = true;
677
+ }
678
+ /** 收口先到 ⇒ 把**此刻**两条缓冲的长度记成这条缓冲上的一个段边界(见 TaskContentState.pendingCuts)。 */
679
+ function recordCut(s) {
680
+ if (s.pendingCuts.length >= MAX_PENDING_CUTS)
569
681
  return;
682
+ s.pendingCuts.push({ text: s.textBuf.length, think: s.thinkBuf.length });
683
+ }
684
+ /** 缓冲头部被裁掉 `removed` 个 UTF-16 单元 ⇒ 压在这条缓冲上的段边界一律左移(夹到 0)。 */
685
+ function shiftPendingCuts(s, half, removed) {
686
+ if (removed <= 0)
687
+ return;
688
+ for (const c of s.pendingCuts) {
689
+ const next = c[half] - removed;
690
+ c[half] = next > 0 ? next : 0;
570
691
  }
571
- cur[half] = eventId;
692
+ }
693
+ /**
694
+ * 这一发**收口**比卡上现在承载的那一阶段旧吗(旧 ⇒ 整帧丢弃)。
695
+ *
696
+ * 🔴 **两半都要问**,这是 0.62.2 修的第二条:只问 `end` 的话,两条独立的流交错时(恢复阶段的
697
+ * `start` 已经到、旧一阶段的 `end` 才姗姗来迟)旧收口会落到新阶段那张开着的卡上 —— 新参数
698
+ * 配上旧阶段的失败结果,新流若中断这个错误状态就一直留在屏上。更新的 `start` 已经**建立了
699
+ * 新的一阶段**,旧收口不属于它;「避免永远在飞」不构成接受一份已知过期结果的理由(那张卡
700
+ * 确实还在飞,显示在飞是**如实**的)。
701
+ */
702
+ function staleEnd(phase, eventId) {
703
+ return notNewer(eventId, phase?.start) || notNewer(eventId, phase?.end);
572
704
  }
573
705
  /**
574
706
  * 这条 `toolCallId` 在本账本里已经有卡了吗(有 ⇒ 返回下标,没有 ⇒ `-1`)。
@@ -665,32 +797,41 @@ export function publishSubagentContentEvent(ev) {
665
797
  // 刻意**不写** `seenAggregateIds`:那本记号账是有界 FIFO,额度按「聚合帧只在重放腿上来」定的
666
798
  // (活体走 `*_delta`,一个字都不写);工具帧**活体也来**,跟着写会把还在屏上的散文身份整窗
667
799
  // 挤掉 —— 修一个臂的重放去重、弄坏另一个臂的,不是修。
668
- // 🔴 顺序上「开着的卡恒收口」排在最前:一张在飞的卡如果被任何一道闸拦掉收口,它就**永远在飞**
669
- // (屏上显示成还在跑),而那比多渲一次结果坏得多。
800
+ // 🔴 收口那一支**不按「卡开着没开着」分岔**(0.62.2 合并):卡在不在只按 `toolCallId` 现查
801
+ // `items`,在账就过同一道闸、走同一条落账。此前开着那一支另有一张下标表、且无条件落结果
802
+ // (理由写的是「在飞的卡被拦掉收口就永远在飞」)—— 那条理由在**旧一阶段的收口迟到**这一形上
803
+ // 是错的:更新的 `start` 已经建立了新的一阶段,那张卡确实还在跑,显示在飞是**如实**的,而
804
+ // 把旧阶段的失败结果配到新参数上并关掉卡才是用户看得见的损坏。
670
805
  case 'tool_start': {
671
806
  const startId = ev.toolCallId;
672
807
  const seen = startId !== undefined && toolCardIndexOf(s, startId) >= 0;
673
- if (seen && notNewer(ev.eventId, phaseOf(s, startId)?.start))
808
+ const known = phaseOf(s, startId);
809
+ if (seen && notNewer(ev.eventId, known?.start))
674
810
  break;
675
811
  // 段闭合可能触发条数裁剪(pushItem 从头 splice + unshift 留痕)⇒ 裁剪**前**取的下标与引用一律
676
812
  // 作废:flush 之后按 toolCallId **重新**找卡。找不到 = 这张卡刚被裁掉 ⇒ 落到下面「新的一次」
677
813
  // 建卡那一支(§26b 留白①),绝不对着已删的引用改名补参、也绝不把差额记到顶上那条留痕上。
678
- if (seen && phaseOf(s, startId)?.start === undefined)
814
+ // 这张卡还欠一次段闭合 现在补做(段闭合会在**所有**还没兑现的段边界上一起切,并把在场
815
+ // 每张卡的这一位置上 —— 兑现者不限于「欠着的那一张」)。
816
+ if (seen && known?.boundary !== true)
679
817
  flushBuffers(s);
680
818
  const at = startId !== undefined ? toolCardIndexOf(s, startId) : -1;
681
819
  if (at >= 0) {
682
- // 这次调用的卡已在账 ⇒ **不铸第二张**。剩下的两件按阶段水位判:
820
+ // 这次调用的卡已在账 ⇒ **不铸第二张**。剩下的两件按这张卡的**阶段账**判:
683
821
  // 🔴 ① 这一发比卡上现在承载的那一阶段旧(或就是同一条帧又来一遍)⇒ 整帧丢弃。少了这一条,
684
822
  // 门 park 那一阶段的 start 重放会把批复时**改过的执行参数**盖回原参数 —— 屏上就成了
685
823
  // 「这个参数产生了那个结果」,而那个结果根本不是它跑出来的。
686
- // 🔴 ② 这张卡有没有落过 `tool_start`:收口先到时下面那支会合成一张占位卡(名字 `Tool`、
687
- // 参数 `{}`),那张卡**没有** start 水位 —— 迟到的这一发是它的**第一个段边界**,要补做
688
- // 一次段闭合(不补的话工具前后两段散文会黏成一段、且一段都进不了已上屏正文那本记号账,
689
- // 随后整轮重放时两段都拦不住)。反过来,水位已在 = 这一发是重放或下一阶段,**不**段闭合:
690
- // 边界在活体那一轮已经给过,在这里切开会把还在流的那一段提前截断。
824
+ // 🔴 ② 这张卡的**首个段边界**给过没有(收口先到时下面那支会合成一张占位卡:名字 `Tool`、
825
+ // 参数 `{}`、边界还没给)—— 没给过 迟到的这一发就是它的第一个段边界,要补做一次段闭合,
826
+ // 并且按合成那一刻记下的边界把「工具之前 / 之后」两段**分开**收(不分开的话两段黏成
827
+ // 一段、只进一条记号,随后整轮重放时两段的内容闸都不命中)。给过了 这一发是重放或
828
+ // 下一阶段,**不**段闭合:边界在活体那一轮已经给过,在这里切会把还在流的那一段提前截断。
829
+ // 🔴 判据是卡上那一位,**不是**「start 水位在不在」:水位是可选位,上游不给 `eventId` 时
830
+ // 它永远抬不起来 ⇒ 每一次重放都会再切一刀(0.62.2 修的第三条)。
691
831
  // 名字与参数就地补齐/更新(重放送来的是同一份值,补齐是空操作;字节按**差额**记账)。
692
832
  const it = s.items[at];
693
833
  const before = itemBytesOf(it);
834
+ const keyBefore = startContentKey(it);
694
835
  if (ev.toolName !== undefined)
695
836
  it.name = ev.toolName;
696
837
  if (ev.args !== undefined)
@@ -698,54 +839,47 @@ export function publishSubagentContentEvent(ev) {
698
839
  const delta = itemBytesOf(it) - before;
699
840
  s.itemBytes[at] = (s.itemBytes[at] ?? 0) + delta;
700
841
  addBytes(s, delta);
701
- markPhase(s, startId, 'start', ev.eventId);
842
+ stampPhase(s, it.id, 'start', ev.eventId, startContentKey(it) !== keyBefore);
702
843
  break;
703
844
  }
704
845
  flushBuffers(s);
705
846
  const id = startId ?? `${ev.taskId}-tool-${s.items.length}`;
706
847
  pushItem(s, { kind: 'tool', id, name: ev.toolName ?? 'Tool', input: ev.args ?? {} });
707
- s.openTools.set(id, s.items.length - 1);
708
- markPhase(s, id, 'start', ev.eventId);
848
+ // 建卡这一支刚刚无条件段闭合过 边界就是此刻,同拍记在卡上
849
+ markBoundary(s, id);
850
+ // 卡是这一刻新铸的 ⇒ 这一半的内容当然是「变了」(此前没有任何一份)
851
+ stampPhase(s, id, 'start', ev.eventId, true);
709
852
  break;
710
853
  }
711
854
  case 'tool_end': {
712
855
  const id = ev.toolCallId;
713
- const idx = id !== undefined ? s.openTools.get(id) : undefined;
714
- if (idx !== undefined && s.items[idx]?.kind === 'tool') {
715
- // 卡还开着 ⇒ 结果照落。这一支**排在所有闸之前**:一张在飞的卡被任何一道闸拦掉收口,
716
- // 它就永远显示成还在跑,那比多渲一次结果坏得多。
717
- const it = s.items[idx];
718
- it.output = ev.output ?? '';
719
- it.isError = ev.isError === true;
720
- // 结果正文是**这一刻**才落到已在账的那条 item 上的补记它的字节。
721
- const add = utf8Len(it.output);
722
- s.itemBytes[idx] = (s.itemBytes[idx] ?? 0) + add;
723
- addBytes(s, add);
724
- if (id !== undefined)
725
- s.openTools.delete(id);
726
- markPhase(s, id, 'end', ev.eventId);
727
- break;
728
- }
729
- const settledAt = id !== undefined ? toolCardIndexOf(s, id) : -1;
730
- if (settledAt >= 0) {
731
- // 卡在账、却已不在「开着」表上 = 这次调用的结果**早就落过**。两种可能,判据是阶段水位:
732
- // · 比卡上现在那一份**旧或就是同一条帧**(durable 重放 / 旧一阶段迟到)⇒ 整帧丢弃 ——
733
- // 再落一次会把结果字节重复计进预算,走到下面那支还会另铸一张已完成的卡;更坏的是
734
- // 旧一阶段会把屏上的真结果盖回去;
735
- // · **更新的一发**收口(门 park 掉这次调用、批复后恢复执行,上游按同一个 `toolCallId`
736
- // 再发一对 start/end)⇒ **后到的才是真结果**,就地替换。把「已 settled」当成永久完成
737
- // 会把真结果吞掉,屏上停在那句「被门拦下」上。
738
- // 🔴 身份缺席(判不出新旧)时走替换那一支:重放送的是同一份值,替换是空操作(差额恒 0)。
739
- if (notNewer(ev.eventId, phaseOf(s, id)?.end))
856
+ // 卡在不在、在哪里,只按 `toolCallId` 现查 items —— **开着**与**已收口**两支从此共用同一条
857
+ // 定位与同一道闸(0.62.2 合并):此前开着那一支走的是另一张「开着的卡 → 下标」表、且**无条件**
858
+ // 落结果,于是同一件事有两处真源、两套规矩,而两条独立的流交错时旧收口正是从那一支进来的。
859
+ const at = id !== undefined ? toolCardIndexOf(s, id) : -1;
860
+ if (at >= 0) {
861
+ // 🔴 一道闸管两支:比卡上现在承载的那一阶段**旧、或就是同一条帧** 整帧丢弃。
862
+ // · 同一条帧重放 再落一次会把结果字节重复计进预算,走到下面那支还会另铸一张已完成的卡;
863
+ // · 旧一阶段的收口(park end 在恢复阶段之后才姗姗来迟)会把屏上的真结果盖回去,
864
+ // 卡还开着时更会**关掉**一张属于新阶段、其实还在跑的卡(见 {@link staleEnd} 头注);
865
+ // · 反过来,**更新的一发**收口( park 掉这次调用、批复后恢复执行,上游按同一个
866
+ // `toolCallId` 再发一对 start/end)⇒ 后到的才是真结果,就地替换/落账。把「已 settled」
867
+ // 当成永久完成会把真结果吞掉,屏上停在那句「被门拦下」上。
868
+ // 🔴 身份缺席(判不出新旧)⇒ 闸不拦,照落(重放送的是同一份值,落账是空操作),同拍把
869
+ // `end` 那一半的水位清掉( {@link stampPhase})
870
+ if (staleEnd(phaseOf(s, id), ev.eventId))
740
871
  break;
741
- const it = s.items[settledAt];
872
+ const it = s.items[at];
742
873
  const before = itemBytesOf(it);
874
+ const outBefore = it.output;
875
+ const errBefore = it.isError;
743
876
  it.output = ev.output ?? '';
744
877
  it.isError = ev.isError === true;
878
+ // 结果正文可能是**这一刻**才落到已在账的那条 item 上的 ⇒ 按差额补记(替换时差额可正可负)。
745
879
  const delta = itemBytesOf(it) - before;
746
- s.itemBytes[settledAt] = (s.itemBytes[settledAt] ?? 0) + delta;
880
+ s.itemBytes[at] = (s.itemBytes[at] ?? 0) + delta;
747
881
  addBytes(s, delta);
748
- markPhase(s, id, 'end', ev.eventId);
882
+ stampPhase(s, it.id, 'end', ev.eventId, it.output !== outBefore || it.isError !== errBefore);
749
883
  break;
750
884
  }
751
885
  // orphan close (start lost to the ring / arrived first) — synthesize a resolved card.
@@ -761,7 +895,13 @@ export function publishSubagentContentEvent(ev) {
761
895
  output: ev.output ?? '',
762
896
  isError: ev.isError === true,
763
897
  });
764
- markPhase(s, synthId, 'end', ev.eventId);
898
+ // 🔴 边界虽然不在这里切,**位置**要在这里记下来:此刻缓冲里的那一截是「工具之前」那一段,
899
+ // 之后再流进来的是「工具之后」那一段。迟到的 `tool_start` 按这个位置切开两段(见
900
+ // {@link TaskContentState.pendingCuts})—— 不记的话它只能把整条缓冲当成一段闭合,
901
+ // 两段就此黏死。"
902
+ recordCut(s);
903
+ // 卡是这一刻新铸的 ⇒ 结果那一半当然是「变了」
904
+ stampPhase(s, synthId, 'end', ev.eventId, true);
765
905
  break;
766
906
  }
767
907
  }