@sema-agent/client-core 0.68.0 → 0.68.2
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 +206 -0
- package/README.md +4 -1
- package/dist/adapt/arms.js +44 -7
- package/dist/adapt/textSegmentAuthority.d.ts +157 -0
- package/dist/adapt/textSegmentAuthority.js +108 -0
- package/dist/adapt/textStream.d.ts +143 -1
- package/dist/adapt/textStream.js +246 -6
- package/dist/adapt.js +18 -1
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +19 -0
- package/dist/adapter/downstream/eventToSdkMessage.js +18 -8
- package/dist/controlRouter.d.ts +16 -0
- package/dist/controlRouter.js +6 -0
- package/dist/engineHttpTools.d.ts +31 -0
- package/dist/engineHttpTools.js +30 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +20 -0
- package/dist/mcpPanel.d.ts +60 -0
- package/dist/mcpPanel.js +105 -0
- package/dist/request/taskRequest.d.ts +54 -0
- package/dist/request/taskRequest.js +75 -0
- package/dist/sdkWireTransit.d.ts +41 -0
- package/dist/sdkWireTransit.js +32 -0
- package/dist/seam.d.ts +86 -8
- package/dist/seam.js +16 -2
- package/dist/toolRoster.d.ts +34 -2
- package/dist/toolRoster.js +16 -2
- package/docs/INTEGRATION-CLIENTS.md +481 -9
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapt/textSegmentAuthority.ts — `text_end.content` **权威段替换**的判决单源(L-318,0.68.2)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么有这个文件(病形一句话)────────────────────────────────────────────────────────────
|
|
5
|
+
* 0.68.1 把权威替换落在 {@link import('./textStream.js').TextStream.replaceAnswerSegment} 里,
|
|
6
|
+
* 那一只服务的是**交互车道**(transcript / chrome 双平面)。而 `-p` 车道走的是另一条链
|
|
7
|
+
* (`eventToSdkMessage` 的内部臂 → 宿主自己的 CC stdout 投影器),它拿不到那只段缓冲 ⇒ cli 1.0.114
|
|
8
|
+
* 在壳里**又实现了一遍同样的六形**(B-122 热修)。同一条语义两份实现 = 上游改口时两边各走各的:
|
|
9
|
+
* 这正是「壳持有第二份真相」那一族病。
|
|
10
|
+
*
|
|
11
|
+
* ⇒ 本模块把**判决**(六形里真正决定结果的那三件事实 + 尾段)抽成一只**纯函数**,两条车道同吃:
|
|
12
|
+
* · 交互车道:`textStream.replaceAnswerSegment` 调它,再按本包的双平面语义落账;
|
|
13
|
+
* · print 车道:宿主(cli `PrintStreamProjector`)调它 + {@link createTextSegmentLedger},
|
|
14
|
+
* 只剩「把尾段写进 CC content block」这一步装配动作留在端上(多端改造设计稿 §8-5:
|
|
15
|
+
* stream-json 帧序/SSE 重铸属端,判定属包)。
|
|
16
|
+
*
|
|
17
|
+
* 🔴 **本模块零宿主形**:进出全是 `string` / `number` / `boolean` —— 没有 CC content block、没有
|
|
18
|
+
* transcript 消息、没有帧。可移植门(`--platform=browser` 真打包)因此一个字节都不受影响。
|
|
19
|
+
*
|
|
20
|
+
* ── 判决与落账刻意分家(读这一段再改本文件)──────────────────────────────────────────────────
|
|
21
|
+
* 两条车道的**账本形状不同**,而且那个差别是有理由的,不许在这里归一:
|
|
22
|
+
* · 交互车道的已提交前缀分两本(`previousSegmentCommitted` / `segmentCommitted`),因为它按
|
|
23
|
+
* **transcript 消息**划界;段跨过消息边界、且已提交那截自己也过期时它**只发信号不动字节**
|
|
24
|
+
* (`handOffOnly`,0.68.1 轮五 finding② 的基线对照结论);
|
|
25
|
+
* · print 车道只有一本(工具卡边界带走的字节跨卡累加),没有那一形 —— 它的 stdout 是只进不退
|
|
26
|
+
* 的流,`(b2)` 恒走「一个字节都不再交」。
|
|
27
|
+
* ⇒ 本模块只交**判决**;「判决出来之后各自怎么落账」仍归各自那一层。把落账也归一 = 改掉其中一条
|
|
28
|
+
* 车道的既有行为,那是行为面改动,不是归层。
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* 权威段替换的**六形判决**(两条车道共用的那一份;`content` 是引擎报的段权威全文)。
|
|
32
|
+
*
|
|
33
|
+
* 六形与它们各自的判据锚:
|
|
34
|
+
* (a) 常态:整段还押在调用层手里 ⇒ `replace_tail`,`committedPrefixLen === 0`;
|
|
35
|
+
* (b1) 半段已出门、且 `content.startsWith(前缀)` ⇒ `replace_tail`,只换未出门那一截;
|
|
36
|
+
* (b2) 半段已出门、**前缀自己也过期** ⇒ `withhold`。🔴 判据锚 = `startsWith`,**不是**「有没有
|
|
37
|
+
* flush/提交过」—— 后者是前置条件,前者才是真正决定结果的那个量;
|
|
38
|
+
* (c) 一 turn 多段(anthropic 车道逐 `content_block_stop` 发 `text_end`):本函数只交尾段,
|
|
39
|
+
* 「换完即封存、下一段另起」由调用层落账 —— 不封存的话第二帧会把第一段正文一起覆盖;
|
|
40
|
+
* (d) 子流(`parentToolCallId` 在场):**根本不该走到这里**,断闸在调用层的臂上(拿子代的段边界
|
|
41
|
+
* 去改 leader 的缓冲 = 跨 lane 状态破坏)。本函数不认识 lane 位,也刻意不去认识它;
|
|
42
|
+
* (e) 迟到的段边界(openai 车道:工具卡先执行、`text_end` 在 `finalize()` 才到)⇒ 由调用层把
|
|
43
|
+
* 两本账相加后交进 {@link TextSegmentAuthorityInput.committedPrefix},照 (b1)/(b2) 分流;
|
|
44
|
+
* (f) 调用层一个字节都没经手这一段(两截皆空)⇒ `untouched`。替换的语义是「换掉本层自己缝合
|
|
45
|
+
* 出来的那一段」,不是「凭空铸一段」:durable 整块 `text` 腿之后再来一帧 `text_end`,塞进
|
|
46
|
+
* 空缓冲就会在收口**再铸一条一模一样的**(0.68.1 `#323-g` 正控守的就是这一形)。
|
|
47
|
+
*
|
|
48
|
+
* @param content 该段的权威全文(UNTRUSTED、仅展示,与 `text_delta` 同契约 —— 渲染,绝不回喂模型)。
|
|
49
|
+
*/
|
|
50
|
+
export function resolveTextSegmentAuthority(content, input) {
|
|
51
|
+
const committedPrefix = input.committedPrefix;
|
|
52
|
+
const openSegment = input.openSegment;
|
|
53
|
+
// (f) 两截皆空 ⇒ 什么都不做。三位全报否(never-false 律:缺席 = 否定)。
|
|
54
|
+
if (committedPrefix.length === 0 && openSegment.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
form: 'untouched',
|
|
57
|
+
diverged: false,
|
|
58
|
+
committedPrefixLen: 0,
|
|
59
|
+
committedPrefixDiverged: false,
|
|
60
|
+
tail: '',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const committedPrefixLen = committedPrefix.length;
|
|
64
|
+
const diverged = committedPrefix + openSegment !== content;
|
|
65
|
+
const committedPrefixDiverged = committedPrefixLen > 0 && !content.startsWith(committedPrefix);
|
|
66
|
+
if (committedPrefixDiverged) {
|
|
67
|
+
// (b2):已出门那截撤不回,而切片拼不出 `content` —— 再补一条就是同一段话上屏两遍。
|
|
68
|
+
return { form: 'withhold', diverged, committedPrefixLen, committedPrefixDiverged: true, tail: '' };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
form: 'replace_tail',
|
|
72
|
+
diverged,
|
|
73
|
+
committedPrefixLen,
|
|
74
|
+
committedPrefixDiverged: false,
|
|
75
|
+
tail: content.slice(committedPrefixLen),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** 建一本 print 形段账(一条流一本;跨流复用会把上一条流的已发前缀算进这一条)。 */
|
|
79
|
+
export function createTextSegmentLedger() {
|
|
80
|
+
let committedPrefix = '';
|
|
81
|
+
const withheld = [];
|
|
82
|
+
return {
|
|
83
|
+
noteFlushed(text) {
|
|
84
|
+
committedPrefix += text;
|
|
85
|
+
},
|
|
86
|
+
closeSegmentAccount() {
|
|
87
|
+
committedPrefix = '';
|
|
88
|
+
},
|
|
89
|
+
applyTextEnd(content, openSegment) {
|
|
90
|
+
const verdict = resolveTextSegmentAuthority(content, { committedPrefix, openSegment });
|
|
91
|
+
if (verdict.form === 'withhold')
|
|
92
|
+
withheld.push(content);
|
|
93
|
+
// 🔴 `untouched` 形也清账:两截皆空本来就没有账可留,清它是幂等的;而漏清会把一条
|
|
94
|
+
// 「本层零经手」的段边界留成下一段的前缀(那正是 (b2) 误判的来源)。
|
|
95
|
+
committedPrefix = '';
|
|
96
|
+
return verdict;
|
|
97
|
+
},
|
|
98
|
+
repeatsWithheldSegment(candidate) {
|
|
99
|
+
const norm = candidate.trim();
|
|
100
|
+
if (norm.length === 0)
|
|
101
|
+
return false;
|
|
102
|
+
return withheld.some(w => w.trim() === norm);
|
|
103
|
+
},
|
|
104
|
+
get committedPrefixLength() {
|
|
105
|
+
return committedPrefix.length;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -15,13 +15,89 @@
|
|
|
15
15
|
* 三个 take/drain 的**相对次序**由调用点决定,本模块只保证每一个自身的内部序;拆分后
|
|
16
16
|
* 收口三形(result 臂 / abort 尾 / T60 尾)的次序差异登记在 ADAPT-RW-MATRIX §2.4,别归一。
|
|
17
17
|
*
|
|
18
|
+
* 🔴 0.68.1 / L-310 追加的**三行状态**(`segmentCommitted` = 当前引擎段里已经撤不回的那一截 /
|
|
19
|
+
* `previousSegmentCommitted` = 上一个包侧边界带走的那一截,迟到的段边界靠它算分歧 /
|
|
20
|
+
* `segmentSealed` = 已由引擎明报收尾、但还没提交的定稿正文)与第七个动作
|
|
21
|
+
* {@link TextStream.replaceAnswerSegment}(`text_end.content` 的权威段替换)。上面那句「12 个变量」
|
|
22
|
+
* 是 REF-CC-SPLIT-02 收编当时的实数,不是上限 —— 新增那三行各有自己的声明处头注,
|
|
23
|
+
* 数目与理由一起记明(封闭性声明必须机械验证,别拿一个对不上的数字自证)。
|
|
24
|
+
*
|
|
18
25
|
* 🔴 时序坑(矩阵 §2.3 ⑤,拆分专属):`drainLive()` 里两处 `pending = ''` 在 `yield` **之后**,
|
|
19
26
|
* 载荷是在 yield 之前**按值捕获**的,所以逻辑正确。**机械地把清空前移会读到已清空的串** ——
|
|
20
27
|
* 谁想「统一成 reset-before-yield」,先读这一条。
|
|
21
28
|
*/
|
|
22
29
|
import type { AdapterContext, AdapterOutput } from '../seam.js';
|
|
23
30
|
import { type Frame, type IdOf } from './ids.js';
|
|
24
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* **段身份键**(CC-01,0.68.2)—— 打在 committed assistant **文本**转录行**顶层**的 `_sema_` 超集键。
|
|
33
|
+
*
|
|
34
|
+
* ── 为什么有它(归层修,不是新功能)──────────────────────────────────────────────────────────
|
|
35
|
+
* `text_segment_end` 到达时若已提交前缀自己也过期(`committedPrefixDiverged`),宿主要在**自己持有的**
|
|
36
|
+
* 转录里找出「这一段交出去的那一行」把它换掉。按字节相等去找会被**同后缀的独立行**冒充(实撞过);
|
|
37
|
+
* 按 uuid 找只证明「是这一行」,不证明「是这一段的」。⇒ 归属要两把钥匙:行身份(`uuid`)∧ **段身份**。
|
|
38
|
+
* 段身份此前由壳在自己的过境口铸(先壳后包);段的边界(开场 / 收口 / 轮换)本来就只有本模块知道,
|
|
39
|
+
* 铸点归这里,壳只读。
|
|
40
|
+
*
|
|
41
|
+
* ── 语义(一句话)────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
* **一个身份覆盖「上一次段收口 → 本次段收口」之间过境的全部 committed 文本行**:流开场一个初值;
|
|
43
|
+
* idle-flush 提交的半段与终态提交的同段共享一个身份(归属正要这个);`text_segment_end` 臂处理完之后
|
|
44
|
+
* 轮换(帧上带的是轮换**前**的那一个,与它盖过的行同值);思考→回答边界**不**轮换;子流(带
|
|
45
|
+
* `parentToolCallId`)的段边界不轮换(它们不产 leader 事件,见臂头注)。
|
|
46
|
+
*
|
|
47
|
+
* ── 铸法与形 ──────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
* 派生自本窗口的**第一帧**锚 + 窗口序号(`idOf(anchor, 'segment<n>')`)⇒ **同流重放同身份**
|
|
49
|
+
* (与 committed 行的 uuid 同一条确定性纪律;宿主的双跑对拍门靠它)。🔴 它是**转录格式**上的键,
|
|
50
|
+
* 不是 wire 键:只出现在宿主落盘的转录行上,不进 provider 请求(顶层自铸键不外溢,与
|
|
51
|
+
* `_sema_degraded` 同律)。🔴 **只盖尾块是 `text` 的 assistant 行**:只带 `tool_use` 的行永远不可能是
|
|
52
|
+
* 替换目标,盖了只是往转录里加噪;思考块同理。缺席 = 「这条不属于任何引擎段」,不是「属于某个未知段」。
|
|
53
|
+
* 🔴 **只加不减**:非本形消息一个字节不动。
|
|
54
|
+
*/
|
|
55
|
+
export declare const SEMA_SEGMENT_ID_KEY = "_sema_segment_id";
|
|
56
|
+
/**
|
|
57
|
+
* {@link TextStream.replaceAnswerSegment} 的回执 —— **三件事实**,一个字节的正文都不复述。
|
|
58
|
+
*
|
|
59
|
+
* 🔴 三位**互不替代**(L-310 设计定谳,2026-09-14):
|
|
60
|
+
* · {@link diverged} 回答「屏上那份对不对」;
|
|
61
|
+
* · {@link committedPrefixLen} 回答「这一段有多少字节**已经撤不回**了」(idle-flush 铸过的
|
|
62
|
+
* transcript 消息早已 yield 给宿主);
|
|
63
|
+
* · {@link committedPrefixDiverged} 回答「撤不回的那一截**自己**是不是也被改过」——
|
|
64
|
+
* 它为真就是本件要防的最坏一形:**未脱敏字节已经进了本地转录**,包内修不了,只有宿主重渲。
|
|
65
|
+
*/
|
|
66
|
+
export interface TextSegmentReplacement {
|
|
67
|
+
/** 替换前按 delta 拼出来的本段全文 ≠ 权威全文。假 = 逐字节相同(旧引擎 / 不含凭据形的文本)。 */
|
|
68
|
+
diverged: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* 本段在替换发生前**已经 committed 上屏**的长度(idle-flush 的那些刀 + 包侧边界带走的那些截)。
|
|
71
|
+
*
|
|
72
|
+
* 🔴 **单位 = JS 字符串长度(UTF-16 代码单元),不是 UTF-8 字节,也不是「几条消息」**
|
|
73
|
+
* (异源对抗复审轮三 finding① 订正 —— 此前文案写「字节」,宿主按 UTF-8 字节去截会在任何非
|
|
74
|
+
* ASCII 正文上截错位置:实测 `'sk-…' + '中文'×30 + '.\n'` 的 UTF-16 长度 82 / UTF-8 字节 202,
|
|
75
|
+
* 按 202 截会把凭据**原样留在屏上**还附带乱码)。它就是给 `String.prototype.slice` 用的那个数:
|
|
76
|
+
* `已committed正文.slice(0, 已committed正文.length - committedPrefixLen) + content`。
|
|
77
|
+
* 🔴 **不是「几条消息」**:本包在 idle-flush 那一形下会把「上一段的定稿 + 这一段的半截」合并进
|
|
78
|
+
* **同一条**消息(分段行为零改动的代价),按**消息**去丢会把已经定稿的上一段一起删掉,而本包
|
|
79
|
+
* 不会再补发它。
|
|
80
|
+
* 🔴 它按**替换前的拼文**计长,**不是** `content` 上的偏移量 —— 前缀自己也可能被脱敏改过字节
|
|
81
|
+
* (见 {@link committedPrefixDiverged});拿它去切 `content` 在那一形上会切出半截乱码。
|
|
82
|
+
*/
|
|
83
|
+
committedPrefixLen: number;
|
|
84
|
+
/**
|
|
85
|
+
* 已提交的那一截**本身**与权威全文对不上(= 凭据落在已经上屏的那一截里)。
|
|
86
|
+
* 🔴 **统一规则**:这一位为真时本模块**一个字节都不再交**给转录面 —— 已提交那截撤不回,而切片
|
|
87
|
+
* 拼不出 `content`,再补一条就是同一段话上屏两遍。该段唯一算数的那一份在帧上的 `content` 里,
|
|
88
|
+
* 宿主用它换掉 {@link committedPrefixLen} 指的那一截(所以 chrome 臂上这一位是
|
|
89
|
+
* never-false 的显形键,不是内部量)。
|
|
90
|
+
* 🔴 这一形下、且**这一截整个都在当前这条 assistant 消息里**时,本模块同批把终答补差账
|
|
91
|
+
* ({@link TextStream.committedAnswerText})同步成「宿主照契约换过之后屏上有什么」——
|
|
92
|
+
* 不同步的话 `result` 臂会把权威全文当成「屏上缺的后缀」**再渲一遍**(轮三 finding② 实抓)。
|
|
93
|
+
* ⚠️ **段跨过包侧边界(工具卡 / 消息划界)时刻意不碰账**:那一截的字节落在**别的**消息里,而两本
|
|
94
|
+
* 补差账是**按消息**记的,写进当前这本就是把别条消息的正文算进这条(轮四 finding①② 实抓的两种
|
|
95
|
+
* 翻车)。那几形的补差基线与「换过之后的屏」对不齐 —— **这是本批之前就有的行为**(主树同条
|
|
96
|
+
* 时序实测同形),根因是包/引擎两套边界对不齐,根治见 `seam.ts` 义务① 末尾那条留白。
|
|
97
|
+
*/
|
|
98
|
+
committedPrefixDiverged: boolean;
|
|
99
|
+
}
|
|
100
|
+
/** M1 对外的七个动作 + 两个读位(矩阵 §3.2 的 #2/#3 两条跨模块接口就是最后那三件)。 */
|
|
25
101
|
export interface TextStream {
|
|
26
102
|
/** A5 `text_delta` 半场:MOD-1 思考→回答边界(思考在场就先 committed 上屏)+ 段锚 + 三缓冲累加。 */
|
|
27
103
|
feedText(delta: string, frame: Frame): Generator<AdapterOutput>;
|
|
@@ -43,6 +119,56 @@ export interface TextStream {
|
|
|
43
119
|
* 两条都不满足时**什么都不做**(缓冲原样押着,活体预览照常由 `drainLive` 走)。
|
|
44
120
|
*/
|
|
45
121
|
takeAnswerSegmentOnIdle(): Generator<AdapterOutput>;
|
|
122
|
+
/**
|
|
123
|
+
* L-310 —— 用引擎报的**段权威全文**整段替换本段已攒的 delta 拼文(server ≥7.75.3)。
|
|
124
|
+
*
|
|
125
|
+
* ## 为什么要有它(病形一句话)
|
|
126
|
+
* server 7.75.3 起 `text_end.content` 走的是与 `result`/账本同一只**脱敏器**,而 `text_delta`
|
|
127
|
+
* 仍逐字(跨 chunk 的凭据无法就地判)⇒ 两者**可以不相等**。本包此前只把 `content` 当段界信号,
|
|
128
|
+
* 于是屏上的活体流与 transcript 平面的 committed 消息用的都是 delta 拼文 —— 一段含凭据的回答
|
|
129
|
+
* 以**未脱敏**的字节留在本地转录里。契约原文(server `ASSISTANT-WIRE-CONTRACT` §5.1 live 面)
|
|
130
|
+
* 逐字:「消费端在 `text_end` 到达时应以它**整段替换**已攒的 delta,而不是只当段界信号」。
|
|
131
|
+
*
|
|
132
|
+
* ## 四形怎么处置(每一形都是**判断**,不是顺手)
|
|
133
|
+
* (a) **`text_end` 先于段提交**(常态):整段还押在缓冲里 ⇒ 段缓冲整段换成 `content`,
|
|
134
|
+
* 下一次 {@link TextStream.takeAnswerSegment} 铸出来的 committed 文本就是权威全文。
|
|
135
|
+
* (b) **半段已被 idle-flush 提交**:那条 transcript 消息**早已 yield 给宿主**,包内撤不回。
|
|
136
|
+
* · 已提交前缀逐字对得上 ⇒ 只封存**未提交尾段**(`content` 去掉前缀那一截),两条 committed
|
|
137
|
+
* 拼起来逐字节 = `content`;
|
|
138
|
+
* · 已提交前缀**自己**就对不上(凭据落在它里面)⇒ **一个字节都不再交**
|
|
139
|
+
* ({@link TextSegmentReplacement.committedPrefixDiverged} 为真),该段唯一算数的那一份在帧上,
|
|
140
|
+
* 由宿主重渲并丢掉此前那些消息 —— 「整条 committed 消息重铸」只能由宿主做(它才持有那条
|
|
141
|
+
* 消息的句柄),包能做的是**把这件事说出来**。
|
|
142
|
+
* 判据锚在 `content.startsWith(<已提交前缀>)`,不锚「有没有 idle-flush 过」——
|
|
143
|
+
* 后者是前置条件,前者才是真正决定结果的量。
|
|
144
|
+
* (c) **一 turn 多段**(anthropic 车道常态:逐 `content_block_stop` 发 `text_end`):定稿正文
|
|
145
|
+
* **封存**、`answerSegment` 清空给下一段用。🔴 不清空 = 下一段的 delta 接着往同一个缓冲里加、
|
|
146
|
+
* 再被下一帧的 `content` 整段覆盖 ⇒ **上一段的正文凭空消失**,且下一帧还会误报 `diverged`
|
|
147
|
+
* (异源对抗复审实抓)。封存**不切消息**:整 turn 仍只提交一条 assistant 文本消息。
|
|
148
|
+
* (d) **子流**(`parentToolCallId` 在场):根本到不了这里 —— 臂在 `arms.ts` 就断闸了
|
|
149
|
+
* (拿子代的段边界去改 leader 的缓冲 = 跨 lane 状态破坏)。
|
|
150
|
+
* (e) 🔴 **迟到的段边界**(openai 车道实测时序:`text_end` 在 `finalize()` 里 push,而
|
|
151
|
+
* `toolcall_end` 先触发 `executor.maybeAdmit` ⇒ **工具卡先到、段边界后到**):那一拍卡前散文
|
|
152
|
+
* 已被 `takeAnswerSegment()` 提交进**上一条**消息,两个缓冲双双为空。据
|
|
153
|
+
* {@link previousSegmentCommitted} **照算分歧、照发信号**,但**不铸任何消息**(那些字节在
|
|
154
|
+
* 上一条消息里,在这里补一条只会落到工具卡**之后**)。修前这一形被当成 (f) 全否返回 ⇒
|
|
155
|
+
* 一段未脱敏正文静静留在转录里、三个键一个都不发(异源对抗复审实抓)。
|
|
156
|
+
* (f) 🔴 **本包一个字节都没经手这一段**(两本前缀账与段缓冲全空):**什么都不做**,三位全报否。
|
|
157
|
+
* 替换的语义是「换掉本包自己缝合出来的那一段」,不是「凭空铸一段」—— durable 内容腿
|
|
158
|
+
* (整块 `text` 帧,本身就是一整条 assistant 消息)之后再来一帧 `text_end`,塞进缓冲就会在
|
|
159
|
+
* turn 收口**再铸一条一模一样的**。红先绿后实抓(`run-client-core-pure-test` `#323-g`)。
|
|
160
|
+
*
|
|
161
|
+
* ## 活体面(`textPending`)按替换后的段重算
|
|
162
|
+
* 已经泄出去的那一截(`stream_delta`)**撤不回**。所以:已泄前缀仍是 `content` 的前缀 ⇒ 尾巴
|
|
163
|
+
* 换成 `content` 剩下那一截(屏上接着往下写,零重复);已泄前缀**不是** `content` 的前缀 ⇒
|
|
164
|
+
* **不再补任何尾巴**(在一段错的前缀后面接上权威后缀,拼出来的是一段谁都没说过的话),
|
|
165
|
+
* 由宿主按 `diverged` 重渲整段。
|
|
166
|
+
*
|
|
167
|
+
* @param content 该段的权威全文(UNTRUSTED、仅展示,与 `text_delta` 同契约)。
|
|
168
|
+
* @param anchor 本帧 —— 段锚此刻为空(整段已被 idle 提交光 / 整段零 delta)时用它当 committed
|
|
169
|
+
* 消息的 id 锚;段锚已在场时**不覆盖**(重放确定性:同流同 id)。
|
|
170
|
+
*/
|
|
171
|
+
replaceAnswerSegment(content: string, anchor?: Frame): TextSegmentReplacement;
|
|
46
172
|
/** D3 IDLE-FLUSH 竞速判据:四个缓冲有任何一个非空。 */
|
|
47
173
|
hasPendingContent(): boolean;
|
|
48
174
|
/** D5 攒批节拍:一个间隔至多一次 flush(判据与 `lastFlushAt` 一起收在本模块内)。 */
|
|
@@ -106,6 +232,22 @@ export interface TextStream {
|
|
|
106
232
|
* 🔴 它**不动** `emittedAssistantText`:那一位问的是「这一轮产没产过正文」,与消息边界无关。
|
|
107
233
|
*/
|
|
108
234
|
beginAssistantMessage(): void;
|
|
235
|
+
/**
|
|
236
|
+
* 当前**段身份**(语义与铸法见 {@link SEMA_SEGMENT_ID_KEY} 头注)。同一窗口内多次读**同值**;
|
|
237
|
+
* 首次读时窗口还没有帧锚(durable 整条消息先于任何增量到达)⇒ 退到 `ctx.uuid()` 并缓存。
|
|
238
|
+
*/
|
|
239
|
+
segmentId(): string;
|
|
240
|
+
/**
|
|
241
|
+
* **段界到此为止**:换新段身份(下一窗口以 `anchor` 为帧锚)。调用点恰一处 = `text_segment_end`
|
|
242
|
+
* 臂处理完之后(帧上带的是轮换前的那一个)。🔴 不在这里之外的任何地方轮换。
|
|
243
|
+
*/
|
|
244
|
+
rotateSegmentIdentity(anchor: Frame): void;
|
|
245
|
+
/**
|
|
246
|
+
* **出口单点**:committed assistant **文本**行盖当前段身份({@link SEMA_SEGMENT_ID_KEY});
|
|
247
|
+
* 非本形(chrome 事件 / 思考块 / 只带 tool_use 的行 / 非 assistant)**原样返回,一个字节不动**。
|
|
248
|
+
* 调用点恰一处 = `adapt()` 的出口(durable 整条与流式分段两条腿都从那里过)。
|
|
249
|
+
*/
|
|
250
|
+
stampSegmentIdentity(out: AdapterOutput): AdapterOutput;
|
|
109
251
|
/** D8 收口日志读位(`answer` 本体不出模块)。 */
|
|
110
252
|
readonly answerLength: number;
|
|
111
253
|
}
|
package/dist/adapt/textStream.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { chrome, MAIN, transcript, messageIdentityOf } from './ids.js';
|
|
2
2
|
import { estimateCjkTokens, FLUSH_INTERVAL_MS } from './wireShapes.js';
|
|
3
|
+
// L-318:六形判决单源(两条车道同吃;落账各归各层,见 textSegmentAuthority.ts 头注)。
|
|
4
|
+
import { resolveTextSegmentAuthority } from './textSegmentAuthority.js';
|
|
5
|
+
/**
|
|
6
|
+
* **段身份键**(CC-01,0.68.2)—— 打在 committed assistant **文本**转录行**顶层**的 `_sema_` 超集键。
|
|
7
|
+
*
|
|
8
|
+
* ── 为什么有它(归层修,不是新功能)──────────────────────────────────────────────────────────
|
|
9
|
+
* `text_segment_end` 到达时若已提交前缀自己也过期(`committedPrefixDiverged`),宿主要在**自己持有的**
|
|
10
|
+
* 转录里找出「这一段交出去的那一行」把它换掉。按字节相等去找会被**同后缀的独立行**冒充(实撞过);
|
|
11
|
+
* 按 uuid 找只证明「是这一行」,不证明「是这一段的」。⇒ 归属要两把钥匙:行身份(`uuid`)∧ **段身份**。
|
|
12
|
+
* 段身份此前由壳在自己的过境口铸(先壳后包);段的边界(开场 / 收口 / 轮换)本来就只有本模块知道,
|
|
13
|
+
* 铸点归这里,壳只读。
|
|
14
|
+
*
|
|
15
|
+
* ── 语义(一句话)────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
* **一个身份覆盖「上一次段收口 → 本次段收口」之间过境的全部 committed 文本行**:流开场一个初值;
|
|
17
|
+
* idle-flush 提交的半段与终态提交的同段共享一个身份(归属正要这个);`text_segment_end` 臂处理完之后
|
|
18
|
+
* 轮换(帧上带的是轮换**前**的那一个,与它盖过的行同值);思考→回答边界**不**轮换;子流(带
|
|
19
|
+
* `parentToolCallId`)的段边界不轮换(它们不产 leader 事件,见臂头注)。
|
|
20
|
+
*
|
|
21
|
+
* ── 铸法与形 ──────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* 派生自本窗口的**第一帧**锚 + 窗口序号(`idOf(anchor, 'segment<n>')`)⇒ **同流重放同身份**
|
|
23
|
+
* (与 committed 行的 uuid 同一条确定性纪律;宿主的双跑对拍门靠它)。🔴 它是**转录格式**上的键,
|
|
24
|
+
* 不是 wire 键:只出现在宿主落盘的转录行上,不进 provider 请求(顶层自铸键不外溢,与
|
|
25
|
+
* `_sema_degraded` 同律)。🔴 **只盖尾块是 `text` 的 assistant 行**:只带 `tool_use` 的行永远不可能是
|
|
26
|
+
* 替换目标,盖了只是往转录里加噪;思考块同理。缺席 = 「这条不属于任何引擎段」,不是「属于某个未知段」。
|
|
27
|
+
* 🔴 **只加不减**:非本形消息一个字节不动。
|
|
28
|
+
*/
|
|
29
|
+
export const SEMA_SEGMENT_ID_KEY = '_sema_segment_id';
|
|
3
30
|
/**
|
|
4
31
|
* 件 A(#323 症状①)判据 (b) 的句末字符集 —— 中英文各一套,**闭集**(开集匹配会把「代码里的点」
|
|
5
32
|
* 之外的一切标点都当句末,等于没收紧)。换行单独在 {@link endsAtSentenceBoundary} 里判(段末)。
|
|
@@ -62,6 +89,46 @@ export function createTextStream(ctx, idOf) {
|
|
|
62
89
|
* 「工具帧来过」本身就是重开新段的事实,与那一拍有没有散文可提交无关。
|
|
63
90
|
*/
|
|
64
91
|
let idleFlushUsedInSegment = false;
|
|
92
|
+
/**
|
|
93
|
+
* L-310:**当前引擎段**里已经 committed 上屏的那一截(idle-flush 的刀)。
|
|
94
|
+
*
|
|
95
|
+
* 🔴 与 `committedText` 刻意分家(两个量、两件事,别合并):`committedText` 记的是「**这条
|
|
96
|
+
* assistant 消息**屏上已经有了哪些字」(跨段累加,补差判据的输入);这一个记的是「**这一段**
|
|
97
|
+
* 有多少字节已经撤不回了」——它是权威段替换唯一能算出「尾段该换成什么」的量。
|
|
98
|
+
* 写于 `emitAnswerSegment`(提交本体,**只计当前段那一截**,不含 {@link segmentSealed});
|
|
99
|
+
* 归零于三处**段到此为止**的事实发生地:正常边界(`takeAnswerSegment`)、权威替换
|
|
100
|
+
* (`replaceAnswerSegment`)、新消息划界(`beginAssistantMessage`)。
|
|
101
|
+
* idle 入口**不**归零 —— 那正是「段还没完、只是先上屏半截」这件事本身。
|
|
102
|
+
*/
|
|
103
|
+
let segmentCommitted = '';
|
|
104
|
+
/**
|
|
105
|
+
* 🔴 **上一个包侧边界带走的那一截**(L-310 / 异源复审 finding①,0.68.1)。
|
|
106
|
+
*
|
|
107
|
+
* 存在的理由是一条**上游实测的时序**:openai 车道的 `text_end` 是在 `finalize()` 里 push 的
|
|
108
|
+
* (装机 core `dist/brain/openai.js` 的 `finalize` 真字节),而 `toolcall_end` 会先触发
|
|
109
|
+
* `executor.maybeAdmit`(`dist/engine/loop/agent-loop.js`)⇒ **工具卡先到、段边界后到**。
|
|
110
|
+
* 那一拍包侧的工具卡臂已经 `takeAnswerSegment()` 把卡前散文提交进**上一条** assistant 消息,
|
|
111
|
+
* 于是 `segmentCommitted` 与 `answerSegment` 双双为空 —— 若据此判「本包没经手过这一段」,
|
|
112
|
+
* 一段**未脱敏**的正文就会静静留在转录里、且三个分歧键一个都不发(宿主连纠正的机会都没有)。
|
|
113
|
+
* ⇒ 边界带走那一截时把它记在这里,迟到的 `text_end` 据它**算分歧、发信号**;
|
|
114
|
+
* 但**不铸任何消息**(那些字节在上一条消息里,在这里补一条只会落到工具卡**之后**)。
|
|
115
|
+
* 与 `previousCommittedText` 是同一条思路的两个量,刻意分开:那一个是「上一条消息的全部正文」
|
|
116
|
+
* (终答补差用),这一个是「上一个包侧边界带走的**本段**那一截」(分歧判据用)。
|
|
117
|
+
*/
|
|
118
|
+
let previousSegmentCommitted = '';
|
|
119
|
+
/**
|
|
120
|
+
* 🔴 **已由引擎明报收尾、但还没提交的段正文**(L-310 / 异源复审 finding②,0.68.1)。
|
|
121
|
+
*
|
|
122
|
+
* 病形(复审实抓):`delta(A) → text_end(A) → delta(B) → text_end(B)` 中间没有工具卡也没有
|
|
123
|
+
* idle 提交时,若替换只是把 `content` 写回 `answerSegment`,B 的 delta 会**接着往同一个缓冲里加**,
|
|
124
|
+
* 然后被 `contentB` 整段覆盖 —— **A 段的正文凭空消失**,而且 B 那一帧还会误报 `diverged`。
|
|
125
|
+
* 这条时序在 anthropic 车道是**常态**(装机 core `dist/brain/anthropic.js` 在
|
|
126
|
+
* `content_block_stop` 上逐块 push `text_end`,一条消息可以有多个 text 块)。
|
|
127
|
+
* ⇒ 替换把定稿正文移进本缓冲(「封存」),`answerSegment` 清空给下一段用;段提交本体交的是
|
|
128
|
+
* `segmentSealed + answerSegment`。分段行为**零改动**(整 turn 仍只提交一条 assistant 文本
|
|
129
|
+
* 消息,除非有工具卡/idle 边界)—— 撤 idle-flush 启发式仍是另一批的事。
|
|
130
|
+
*/
|
|
131
|
+
let segmentSealed = '';
|
|
65
132
|
/** T29 攒批节拍:宿主策略优先(桌面/web 可传 16ms 对齐 CC 桌面端),缺席=207 CLI 口径 100ms。 */
|
|
66
133
|
const flushEveryMs = typeof ctx.coalesceIntervalMs === 'number' && ctx.coalesceIntervalMs >= 0
|
|
67
134
|
? ctx.coalesceIntervalMs
|
|
@@ -69,6 +136,18 @@ export function createTextStream(ctx, idOf) {
|
|
|
69
136
|
/** committed 消息的 id 锚 —— 该段/该思考块的**第一帧**(重放确定性:同流同 id)。 */
|
|
70
137
|
let segmentAnchor = null;
|
|
71
138
|
let thinkingAnchor = null;
|
|
139
|
+
/**
|
|
140
|
+
* 段身份窗口(见 {@link SEMA_SEGMENT_ID_KEY} 头注):窗口的第一帧锚 + 序号 + 派生结果缓存。
|
|
141
|
+
* 🔴 缓存不是优化:锚缺席那一形派生落到 `ctx.uuid()`,不缓存的话同一窗口两次读会得到两个身份。
|
|
142
|
+
*/
|
|
143
|
+
let segmentWindowAnchor = null;
|
|
144
|
+
let segmentWindowSerial = 0;
|
|
145
|
+
let segmentIdCache = null;
|
|
146
|
+
const segmentIdNow = () => {
|
|
147
|
+
if (segmentIdCache === null)
|
|
148
|
+
segmentIdCache = idOf(segmentWindowAnchor ?? {}, `segment${segmentWindowSerial}`);
|
|
149
|
+
return segmentIdCache;
|
|
150
|
+
};
|
|
72
151
|
/** 提交累积的思考块(cli takeThinking:committed 形 + 关活体块 + 收活动行)。 */
|
|
73
152
|
function* takeThinking() {
|
|
74
153
|
if (thinking.length === 0)
|
|
@@ -93,18 +172,24 @@ export function createTextStream(ctx, idOf) {
|
|
|
93
172
|
/** 段提交的**唯一本体**(两个入口 `takeAnswerSegment` / `takeAnswerSegmentOnIdle` 共用;
|
|
94
173
|
* 两个入口的差别只在准入判据,提交出来的消息一个字节都不分叉)。 */
|
|
95
174
|
function* emitAnswerSegment() {
|
|
96
|
-
|
|
175
|
+
// L-310:提交的是「已封存的定稿段 + 还在攒的当前段」。分段行为零改动 —— 封存只换字节,不切消息。
|
|
176
|
+
const body = segmentSealed + answerSegment;
|
|
177
|
+
if (body.length === 0)
|
|
97
178
|
return;
|
|
98
179
|
const anchor = segmentAnchor ?? {};
|
|
99
180
|
const msg = {
|
|
100
181
|
type: 'assistant',
|
|
101
182
|
// L-215③:同 takeThinking —— 身份位接力(帧优先、ctx 兜底;两位都读不出就一个都不铸)。
|
|
102
|
-
message: { role: 'assistant', content: [{ type: 'text', text:
|
|
183
|
+
message: { role: 'assistant', content: [{ type: 'text', text: body }], ...messageIdentityOf(anchor, ctx) },
|
|
103
184
|
uuid: idOf(anchor, 'text'),
|
|
104
185
|
session_id: ctx.sessionId,
|
|
105
186
|
parent_tool_use_id: null,
|
|
106
187
|
};
|
|
107
|
-
committedText +=
|
|
188
|
+
committedText += body;
|
|
189
|
+
// L-310:🔴 **只有当前段那一截**算进「本段已提交前缀」—— 已封存的定稿段属于**上一个**引擎段,
|
|
190
|
+
// 把它算进来会让下一帧 `text_end` 拿一个跨段的前缀去比 `content`,当场误报前缀分歧。
|
|
191
|
+
segmentCommitted += answerSegment;
|
|
192
|
+
segmentSealed = '';
|
|
108
193
|
answerSegment = '';
|
|
109
194
|
segmentAnchor = null;
|
|
110
195
|
emittedAssistantText = true;
|
|
@@ -116,14 +201,22 @@ export function createTextStream(ctx, idOf) {
|
|
|
116
201
|
function* takeAnswerSegment() {
|
|
117
202
|
idleFlushUsedInSegment = false;
|
|
118
203
|
yield* emitAnswerSegment();
|
|
204
|
+
// L-310:正常边界带走了当前段的那一截 ⇒ 本段前缀账归零(下一段的替换不许把上一段的字节
|
|
205
|
+
// 算成自己的前缀),但**记进** `previousSegmentCommitted` —— 这是包侧边界,不是引擎段边界:
|
|
206
|
+
// openai 车道的 `text_end` 会在这之后才到,那一拍还得拿它算分歧(见 replaceAnswerSegment)。
|
|
207
|
+
previousSegmentCommitted += segmentCommitted;
|
|
208
|
+
segmentCommitted = '';
|
|
119
209
|
}
|
|
120
210
|
/** 件 A:IDLE-FLUSH 专用入口(准入 = 每段一刀 + 句末/段末边界;两条都过才提交)。 */
|
|
121
211
|
function* takeAnswerSegmentOnIdle() {
|
|
122
|
-
|
|
212
|
+
// L-310:准入判的是**提交本体真要交的那份**(封存段 + 当前段),不是只看当前段 ——
|
|
213
|
+
// 否则「定稿段已封存、当前段还空着」那一拍会被判成没东西可交,把已定稿的正文继续押着。
|
|
214
|
+
const body = segmentSealed + answerSegment;
|
|
215
|
+
if (body.length === 0)
|
|
123
216
|
return;
|
|
124
217
|
if (idleFlushUsedInSegment)
|
|
125
218
|
return;
|
|
126
|
-
if (!endsAtSentenceBoundary(
|
|
219
|
+
if (!endsAtSentenceBoundary(body))
|
|
127
220
|
return;
|
|
128
221
|
idleFlushUsedInSegment = true;
|
|
129
222
|
yield* emitAnswerSegment();
|
|
@@ -168,13 +261,18 @@ export function createTextStream(ctx, idOf) {
|
|
|
168
261
|
yield* drainLive();
|
|
169
262
|
yield* takeThinking();
|
|
170
263
|
}
|
|
171
|
-
|
|
264
|
+
// L-310:已封存的定稿段也算「这条消息已经开了头」⇒ 锚不重置(同流重放同 id)。
|
|
265
|
+
if (answerSegment.length === 0 && segmentSealed.length === 0)
|
|
172
266
|
segmentAnchor = frame;
|
|
267
|
+
if (segmentWindowAnchor === null)
|
|
268
|
+
segmentWindowAnchor = frame;
|
|
173
269
|
answer += delta;
|
|
174
270
|
answerSegment += delta;
|
|
175
271
|
textPending += delta;
|
|
176
272
|
},
|
|
177
273
|
*feedThinking(delta, frame) {
|
|
274
|
+
if (segmentWindowAnchor === null)
|
|
275
|
+
segmentWindowAnchor = frame;
|
|
178
276
|
if (thinking.length === 0) {
|
|
179
277
|
thinkingAnchor = frame;
|
|
180
278
|
// P2d:elapsed 锚在**首条** leader 推理增量(token 累计由 stream_delta.estimatedTokens
|
|
@@ -188,7 +286,122 @@ export function createTextStream(ctx, idOf) {
|
|
|
188
286
|
takeThinking,
|
|
189
287
|
takeAnswerSegment,
|
|
190
288
|
takeAnswerSegmentOnIdle,
|
|
289
|
+
replaceAnswerSegment: (content, anchor) => {
|
|
290
|
+
const prevSegment = answerSegment;
|
|
291
|
+
/**
|
|
292
|
+
* 本段**已经撤不回**的那一截 = 包侧边界带走的(可能跨了工具卡 / 消息划界,见
|
|
293
|
+
* {@link previousSegmentCommitted})+ 本消息内 idle-flush 掉的。
|
|
294
|
+
* 🔴 **两本账必须相加,不是二选一**(异源对抗复审轮二 finding① 实抓):修前只在「当前缓冲
|
|
295
|
+
* 全空」时才去读前一本,于是 `delta(含凭据) → 工具卡 → 同段后续 delta → text_end` 这条
|
|
296
|
+
* **openai 车道真实交错**(brain 持续累加文本直到 `finalize`,而 streamingToolExecution
|
|
297
|
+
* 允许工具先执行)上,卡前那截明文**不算进前缀** ⇒ 两个前缀键双双缺席、旧明文消息留存,
|
|
298
|
+
* 而权威全文又被完整提交一次(同一段话上屏两遍 + 明文没人清)。实测复现过。
|
|
299
|
+
*/
|
|
300
|
+
const livePrefix = previousSegmentCommitted + segmentCommitted;
|
|
301
|
+
/**
|
|
302
|
+
* 🔴 **判决单源**(L-318,0.68.2):六形判据整只让位给 `adapt/textSegmentAuthority.ts` 的
|
|
303
|
+
* {@link resolveTextSegmentAuthority} —— 与 `-p` 形车道(宿主的 CC stdout 投影器)吃的是
|
|
304
|
+
* **同一只**。修前这里是本包这一份,壳里另有逐形对照的第二份(cli 1.0.114 B-122 热修),
|
|
305
|
+
* 上游改口时两边各走各的。判据语义**逐字未变**:
|
|
306
|
+
* · 形 (f)「本层一个字节都没经手」⇒ `form: 'untouched'`,三位全报否;
|
|
307
|
+
* · `diverged` = 缝出来的那一段 ≠ 权威全文;
|
|
308
|
+
* · `committedPrefixLen` = 已撤不回那一截的 UTF-16 长度;
|
|
309
|
+
* · `committedPrefixDiverged` 锚 `content.startsWith(前缀)`(不锚「有没有 flush 过」)。
|
|
310
|
+
* **落账仍归本模块**:下面的 handOffOnly / 活体尾巴重算 / 封存 / 补差账同步,一行未动
|
|
311
|
+
* (两条车道的账本形状不同,理由见 textSegmentAuthority.ts 头注「判决与落账刻意分家」)。
|
|
312
|
+
*/
|
|
313
|
+
const verdict = resolveTextSegmentAuthority(content, {
|
|
314
|
+
committedPrefix: livePrefix,
|
|
315
|
+
openSegment: prevSegment,
|
|
316
|
+
});
|
|
317
|
+
if (verdict.form === 'untouched') {
|
|
318
|
+
return { diverged: false, committedPrefixLen: 0, committedPrefixDiverged: false };
|
|
319
|
+
}
|
|
320
|
+
const liveSegment = livePrefix + prevSegment;
|
|
321
|
+
const diverged = verdict.diverged;
|
|
322
|
+
const committedPrefixLen = verdict.committedPrefixLen;
|
|
323
|
+
const committedPrefixDiverged = verdict.committedPrefixDiverged;
|
|
324
|
+
/**
|
|
325
|
+
* 🔴 **段跨过包侧边界、而且已提交那截自己也过期** ⇒ 本模块**一个字节都不动**(只发信号)。
|
|
326
|
+
*
|
|
327
|
+
* 理由是**不许把一条既有行为改坏**(异源对抗复审轮五 finding② 实抓,基线对照确认):这一形下
|
|
328
|
+
* 那一截的字节在**别的** assistant 消息里,而本模块能动的只有当前这条 —— 清掉未提交尾段会让
|
|
329
|
+
* `lastCommittedAnswerText` 回退到上一条,`result` 臂据它落第④臂**补吐一整段**;而 e4cd371
|
|
330
|
+
* 基线上同一条时序只是提交那截尾段 + 落第⑤臂报一行分岔(不补吐)。⇒ 清空在这里是**新增**的
|
|
331
|
+
* 重复转录路径,不是「更安全」。
|
|
332
|
+
* ⇒ 分界线一句话:**整段都在自己手里(同一条消息内)时才改自己交的字节;段一旦跨过包侧边界,
|
|
333
|
+
* 就只发信号、不动任何既有行为**。宿主照三个键把那截过期前缀换掉,其余与 0.68.0 逐字相同。
|
|
334
|
+
*/
|
|
335
|
+
const handOffOnly = committedPrefixDiverged && previousSegmentCommitted.length > 0;
|
|
336
|
+
if (handOffOnly) {
|
|
337
|
+
segmentCommitted = '';
|
|
338
|
+
previousSegmentCommitted = '';
|
|
339
|
+
idleFlushUsedInSegment = false;
|
|
340
|
+
return { diverged, committedPrefixLen, committedPrefixDiverged };
|
|
341
|
+
}
|
|
342
|
+
// ── ① 活体面:已泄出去的那一截撤不回 ⇒ 尾巴按替换后的段重算(判据见接口注「活体面」段)──
|
|
343
|
+
// `textPending` 是段缓冲的**后缀**;工具卡边界只提交不 drain,所以它也可能还押着**上一段**
|
|
344
|
+
// 的尾巴(那一截与本次替换无关,原样留着)。
|
|
345
|
+
const segTailLen = Math.min(textPending.length, prevSegment.length);
|
|
346
|
+
const carry = textPending.slice(0, textPending.length - segTailLen);
|
|
347
|
+
const emittedForSegment = livePrefix + prevSegment.slice(0, prevSegment.length - segTailLen);
|
|
348
|
+
textPending = content.startsWith(emittedForSegment)
|
|
349
|
+
? carry + content.slice(emittedForSegment.length)
|
|
350
|
+
: carry;
|
|
351
|
+
// ── ② 转录面:**封存**这一段的定稿正文,`answerSegment` 清空给下一段用 ──────────────────
|
|
352
|
+
// 🔴 清空是轮一 finding② 的修复本体:不清空的话,下一段的 delta 会接着往同一个缓冲里加,
|
|
353
|
+
// 然后被下一帧的 `content` 整段覆盖 —— 上一段的正文凭空消失(anthropic 车道逐块发
|
|
354
|
+
// `text_end`,这条时序是常态)。封存**不切消息**:段提交本体交的是 `sealed + segment`。
|
|
355
|
+
// 🔴 `committedPrefixDiverged` 那一形**一个字节都不交**(统一规则,见接口注):已提交的
|
|
356
|
+
// 那截撤不回,而切片拼不出 `content` —— 再补一条就是同一段话上屏两遍;该段唯一算数的
|
|
357
|
+
// 那一份在帧上,由宿主按 `committedPrefixLen` 指的那一截换掉(单位见回执头注)。
|
|
358
|
+
if (committedPrefixDiverged) {
|
|
359
|
+
// 🔴 **同步终答补差账 —— 但只在「整截都在当前这条消息里」那一形**(轮三 finding② 采纳,
|
|
360
|
+
// 轮四 finding①② 收窄)。这一形下本包不交正文,而屏上那一段在宿主照契约换过之后**就是**
|
|
361
|
+
// `content`;补差账若还留着旧的那一份,`result` 臂会把权威全文当成「屏上缺的后缀」
|
|
362
|
+
// 再渲一遍(第④臂),正好违反三处文案承诺的「不再交正文」。
|
|
363
|
+
//
|
|
364
|
+
// 🔴 **为什么只同步这一形**(轮四两条 finding 的根因,记明免得下一棒又去「补全」它):
|
|
365
|
+
// 段跨过包侧边界(工具卡 / 消息划界)时,这一截的字节落在**别的** assistant 消息里,而
|
|
366
|
+
// `committedText` / `previousCommittedText` 是**按消息**记的两本账 —— 把 `content` 往当前
|
|
367
|
+
// 这本账上写,就等于把上一条消息的正文算进当前这条:轮四实测 ① 迟到边界那一形会让随后
|
|
368
|
+
// 正常的 B 段被误报 `result_text_diverged`、甚至把该补的后缀压掉;② 同段跨两张卡时
|
|
369
|
+
// `previousCommittedText` 只留得下最后一条,守卫直接不成立、同步静默跳过。
|
|
370
|
+
// ⇒ 跨消息那几形**不碰账**。代价如实认领:那里的补差基线与「宿主换过之后的屏」对不齐,
|
|
371
|
+
// `result` 可能落第④臂补一截或落第⑤臂报一行 `result_text_diverged` —— 而这两者
|
|
372
|
+
// **都是本批之前就有的行为**(主树 0.68.0 上同一条时序实测同形:卡后有散文时
|
|
373
|
+
// `lastCommittedAnswerText` 取的就是卡**之后**那一条,终帧却是整条消息的全文)。
|
|
374
|
+
// 根因是「包按自己的边界切消息、引擎按 content block 切段」这两套边界对不齐,根治 =
|
|
375
|
+
// 「每个引擎段各自一条 committed 消息」(= CC 原生做法),那是分段行为改动,单独走。
|
|
376
|
+
if (committedText.endsWith(livePrefix)) {
|
|
377
|
+
committedText = committedText.slice(0, committedText.length - livePrefix.length) + content;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// L-318:尾段切片也走判决那一份(`content.slice(committedPrefixLen)` 逐字等价),
|
|
382
|
+
// 免得「怎么切」这件事在两条车道上各留一个算式。
|
|
383
|
+
segmentSealed += verdict.tail;
|
|
384
|
+
}
|
|
385
|
+
answerSegment = '';
|
|
386
|
+
// ── ③ `answer`(D8 调试行的长度读位)跟着换尾 —— 只在尾巴真对得上时换 ──────────────────
|
|
387
|
+
// `appendAnswer`(durable 整条腿)会往同一个累加器里加字,那一形下本段已经不是它的后缀;
|
|
388
|
+
// 猜着改一个诊断计数器比让它原样更坏,所以对不上就不动(它只喂 `answerLen=` 那一行)。
|
|
389
|
+
if (answer.endsWith(liveSegment)) {
|
|
390
|
+
answer = answer.slice(0, answer.length - liveSegment.length) + content;
|
|
391
|
+
}
|
|
392
|
+
// 段锚缺席(整段已被 idle 提交光、或整段零 delta)才用本帧兜底 —— 在场**不覆盖**,
|
|
393
|
+
// 否则同一条流重放两次会拿到两个不同的 committed 消息 id(replay-id 不变量)。
|
|
394
|
+
if (segmentAnchor === null && anchor !== undefined)
|
|
395
|
+
segmentAnchor = anchor;
|
|
396
|
+
// ── ④ 段账收口:这一段由引擎明说已经写完 ⇒ 两本前缀账都归零、idle 配额重开 ──────────────
|
|
397
|
+
segmentCommitted = '';
|
|
398
|
+
previousSegmentCommitted = '';
|
|
399
|
+
idleFlushUsedInSegment = false;
|
|
400
|
+
return { diverged, committedPrefixLen, committedPrefixDiverged };
|
|
401
|
+
},
|
|
191
402
|
hasPendingContent: () => thinking.length > 0 ||
|
|
403
|
+
// L-310:已封存的定稿段同样是「有已生成未提交的内容」(不算进来会让它一直押到 turn 收口)。
|
|
404
|
+
segmentSealed.length > 0 ||
|
|
192
405
|
answerSegment.length > 0 ||
|
|
193
406
|
thinkingPending.length > 0 ||
|
|
194
407
|
textPending.length > 0,
|
|
@@ -207,10 +420,37 @@ export function createTextStream(ctx, idOf) {
|
|
|
207
420
|
if (typeof committed === 'string')
|
|
208
421
|
committedText += committed;
|
|
209
422
|
},
|
|
423
|
+
segmentId: segmentIdNow,
|
|
424
|
+
rotateSegmentIdentity: (anchor) => {
|
|
425
|
+
segmentWindowSerial += 1;
|
|
426
|
+
segmentWindowAnchor = anchor;
|
|
427
|
+
segmentIdCache = null;
|
|
428
|
+
},
|
|
429
|
+
stampSegmentIdentity: (out) => {
|
|
430
|
+
if (out.plane !== 'transcript')
|
|
431
|
+
return out;
|
|
432
|
+
// ⚠️ 局部名刻意不叫 `msg`/`content`:message-branching 门把 `msg.message` 读成「错误文案」,
|
|
433
|
+
// 并按名字把污染扩到整个文件 —— 同名的 `content.startsWith` 会被误判成按文案分支。
|
|
434
|
+
const row = out.message;
|
|
435
|
+
if (row.type !== 'assistant')
|
|
436
|
+
return out;
|
|
437
|
+
const blocks = row.message?.content;
|
|
438
|
+
if (!Array.isArray(blocks) || blocks.length === 0)
|
|
439
|
+
return out;
|
|
440
|
+
const last = blocks[blocks.length - 1];
|
|
441
|
+
if (last === undefined || last.type !== 'text' || typeof last.text !== 'string')
|
|
442
|
+
return out;
|
|
443
|
+
out.message[SEMA_SEGMENT_ID_KEY] = segmentIdNow();
|
|
444
|
+
return out;
|
|
445
|
+
},
|
|
210
446
|
beginAssistantMessage: () => {
|
|
211
447
|
if (committedText.length > 0)
|
|
212
448
|
previousCommittedText = committedText;
|
|
213
449
|
committedText = '';
|
|
450
|
+
// L-310:新消息开始 = 旧段的已提交前缀与**新**段无关 ⇒ 本段账归零;同 takeAnswerSegment,
|
|
451
|
+
// 归零前记进 `previousSegmentCommitted`(迟到的 `text_end` 还要拿它算分歧)。
|
|
452
|
+
previousSegmentCommitted += segmentCommitted;
|
|
453
|
+
segmentCommitted = '';
|
|
214
454
|
},
|
|
215
455
|
get committedAnswerText() {
|
|
216
456
|
return committedText;
|