@sema-agent/client-core 0.68.0 → 0.68.1
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 +137 -0
- package/README.md +2 -1
- package/dist/adapt/arms.js +40 -7
- package/dist/adapt/textStream.d.ts +102 -1
- package/dist/adapt/textStream.js +169 -6
- package/dist/adapt.js +4 -1
- package/dist/adapter/downstream/eventToSdkMessage.js +17 -7
- package/dist/controlRouter.d.ts +16 -0
- package/dist/controlRouter.js +6 -0
- package/dist/request/taskRequest.d.ts +6 -6
- package/dist/request/taskRequest.js +45 -0
- package/dist/seam.d.ts +78 -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 +267 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -49,6 +49,143 @@
|
|
|
49
49
|
> 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
|
|
50
50
|
> commit 漏转在发布前就红,不再靠人记。
|
|
51
51
|
|
|
52
|
+
## 0.68.1(2026-09-15)
|
|
53
|
+
|
|
54
|
+
🔴 **内容批**(零上游换钉:devDep / peer 地板一行未动)。逐件的铸点读法 / 缺席语义 / 三端升级必读三条 /
|
|
55
|
+
黑盒判据 G34-01…13 / 逐键处置表见 `docs/INTEGRATION-CLIENTS.md` **§34**(母本 §34z)。
|
|
56
|
+
|
|
57
|
+
🔴 **本批最该记住的一句话**:`text_end.content` 与 `text_delta` 从「逐字节相等」变成「**可以不相等**」
|
|
58
|
+
(server 7.75.3 起前者经脱敏器、后者仍逐字)。同一段话换了两种字节之后,本包此前那句「拿它再渲一行 =
|
|
59
|
+
同一段文字上屏两遍」就从**对的**变成了**危险的** —— 按它不渲,留在本地转录里的是**未脱敏**那一份。
|
|
60
|
+
|
|
61
|
+
### L-310 🔴 `text_end` 权威段替换(安全面)
|
|
62
|
+
|
|
63
|
+
- `adapt/arms.ts` 的 `textSegmentEndArm` **改口**:修前头注自陈「本批**不动**文本缓冲」,现在它调
|
|
64
|
+
`adapt/textStream.ts` 新增的具名动作 `replaceAnswerSegment(content, frame)` —— **整段替换**段缓冲与
|
|
65
|
+
活体尾巴,再发 chrome 信号(替换**在**发信号之前:反过来会留一拍窗口,宿主按信号去读转录面读到的
|
|
66
|
+
还是旧拼文)。臂的签名随之从 `ProjectionArmFn` 改成 `ArmFn`(封闭性声明就是签名,改签名在 diff 里显形)。
|
|
67
|
+
- chrome `text_segment_end` 新增**三个 additive 键,全部 never-false**(缺席 = 否定,与 `_sema_usage_absent`
|
|
68
|
+
同律):`diverged`(活体面那份过期了 ⇒ 重渲)、`committedPrefixDiverged`(转录面已 committed 的那一截
|
|
69
|
+
**也**过期了 ⇒ 丢掉它们并用 `content` 重渲;包在这一形上**一个字节都不再交**)、`committedPrefixLen`
|
|
70
|
+
(丢到哪的定位量;按**增量拼文**计长,不是 `content` 上的偏移)。🔴 **b1/b2 两形靠第二个键分**:另两个键
|
|
71
|
+
在两形上完全同形,而宿主动作正相反,所以合成一个位办不到。刻意**没有**第四个键 `authoritative: true`
|
|
72
|
+
(每帧恒真 ⇒ 零判别力)。
|
|
73
|
+
- 🔴 `CHROME_ARM_TABLE` 上本臂的 `required` **`false` → `true`**:本表的 `false` 语义逐字是「不接 = 这条
|
|
74
|
+
披露看不见,不属『已发生的行为丢失』」,而现在不接的后果是**未脱敏字节留在本地转录里**(那条明文消息
|
|
75
|
+
**已经渲过**)。留 `false` = 把一条安全面义务标成可选。按本表自检覆盖率的端会当场显形,那正是目的。
|
|
76
|
+
- **替换语义六形**逐形处置(判据锚在 `content.startsWith(<已提交前缀>)`,不锚「有没有 idle-flush 过」):
|
|
77
|
+
(a) 常态整段换;(b1) 半段已 flush 且前缀仍对得上 ⇒ 只封存未提交尾段,两条 committed 拼起来逐字节 =
|
|
78
|
+
`content`;(b2) 前缀自己也过期 ⇒ **一个字节都不再交** + 立 `committedPrefixDiverged`(**包内撤不回**:
|
|
79
|
+
那条 transcript 消息早已 yield,只有宿主持有句柄;再补一条 = 同一段话上屏两遍);
|
|
80
|
+
(c) 🔴 一 turn 多段 ⇒ 定稿正文**封存**、段缓冲清空给下一段用;(d) 子流断闸**在替换之前**;
|
|
81
|
+
(e) 🔴 **迟到的段边界**(含卡后同段继续出 delta)⇒ **两本前缀账相加**后照算分歧照发信号;
|
|
82
|
+
(f) 🔴 **本包零经手这一段** ⇒ **什么都不做**。
|
|
83
|
+
- 🔴 **(c)(e) 两形是异源对抗复审当场实抓的真病**(两条都对着装机 core 的真字节复现),第一版设计两条都错:
|
|
84
|
+
· (c)【anthropic 车道常态,`brain/anthropic.js` 在 `content_block_stop` 上逐块 push `text_end`】
|
|
85
|
+
第一版把 `content` 写回段缓冲而不清空 ⇒ 第二段的 delta 接着往同一个缓冲里加、再被第二帧的 `content`
|
|
86
|
+
整段覆盖 ⇒ **第一段正文凭空消失**,且第二帧还误报 `diverged`。修形 = 定稿正文移进「封存」缓冲,
|
|
87
|
+
段提交本体交 `封存 + 当前段`;**分段行为零改动**(整 turn 仍只提交一条 assistant 文本消息)。
|
|
88
|
+
· (e)【openai 车道实测时序,`brain/openai.js` 在 `finalize()` 里 push `text_end`,而 `toolcall_end` 先触发
|
|
89
|
+
`engine/loop/agent-loop.js` 的 `executor.maybeAdmit` ⇒ 工具卡先到、段边界后到】第一版按「两个缓冲都空
|
|
90
|
+
= 没经手过」全否返回 ⇒ **一段未脱敏正文静静留在转录里、三个键一个都不发**。修形 = 记下「上一个包侧
|
|
91
|
+
边界带走的那一截」,迟到的边界据它照算分歧、照发三个键,但**不铸消息**(字节在上一条消息里)。
|
|
92
|
+
· 两条都带常驻回归格,且 (c) 的格**刻意不手动插段提交**(手插正是第一版测试掩盖该病的原因)。
|
|
93
|
+
- 🔴 **复审轮二又抓出两条组合时序**(同样对着装机 core 的真字节复现,两条都在轮一修法之上):
|
|
94
|
+
· 工具卡**之后同段还有 delta**(brain 累加到 finalize + streamingToolExecution)⇒ 轮一的
|
|
95
|
+
「只在当前缓冲全空时才读上一本前缀账」当场失效:卡前那截明文**不算进前缀** ⇒ 两个前缀键双双
|
|
96
|
+
缺席、旧明文消息留存,而权威全文又被**完整提交一次**。修形 = **两本前缀账相加**
|
|
97
|
+
(包侧边界带走的 + 本消息内 idle-flush 掉的),「当前缓冲空不空」不再参与判据。
|
|
98
|
+
· `committedPrefixLen` 的**单位**此前写成「几条消息」⇒ `A → text_end(A) → B前缀 → idle-flush →
|
|
99
|
+
B尾 → text_end(B)` 时包把 A 的定稿与 B 的半截合并进**同一条**消息,宿主按消息丢会连 A 一起
|
|
100
|
+
删掉、而包不会再补发。修形 = 契约改成**字节区间**(从已 committed 正文尾部往回数),
|
|
101
|
+
三处宿主义务文案(`seam.ts` 义务① / `CHROME_ARM_TABLE` duty / §34)同批改齐。
|
|
102
|
+
· 轮二第三条 [medium]:G34-04 与臂注仍写「随后那条 committed 消息 = 整段 content」,与实现
|
|
103
|
+
(该位为真时**不交正文**)相反 —— 同批改口,并把验收判据从「三个键在不在」改成**断言最终转录**。
|
|
104
|
+
- 🔴 **复审轮三再抓出两条**(同样先自己复现再改;复现脚本 `repro3.mjs`):
|
|
105
|
+
· `committedPrefixLen` 的**单位**此前写成「字节」,而实现返回的是 `String.length`(**UTF-16 代码
|
|
106
|
+
单元**)⇒ 宿主照文案按 UTF-8 字节去截,在任何非 ASCII 正文上都会截错位置:实测
|
|
107
|
+
`'sk-…' + '中文'×30` 的 UTF-16 长度 82 / UTF-8 字节 202,按 202 截**凭据原样留在屏上**还附带乱码。
|
|
108
|
+
修形 = 文案正名成「JS 字符串长度(UTF-16 代码单元)」并给出可照抄的那一行
|
|
109
|
+
`已committed正文.slice(0, 长度 - committedPrefixLen) + content`;**实现一字未改**(它本来就对),
|
|
110
|
+
错的是三处宿主义务文案。门用真实 CJK 前缀把两条重建路径都跑一遍,反向自证「按字节截的后果」。
|
|
111
|
+
· `committedPrefixDiverged` 那一形**没同步终答补差账**:包不交正文,但 `committedAnswerText` 还留着
|
|
112
|
+
旧的那一份 ⇒ `result` 臂把权威全文当成「屏上缺的后缀」**再渲一遍**(第④臂),正好违反三处文案
|
|
113
|
+
承诺的「不再交正文」。修形 = 这一形下同批把补差账里属于本段的那一截换成 `content`
|
|
114
|
+
(「宿主照契约换过之后屏上有什么」),于是补差落「两份是同一段话」那一臂、什么都不补。
|
|
115
|
+
- 🔴 **复审轮四把上一条的射程收窄**(两条 finding,都先复现再改):同步**只在「整截都在当前这条
|
|
116
|
+
assistant 消息里」**那一形做。段跨过包侧边界(工具卡 / 消息划界)时那一截的字节在**别的**消息里,
|
|
117
|
+
而两本补差账是**按消息**记的 —— 往当前那本写就是把别条消息的正文算进这条:实测 ① 迟到边界那一形
|
|
118
|
+
会让随后正常的段被误报 `result_text_diverged`、甚至把该补的后缀压掉;② 同段跨两张卡时
|
|
119
|
+
`previousCommittedText` 只留得下最后一条,守卫不成立、同步静默跳过。⇒ 跨消息那几形**不碰账**。
|
|
120
|
+
🔴 **代价如实认领,且本批之前就有**:那里的补差基线与「宿主换过之后的屏」对不齐 ——
|
|
121
|
+
主树 0.68.0 上同一条时序**实测同形**(卡后有散文时基线取的就是卡**之后**那一条,而终帧是整条消息
|
|
122
|
+
的全文)。根因 = 包按自己的边界切 committed 消息、引擎按 content block 切段;根治 = 「每个引擎段
|
|
123
|
+
各自一条 committed 消息」(CC 原生做法),**分段行为改动,单独走**。四条射程格 + 反向自证入门。
|
|
124
|
+
- **活体面**:已泄前缀仍是 `content` 前缀 ⇒ 尾巴按替换后的段重算;不是前缀 ⇒ **不再补尾巴**
|
|
125
|
+
(绝不在一段错的前缀后面接上权威后缀,拼出一段谁都没说过的话)。
|
|
126
|
+
- 文案同批改口三处:`seam.ts` 的 `TextSegmentEndChromeEvent` 义务①、
|
|
127
|
+
`adapter/downstream/eventToSdkMessage.ts` 的 `textEndProjection` 臂注(那句「与 delta 拼接逐字节相等」)、
|
|
128
|
+
`adapt.ts` 臂名表的括注。
|
|
129
|
+
- 📋 **仍然留白**:撤掉 idle-flush 启发式要按**每条流**判「这条流带不带边界帧」,属行为面改动,单独走。
|
|
130
|
+
权威替换是**兼容**它的,不是它的继任。
|
|
131
|
+
- 🆕 常驻门 `scripts/run-text-segment-authority-test.mjs`(五形 + 活体两形 + 三键 never-false +
|
|
132
|
+
b1/b2 分水岭 + 端到端一趟)。
|
|
133
|
+
|
|
134
|
+
### L-290 `ToolRosterEntryView` 出身三键(additive)
|
|
135
|
+
|
|
136
|
+
- 补 `contentOriginProvenance`(四词:`declared`/`server`/`exempted`/`default`)、`effectProvenance`、
|
|
137
|
+
`egressProvenance`,三者**开集读**、缺席即缺席、坏形只丢那一格不丢行。
|
|
138
|
+
🔴 **缺席 ≠ `"default"`**:缺席是「这台引擎没报」,`default` 是「报了,谁都没声明」。
|
|
139
|
+
- 🔴 **同形族扫**:病形 = 「逐键窄读器对上游已透传的键静默无感」(server 那一段是**整只判形 + 逐字透传**);
|
|
140
|
+
同形存量 = core `ToolRosterEntry` 32 个成员里本包此前只挑 17 个。处置不是只补被点名的那一个 ——
|
|
141
|
+
同族三键一起挑,并把族扫落成机器账:`run-tool-roster-projection-test.mjs` **G 段**拿**实装 core 的
|
|
142
|
+
schema 成员表**与窄读器真挑出来的键集**双向对账**,12 条 declined 逐条写理由,**上游再加成员当天红**。
|
|
143
|
+
- ⚠️ 出身键是**诊断面**,刻意**不进** `ToolShim`(渲染面 vs 诊断面分家)。
|
|
144
|
+
|
|
145
|
+
### 🔴 本件的总不变量(三轮异源对抗复审逼出来的那句话)
|
|
146
|
+
|
|
147
|
+
**本包只在「整段都在自己手里(同一条 committed 消息内)」时改自己交的字节;段一旦跨过包侧边界
|
|
148
|
+
(工具卡 / 消息划界),就只发那三个键、不动任何既有行为** —— 转录逐条、终帧补差走向都与 **0.68.0
|
|
149
|
+
逐字相同**(常驻门按「补差臂走向」这个真正决定结果的量做基线对照)。轮五实测的反例正是踩了这条:
|
|
150
|
+
在跨消息那一形清掉未提交尾段,会让 `result` 从第⑤臂(只报一行分岔)改成第④臂(**补吐一整段**),
|
|
151
|
+
那是**新增**的重复转录路径,不是「更安全」。⇒ 随之而来的留白(那一截可能落在别条消息里、甚至夹着
|
|
152
|
+
一张工具卡;补差基线也不跟着换 —— 它本来就不跟)如实写进 `seam.ts` 义务① 与 §34,根治 =
|
|
153
|
+
「每个引擎段各自一条 committed 消息」(CC 原生做法),**分段行为改动,单独走**。
|
|
154
|
+
|
|
155
|
+
### [7226] 包侧缺口 ①⑤
|
|
156
|
+
|
|
157
|
+
- 🔴 **`ControlSafetyCode` 加员 `blocked_by_hook`**(`steering.blocked_by_hook`,422:部署的
|
|
158
|
+
`userPromptSubmit` 门拦下,block/超时/崩溃同码 fail-closed)。输入**未被受理**(无 `human_input` 帧、
|
|
159
|
+
`inputId` 不入账)⇒ 处置 = **改内容自由重试**。修前它落开集兜底位 `steering_other`,而那一位的判词
|
|
160
|
+
是「别按成员猜它的意思」—— 把一条能自救的拒绝渲成一条不知道怎么办的拒绝。
|
|
161
|
+
⚠️ 端上按这个联合做**穷举 switch** 的地方会编译期红(没有 `default` 臂的话),这是设计。
|
|
162
|
+
- `REQUEST_FIELD_MATRIX` 补 `memoryCapture` 行 + `TaskRequestInput.memoryCapture?: 'off'` + 构造器
|
|
163
|
+
`=== 'off'` 才 stamp(**单成员闭集**,没有 `"on"`)。只补表不补构造器 = 端还得在唯一构造口外面
|
|
164
|
+
手加一行,所以三件同批。
|
|
165
|
+
- 🔴 **坏值响亮拒**(异源对抗复审轮五 finding① 采纳):`'on'` / `'OFF'` / 空串 / 布尔 / 对象等一切
|
|
166
|
+
表外写法 ⇒ **抛 `TypeError` 拒绝构造请求**(两条车道、**不受 live 门**;错误文本只报形状不回显值)。
|
|
167
|
+
修前是「不是 `'off'` 就整键不 stamp」—— **静默删键**,而删掉的恰是一条**隐私声明**:一个把
|
|
168
|
+
`/memory-capture off` 打成 `OFF` 的会话,请求照发、引擎照常采集,**没有任何人会知道**。
|
|
169
|
+
装机 core `dist/core/memory.d.ts` 的 `capture?: "off"` 头注逐字写着「Any other value … is REFUSED
|
|
170
|
+
loudly (`config.memory_capture_spelling`) … **a privacy request must not be dropped by a typo**」,
|
|
171
|
+
server 契约 §12.4 同样点名「把一条隐私请求按打字错误静默丢掉恰是**禁的方向**」⇒ 提前删键 =
|
|
172
|
+
把上游那道响亮门绕过去。与本文件 `resolvedSnapshotForWire` 对畸形权限快照的处置同一条纪律。
|
|
173
|
+
⚠️ `undefined` / `null` 仍是**合法缺席**(端没有这个入口 / 没声明),不拒不 stamp。
|
|
174
|
+
⚠️ **端要看这一条**:若曾把用户输入直接透给这一位,打错字现在会抛(这是设计)。
|
|
175
|
+
|
|
176
|
+
### 已知局限(本版新增)
|
|
177
|
+
|
|
178
|
+
- `committedPrefixDiverged` 那两形(b2 / 迟到边界)包内**撤不回**已 yield 的 transcript 消息,也**不再补交**
|
|
179
|
+
这一段的任何正文 ⇒ **不消费该键的宿主**在转录里只剩未脱敏的那半截、且缺后半段。这是如实认领的代价:
|
|
180
|
+
再补一条会让同一段话上屏两遍,而该键是 never-false 的显形键 + 本臂已翻 `required: true`。
|
|
181
|
+
- **迟到边界**那一形的 `committedPrefixLen` 指的字节区间可能落在**上一条** assistant 消息里,甚至
|
|
182
|
+
**中间夹着一张工具卡**(工具卡先到、段边界后到)。宿主按同一条规矩换;本包**不**替它决定新正文落在
|
|
183
|
+
哪条消息上(在这里补只会落到工具卡之后)。🔴 **根治办法是「每个引擎段各自一条 committed 消息」**
|
|
184
|
+
(= CC 在 `content_block_stop` 上的原生做法),那是**分段行为改动**、与撤 idle-flush 启发式同一件事,
|
|
185
|
+
按宪法三问单独走 —— 本批如实留白,不假装没有。
|
|
186
|
+
- 终帧补差在上述两形下可能落既有的第⑤臂 `result_text_diverged`(两份互相都不是对方前缀)——那是**诚实结局**
|
|
187
|
+
(只交长度位、不复述正文、不补吐),不是回归。
|
|
188
|
+
|
|
52
189
|
## 0.68.0(2026-09-13)
|
|
53
190
|
|
|
54
191
|
🔴 **BREAKING 提货批**(core **7.16.0 → 7.17.1**;三件破坏性 + 一次**引擎地板抬升**到 core ≥7.17.0)。
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
|
|
|
35
35
|
|
|
36
36
|
## Scope
|
|
37
37
|
|
|
38
|
-
**Version:** 0.68.
|
|
38
|
+
**Version:** 0.68.1
|
|
39
39
|
|
|
40
40
|
- **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
|
|
41
41
|
B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
|
|
@@ -363,6 +363,7 @@ public-surface guard checks that last one).
|
|
|
363
363
|
| `scripts/run-cost-reconcile-projection-test.mjs` | The **end-of-run cost reconciliation** reaching consumers at all. The engine splits a run's spend on the wire — the task's own cost, which deliberately excludes delegated sub-agents, the delegated total itself, and the within-task compaction subtotal that sits inside the own figure — and states two reconciliation identities for them. The package used to project none of it, so a cost view could only ever see one number and under-reported both delegated and compaction spend. Both structures are now projected onto the result as superset fields in the wire's integer micro-currency unit, read key by key, with unreadable keys dropped individually, an entirely unreadable structure omitted rather than emitted empty, and unknown categories passed through since the vocabulary belongs upstream. The delegated cost stays **absent when it was never priced**, never a fabricated zero. The same reader also feeds a terminal chrome arm carrying the three parts plus the reconciled total, so the two faces can never compute different answers; the reconciled total is minted only when both sides are known, and otherwise a discriminator bit says which side is unknown. **The reference field for total cost keeps its meaning** — it remains the task's own spend and the delegated total is not folded into it — because that is a shape the wider ecosystem reads; the reconciled figure is offered beside it, not in place of it. A frame that carries no stats emits no arm at all, and the existing rule that in-stream per-turn usage is not published for sub-flows is pinned unchanged, since delegated spend arrives once, at the end. The bit that says those figures are a lower bound is **per stream**, not per context: the emit context belongs to the caller and may be reused across streams, so a gap observed on one run is no evidence at all about the next one — the observation is held for the duration of one stream and handed to both projection faces by value, and the guard drives a reused context both sequentially and concurrently to prove neither direction leaks |
|
|
364
364
|
| `scripts/run-task-progress-terminal-projection-test.mjs` | The one tick that says a delegated child **finished**. The engine fires exactly one final beat carrying a terminal face, and says in the same breath why it exists — so a consumer sees the row finish instead of watching it vanish after the last running beat — but the package's projection whitelist had no seat for that field and its adapter still carried the older premise in a comment, so the terminal beat arrived byte-identical to another running one: the panel row stayed up waiting for a defensive sweep (which only ever settles rows bound to a card still open this turn) or for a separate notification frame. The status now rides through as an **open set** with the vocabulary left upstream, while the question *which words are terminal* is answered by a closed pair on the adapter side — an unrecognised new word takes the running path, because guessing it terminal ends a row that is still working whereas one extra running beat merely renders late. A terminal beat settles the row directly under the lane proof its binding gives it (not the main lane a notification would use, and not by card id, since the engine is naming a child rather than closing a card), freezes the inline group-row twin in the same beat so a later sweep cannot reset the real tool count, clears the session-resident ledger, and fires the stop hook only for a child whose start really fired. It does not mark the row live or emit a second progress beat, and it shares the settled-row ledger with the other two settle legs so a replay or a double-delivery cannot produce a second end. Three things are pinned **unchanged**: a running beat, an absent status (older engines never send the field, and reading absence as terminal would make every child row disappear on its first beat), and the workflow lane gate, which still runs before any of this |
|
|
365
365
|
| `scripts/run-assistant-arm-identity-test.mjs` | The identity keys on an assistant row, and an explicit account of the two that are **deliberately not** there. What the renderer received was a bare role-and-content object, so a dozen consumer sites downstream were each estimating what the message envelope should have told them. The id is taken from the engine's own event id rather than minted locally, because it has to be **the same value** on the live leg and on a durable replay — a freshly minted one would make a replayed message look new to a host's dedup and to rewind — and when the wire carries none the key is simply absent rather than filled with a random stand-in wearing an identity it does not have; it is also kept distinct from the envelope's own local render key, which is a different identity. The model name comes from what the host pinned when it opened the stream (the request was the host's to build) and is never guessed, since a wrong model name is worse than none once a billing or capability face looks it up. Usage and stop reason are **not** minted on this arm, and the reason is frame order rather than effort: content arms arrive before the turn's closing frame, so at the moment the arm is emitted the engine has not yet said what the round cost — anything put there would be an estimate, which is the very thing this work exists to remove — and synthesising a follow-up assistant update when the real figure lands is also refused, because that shape does not exist upstream and would place a message in the transcript the engine never sent. Their real values leave through the turn's own neutral arm as two superset keys, the usage one reusing the **same single mint point** the footer rollup already folds so the two faces cannot diverge, and the stop reason passed through verbatim as an open set — the machine signal for *was this turn cut short*, previously blind on both the stream and the trace. The existing behaviours beside them are pinned too: no arm at all when usage is wholly absent, and the sub-flow cut-out that keeps a child's turn from driving the leader's face |
|
|
366
|
+
| `scripts/run-text-segment-authority-test.mjs` | The **authoritative segment replacement** on `text_end` (L-310, server >=7.75.3). `text_end.content` now goes through the same redactor as `result` and the ledger while `text_delta` stays verbatim, so the two **may differ** — an answer that quoted a credential used to be committed to the local transcript in its unredacted form, because the arm only forwarded the boundary signal. Six timing shapes are pinned, two of which an adversarial review reproduced against the installed engine's real bytes and which the first design got wrong in both directions: a second boundary in the same turn (the per-block case on one provider lane) used to make the first segment's prose vanish, and a boundary that arrives *after* the tool card (the other lane emits it at finalize) used to be read as "this package never handled that segment" and reported nothing at all. Three additive keys, all never-false; the two shapes that look alike are told apart by the second one, because the host's action in them is the opposite. The end-to-end legs drive the real pipeline without hand-inserting a segment commit — doing so is exactly what hid the first defect. A second review round then found two combination timings on top of the first fix — a tool card followed by *more* deltas in the same segment, and a byte count that had been documented as a message count — and both are pinned here too. A third round caught a length that the prose called bytes while the code returned UTF-16 units — harmless in ASCII, and on CJK text enough to leave the credential on screen — plus a backfill ledger that had to be kept in step, so the terminal frame does not re-render the segment a second time — kept in step only where the whole stretch sits in one message, because those ledgers are per-message and a fourth round showed that writing across them charges one message's prose to another. A fifth round settled the whole class into one invariant the guard now checks against the previous release's behaviour: this package only rewrites bytes it is still holding in the current message — once a segment has crossed a package-side boundary it emits the three keys and changes nothing else |
|
|
366
367
|
| `scripts/run-gate-negative-controls-test.mjs` | Whether the registry-shaped guards among the 74 suites above actually turn red when the material they check really breaks — a census had found 16 of them clean enough to rehearse safely (closed sets, mirrors, baselines, floors, a type-shape ratchet) without touching any judgement code. Each is exercised by tampering a disk copy of the real material, spawning the guard's own unmodified script, asserting it exits non-zero and names the disease, then restoring the file byte-for-byte. Seven guards of the same shape and 51 behaviour/projection suites are catalogued rather than rehearsed this round — see `docs/GATE-NEGATIVE-CONTROLS.md` for the full table, the reasons, and a one-minute manual replay recipe for each blind one. The suite cross-checks its own case count against that document's row counts in both directions, so a case quietly dropped from the array without the document following is itself an undeclared blind guard. The backup that makes the restore possible is taken by **exclusive create**: checking for it and then copying are otherwise two steps, and two instances can pass the check together — the later one overwrites the only clean copy with material the earlier one has already tampered, and the rehearsal that promises to leave no trace leaves a permanently corrupted file instead. That interleaving is rehearsed too, in a throwaway directory of its own |
|
|
367
368
|
|
|
368
369
|
Each suite carries a floor that only moves up — a refactor that stops executing a group of
|
package/dist/adapt/arms.js
CHANGED
|
@@ -407,19 +407,46 @@ const approvalFrameArm = (kind) => function* (m) {
|
|
|
407
407
|
yield chrome({ kind, laneProof: MAIN, frame, schemaVersion });
|
|
408
408
|
};
|
|
409
409
|
/**
|
|
410
|
-
* `text_end` 内部臂 → chrome `text_segment_end`(#323 / core #447
|
|
410
|
+
* `text_end` 内部臂 → **权威段替换** + chrome `text_segment_end`(#323 / core #447;L-310,
|
|
411
|
+
* server ≥7.75.3)。
|
|
411
412
|
*
|
|
412
413
|
* 契约本体在 `seam.ts` 的 {@link TextSegmentEndChromeEvent} 头注(它是宿主要读的那一份)。
|
|
413
|
-
*
|
|
414
|
+
* 本臂做四件:
|
|
414
415
|
* 🔴 **子流断闸**(与 `engine_notice`/`prompt_suggestions`/thinking 半场同族):带 `parentToolCallId`
|
|
415
|
-
* 的段边界属于子代/编排流,上 leader 面就是跨 lane 状态破坏 ——
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
416
|
+
* 的段边界属于子代/编排流,上 leader 面就是跨 lane 状态破坏 —— 直接不产事件,
|
|
417
|
+
* **也不动 leader 的文本缓冲**(断闸在替换之前,顺序不许倒过来)。
|
|
418
|
+
* 🔴 **整段替换文本缓冲**(0.68.1 改口;修前这里写的是「本批不动文本缓冲」)。改口的事实依据:
|
|
419
|
+
* server 7.75.3 起 `text_end.content` 与 `result`/账本走**同一只脱敏器**,而 `text_delta` 仍
|
|
420
|
+
* 逐字(跨 chunk 的凭据无法就地判)⇒ 两者**可以不相等**,而此前包内 committed 出去的是 delta
|
|
421
|
+
* 拼文 = **未脱敏**字节留在本地转录里。契约原文逐字要求消费端「在 `text_end` 到达时以它整段
|
|
422
|
+
* 替换已攒的 delta,而不是只当段界信号」。替换语义(四形处置 / 已提交前缀 / 活体尾巴重算)
|
|
423
|
+
* 全在 {@link TextStream.replaceAnswerSegment} 的头注。
|
|
424
|
+
* ⚠️ **这不等于撤掉 idle-flush 启发式**:那条要按 core 的「诚实缺席」纪律做 **per-stream** 判据
|
|
425
|
+
* (整条流一帧都没有才回落启发式),仍未做 —— 替换是**兼容**启发式的(半段已被 flush 那一形
|
|
426
|
+
* 由 `committedPrefixLen` / `committedPrefixDiverged` 如实交代),不是它的继任。
|
|
427
|
+
* 🔴 **三个 additive 键,全部 never-false**(缺席 = 否定;与包内 `_sema_usage_absent` 同律)。
|
|
428
|
+
* 铸一个 `false`/`0` 出来 = 把「这一段没分歧」与「这台引擎不报分歧」折成同一个字节,宿主分不出。
|
|
429
|
+
* **为什么恰是三个**(「宿主能不能只凭一帧就知道要不要重渲」的最小解):
|
|
430
|
+
* · `diverged` —— **活体面**那份(`stream_delta` 拼出来的)是不是过期的 ⇒ 要不要重渲;
|
|
431
|
+
* · `committedPrefixDiverged` —— **转录面**已经 committed 的那一截是不是过期的。它是真正的
|
|
432
|
+
* 分水岭:缺席时本臂随后交的 transcript 消息是**尾段**(前缀 + 尾段拼起来 = `content`,
|
|
433
|
+
* 宿主什么都不用丢);在场时本包**一个字节都不再交**(撤不回的那截拼不出 `content`,
|
|
434
|
+
* 再补一条就是同一段话上屏两遍)⇒ 该段唯一算数的那一份就是帧上的 `content`,
|
|
435
|
+
* 宿主用它换掉 `committedPrefixLen` 指的那一截。两个键合成一个位办不到:
|
|
436
|
+
* b1/b2 两形的 `diverged` 与 `committedPrefixLen` 完全同形,而宿主动作正相反。
|
|
437
|
+
* · `committedPrefixLen` —— **定位量**。单位 = **JS 字符串长度(UTF-16 代码单元)**,
|
|
438
|
+
* 🔴 **不是 UTF-8 字节**(轮三 finding① 订正:按字节截会在非 ASCII 正文上截错位置,凭据
|
|
439
|
+
* 原样留在屏上)、🔴 **也不是「几条消息」**(轮二 finding② 订正:本包在 idle-flush 那一形下
|
|
440
|
+
* 会把「上一段的定稿 + 这一段的半截」合并进**同一条**消息,按消息丢会把已经定稿的上一段
|
|
441
|
+
* 一起删掉,而本包不会再补发它)。宿主照抄
|
|
442
|
+
* `已committed正文.slice(0, 长度 - committedPrefixLen) + content` 即可。
|
|
443
|
+
* 📋 **刻意没有第四个键 `authoritative: true`**(设计取舍,写明免得下一棒再问):它会在**每一帧**
|
|
444
|
+
* 上恒为真 ⇒ 零判别力,既分不出新旧引擎(引擎版本不在帧上,本包不猜版本 —— 与 §33b 那条
|
|
445
|
+
* 「刻意不留按引擎版本分岔的兼容读」同一条纪律),也不驱动宿主的任何分支。
|
|
419
446
|
* · 第二道 `content` 在场判(投影层已判 malformed/empty):同 `engine_notice` 的 carrier 二道判,
|
|
420
447
|
* 防的是**非投影口喂进来的帧**(宿主自建管线 / 重放存量转录),不是重复判据。
|
|
421
448
|
*/
|
|
422
|
-
const textSegmentEndArm = function* (m) {
|
|
449
|
+
const textSegmentEndArm = function* (m, { text }) {
|
|
423
450
|
// 🔴 断闸按「**键在不在**」判,不按「是不是串」判(异源对抗复审第三轮 [medium] 采纳)。
|
|
424
451
|
// 投影层已对坏 lane 位整帧 fail-closed;这一道是给**非投影口**喂进来的帧(宿主自建管线 /
|
|
425
452
|
// 重放存量转录)兜底 —— 那里若沿用同族其它臂的 `typeof === 'string'` 写法,一个坏值就会把
|
|
@@ -431,10 +458,16 @@ const textSegmentEndArm = function* (m) {
|
|
|
431
458
|
const content = typeof m.content === 'string' ? m.content : '';
|
|
432
459
|
if (content.length === 0)
|
|
433
460
|
return;
|
|
461
|
+
// 🔴 替换在**发事件之前**:宿主拿到 `diverged` 那一刻,包内的转录面已经是权威全文了 ——
|
|
462
|
+
// 反过来(先发信号再改缓冲)会有一拍窗口,宿主按信号去读转录面读到的还是旧拼文。
|
|
463
|
+
const r = text.replaceAnswerSegment(content, m);
|
|
434
464
|
yield chrome({
|
|
435
465
|
kind: 'text_segment_end',
|
|
436
466
|
laneProof: MAIN,
|
|
437
467
|
content,
|
|
468
|
+
...(r.diverged ? { diverged: true } : {}),
|
|
469
|
+
...(r.committedPrefixDiverged ? { committedPrefixDiverged: true } : {}),
|
|
470
|
+
...(r.committedPrefixLen > 0 ? { committedPrefixLen: r.committedPrefixLen } : {}),
|
|
438
471
|
...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
|
|
439
472
|
});
|
|
440
473
|
};
|
|
@@ -15,13 +15,64 @@
|
|
|
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
|
+
* {@link TextStream.replaceAnswerSegment} 的回执 —— **三件事实**,一个字节的正文都不复述。
|
|
33
|
+
*
|
|
34
|
+
* 🔴 三位**互不替代**(L-310 设计定谳,2026-09-14):
|
|
35
|
+
* · {@link diverged} 回答「屏上那份对不对」;
|
|
36
|
+
* · {@link committedPrefixLen} 回答「这一段有多少字节**已经撤不回**了」(idle-flush 铸过的
|
|
37
|
+
* transcript 消息早已 yield 给宿主);
|
|
38
|
+
* · {@link committedPrefixDiverged} 回答「撤不回的那一截**自己**是不是也被改过」——
|
|
39
|
+
* 它为真就是本件要防的最坏一形:**未脱敏字节已经进了本地转录**,包内修不了,只有宿主重渲。
|
|
40
|
+
*/
|
|
41
|
+
export interface TextSegmentReplacement {
|
|
42
|
+
/** 替换前按 delta 拼出来的本段全文 ≠ 权威全文。假 = 逐字节相同(旧引擎 / 不含凭据形的文本)。 */
|
|
43
|
+
diverged: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* 本段在替换发生前**已经 committed 上屏**的长度(idle-flush 的那些刀 + 包侧边界带走的那些截)。
|
|
46
|
+
*
|
|
47
|
+
* 🔴 **单位 = JS 字符串长度(UTF-16 代码单元),不是 UTF-8 字节,也不是「几条消息」**
|
|
48
|
+
* (异源对抗复审轮三 finding① 订正 —— 此前文案写「字节」,宿主按 UTF-8 字节去截会在任何非
|
|
49
|
+
* ASCII 正文上截错位置:实测 `'sk-…' + '中文'×30 + '.\n'` 的 UTF-16 长度 82 / UTF-8 字节 202,
|
|
50
|
+
* 按 202 截会把凭据**原样留在屏上**还附带乱码)。它就是给 `String.prototype.slice` 用的那个数:
|
|
51
|
+
* `已committed正文.slice(0, 已committed正文.length - committedPrefixLen) + content`。
|
|
52
|
+
* 🔴 **不是「几条消息」**:本包在 idle-flush 那一形下会把「上一段的定稿 + 这一段的半截」合并进
|
|
53
|
+
* **同一条**消息(分段行为零改动的代价),按**消息**去丢会把已经定稿的上一段一起删掉,而本包
|
|
54
|
+
* 不会再补发它。
|
|
55
|
+
* 🔴 它按**替换前的拼文**计长,**不是** `content` 上的偏移量 —— 前缀自己也可能被脱敏改过字节
|
|
56
|
+
* (见 {@link committedPrefixDiverged});拿它去切 `content` 在那一形上会切出半截乱码。
|
|
57
|
+
*/
|
|
58
|
+
committedPrefixLen: number;
|
|
59
|
+
/**
|
|
60
|
+
* 已提交的那一截**本身**与权威全文对不上(= 凭据落在已经上屏的那一截里)。
|
|
61
|
+
* 🔴 **统一规则**:这一位为真时本模块**一个字节都不再交**给转录面 —— 已提交那截撤不回,而切片
|
|
62
|
+
* 拼不出 `content`,再补一条就是同一段话上屏两遍。该段唯一算数的那一份在帧上的 `content` 里,
|
|
63
|
+
* 宿主用它换掉 {@link committedPrefixLen} 指的那一截(所以 chrome 臂上这一位是
|
|
64
|
+
* never-false 的显形键,不是内部量)。
|
|
65
|
+
* 🔴 这一形下、且**这一截整个都在当前这条 assistant 消息里**时,本模块同批把终答补差账
|
|
66
|
+
* ({@link TextStream.committedAnswerText})同步成「宿主照契约换过之后屏上有什么」——
|
|
67
|
+
* 不同步的话 `result` 臂会把权威全文当成「屏上缺的后缀」**再渲一遍**(轮三 finding② 实抓)。
|
|
68
|
+
* ⚠️ **段跨过包侧边界(工具卡 / 消息划界)时刻意不碰账**:那一截的字节落在**别的**消息里,而两本
|
|
69
|
+
* 补差账是**按消息**记的,写进当前这本就是把别条消息的正文算进这条(轮四 finding①② 实抓的两种
|
|
70
|
+
* 翻车)。那几形的补差基线与「换过之后的屏」对不齐 —— **这是本批之前就有的行为**(主树同条
|
|
71
|
+
* 时序实测同形),根因是包/引擎两套边界对不齐,根治见 `seam.ts` 义务① 末尾那条留白。
|
|
72
|
+
*/
|
|
73
|
+
committedPrefixDiverged: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** M1 对外的七个动作 + 两个读位(矩阵 §3.2 的 #2/#3 两条跨模块接口就是最后那三件)。 */
|
|
25
76
|
export interface TextStream {
|
|
26
77
|
/** A5 `text_delta` 半场:MOD-1 思考→回答边界(思考在场就先 committed 上屏)+ 段锚 + 三缓冲累加。 */
|
|
27
78
|
feedText(delta: string, frame: Frame): Generator<AdapterOutput>;
|
|
@@ -43,6 +94,56 @@ export interface TextStream {
|
|
|
43
94
|
* 两条都不满足时**什么都不做**(缓冲原样押着,活体预览照常由 `drainLive` 走)。
|
|
44
95
|
*/
|
|
45
96
|
takeAnswerSegmentOnIdle(): Generator<AdapterOutput>;
|
|
97
|
+
/**
|
|
98
|
+
* L-310 —— 用引擎报的**段权威全文**整段替换本段已攒的 delta 拼文(server ≥7.75.3)。
|
|
99
|
+
*
|
|
100
|
+
* ## 为什么要有它(病形一句话)
|
|
101
|
+
* server 7.75.3 起 `text_end.content` 走的是与 `result`/账本同一只**脱敏器**,而 `text_delta`
|
|
102
|
+
* 仍逐字(跨 chunk 的凭据无法就地判)⇒ 两者**可以不相等**。本包此前只把 `content` 当段界信号,
|
|
103
|
+
* 于是屏上的活体流与 transcript 平面的 committed 消息用的都是 delta 拼文 —— 一段含凭据的回答
|
|
104
|
+
* 以**未脱敏**的字节留在本地转录里。契约原文(server `ASSISTANT-WIRE-CONTRACT` §5.1 live 面)
|
|
105
|
+
* 逐字:「消费端在 `text_end` 到达时应以它**整段替换**已攒的 delta,而不是只当段界信号」。
|
|
106
|
+
*
|
|
107
|
+
* ## 四形怎么处置(每一形都是**判断**,不是顺手)
|
|
108
|
+
* (a) **`text_end` 先于段提交**(常态):整段还押在缓冲里 ⇒ 段缓冲整段换成 `content`,
|
|
109
|
+
* 下一次 {@link TextStream.takeAnswerSegment} 铸出来的 committed 文本就是权威全文。
|
|
110
|
+
* (b) **半段已被 idle-flush 提交**:那条 transcript 消息**早已 yield 给宿主**,包内撤不回。
|
|
111
|
+
* · 已提交前缀逐字对得上 ⇒ 只封存**未提交尾段**(`content` 去掉前缀那一截),两条 committed
|
|
112
|
+
* 拼起来逐字节 = `content`;
|
|
113
|
+
* · 已提交前缀**自己**就对不上(凭据落在它里面)⇒ **一个字节都不再交**
|
|
114
|
+
* ({@link TextSegmentReplacement.committedPrefixDiverged} 为真),该段唯一算数的那一份在帧上,
|
|
115
|
+
* 由宿主重渲并丢掉此前那些消息 —— 「整条 committed 消息重铸」只能由宿主做(它才持有那条
|
|
116
|
+
* 消息的句柄),包能做的是**把这件事说出来**。
|
|
117
|
+
* 判据锚在 `content.startsWith(<已提交前缀>)`,不锚「有没有 idle-flush 过」——
|
|
118
|
+
* 后者是前置条件,前者才是真正决定结果的量。
|
|
119
|
+
* (c) **一 turn 多段**(anthropic 车道常态:逐 `content_block_stop` 发 `text_end`):定稿正文
|
|
120
|
+
* **封存**、`answerSegment` 清空给下一段用。🔴 不清空 = 下一段的 delta 接着往同一个缓冲里加、
|
|
121
|
+
* 再被下一帧的 `content` 整段覆盖 ⇒ **上一段的正文凭空消失**,且下一帧还会误报 `diverged`
|
|
122
|
+
* (异源对抗复审实抓)。封存**不切消息**:整 turn 仍只提交一条 assistant 文本消息。
|
|
123
|
+
* (d) **子流**(`parentToolCallId` 在场):根本到不了这里 —— 臂在 `arms.ts` 就断闸了
|
|
124
|
+
* (拿子代的段边界去改 leader 的缓冲 = 跨 lane 状态破坏)。
|
|
125
|
+
* (e) 🔴 **迟到的段边界**(openai 车道实测时序:`text_end` 在 `finalize()` 里 push,而
|
|
126
|
+
* `toolcall_end` 先触发 `executor.maybeAdmit` ⇒ **工具卡先到、段边界后到**):那一拍卡前散文
|
|
127
|
+
* 已被 `takeAnswerSegment()` 提交进**上一条**消息,两个缓冲双双为空。据
|
|
128
|
+
* {@link previousSegmentCommitted} **照算分歧、照发信号**,但**不铸任何消息**(那些字节在
|
|
129
|
+
* 上一条消息里,在这里补一条只会落到工具卡**之后**)。修前这一形被当成 (f) 全否返回 ⇒
|
|
130
|
+
* 一段未脱敏正文静静留在转录里、三个键一个都不发(异源对抗复审实抓)。
|
|
131
|
+
* (f) 🔴 **本包一个字节都没经手这一段**(两本前缀账与段缓冲全空):**什么都不做**,三位全报否。
|
|
132
|
+
* 替换的语义是「换掉本包自己缝合出来的那一段」,不是「凭空铸一段」—— durable 内容腿
|
|
133
|
+
* (整块 `text` 帧,本身就是一整条 assistant 消息)之后再来一帧 `text_end`,塞进缓冲就会在
|
|
134
|
+
* turn 收口**再铸一条一模一样的**。红先绿后实抓(`run-client-core-pure-test` `#323-g`)。
|
|
135
|
+
*
|
|
136
|
+
* ## 活体面(`textPending`)按替换后的段重算
|
|
137
|
+
* 已经泄出去的那一截(`stream_delta`)**撤不回**。所以:已泄前缀仍是 `content` 的前缀 ⇒ 尾巴
|
|
138
|
+
* 换成 `content` 剩下那一截(屏上接着往下写,零重复);已泄前缀**不是** `content` 的前缀 ⇒
|
|
139
|
+
* **不再补任何尾巴**(在一段错的前缀后面接上权威后缀,拼出来的是一段谁都没说过的话),
|
|
140
|
+
* 由宿主按 `diverged` 重渲整段。
|
|
141
|
+
*
|
|
142
|
+
* @param content 该段的权威全文(UNTRUSTED、仅展示,与 `text_delta` 同契约)。
|
|
143
|
+
* @param anchor 本帧 —— 段锚此刻为空(整段已被 idle 提交光 / 整段零 delta)时用它当 committed
|
|
144
|
+
* 消息的 id 锚;段锚已在场时**不覆盖**(重放确定性:同流同 id)。
|
|
145
|
+
*/
|
|
146
|
+
replaceAnswerSegment(content: string, anchor?: Frame): TextSegmentReplacement;
|
|
46
147
|
/** D3 IDLE-FLUSH 竞速判据:四个缓冲有任何一个非空。 */
|
|
47
148
|
hasPendingContent(): boolean;
|
|
48
149
|
/** D5 攒批节拍:一个间隔至多一次 flush(判据与 `lastFlushAt` 一起收在本模块内)。 */
|
package/dist/adapt/textStream.js
CHANGED
|
@@ -62,6 +62,46 @@ export function createTextStream(ctx, idOf) {
|
|
|
62
62
|
* 「工具帧来过」本身就是重开新段的事实,与那一拍有没有散文可提交无关。
|
|
63
63
|
*/
|
|
64
64
|
let idleFlushUsedInSegment = false;
|
|
65
|
+
/**
|
|
66
|
+
* L-310:**当前引擎段**里已经 committed 上屏的那一截(idle-flush 的刀)。
|
|
67
|
+
*
|
|
68
|
+
* 🔴 与 `committedText` 刻意分家(两个量、两件事,别合并):`committedText` 记的是「**这条
|
|
69
|
+
* assistant 消息**屏上已经有了哪些字」(跨段累加,补差判据的输入);这一个记的是「**这一段**
|
|
70
|
+
* 有多少字节已经撤不回了」——它是权威段替换唯一能算出「尾段该换成什么」的量。
|
|
71
|
+
* 写于 `emitAnswerSegment`(提交本体,**只计当前段那一截**,不含 {@link segmentSealed});
|
|
72
|
+
* 归零于三处**段到此为止**的事实发生地:正常边界(`takeAnswerSegment`)、权威替换
|
|
73
|
+
* (`replaceAnswerSegment`)、新消息划界(`beginAssistantMessage`)。
|
|
74
|
+
* idle 入口**不**归零 —— 那正是「段还没完、只是先上屏半截」这件事本身。
|
|
75
|
+
*/
|
|
76
|
+
let segmentCommitted = '';
|
|
77
|
+
/**
|
|
78
|
+
* 🔴 **上一个包侧边界带走的那一截**(L-310 / 异源复审 finding①,0.68.1)。
|
|
79
|
+
*
|
|
80
|
+
* 存在的理由是一条**上游实测的时序**:openai 车道的 `text_end` 是在 `finalize()` 里 push 的
|
|
81
|
+
* (装机 core `dist/brain/openai.js` 的 `finalize` 真字节),而 `toolcall_end` 会先触发
|
|
82
|
+
* `executor.maybeAdmit`(`dist/engine/loop/agent-loop.js`)⇒ **工具卡先到、段边界后到**。
|
|
83
|
+
* 那一拍包侧的工具卡臂已经 `takeAnswerSegment()` 把卡前散文提交进**上一条** assistant 消息,
|
|
84
|
+
* 于是 `segmentCommitted` 与 `answerSegment` 双双为空 —— 若据此判「本包没经手过这一段」,
|
|
85
|
+
* 一段**未脱敏**的正文就会静静留在转录里、且三个分歧键一个都不发(宿主连纠正的机会都没有)。
|
|
86
|
+
* ⇒ 边界带走那一截时把它记在这里,迟到的 `text_end` 据它**算分歧、发信号**;
|
|
87
|
+
* 但**不铸任何消息**(那些字节在上一条消息里,在这里补一条只会落到工具卡**之后**)。
|
|
88
|
+
* 与 `previousCommittedText` 是同一条思路的两个量,刻意分开:那一个是「上一条消息的全部正文」
|
|
89
|
+
* (终答补差用),这一个是「上一个包侧边界带走的**本段**那一截」(分歧判据用)。
|
|
90
|
+
*/
|
|
91
|
+
let previousSegmentCommitted = '';
|
|
92
|
+
/**
|
|
93
|
+
* 🔴 **已由引擎明报收尾、但还没提交的段正文**(L-310 / 异源复审 finding②,0.68.1)。
|
|
94
|
+
*
|
|
95
|
+
* 病形(复审实抓):`delta(A) → text_end(A) → delta(B) → text_end(B)` 中间没有工具卡也没有
|
|
96
|
+
* idle 提交时,若替换只是把 `content` 写回 `answerSegment`,B 的 delta 会**接着往同一个缓冲里加**,
|
|
97
|
+
* 然后被 `contentB` 整段覆盖 —— **A 段的正文凭空消失**,而且 B 那一帧还会误报 `diverged`。
|
|
98
|
+
* 这条时序在 anthropic 车道是**常态**(装机 core `dist/brain/anthropic.js` 在
|
|
99
|
+
* `content_block_stop` 上逐块 push `text_end`,一条消息可以有多个 text 块)。
|
|
100
|
+
* ⇒ 替换把定稿正文移进本缓冲(「封存」),`answerSegment` 清空给下一段用;段提交本体交的是
|
|
101
|
+
* `segmentSealed + answerSegment`。分段行为**零改动**(整 turn 仍只提交一条 assistant 文本
|
|
102
|
+
* 消息,除非有工具卡/idle 边界)—— 撤 idle-flush 启发式仍是另一批的事。
|
|
103
|
+
*/
|
|
104
|
+
let segmentSealed = '';
|
|
65
105
|
/** T29 攒批节拍:宿主策略优先(桌面/web 可传 16ms 对齐 CC 桌面端),缺席=207 CLI 口径 100ms。 */
|
|
66
106
|
const flushEveryMs = typeof ctx.coalesceIntervalMs === 'number' && ctx.coalesceIntervalMs >= 0
|
|
67
107
|
? ctx.coalesceIntervalMs
|
|
@@ -93,18 +133,24 @@ export function createTextStream(ctx, idOf) {
|
|
|
93
133
|
/** 段提交的**唯一本体**(两个入口 `takeAnswerSegment` / `takeAnswerSegmentOnIdle` 共用;
|
|
94
134
|
* 两个入口的差别只在准入判据,提交出来的消息一个字节都不分叉)。 */
|
|
95
135
|
function* emitAnswerSegment() {
|
|
96
|
-
|
|
136
|
+
// L-310:提交的是「已封存的定稿段 + 还在攒的当前段」。分段行为零改动 —— 封存只换字节,不切消息。
|
|
137
|
+
const body = segmentSealed + answerSegment;
|
|
138
|
+
if (body.length === 0)
|
|
97
139
|
return;
|
|
98
140
|
const anchor = segmentAnchor ?? {};
|
|
99
141
|
const msg = {
|
|
100
142
|
type: 'assistant',
|
|
101
143
|
// L-215③:同 takeThinking —— 身份位接力(帧优先、ctx 兜底;两位都读不出就一个都不铸)。
|
|
102
|
-
message: { role: 'assistant', content: [{ type: 'text', text:
|
|
144
|
+
message: { role: 'assistant', content: [{ type: 'text', text: body }], ...messageIdentityOf(anchor, ctx) },
|
|
103
145
|
uuid: idOf(anchor, 'text'),
|
|
104
146
|
session_id: ctx.sessionId,
|
|
105
147
|
parent_tool_use_id: null,
|
|
106
148
|
};
|
|
107
|
-
committedText +=
|
|
149
|
+
committedText += body;
|
|
150
|
+
// L-310:🔴 **只有当前段那一截**算进「本段已提交前缀」—— 已封存的定稿段属于**上一个**引擎段,
|
|
151
|
+
// 把它算进来会让下一帧 `text_end` 拿一个跨段的前缀去比 `content`,当场误报前缀分歧。
|
|
152
|
+
segmentCommitted += answerSegment;
|
|
153
|
+
segmentSealed = '';
|
|
108
154
|
answerSegment = '';
|
|
109
155
|
segmentAnchor = null;
|
|
110
156
|
emittedAssistantText = true;
|
|
@@ -116,14 +162,22 @@ export function createTextStream(ctx, idOf) {
|
|
|
116
162
|
function* takeAnswerSegment() {
|
|
117
163
|
idleFlushUsedInSegment = false;
|
|
118
164
|
yield* emitAnswerSegment();
|
|
165
|
+
// L-310:正常边界带走了当前段的那一截 ⇒ 本段前缀账归零(下一段的替换不许把上一段的字节
|
|
166
|
+
// 算成自己的前缀),但**记进** `previousSegmentCommitted` —— 这是包侧边界,不是引擎段边界:
|
|
167
|
+
// openai 车道的 `text_end` 会在这之后才到,那一拍还得拿它算分歧(见 replaceAnswerSegment)。
|
|
168
|
+
previousSegmentCommitted += segmentCommitted;
|
|
169
|
+
segmentCommitted = '';
|
|
119
170
|
}
|
|
120
171
|
/** 件 A:IDLE-FLUSH 专用入口(准入 = 每段一刀 + 句末/段末边界;两条都过才提交)。 */
|
|
121
172
|
function* takeAnswerSegmentOnIdle() {
|
|
122
|
-
|
|
173
|
+
// L-310:准入判的是**提交本体真要交的那份**(封存段 + 当前段),不是只看当前段 ——
|
|
174
|
+
// 否则「定稿段已封存、当前段还空着」那一拍会被判成没东西可交,把已定稿的正文继续押着。
|
|
175
|
+
const body = segmentSealed + answerSegment;
|
|
176
|
+
if (body.length === 0)
|
|
123
177
|
return;
|
|
124
178
|
if (idleFlushUsedInSegment)
|
|
125
179
|
return;
|
|
126
|
-
if (!endsAtSentenceBoundary(
|
|
180
|
+
if (!endsAtSentenceBoundary(body))
|
|
127
181
|
return;
|
|
128
182
|
idleFlushUsedInSegment = true;
|
|
129
183
|
yield* emitAnswerSegment();
|
|
@@ -168,7 +222,8 @@ export function createTextStream(ctx, idOf) {
|
|
|
168
222
|
yield* drainLive();
|
|
169
223
|
yield* takeThinking();
|
|
170
224
|
}
|
|
171
|
-
|
|
225
|
+
// L-310:已封存的定稿段也算「这条消息已经开了头」⇒ 锚不重置(同流重放同 id)。
|
|
226
|
+
if (answerSegment.length === 0 && segmentSealed.length === 0)
|
|
172
227
|
segmentAnchor = frame;
|
|
173
228
|
answer += delta;
|
|
174
229
|
answerSegment += delta;
|
|
@@ -188,7 +243,111 @@ export function createTextStream(ctx, idOf) {
|
|
|
188
243
|
takeThinking,
|
|
189
244
|
takeAnswerSegment,
|
|
190
245
|
takeAnswerSegmentOnIdle,
|
|
246
|
+
replaceAnswerSegment: (content, anchor) => {
|
|
247
|
+
const prevSegment = answerSegment;
|
|
248
|
+
/**
|
|
249
|
+
* 本段**已经撤不回**的那一截 = 包侧边界带走的(可能跨了工具卡 / 消息划界,见
|
|
250
|
+
* {@link previousSegmentCommitted})+ 本消息内 idle-flush 掉的。
|
|
251
|
+
* 🔴 **两本账必须相加,不是二选一**(异源对抗复审轮二 finding① 实抓):修前只在「当前缓冲
|
|
252
|
+
* 全空」时才去读前一本,于是 `delta(含凭据) → 工具卡 → 同段后续 delta → text_end` 这条
|
|
253
|
+
* **openai 车道真实交错**(brain 持续累加文本直到 `finalize`,而 streamingToolExecution
|
|
254
|
+
* 允许工具先执行)上,卡前那截明文**不算进前缀** ⇒ 两个前缀键双双缺席、旧明文消息留存,
|
|
255
|
+
* 而权威全文又被完整提交一次(同一段话上屏两遍 + 明文没人清)。实测复现过。
|
|
256
|
+
*/
|
|
257
|
+
const livePrefix = previousSegmentCommitted + segmentCommitted;
|
|
258
|
+
// 🔴 **本包一个字节都没经手这一段 ⇒ 什么都不做**(形 (f);#323-g 那条正控守的就是这一形)。
|
|
259
|
+
// 可达形两条,都不是「流式段边界」:① durable 内容腿(`assistant` 臂的整块 `text`)——
|
|
260
|
+
// 那一帧本身就是一整条 assistant 消息、已经铸过 transcript 行,随后到的 `text_end` 若在这里
|
|
261
|
+
// 把 `content` 塞进段缓冲,turn 收口就会**再铸一条一模一样的**;② 宿主只喂 `text_end`、
|
|
262
|
+
// 不喂 `text_delta` 的自建管线。共同事实 = 没有可替换的对象,而替换的语义是**换掉本包自己
|
|
263
|
+
// 缝合出来的那一段**,不是「凭空铸一段」。
|
|
264
|
+
// (durable 腿本身不缺脱敏:server 的 `text` 行在写口与读口各脱一次,见契约 §5.1。)
|
|
265
|
+
if (livePrefix.length === 0 && prevSegment.length === 0) {
|
|
266
|
+
return { diverged: false, committedPrefixLen: 0, committedPrefixDiverged: false };
|
|
267
|
+
}
|
|
268
|
+
const liveSegment = livePrefix + prevSegment;
|
|
269
|
+
const diverged = liveSegment !== content;
|
|
270
|
+
const committedPrefixLen = livePrefix.length;
|
|
271
|
+
const committedPrefixDiverged = committedPrefixLen > 0 && !content.startsWith(livePrefix);
|
|
272
|
+
/**
|
|
273
|
+
* 🔴 **段跨过包侧边界、而且已提交那截自己也过期** ⇒ 本模块**一个字节都不动**(只发信号)。
|
|
274
|
+
*
|
|
275
|
+
* 理由是**不许把一条既有行为改坏**(异源对抗复审轮五 finding② 实抓,基线对照确认):这一形下
|
|
276
|
+
* 那一截的字节在**别的** assistant 消息里,而本模块能动的只有当前这条 —— 清掉未提交尾段会让
|
|
277
|
+
* `lastCommittedAnswerText` 回退到上一条,`result` 臂据它落第④臂**补吐一整段**;而 e4cd371
|
|
278
|
+
* 基线上同一条时序只是提交那截尾段 + 落第⑤臂报一行分岔(不补吐)。⇒ 清空在这里是**新增**的
|
|
279
|
+
* 重复转录路径,不是「更安全」。
|
|
280
|
+
* ⇒ 分界线一句话:**整段都在自己手里(同一条消息内)时才改自己交的字节;段一旦跨过包侧边界,
|
|
281
|
+
* 就只发信号、不动任何既有行为**。宿主照三个键把那截过期前缀换掉,其余与 0.68.0 逐字相同。
|
|
282
|
+
*/
|
|
283
|
+
const handOffOnly = committedPrefixDiverged && previousSegmentCommitted.length > 0;
|
|
284
|
+
if (handOffOnly) {
|
|
285
|
+
segmentCommitted = '';
|
|
286
|
+
previousSegmentCommitted = '';
|
|
287
|
+
idleFlushUsedInSegment = false;
|
|
288
|
+
return { diverged, committedPrefixLen, committedPrefixDiverged };
|
|
289
|
+
}
|
|
290
|
+
// ── ① 活体面:已泄出去的那一截撤不回 ⇒ 尾巴按替换后的段重算(判据见接口注「活体面」段)──
|
|
291
|
+
// `textPending` 是段缓冲的**后缀**;工具卡边界只提交不 drain,所以它也可能还押着**上一段**
|
|
292
|
+
// 的尾巴(那一截与本次替换无关,原样留着)。
|
|
293
|
+
const segTailLen = Math.min(textPending.length, prevSegment.length);
|
|
294
|
+
const carry = textPending.slice(0, textPending.length - segTailLen);
|
|
295
|
+
const emittedForSegment = livePrefix + prevSegment.slice(0, prevSegment.length - segTailLen);
|
|
296
|
+
textPending = content.startsWith(emittedForSegment)
|
|
297
|
+
? carry + content.slice(emittedForSegment.length)
|
|
298
|
+
: carry;
|
|
299
|
+
// ── ② 转录面:**封存**这一段的定稿正文,`answerSegment` 清空给下一段用 ──────────────────
|
|
300
|
+
// 🔴 清空是轮一 finding② 的修复本体:不清空的话,下一段的 delta 会接着往同一个缓冲里加,
|
|
301
|
+
// 然后被下一帧的 `content` 整段覆盖 —— 上一段的正文凭空消失(anthropic 车道逐块发
|
|
302
|
+
// `text_end`,这条时序是常态)。封存**不切消息**:段提交本体交的是 `sealed + segment`。
|
|
303
|
+
// 🔴 `committedPrefixDiverged` 那一形**一个字节都不交**(统一规则,见接口注):已提交的
|
|
304
|
+
// 那截撤不回,而切片拼不出 `content` —— 再补一条就是同一段话上屏两遍;该段唯一算数的
|
|
305
|
+
// 那一份在帧上,由宿主按 `committedPrefixLen` 指的那一截换掉(单位见回执头注)。
|
|
306
|
+
if (committedPrefixDiverged) {
|
|
307
|
+
// 🔴 **同步终答补差账 —— 但只在「整截都在当前这条消息里」那一形**(轮三 finding② 采纳,
|
|
308
|
+
// 轮四 finding①② 收窄)。这一形下本包不交正文,而屏上那一段在宿主照契约换过之后**就是**
|
|
309
|
+
// `content`;补差账若还留着旧的那一份,`result` 臂会把权威全文当成「屏上缺的后缀」
|
|
310
|
+
// 再渲一遍(第④臂),正好违反三处文案承诺的「不再交正文」。
|
|
311
|
+
//
|
|
312
|
+
// 🔴 **为什么只同步这一形**(轮四两条 finding 的根因,记明免得下一棒又去「补全」它):
|
|
313
|
+
// 段跨过包侧边界(工具卡 / 消息划界)时,这一截的字节落在**别的** assistant 消息里,而
|
|
314
|
+
// `committedText` / `previousCommittedText` 是**按消息**记的两本账 —— 把 `content` 往当前
|
|
315
|
+
// 这本账上写,就等于把上一条消息的正文算进当前这条:轮四实测 ① 迟到边界那一形会让随后
|
|
316
|
+
// 正常的 B 段被误报 `result_text_diverged`、甚至把该补的后缀压掉;② 同段跨两张卡时
|
|
317
|
+
// `previousCommittedText` 只留得下最后一条,守卫直接不成立、同步静默跳过。
|
|
318
|
+
// ⇒ 跨消息那几形**不碰账**。代价如实认领:那里的补差基线与「宿主换过之后的屏」对不齐,
|
|
319
|
+
// `result` 可能落第④臂补一截或落第⑤臂报一行 `result_text_diverged` —— 而这两者
|
|
320
|
+
// **都是本批之前就有的行为**(主树 0.68.0 上同一条时序实测同形:卡后有散文时
|
|
321
|
+
// `lastCommittedAnswerText` 取的就是卡**之后**那一条,终帧却是整条消息的全文)。
|
|
322
|
+
// 根因是「包按自己的边界切消息、引擎按 content block 切段」这两套边界对不齐,根治 =
|
|
323
|
+
// 「每个引擎段各自一条 committed 消息」(= CC 原生做法),那是分段行为改动,单独走。
|
|
324
|
+
if (committedText.endsWith(livePrefix)) {
|
|
325
|
+
committedText = committedText.slice(0, committedText.length - livePrefix.length) + content;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
segmentSealed += content.slice(committedPrefixLen);
|
|
330
|
+
}
|
|
331
|
+
answerSegment = '';
|
|
332
|
+
// ── ③ `answer`(D8 调试行的长度读位)跟着换尾 —— 只在尾巴真对得上时换 ──────────────────
|
|
333
|
+
// `appendAnswer`(durable 整条腿)会往同一个累加器里加字,那一形下本段已经不是它的后缀;
|
|
334
|
+
// 猜着改一个诊断计数器比让它原样更坏,所以对不上就不动(它只喂 `answerLen=` 那一行)。
|
|
335
|
+
if (answer.endsWith(liveSegment)) {
|
|
336
|
+
answer = answer.slice(0, answer.length - liveSegment.length) + content;
|
|
337
|
+
}
|
|
338
|
+
// 段锚缺席(整段已被 idle 提交光、或整段零 delta)才用本帧兜底 —— 在场**不覆盖**,
|
|
339
|
+
// 否则同一条流重放两次会拿到两个不同的 committed 消息 id(replay-id 不变量)。
|
|
340
|
+
if (segmentAnchor === null && anchor !== undefined)
|
|
341
|
+
segmentAnchor = anchor;
|
|
342
|
+
// ── ④ 段账收口:这一段由引擎明说已经写完 ⇒ 两本前缀账都归零、idle 配额重开 ──────────────
|
|
343
|
+
segmentCommitted = '';
|
|
344
|
+
previousSegmentCommitted = '';
|
|
345
|
+
idleFlushUsedInSegment = false;
|
|
346
|
+
return { diverged, committedPrefixLen, committedPrefixDiverged };
|
|
347
|
+
},
|
|
191
348
|
hasPendingContent: () => thinking.length > 0 ||
|
|
349
|
+
// L-310:已封存的定稿段同样是「有已生成未提交的内容」(不算进来会让它一直押到 turn 收口)。
|
|
350
|
+
segmentSealed.length > 0 ||
|
|
192
351
|
answerSegment.length > 0 ||
|
|
193
352
|
thinkingPending.length > 0 ||
|
|
194
353
|
textPending.length > 0,
|
|
@@ -211,6 +370,10 @@ export function createTextStream(ctx, idOf) {
|
|
|
211
370
|
if (committedText.length > 0)
|
|
212
371
|
previousCommittedText = committedText;
|
|
213
372
|
committedText = '';
|
|
373
|
+
// L-310:新消息开始 = 旧段的已提交前缀与**新**段无关 ⇒ 本段账归零;同 takeAnswerSegment,
|
|
374
|
+
// 归零前记进 `previousSegmentCommitted`(迟到的 `text_end` 还要拿它算分歧)。
|
|
375
|
+
previousSegmentCommitted += segmentCommitted;
|
|
376
|
+
segmentCommitted = '';
|
|
214
377
|
},
|
|
215
378
|
get committedAnswerText() {
|
|
216
379
|
return committedText;
|