@sema-agent/client-core 0.67.2 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/README.md +65 -1
  3. package/dist/adapt/arms.js +67 -9
  4. package/dist/adapt/textStream.d.ts +102 -1
  5. package/dist/adapt/textStream.js +169 -6
  6. package/dist/adapt/turnFlags.d.ts +14 -0
  7. package/dist/adapt/turnFlags.js +4 -1
  8. package/dist/adapt.js +4 -1
  9. package/dist/adapter/activeRunSelfHeal.d.ts +53 -6
  10. package/dist/adapter/activeRunSelfHeal.js +79 -8
  11. package/dist/adapter/downstream/eventToSdkMessage.d.ts +18 -1
  12. package/dist/adapter/downstream/eventToSdkMessage.js +50 -9
  13. package/dist/adapter/downstream/terminalToSdkResult.d.ts +29 -0
  14. package/dist/adapter/downstream/terminalToSdkResult.js +46 -15
  15. package/dist/adapter/runStream.d.ts +22 -2
  16. package/dist/adapter/runStream.js +139 -38
  17. package/dist/adapter/types.d.ts +4 -1
  18. package/dist/autoModeUnavailable.d.ts +17 -9
  19. package/dist/autoModeUnavailable.js +26 -8
  20. package/dist/classifierStatus.d.ts +32 -4
  21. package/dist/classifierStatus.js +5 -3
  22. package/dist/controlRouter.d.ts +16 -0
  23. package/dist/controlRouter.js +6 -0
  24. package/dist/engineErrorCodes.d.ts +52 -0
  25. package/dist/engineErrorCodes.js +117 -0
  26. package/dist/engineNoticeCodes.d.ts +95 -1
  27. package/dist/engineNoticeCodes.js +124 -1
  28. package/dist/gateVocabulary.d.ts +18 -7
  29. package/dist/gateVocabulary.js +21 -8
  30. package/dist/hitl/parkResolver.d.ts +0 -14
  31. package/dist/hitl/parkResolver.js +22 -9
  32. package/dist/hitl/toolApprovalWire.d.ts +2 -1
  33. package/dist/hitl/toolApprovalWire.js +1 -0
  34. package/dist/ownKey.d.ts +34 -0
  35. package/dist/ownKey.js +36 -0
  36. package/dist/request/taskRequest.d.ts +6 -6
  37. package/dist/request/taskRequest.js +45 -0
  38. package/dist/retryStatus.d.ts +13 -2
  39. package/dist/retryStatus.js +4 -1
  40. package/dist/runTerminal.d.ts +87 -14
  41. package/dist/runTerminal.js +89 -15
  42. package/dist/seam.d.ts +78 -8
  43. package/dist/seam.js +16 -2
  44. package/dist/toolResult.js +8 -0
  45. package/dist/toolRoster.d.ts +34 -2
  46. package/dist/toolRoster.js +16 -2
  47. package/dist/workflowClient.d.ts +22 -0
  48. package/dist/workflowClient.js +37 -0
  49. package/docs/INTEGRATION-CLIENTS.md +633 -9
  50. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -49,6 +49,316 @@
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
+
189
+ ## 0.68.0(2026-09-13)
190
+
191
+ 🔴 **BREAKING 提货批**(core **7.16.0 → 7.17.1**;三件破坏性 + 一次**引擎地板抬升**到 core ≥7.17.0)。
192
+ 逐件的铸点读法 / 缺席语义 / 三端换装清单 / 黑盒判据 G33-01…21(含 05b–05f 六格)/ 逐键处置表见
193
+ `docs/INTEGRATION-CLIENTS.md` **§33**(母本 §33z)。
194
+
195
+ 🔴 **本批最该记住的一句话**:`#711` 把「这一轮没量出账」的 wire 形从**不发 usage** 换成了
196
+ **六个 0 + 判别位**。同一个真实情形换了字节之后,本包里每一处「读 usage 的数字」都从「读到缺席」
197
+ 变成了「读到一个精确的零」—— 异源对抗复审**连跑六轮**,一共在**五处**读点 + 一条**跨轮边界**上
198
+ 抓到同一个病形(逐处逐条记在 §33b 的表里)。**占位零的判据不是「有没有 usageMissing」,是
199
+ 「这一格是不是零」**:`usageMissing` 可以与真数字同帧,而占位恒为全零 ⇒ 非零必是真读数。
200
+ 另一句同样承重:**「这一轮结束了」与「这一轮花了多少」是两件事** —— 合成一个动作,
201
+ 过滤掉数字就会顺手把模型轮边界也过滤掉。
202
+
203
+ ### 🔴 BREAKING(三件,逐件说清「为什么不留兼容尾门」)
204
+
205
+ - **B1 `turn_end.usage` 恒在场 ⇒ 「usage 缺席」臂整条退役**(core 7.17.0 #711)。上游的铸点自 7.17.0
206
+ 起是无条件的(`turnUsage ?? {六个 0}` + `usageMissing:true`),**「没有 usage 的 `turn_end`」不再是
207
+ 一条合法形** ⇒ 0.65.1 / B-088 那条「三者任一在场即发」的臂、以及它逼出来的三处
208
+ `usage !== undefined ? … : …` 与两处 `usage?.x ?? 0` **全部删掉**。缺席改按**契约违约**收口:
209
+ ①走宿主丢帧留痕口(判词 `turn_end_usage_absent`,开集 ⇒ 宿主不必改型),且**那一行说的话**与别的
210
+ 丢帧不同(缺省句「this build's projector has no arm for it」对本形是假话);②**整帧不投影**
211
+ (没有 usage 就没有数字可交,折 0 就是把「不知道」写成已知账);③**同时立下界位**(不立的话,
212
+ 一条真丢了账的 run 会在终帧上被渲成一笔精确的账)。
213
+ 🔴 **不留按引擎版本分岔的兼容读** —— 那要求包边界持有一个它读不出来的量(引擎版本不在帧上),
214
+ 猜版本比响亮地说「这帧不合契约」更坏。⇒ 引擎地板抬到 **core ≥7.17.0**(如实登记:更老的引擎上
215
+ 真实的「该轮零 usage」帧会被判成违约,账仍诚实但少那一拍的实时增量)。
216
+ 同批**族扫产物**(异源对抗复审轮一订正过一次,订正本身值得记):`usageMissing` 那一轮的
217
+ **占位零**不许当读数。⚠️ 关键一条 —— `usageMissing:true` **不保证**六格是零(core 在同一轮里
218
+ 可能已攒到真数字而另一次调用报了缺账,判别位与真数字同帧并存)⇒ 判据只能锚在**能证明是真读数
219
+ 的那一半**:占位恒为 `0`,所以**非零有限数必是真读数**(照铸/照累加),而 `0` 分不出占位与真零
220
+ (不铸)。落在 `turn_usage.outputTokens` 与子代分表行的 `cacheReadTokens` 两处。
221
+ (轮一的写法是「缺账轮整条跳过」——那会把真读数丢掉:两帧 cache 10 / 100 且第二帧带判别位时
222
+ 合计应为 110,整条跳过给出 10,而实时增量腿仍交 10 与 100,实时面与终局分表当场对不上。)
223
+ 同族第三处:**`RunStreamHandle` 这个公开出口**此前无条件覆盖两份读数且**没有任何判别位** ⇒
224
+ 只吃它的 footer 会把「不知道」渲成一笔精确的零账。修形 = **未测量的轮不覆盖**(保留上一次真读数,
225
+ 与 #711 之前逐字相同 ⇒ 没跟车的消费者零回归)+ 新增 `latestUsageMissing`(never false,测到账的
226
+ 轮删键)。
227
+ 同族**第四处**(轮二实抓,也是这条链的**末端**):A 层 `result` 臂的**终帧补发腿**
228
+ (`response_metrics{phase:'end'}`)拿的是终帧 usage,而缺账 run 的终帧 usage 是占位全零 ⇒ 修前
229
+ 会补发一条没有任何判别位的精确零度量,而本臂的宿主义务逐字是「驱动 responseLength reducer」。
230
+ 前三处收口了而末端没跟上 = 过滤没贯穿到底。修形同一条判据(下界位在场且读数为 0 ⇒ 不补发,
231
+ 非零照发),并补一条 **`runStream` → `adapt` 整链**回归(缺账零 / 真零 / 缺账非零三形)。
232
+ 同族**第五处(🔴 公面 BREAKING,轮三实抓)**:公开读器 `turnEndUsage` 的 `undefined` 含义从
233
+ 「`usage` 这一格缺席」收窄成**「这一轮的账不知道」**(整格缺席 / `usageMissing` 且六格全零两种
234
+ 入形都答它;**非零照交**)。理由是**跨版本一致性**:#711 之前「这一轮没量出账」的 wire 形是
235
+ 不发 usage ⇒ 它答 `undefined`;#711 之后同一件事的形变成六个 0,不改的话同一个真实情形在引擎
236
+ 升级前后由同一个公开读器给出两个相反的答案,而调用方一个字都没改。⚠️ 要**原样**镜像的调用方改调
237
+ `turnUsageToModelUsage`(纯映射,不带缺席语义)—— `runStream` 的违约闸走的正是后者,否则一条
238
+ **合法**的占位帧会被误判成违约并整帧丢掉(反向钉在门里)。
239
+ 🔴 **轮四再订正两处**(两条都对照 main 复现过):① `RunStreamHandle` 的规矩从「缺账轮不覆盖」
240
+ 收窄成「**占位**轮不覆盖」—— `usageMissing` 可以与真数字同帧(core 在同一轮里攒到过数字而另一次
241
+ 调用报了缺账),blanket 跳过会把真读数丢掉(实测 5 → 42+缺账位,main 的 handle 到 42、blanket
242
+ 写法停在 5);读数更新与判别位从此**分开决定**。② **坏形 `usage`**(`null` / 标量 / 数组):wire 是
243
+ JSON、SSE 解析原样透传 ⇒ 这些形真到得了包边界,而 `usage: null` 修前会让映射当场抛 `TypeError`
244
+ ——**整条流断掉**(后面的 `done` 一并丢);标量则被映成一份「看起来已测量」的全零账。成形判据
245
+ 一律改成「非 null 的非数组对象」,坏形与缺席走同一条违约路,**流不断**。
246
+ 🔴 **轮五再订正一处(过滤本身引入的跨轮回归)**:A 层 `turn_usage` 臂的 `emitTurnUsageEnd` 一直在
247
+ 做**两件事** —— 发 end 度量 **与** 复位下一 model round 的 TTFT 基线。缺账轮没有数字可发,整条
248
+ 跳过那个动作就**连模型轮边界也一起跳过**:下一轮不再发 `response_metrics{start}`,它的用量按
249
+ **上一轮**的基线对账(整链实测:首轮 400 字符缺账、次轮 4 字符 + 50 tokens,计数从 150 掉到 101,
250
+ 下一轮 TTFT 一并丢失)。⇒ 边界拆成独立动作 `noteModelRoundBoundary()` 无条件走,**只压基线、
251
+ 不置「已发过 end」**(终帧若真带了一笔非零账,补发腿仍该补)。判据分家的那句话值得记下来:
252
+ **「这一轮结束了」与「这一轮花了多少」是两件事** —— 合成一个动作,过滤数字就会顺手把边界也过滤掉。
253
+ - **B2 终态词两表分源**(L-247;core [7067] @cli 行顺答)。`TERMINAL_STATUSES` /
254
+ `TERMINAL_NOT_SUCCESS_STATUSES` **删除(不留别名)**,换成三张**字面元组** + 派生型:
255
+ `TERMINAL_CAUSE_KINDS`(core 终局**因由**闭集 `completed|failed|blocked|paused`)与
256
+ `RUN_TERMINAL_STATUSES` / `RUN_TERMINAL_NOT_SUCCESS_STATUSES`(**server run 行状态面**)。
257
+ 病根:修前一张表把**两个属主**的词合在一起 —— `killed` 根本不是 core 的词(core 因由集里没有它),
258
+ 而 `paused` 是 core 的词却**不是**「run 结束了」。合成一张之后,「core 加第五个因由」与「server 加
259
+ 一个行状态词」在这一端长得一模一样,消费方只能把新词折进已知词(不安全侧)或两边都漏。
260
+ 🔴 **不留别名**的理由:留一个别名 = 端可以继续按合起来的那张表读,分源就白做了。
261
+ 谓词 `isTerminalStatus` / `isTerminalNotSuccess` 名字与语义**未动**(现在是类型守卫),新增
262
+ `isTerminalCauseKind`。core 那张闭集是**抄件**不是意见:新门对**实装 devDep core** 的
263
+ `TERMINAL_CAUSE_IS_REPLAYABLE` 键集(声明面与运行期面双读且要求同源)逐词双向对账 + 顺序同源,
264
+ 并把「为什么是镜像而不是 re-export」做成**存活断言**(core barrel 今天只 re-export 类型
265
+ `TerminalCause`;它哪天导出闭集值,门当场红逼人复核)。
266
+ - **B3 `delegation.ask_unresolvable` 的 `detail.settlementKind` → `detail.cause`**(core 7.17.0 #709 ②)。
267
+ 本包新给 `ASK_UNRESOLVABLE_CAUSES` 闭集与事实窄读器 `readAskUnresolvable`;`settlementKind`
268
+ **零残留**并登记进退役普查(`src/` 代码位置零命中 + 上游存活断言)。
269
+ 🔴 **不做双读**:两者不是改名 —— in-fold 拒绝那一臂(`mandate_unreconstructible`)**根本没有结算**
270
+ 骑在帧上,旧位在那一形上恒缺席;一条「读不到 cause 就读 settlementKind」的兼容读会在**最需要它的
271
+ 那一形**上给出缺席,并且让端以为自己兼容了老引擎。
272
+ - **B4(编译期 BREAKING,L-245)两只措辞铸点的入参从 `unknown` 收窄成闭集成员型** ——
273
+ `classifierDenyCauseDetail(cause: ClassifierDenyCause)` / `ruleStoreUnreadableDetail(kind:
274
+ RuleStoreUnreadableKind)`;`CLASSIFIER_DENY_CAUSES` / `RULE_STORE_UNREADABLE_KINDS` 同批改字面元组
275
+ 并各出一个成员型,措辞表的型改 `Record<成员型, string>`。那句「a word newer than this client」的兜底
276
+ **结构不可达**(唯一到达铸点的路已按闭集判过)、而且**说的是假话**(真有一个比这一端新的词时它压根
277
+ 不会到这里),更坏的是它假装这一面是开集 ⇒ 没人给这张表配编译期围栏,core 加词那天一声不响。
278
+ 收窄之后**加词 = 表少一个键 = 编译期当场红**。
279
+ ⚠️ **隔壁那一句刻意相反且两边都对**:`askOriginDetail` 的兜底**留着** —— `AskOrigin` 在 wire 上是
280
+ 开集,那一句真的会被走到。一套面一条规矩:闭集则收窄 + 围栏,开集则保留兜底。
281
+
282
+ ### Added(additive;端可以不接)
283
+
284
+ - **`readWorkflowParks`**(core 7.17.0 #652 / server [7084] D 段)—— `GET /v1/workflows/:id` 的
285
+ `parks[]` 三键读器。🔴 **凭据在本包是结构性不可达的**:读器**逐键挑** `{callKey, sessionId,
286
+ originRunId}` 铸行、从不 spread,所以上游哪天在这张行上多放一个 `token`(或任何别的凭据形),
287
+ 它**在结构上**到不了本包的产物;门用一条带凭据的投毒行直接证它(产物**整棵树**序列化后零命中,
288
+ 且同一把尺子对**输入**判得出),并对源码本身钉「读器里零对象展开」。
289
+ 🔴 **缺席两义**:`parks` 整键缺席 = 旧引擎写的记录、**证不出**有没有 park;`parks: []` = 一句正面
290
+ 事实。两者的下一步相反(拒 resume / 放行 resume),折成一个字节就是本仓反复在修的那条病。
291
+ - **`WORKFLOW_PARK_REFUSAL_CODES` / `isWorkflowParkRefusalCode`** —— workflow **park 真相**四拒码
292
+ (`park_truth_unreadable` / `park_not_pending` / `park_binding_broken` / `park_requires_run_store`)。
293
+ 判据**绝不靠 `workflow.park_` 前缀放宽**(前缀是命名巧合不是契约)。
294
+ 🔴 `workflow.journal_incompatible` **仍是一个活码**(退役的是它的「journal entry missing」那一条臂),
295
+ 门里有反向钉守着 —— 把整个码当退役会让一族真拒绝在这一端无声消失。
296
+ - **`DURABLE_MANDATE_SOURCES` / `readDurableGateUnavailable`**(core 7.17.0 #709 ①)——
297
+ `source` **闭集**(恢复动作的分支键)、`cause` **开集透传**(core 今天不导出那两个词的闭集,铸点是
298
+ 一个三元表达式 ⇒ 抄一份就是本包自铸词表)、两个布尔位**读不出则键不铸**(不折 `false`)。
299
+ - **`heldBy` / `cancelRequested` 读点**(L-230;server 7.73.0 S-122 P-45)—— `waitForClaimRelease` 多一条
300
+ **更早、更硬**的释放证据:`heldBy === null` 是引擎**直说**「会话交出来了」,比拿终态词推断强
301
+ (park 态保留 claim 正是那条推断会踩的坑)。三态刻意不是布尔:非空串 = 还占着 / `null` = 释放了 /
302
+ **整键缺席 = 老引擎,读不出**(回落既有白名单,**老引擎零行为变化**)。`ClaimReleaseVerdict` 新增
303
+ `lastHolder`(与 `lastStatus` 同律:只记最近一次**有回答**的探测);`confirmedHeld` 改两条取并。
304
+ 新增 `readCancelRequested` 三态读器,且它读的是**「请求已受理」不是「已取消」**。
305
+ - **`SEMA_SUBAGENT_USAGE_PARTIAL_KEY` / `subagentUsageIsPartial`**(L-244 包侧半场)—— 子代用量面的
306
+ 下界判别位**自有名**,与终帧的 `_sema_usage_lower_bound` **分名**(修前壳把两件事铸成同一个键名,
307
+ 于是一个按键名聚合的面会把「一条只是还没收口的子代行」算成整条 run 的账不可信)。
308
+ - **`ENGINE_STOP_REASONS` / `engineStopReasonToCc`**(L-246 A13)—— 引擎 turn 停止原词 → CC
309
+ `stop_reason` 的**唯一映射口**,从壳下沉包内(CC 皮肤词汇表本来就该住这里;住在壳里的后果是三端
310
+ 各抄一份五个词)。**三态**:`string` / `null`(映到**诚实缺席**)/ `undefined`(**本端不认识**)——
311
+ 后两者合成一个值会让引擎加第六个词那天被渲成一次「这一轮没有 stop_reason」的肯定事实。
312
+ - **`ClassifierRoundObservation`**(L-245 B6)—— `classifierStatusOf` 第二参的**窄观测型**:端手里只有
313
+ 两格裸串时**直接交 `{disposition}`**,不必再铸一个假门记录喂进来(索引签名是故意的,整只 wire 帧
314
+ 照样喂得进来)。
315
+ - **码册 / 词表跟车四件**:`ENGINE_NOTICE_CODES` +1 `config.artifact_host_invalid`(audience `operator`;
316
+ 五十一码 ⇒ 五十二码)/ `STRUCTURED_DETAIL_TYPES` +1 `artifact`(**同一个恒绿病根第五次**,抬对账物后
317
+ engine-vocab ⑤ 段当天红抓出)/ `BrainRetryErrClass` 补第七桶 **`stall`**(它与 `transport` 分家,
318
+ 下一步不同)/ `RetryStatus` 的 `stalled` 臂**透传引擎那句 `detail`**(修前这一位只在两条 error 臂上
319
+ 被读,最长的那一段等待反而看不见引擎给的解释)。
320
+
321
+ ### Changed(内部结构,端零感知)
322
+
323
+ - **落键姿势单源化**(L-246 B2)—— `__proto__` 陷阱的 `defineProperty` 落键从两份同形实现
324
+ (`terminalToSdkResult` / `parkResolver`)收成单源 `src/ownKey.ts` 的 `putOwnKey`。
325
+ index 闭包文件数棘轮 158 → **159**(逐条账在 `scripts/run-client-core-portability-test.mjs` 头注:
326
+ 零 import 纯叶、零 Node 内建、零新外部包;抬这个数换来的是**少一份同形实现**)。
327
+ - **`DurableRunRecordView` 具名**(修前是 `DurableRunVerbs.get` 返回位上的内联匿名形)。
328
+ - **typeshape 棘轮**:`unknownExport` 324 → **336**(−4 收窄/具名、+16 全是 wire 边界读器入参或 wire 值位,
329
+ 逐条实测差集写在门的 `RATCHET` 头注里);`b4` 恒 **20** 不动、`bareUnknownReturn` 恒 **0** 不动。
330
+
331
+ ### Removed(BREAKING)
332
+
333
+ - `TERMINAL_STATUSES` / `TERMINAL_NOT_SUCCESS_STATUSES`(随 B2 更名,**不留别名**)。
334
+
335
+ ### 门(新增两道 + 扩六道)
336
+
337
+ - 🆕 `scripts/run-terminal-word-source-test.mjs` —— 两表分源的对账门(见 B2)。
338
+ - 🆕 `scripts/run-workflow-park-truth-projection-test.mjs` —— parks 三键读器的**凭据结构性不可达**、
339
+ 缺席两义、四拒码对 core 真字节直证、`journal_incompatible` 仍活的反向钉。
340
+ - 扩:`run-assistant-arm-identity-test`(S-1 四组,**红先绿后**的证据就在这一段)/
341
+ `run-subagent-usage-projection-test`(占位六零不当读数)/ `run-engine-notice-catalog-test`(两只窄读器)/
342
+ `run-selfheal-reopen-test`(`heldBy` 三态与老引擎既有路)/ `run-engine-vocab-floor-test`
343
+ (**G2-d**:`BrainRetryErrClass` 对实装 core 双向等值 —— 此前这一族**连门都没有**)/
344
+ `run-retired-vocabulary-census-test`(登记 +1 `settlementKind`)。
345
+ - **枚举器补层**(`run-client-core-singleton-test`):`Object.freeze([… ] as const)` 且**无类型注解**的
346
+ 写法此前**整类**不进模块级单例普查(初值是属性访问调用 ⇒ 工厂判据不认;没有注解 ⇒ 注解路走不到)。
347
+ 补层当天在 `src/` 里实捞出 **5 条存量**(`peerFrames` ×4 / `subagentContentStore` ×1)——
348
+ 它们不是新风险,是本来就该在册而一直隐身的。自检语料同批加两条已知判决。
349
+
350
+ ### 已知局限(本版新增;完整台账见 `docs/INTEGRATION-CLIENTS.md` §6e/§7)
351
+
352
+ - **引擎地板**:B1 起本包的 `turn_usage` 臂按 core ≥7.17.0 的铸形写。在更老的引擎上,真实的
353
+ 「该轮零 usage」帧走违约路(留痕 + 不投影 + 立下界位)—— 账仍诚实,丢的是那一拍的实时增量。
354
+ - **`parks[]` 供给面未端到端实证**:server 7.74.0 尚未发布(本批写作时 registry latest = 7.73.1),
355
+ 读器按 [7084] D 段的契约写并由构造素材覆盖;真 wire 上的首次实证归 test 线。
356
+ - **sdk / agent-types 本批不升**(devDep 停 `^8.8.0` / `^0.2.0`):①抬 peer 地板本身是一次**非 additive**
357
+ 面,按本包既例要独立记账与影响面账,不搭 BREAKING 车;②实测直证 sdk 9.1.0 **不带**本批任何一个键
358
+ (`RunStatus` 逐字未变、无 `WorkflowRun.parks`、无 `heldBy`/`cancelRequested`)⇒ 本批零受迫。
359
+ `heldBy` / `cancelRequested` / `parks` 三处因此走**结构视图读**(与本包 `riskDescriptor.probeCause` /
360
+ `ruleOffers` 同款姿势),类型半场候 sdk 班车。
361
+
52
362
  ## 0.67.2(2026-09-12)
53
363
 
54
364
  **异源对抗复审轮一三条 [medium] 的修复批**(patch;零公面导出
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.67.2
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
@@ -206,6 +206,67 @@ Known intentional deltas from the CLI are enumerated in `ADAPTER_DIVERGENCES`. T
206
206
  ledger is a different table (`MIGRATED_COMPENSATIONS`): the entries there behave identically on
207
207
  both sides — what it records is why a compensation exists and when it can be deleted.
208
208
 
209
+ ## Upgrading to 0.68.0 — read this first (breaking)
210
+
211
+ This release follows an engine release in which three facts that used to be *sometimes absent* became
212
+ *always present*. Wherever this package had an arm for "the engine did not send it", that arm was either
213
+ dead code or was quietly reading a positive fact as nothing. The arms are gone; absence now means one of
214
+ three **distinguishable** things, and the distinction is the point.
215
+
216
+ **1. Per-turn usage is now always on the wire.** The engine emits a zeroed usage object together with an
217
+ explicit "this turn was not measured" flag instead of omitting usage. So a turn-end frame with **no** usage
218
+ at all is no longer a legal shape — it is a contract violation, and this package now says so out loud
219
+ (through the host's dropped-frame sink, with its own verdict word and its own sentence, because the generic
220
+ one — *this build has no arm for it* — would point the reader at the wrong side) rather than silently
221
+ projecting something. Such a frame is **not** projected at all, and the run's totals are marked as a lower
222
+ bound, because dropping the frame must not let a run that genuinely lost an accounting turn report an exact
223
+ number. **The engine floor moves with it**: on older engines a real "no usage this turn" frame takes that
224
+ path. There is deliberately no version-sniffing compatibility read — that would require this package to hold
225
+ a number it cannot see, and guessing is worse than saying plainly that a frame does not match the contract.
226
+ One consequence worth knowing: on a turn flagged as unmeasured, the zeroes are a *placeholder*, and that
227
+ rule is carried all the way down the chain — the per-turn output count, the per-sub-agent cache-read total,
228
+ the handle a footer reads, the end-of-turn metric the response-length reducer consumes, and the public
229
+ per-turn usage reader all withhold the placeholder rather than hand out an exact zero. **A non-zero value is
230
+ never withheld**: a placeholder is by construction all zeroes, so anything non-zero was genuinely measured
231
+ and is passed through as a lower bound. One of those five is a **public** reader whose meaning therefore
232
+ changed: the value it returns for *nothing known about this turn* is now the same before and after the
233
+ engine upgrade — which is the point, since otherwise one unchanged caller would have silently started
234
+ reading "this turn cost exactly zero". If you want the raw, judgement-free mapping instead, call the pure
235
+ mapper beside it.
236
+
237
+ **2. The ending words are now two tables, kept apart by who owns them.** The single combined list is
238
+ **removed with no alias** and replaced by the engine's closed set of *reasons a run ended* and the server's
239
+ set of *row states a run can finish in*. They share three words but not all of them: one word for *something
240
+ outside stopped it* exists only on the server side, and one for *it paused and can be resumed* exists only on
241
+ the engine side and means very nearly the opposite of an ending. Merged, a new word on either side looked
242
+ identical, and the tempting move — folding the unknown word into a known one — is exactly the mistake this
243
+ package exists to prevent. Both tables are literal tuples with derived types, so a client should **derive**
244
+ rather than hand-copy the words. The two predicates keep their names and meanings (and are now type guards);
245
+ an alias was deliberately not left behind, since one would let a reader keep consuming the merged list and the
246
+ split would have bought nothing.
247
+
248
+ **3. The "an approval could not be resolved" notice now carries a cause, not a settlement word.** These are
249
+ not two names for one thing: one of the three causes is an in-fold refusal that carries no settlement at all,
250
+ so the old field was always absent exactly where it mattered most. A "read the new field, else the old one"
251
+ compatibility read would therefore answer *nothing* on the one shape that needs it, while letting a client
252
+ believe it had covered older engines. This package does not do that read, and a standing check keeps anyone
253
+ from adding one later.
254
+
255
+ **Also breaking, at compile time only:** two sentence-minting functions narrowed their parameter from an
256
+ unvalidated value to the closed set they actually serve. The fallback sentence they carried was structurally
257
+ unreachable *and* untrue — it announced "a word newer than this client" for a value that could never arrive —
258
+ and worse, it made the surface look open, so nobody had put a compile-time fence on the table. With the
259
+ parameter narrowed, adding a word upstream now fails to compile until the sentence is written. The neighbouring
260
+ function whose vocabulary really **is** open keeps its fallback: one rule per surface, not one rule for all.
261
+
262
+ Everything else in this release is additive and safe to ignore until you want it: a reader for the parked
263
+ approvals on a workflow record (built so that a credential **cannot** structurally reach the output, and with
264
+ *no field at all* kept distinguishable from *an empty list*), a closed set for that family's refusal codes,
265
+ readers for two notice payloads, a direct answer for "has that run released the session yet" that supersedes
266
+ inferring it from a status word (absent on older engines, where behaviour is byte-identical), a distinctly
267
+ **named** key for the sub-agent lower-bound bit that used to collide with the run-level one, and the
268
+ engine-stop-word to session-vocabulary mapping moved in here so the clients stop each keeping a copy.
269
+
209
270
  ## Guards
210
271
 
211
272
  ```bash
@@ -292,6 +353,8 @@ public-surface guard checks that last one).
292
353
  | `scripts/run-decide-receipt-test.mjs` | What a decision verb actually **answered** — and, more importantly, what it did not. A success response on the newest lane is only an acknowledgement that the decision was accepted for delivery: the approval is still pending, and a client that clears the card on it shows either a ghost card that was already approved or a card that vanished while the decision was lost. So the package deliberately has **no** "was it resolved" predicate — nothing in that body can answer it — only the opposite one, whose `false` is likewise not evidence of resolution; resolution is only ever the next running arm on the stream. The guard pins that inversion in the product source too: the success path must no longer clear the latched gate, while the stream-observing path that really clears it must still be there. The body has four shapes with **no** key common to all of them, so every position is read as honestly absent, and the handoff handle — which run to watch from here on — requires **two** facts together, since either one alone would either point the stream at the run it already had or mint an empty handle. The record of what finally happened to an already-decided action is read through the **same** reader as every other gate record rather than a second copy, and its absence means **unknown**, never *it was allowed* — the two can even contradict each other, so the card says nothing at all when it is missing. The three refusals on that lane each get one distinct sentence and a disposition taken from **why** each was refused rather than from severity: one cannot be helped by re-sending at all, one waits on the host, one just drops an option — and none of them carries a countdown, because the server never mints a wait for them. Recognition is a **closed set**: an unrecognised code on the same prefix returns nothing rather than a guess, since that prefix also houses a safety signal whose whole rule is never to retry automatically, and the recovery handle is read as absent when unreadable rather than substituted from a different identifier that no longer appears on that lane |
293
354
  | `scripts/run-approval-frame-chrome-arms-test.mjs` | The two in-stream approval frames finally reaching every host through the shared pipeline instead of one shell's private branch — the shape of a layering defect: hosts that only consume the package could not rebuild their pending cards after a reconnect, and did not clear a card the engine had withdrawn. The payload is deliberately carried as the **envelope** the upstream types declare rather than the first-version card: the stream parser applies no predicate, so narrowing here would let a legitimately newer frame pass as the older shape and invite consumers to read keys a newer card never promised. The guard therefore pins that every open key survives untouched, that an unknown version still passes through, and that narrowing is left to the host's own predicates — with the fallback being a generic card and a person, **never** an automatic denial. A frame whose version cannot be read at all is reported as malformed rather than dropped in silence, because both frames carry user-visible decisions and state changes. Both arms are registered as **required** host duties, and their duty text names the load-bearing rules a host would otherwise have to rediscover: which predicate to narrow with, that the reconnect preamble — not a replayed historical frame — is the authority on which cards exist, and that a withdrawal frame can be lost entirely. Unlike the sibling arms, these carry **no** sub-stream cutoff: an approval raised under a delegated call still has to reach a person, and filtering it by ownership is the host's job, not a reason to discard it. Finally the upstream bytes that justify the envelope discipline are checked to still be there, since the whole design rests on them |
294
355
  | `scripts/run-terminal-status-vocabulary-test.mjs` | One place that decides whether a run has **ended** and whether it ended badly — written because that judgement had already been hand-copied three times, so the day the engine added a word for *the agent itself reported it cannot continue*, every copy missed it and a panel settled a self-reported failure as a success. The distinction the table exists for is pinned from both sides: that word belongs in it, while the two words meaning *waiting for a person to decide* deliberately do **not** — reading those as endings would bury a run that is actively waiting on the reader. A word this client does not know answers *no*, and the guard states plainly that *no* is not evidence of success: proving success means reading the positive side, so negating this predicate is the very mistake that caused two earlier incidents. The fleet lane gets the same treatment from the other direction: a workflow parked on a durable approval used to fall through to *running*, leaving the person with no hint that a card was waiting, and it now lands on the same rendered word the task lane already used — same fact, same word, checked end to end on a real row. Why the word was added directly rather than carried as a private superset key is checked mechanically against the upstream declaration being open, so the day it closes this reds and the decision gets revisited. The residue sweep is the point: the source tree must contain **no** further inlined copy of the judgement, each of the three former sites is checked to really read the single predicate, and the one reviewed exemption carries its reason **and** a liveness assertion, so an exemption whose justification expires cannot quietly keep standing |
356
+ | `scripts/run-terminal-word-source-test.mjs` | Two tables of ending words, kept apart by **who owns them** — because they used to be one. The engine's own closed set of reasons a run ended, and the server's set of row states a run can finish in, overlap in three words but not in all of them: one word for *something outside stopped it* exists only on the server side, and one for *it paused and can be resumed* exists only on the engine side and means very nearly the opposite of an ending. Merged into a single list, those two sources became indistinguishable, so a new word on either side looked the same as a new word on the other, and the safest-looking move — folding the unknown word into a known one — is the exact mistake that has caused incidents here before. The engine-owned table is checked as a **copy, not an opinion**: it is reconciled word-for-word and in order against the installed engine package, read from both its declaration and its runtime bytes with the two required to agree, so the day upstream adds a fifth reason this reds before anything ships. The two dividing words are each pinned from both sides, including against the upstream declaration directly rather than only against this package's own list. Why the table is copied rather than re-exported is itself an assertion with an expiry: the day upstream publishes the set as a value, this guard reds and the decision gets revisited. The renamed tables leave **no alias** behind, since an alias would let a reader keep consuming the merged list and the split would have bought nothing |
357
+ | `scripts/run-workflow-park-truth-projection-test.mjs` | The read face for *which approvals a workflow run left parked* — and the credential that must never ride along with it. Upstream strips the redemption token from that response, and this package's reader is built so the token **cannot** come back: each row is assembled field by field from the three identity keys, never copied wholesale, so an extra key appearing upstream is structurally unable to reach anything this package hands a UI. The guard proves that rather than asserting it — a poisoned row carrying a secret is read, and the secret is searched for across the **entire** serialized result, with the same search proven to find it in the input so a blind search cannot pass; renaming the credential key does not help it through, because the rule is *only these three*, not a blocklist; and the reader's own source is checked to contain no object spread, since one such line would quietly void all of it. The other half is an absence distinction with opposite consequences: a record with **no** parks field at all was written by an older engine and proves nothing about whether approvals are waiting, while an empty list is a positive statement that none are — collapsing those two would let a run whose parked approvals cannot be proven be resumed anyway, so they are kept literally distinguishable, and a payload whose rows are all unreadable answers *unknown* rather than *none*. The four refusal codes for this family are checked code by code against the engine's real bytes, never matched by name prefix, and the older umbrella code they were split out of is asserted to still be **alive** — treating the whole code as retired would make a family of real refusals vanish silently |
295
358
  | `scripts/run-retired-vocabulary-census-test.mjs` | Whether a retirement really happened. When upstream removes a family, a downstream package can cut it out or keep a courteous alias — and the alias is the worse outcome: three clients keep writing branches for something nobody emits, and a status line advertises a state it can never reach. Choosing the clean cut only means something if a guard holds it, since a comment saying *retired* is not an exit code. Each registered entry is held two ways: the name must be gone from **code positions** in this package (comments stripped first, because the explanation is supposed to stay) and off the published surface, and — the half that keeps this from being self-congratulation — it must really be gone **upstream**, since that is the entire reason it was removed here; if it comes back, the disposition deserves reconsideration rather than silence. The scanner proves it can speak by finding a symbol that is genuinely present before any absence is believed, and distinguishes a mention inside a comment from one in a string literal, which is exactly the form being cleared. A closing check runs the other way: the retirement **story** must remain in the comments, including a promise this package made earlier and has now had to withdraw — deleting the history alongside the code is a bad way to satisfy *zero hits*, and leaves the next reader with code that has no reason |
296
359
  | `scripts/run-classifier-status-test.mjs` | What state the auto-mode classifier is in **on this session** — the question a doctor line, a model settings page and a permission card’s status row all ask, and a different question from the one the approval card asks (*why am I being asked right now*), so the sentences are pinned mutually distinct from that face’s as well as from each other. The session-level half of this reading — a breaker record the engine used to keep — was **retired upstream**, and the guard now holds that retirement from **both** sides: the engine's own declarations must really no longer carry it (a fact coming back would mean the removal here was the wrong disposition, and that deserves a conversation rather than silence), and this package must carry no alias, no state word and no leftover narrowing for it — a reading kept alive for something nobody emits any more is a promise the interface cannot keep, and it left the doctor line advertising a state it can never reach. What remains is ordered by the quantity that actually decides whether the classifier is running: the fact from **this round** first, then whether this leg is armed — a decider is minted per run, so a later leg can be armed again. Not armed, and a section that never arrived, both answer **undefined** rather than *available*; that arming question has its own field and answering it twice grows a second ledger. Arming and availability are also **two words, not one**: the engine says a decider was minted *for this leg*, which is an assembly-time fact, while whether that decider answers any given round is a **per-call** one — so an armed leg reads `armed` and only a positive per-call fact (an ask whose origin is the classifier's own denial-bound fallback, which by construction stands *after* the classifier ran) reads `available`. Every other ask origin is refused as evidence and for a stated reason rather than out of caution: several are ones the classifier is structurally forbidden to answer, and for the rest a surviving ask is precisely the case where it did **not** resolve one — so reading availability off them would be a guess. The projection is a **whitelist**, so an older engine still sending the retired member loses it at the boundary while the two live facts beside it ride through untouched. Rendering never throws and never impersonates: a state word this client does not know — including the retired one, which a restored view can still carry — reaches an honest fallback that names it verbatim, carries no invented explanation of a mechanism that no longer exists, and is proven distinct from all three real sentences; prototype keys reach that same fallback rather than a function body, checked against a real out-of-table word so the comparison cannot hold vacuously |
297
360
  | `scripts/run-compaction-boundary-projection-test.mjs` | The compaction divider and the one frame that makes its anchor resolvable. The trigger word is passed through as an **open set** instead of being folded to two: the engine deliberately stopped flattening its third value (a compaction that was not optional — a prompt-too-long recovery or trim pressure) and carries what the hook layer saw, so folding it again at the package boundary re-introduces exactly what upstream had just removed, while a consumer branching on *is it manual* keeps its behaviour byte for byte. Only an unreadable word (absent, empty, non-string) falls back — that is *could not read it*, not *read it and did not recognise it*. Two superset keys ride the metadata and neither fabricates: the preserved-segment anchor is minted only when its id really reads out, because half an anchor sends the host looking up an empty string in its map, and the clamp ratio is a **disclosure** whose real zero is a fact rather than an absence. The clamp ratio also carries a registered exit condition — the service really sends it while the SDK arm has no seat for it yet, so the read is defensive and this guard reds the day that seat appears, forcing a re-check instead of leaving a cast to rot. The committed-message frame moves out of *deliberately not projected*: that classification was true about transcript rows and false about **positioning**, since the engine states that consumers build their own id-to-message map from this frame to place the divider — projecting the anchor without it hands the host something it cannot resolve. It becomes a neutral internal arm and an optional chrome ledger event, never a transcript row (the frame carries no body, so minting one would put words in the engine's mouth), with both required ids narrowed and a malformed frame recorded rather than half-minted |
@@ -300,6 +363,7 @@ public-surface guard checks that last one).
300
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 |
301
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 |
302
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 |
303
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 |
304
368
 
305
369
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
@@ -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
- * 🔴 **不动文本缓冲**:本批**不**在这里调 `takeAnswerSegment()`。撤 idle-flush 启发式要先按
417
- * core 的「诚实缺席」纪律做一条 **per-stream** 的判据(整条流一帧都没有才回落启发式),
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
  };
@@ -717,11 +750,22 @@ const streamEventArm = function* (m, { text }) {
717
750
  };
718
751
  const turnUsageArm = function* (m, { text, flags }) {
719
752
  const ot = typeof m.outputTokens === 'number' ? m.outputTokens : undefined;
753
+ // 🔴 0.68.0(异源对抗复审轮五实抓)——**两件事分家**:
754
+ // · 「这一轮结束了」⇒ 无条件 drain + 压下一 model round 的基线(TTFT 那一拍);
755
+ // · 「这一轮花了多少」⇒ 只有**真有数字**时才发 end 度量。
756
+ // #711 之后,缺账轮这一拍**没有数字可发**(占位零不许当读数)。修前两件事合在
757
+ // `emitTurnUsageEnd` 一个动作里 ⇒ 过滤掉数字就连**模型轮边界**也一起过滤掉了:下一轮不再发
758
+ // `response_metrics{start}`,它的用量按**上一轮**的基线对账(整链实测:首轮 400 字符缺账、
759
+ // 次轮 4 字符 + 50 tokens,计数从 150 掉到 101,下一轮的 TTFT 记录一并丢失)。
760
+ yield* text.drainLive();
720
761
  if (ot !== undefined) {
721
- yield* text.drainLive();
722
762
  // ⟨帧序耦合 7/7⟩ 这一拍置 endEmitted,后到的 result 臂据它决定要不要补发 end 度量。
723
763
  yield* flags.emitTurnUsageEnd(ot);
724
764
  }
765
+ else {
766
+ // 🔴 不发假零度量,但边界照压(`endEmitted` **不置** —— 终帧若真有一笔账,补发腿仍该补)。
767
+ flags.noteModelRoundBoundary();
768
+ }
725
769
  };
726
770
  // ══ M0 编排半场 —— result(收口三形之一;与 D7/D8 的次序差异见矩阵 §2.4,不可归一)═══════════════
727
771
  /**
@@ -749,7 +793,21 @@ const resultArm = function* (m, { ctx, idOf, text, cards, panel, flags }) {
749
793
  yield* text.takeThinking();
750
794
  yield* flags.emitResultEndIfNeeded(() => {
751
795
  const u = m.usage;
752
- return typeof u?.outputTokens === 'number' ? u.outputTokens : undefined;
796
+ const ot = typeof u?.outputTokens === 'number' && Number.isFinite(u.outputTokens) ? u.outputTokens : undefined;
797
+ // ── 🔴 0.68.0(#711 同形的**第四处**,异源对抗复审轮二实抓)────────────────────────────────
798
+ // 这是终帧的**补发腿**:`turn_usage` 那一拍没发过 end 度量时,由它拿终帧的 usage 补一发。
799
+ // 而 core 7.17.0 之后,一轮没量出账的 run,它的终帧 usage 是**占位全零**(同帧的
800
+ // `_sema_usage_lower_bound` 才是那句「这笔账不知道」)⇒ 修前这里会补发一条**没有任何判别位**
801
+ // 的 `response_metrics{phase:'end', outputTokens: 0}`,而本臂的宿主义务逐字是「驱动
802
+ // responseLength reducer(ttft/对账)」—— 一条精确零会被当成一次真的「这一轮吐了 0 个 token」。
803
+ // 同一条判据在本包一共五处:公面读器 `turnEndUsage`(`eventToSdkMessage.ts`)/
804
+ // `turn_usage.outputTokens` / 子代分表 `cacheReadTokens` / `RunStreamHandle`(三处在
805
+ // `adapter/runStream.ts`)/ **本处**。前四处收口了而这条**消费链的末端**没跟上 = 过滤没贯穿到底。
806
+ // ⇒ 同一条判据:**下界位在场且读数是 `0` ⇒ 那是占位,不补发**(`outputTokens` 读不出时本来
807
+ // 就不发,这一形沿用既有的那条路);**非零照发** —— 占位恒为 0,非零必是真的量到过,
808
+ // 连带丢掉它会让一条有产出的缺账轮在对账面上凭空少一笔。
809
+ const lowerBound = m._sema_usage_lower_bound === true;
810
+ return lowerBound && ot === 0 ? undefined : ot;
753
811
  });
754
812
  yield* text.takeAnswerSegment();
755
813
  // 具名成 terminalText:M1 收编后 `text` 是流合并器的名字,原来的同名局部会遮蔽它。