@sema-agent/client-core 0.61.0 → 0.62.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 CHANGED
@@ -49,6 +49,166 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.62.1(2026-09-09)
53
+
54
+ > patch 内容批(两件)。**本段只记内容**:`package.json.version` 未动、README `Version` 行未动、
55
+ > `FROZEN` 未动 —— bump 与段头转日期形归**发包批**(阶段一义务见本档头注)。两件都**零新导出、
56
+ > 零型面变化**,老宿主一个字节不改照常跑;`peerDependencies` 未动(sdk 地板仍 `>=8.5.0`)。
57
+
58
+ - 🔴 **子代内容账本:工具两臂补上重放幂等闸**(行为**修复**)。0.62.0 只给聚合两臂
59
+ (`text` / `reasoning`)上了两道闸,`tool_start` / `tool_end` **一道都没有** —— 断线重连后
60
+ durable 腿整轮重放时,同一次工具调用在子代查看态里排出**两张卡**、结果正文被记两遍
61
+ (内容字节预算跟着虚高)。现形:判据形与散文两臂相同(先问身份、再问账本),身份 = `toolCallId`、
62
+ 账本 = 查看态 `items` 上那张卡 —— **刻意不共用聚合帧那本有界身份记号**(它的额度按「聚合帧只在
63
+ 重放腿上来」定,而工具帧活体也来,跟着写会把还在屏上的散文身份挤掉)。
64
+ 🔴 判据不是「见过没见过」而是**阶段的先后**:同一个 `toolCallId` 真的会有**第二对** start/end ——
65
+ 门把调用 park 掉、批复后恢复执行时引擎复用同一个 id 再发一对,第二对带的才是真结果(批复还可能
66
+ 改过参数)。库为每张卡记一条**阶段水位**(已落账的 start / end 身份),按 `eventId` 的先后判新旧:
67
+ 比水位旧或就是同一条帧 ⇒ 整帧丢弃;更新 ⇒ 后到的才算数、就地替换(按差额记账)。可比性的前提是
68
+ `eventId` **时间可排序**(上游铸的 uuidv7);缺席 ⇒ 判不出 ⇒ 退回「后到的落账」(空操作)。
69
+ 水位与卡**同生共死**。四条细则:①`tool_start` 不铸第二张卡;这张卡**还没落过 start**(收口先到时
70
+ 合成的占位卡)⇒ 先段闭合再补齐名参,水位已在 ⇒ 不段闭合;②旧一阶段的 start 不许把改后参数盖回;
71
+ ③`tool_end` **先问卡还开着吗** —— 开着就落结果(在飞的卡被拦掉收口 = 永远在飞);④收口找不到卡时
72
+ 合成一张已完成的卡、**不**在那里段闭合(边界由迟到的 start 补做;在收口处闭合会把还在流的一段
73
+ 提前切成历史 item,紧帽下随即被丢)。去重射程严格是**同一次调用** —— 另一个 `toolCallId` 的卡照常进。
74
+ 判据见 `docs/INTEGRATION-CLIENTS.md` §26b、§26j **G-14**;常驻门
75
+ `scripts/run-subagent-durable-divert-test.mjs` 新增 G8 / G9 / G10 / G11 四段。
76
+ - 📋 **§26j 黑盒判据 G-12 改写**(判据面订正,**产品字节零变化**)。原文「诊断行显示长度**不超过**
77
+ 同长度良性值那一行」超出实现的承诺:呈前封长是**先转义、后按转义结果封长**,一个不可见字符转义后
78
+ 占 6 个显示字符 —— 转义后仍在帽内时一枚都不用截,那一行必然**长于**同长度的良性行(良性行 1:1)。
79
+ 改写后的 G-12 判的是真正承诺的量:**≤ 帽、行尾无半截转义序列、转义后仍在帽内的值不截**,且判据量
80
+ 一律是**转义后的显示长度**(原文短于帽但转义后超帽 ⇒ 照样截)。常驻门
81
+ `scripts/run-display-cap-order-test.mjs` 同批补三档(转义后在帽内不截且逐序列完整 / 原文短而转义
82
+ 超帽照截在序列边界 / 恰好抵帽时行尾不是半截)。
83
+ - 子代内容账本:迟到的 `tool_start` 触发条数裁剪后不再按裁剪前的下标/引用改名补参、写差额(异源复审第四轮 [high]);flush 之后按 `toolCallId` 重新找卡,卡已被裁掉 ⇒ 按「新的一次」重建(§26b 留白①);门 G12 三格。
84
+
85
+ ## 0.62.0(2026-09-09)
86
+
87
+ > 内容批(隔离树交付,七件)。**本段只记内容**(0.60.0/0.61.0 先例逐字):`package.json.version`
88
+ > 未动、README `Version` 行未动、`FROZEN` 未动 —— bump 与段头转日期形归**发包批**(阶段一义务见本档
89
+ > 头注)。七件**全部 additive**:新导出、新可选入参键、新 chrome 臂、新配置口,老宿主一个字节不改
90
+ > 照常跑;`peerDependencies` 未动(sdk 地板仍 `>=8.5.0`)。逐件的签名/缺席语义/换装坐标/黑盒判据在
91
+ > `docs/INTEGRATION-CLIENTS.md` **§26**。
92
+
93
+ - **① 子代内容分流补 durable 聚合形**(修复;`SubagentContentEvent` additive 两臂 `text`/`reasoning`
94
+ + additive 位 `text`)。带 `parentToolCallId` 的内容帧属于**子代**,必须走侧信道进子代转录账本、
95
+ 绝不进主转录。而分流判据此前只列了四个**活体**臂 —— durable 腿(事件重放)送的是同一段的**聚合**形
96
+ (整段全文),于是重放尾上带 `parentToolCallId` 的聚合帧**漏过分流**、一路渲成 leader 自己的正文。
97
+ 断线续听与交互续听重放尾都踩这一形。修法是让两类臂共用**同一条**判据:`parentToolCallId` 在场 =
98
+ 这一帧属于子代,帧是增量还是整段与「它属于谁」无关(各判一次正是漏的成因)。
99
+ 🔴 **不折成一发增量**(那是更小的 diff 也是错的):增量的语义是「追加」,已经活体流过、又被重放
100
+ 一遍的段会被数**两次**。两类分开、聚合臂按**吸收**处置 —— 全文以当前缓冲为前缀 ⇒ 换掉而不是追加
101
+ (同一条聚合帧重复送达因此**幂等**);前缀对不上 ⇒ 退回追加、两段都留下(少渲一段比替引擎判定
102
+ 「哪一段才算数」更坏)。段闭合仍由工具帧/收口给,聚合臂**不**就地闭合(就地闭合会让同段的第二次
103
+ 重放落成第二个 item);增量臂恒追加,不被吸收规则改写。
104
+ 🔴 **车内异源复审跟修**:只按「当前缓冲的前缀」吸收还不够 —— 一条跨过工具边界的 turn,早先那些段
105
+ 早就段闭合成 item、缓冲已空,重放时 `''` 是任何串的前缀,每一段都会被当成新内容塞回缓冲(内容重复
106
+ **且顺序错乱**)。补两道闸:①**事件身份**(聚合帧的 `eventId`,精确无损,跨流也管用 —— 缝前的事件
107
+ 序号去重只在同一条流内有效,重连后是新的一条流);②**整段内容**(已段闭合上屏过的整段,兜底)。
108
+ 另补一形:被预算**裁过头部**的那一段,缓冲此时只剩尾巴,前缀判据会判成新内容整只追加回来 ——
109
+ 「全文以缓冲结尾」⇒ 判为同一段、整帧丢弃(否则「丢了多少」那个数会一路虚涨成一句假话)。
110
+ 两道闸各带一格常驻回归,其中「跨 runStream 实例」那一格按**生产真形**做(重连 = 新的一条流,
111
+ 缝前的事件序号去重对跨流重放零判别力,拦得住的只能是 store 侧)。
112
+ 📋 如实留白:内容闸的代价是「一条 turn 里出现两段逐字节相同的正文时,durable 腿上只留一段」——
113
+ 取舍明写:每次重连把整轮正文再渲一遍是**必然**的用户可见损坏,而同一 turn 说两遍一模一样的话是罕见形。
114
+ 常驻门 `scripts/run-subagent-durable-divert-test.mjs`。
115
+
116
+ - **④ 出站凭证位放宽成「串或取值函数」**(`WireAuthTokenSource`,additive 型;引擎 wire 构造口 +
117
+ 探测入参 + prepare 腿连线配置三处同批放宽)。串是在**构造那一刻**被读走的 —— 底层客户端把它捕获进
118
+ 闭包,此后每一发请求都用那一份;引擎被别的会话换代、凭证换新之后,一个长命的 client 会一直拿旧凭证
119
+ 发请求,而端唯一的出路是把整个 client 连同它身上的状态重建一遍。取值函数形**每发一次请求读一次**,
120
+ 端从此只换值。🔴 实现刻意**不在构造期读**(构造时读一次只是把快照挪了个位置):构造给的是不读取值
121
+ 函数的三态形(只用来过底层的构造期形校验与浏览器守卫),真正上 wire 的那一份在出站腿逐发重写 ——
122
+ 「读取次数 = 请求次数」是可验证的承诺,门里逐条核。三态语义逐格重演:取不到值(返回空/`undefined`/
123
+ **抛错**)时,回环出**零** Authorization 头(绝不伪造)、非回环出 fail-closed 匿名身份;取值口抛错是
124
+ **fail-soft**(一个坏掉的取值口不该让整条 wire 不可用,而且异常里可能夹带凭证材料)。
125
+ 串形逐字节零行为差;同源中继声明形不进取值路径。
126
+ 📋 **如实留白(同族存量两处刻意不放宽,理由已记账)**:①workflow 活动台账的连线身份**按凭证值比相等**
127
+ 来判「还是不是同一条连线」,而函数没有值身份(宿主每次渲染新建一个箭头函数就会让台账每拍重开一条流)
128
+ —— 放宽它要先给那条 lane 一个连线身份口径;②信号路径的 cancel 兜底是**在武装那一刻快照**的,它由
129
+ 信号处理器同步读取,那里不该调用任意宿主代码。两处都不是遗漏。
130
+ 常驻门 `scripts/run-wire-auth-source-test.mjs`。
131
+
132
+ - **⑥ 终答与已上屏正文的补差**(修复;新增 additive chrome 臂 `result_text_diverged`)。一个 turn 的
133
+ 正文正常是流着上屏的,终帧带的那份是同一段话的全文。断线前已经流过一部分、重连后终帧带**完整**
134
+ 那份时,「这一轮已经产过 assistant 文本」为真 ⇒ 整条终答兜底被跳过,屏上永远缺断线期间那一截,
135
+ 而且没有任何东西说少了。修前只有一格(零已提交 ⇒ 整段渲),本批补齐四格:
136
+ ①零已提交 ⇒ 整段渲(逐字保留);②已上屏是终帧的**前缀** ⇒ **只补后缀**那一截(重渲整段会让前半
137
+ 在屏上出现两遍,那比缺一截更坏;相等时一个字都不发);③终帧是已上屏的前缀(引擎侧修剪)⇒ 屏上
138
+ 一个字不动(补吐没有对象,拿更短的覆盖更长的会抹掉用户已经读到的);④两份**互相都不是对方前缀**
139
+ ⇒ **零补吐** + 一个 `result_text_diverged` 事实位让宿主的转录行改口 —— 谁算数只有引擎说得清,把
140
+ 终帧那份接在屏上那段后面会拼出一段谁都没说过的话。事实位带的是**长度**不是内容(复述一遍等于给
141
+ 渲染端第三个版本去挑),义务是「改口」不是「补渲」,不接 = 这条披露看不见但**绝不许**渲成
142
+ 「已完成」的正面确认。
143
+ 🔴 **车内异源复审逼出的定谳(本件最重要的一条)**:比较的两边必须是**同一种东西**。终帧那一位在
144
+ 引擎侧取的是**最后一条** assistant 消息的文本、且出厂 `.trim()` 过(装在盘上的引擎产物直证),
145
+ **不是**整轮正文的拼接。拿「本轮全部正文」去比,一个「散文 → 工具 → 散文」的**正常多步回答**就会被
146
+ 判成分岔 —— 对着一条完全正常的 turn 报一句「最终答复与显示的不一致」。⇒ 左边读的是**当前这条
147
+ assistant 消息**已上屏的正文(工具边界重置、durable 整条帧整段替换),并且相等判定先过一次 `trim()`
148
+ (否则只差首尾空白的回答同样会被判成分岔);前缀判据也容忍引擎侧的首部修剪,且切点按**真正对上的
149
+ 那个前缀**算,不会错位。
150
+ 同批给流合并器加 `committedAnswerText` 读位(**当前这条消息**已提交上屏的正文)与
151
+ `beginAssistantMessage()` 划界口,与既有的 `answerLength` 那个累加器**刻意分家**:后者含还押在
152
+ 缓冲里、尚未提交的字且跨消息累加,拿错那一个会把还在缓冲里的内容当成缺的再补一遍。
153
+ 常驻门 `scripts/run-result-text-backfill-test.mjs`。
154
+
155
+ - **⑤ Esc 停止判定层补 detach 那一格**(`planInteractiveHalt` 入参 additive 键 `detachArmed`)。
156
+ 撕 SSE 的**后果**因 detach 而变:老形态下撕流 = 服务端按断连语义当场收尾那条 run,所以「interrupt
157
+ 那一发没落地」还有一层兜底;detach 武装之后撕流只断开**观看**,run 会一路跑到 turn 结束 —— 此时
158
+ interrupt 自身失败(传输失败/超时/未武装)= 用户按下的 Esc **一点效果都没有**,而屏上没有任何东西
159
+ 会告诉他。新格补的就是那层兜底消失后留下的洞:没拿到判决 × detach 在场 ⇒ 升级 cancel。
160
+ 三条硬约束随格写死:①只在「没有判决」那一格被读(引擎给了判决时判决说了算,与 `parked` 同纪律);
161
+ ②`parked` **先判** ⇒ 老宿主的 reason 逐字不变,两者同真时仍报 parked 那一句;③严格 `=== true`。
162
+ 🔴 **缺席 = 老语义零差**,而且是**证明**出来的:门里带一份上一版判定的参照实现,把 45 格全表跑两遍
163
+ 要求零差异,并先证明这个比较器在该有差异的那一格真的报得出差异。
164
+ 常驻门 `scripts/run-esc-halt-plan-test.mjs` 扩 G6/G6b 两段。
165
+
166
+ - **② 子代内容账本补字节预算**(`configureSubagentContentStore` / `subagentContentStoreConfig` /
167
+ `SUBAGENT_CONTENT_STORE_DEFAULTS`,三件 additive 导出 + 一个测试钩)。这本账本此前只有**条数**帽
168
+ (每条子代 ≤ 200 条、≤ 32 条子代),而条数帽在**单条无上限**的前提下不是一个预算:一条工具结果里
169
+ 的整个内联附件、一段几 MB 的模型输出,都能让一个子代槽位吃到十几 MB 常驻。
170
+ 缺省 **每子代 2 MiB / 总计 16 MiB**;单位是 **UTF-8 字节**(按串长度算会让一段 CJK 的真实占用是
171
+ 名义值的三倍)。🔴 缺省值的来历如实写在代码里:对标产品的语料里**没有**对应的「子代转录字节预算」
172
+ 可对齐(查过),这两个数不假称是 parity —— 总帽按实测观测到的量级取,每子代帽取总帽的 1/8。
173
+ 越限处置 = 从**最早**的内容开始丢(活体尾巴最后才动:丢用户眼前正在流的字而留着更早的历史是错的
174
+ 一头),并留一条 `truncated` 记录(丢了多少 + 从哪个偏移起是保留的)—— **恒一条、恒在最前、也进
175
+ 渲染计划**;没有它,屏上就是一段**无声消失**的输出。总帽淘汰**整条最久未用**的子代账本(不做
176
+ 「每条各裁一点」——半截的转录比一条整齐的缺席更难读)。顺带把条数帽丢弃那条老路也接上了留痕。
177
+ 配置面 **fail-loud**:0 / 负数 / 非有限数 / 非整数一律 `throw`,且拒绝是原子的(半套配置比不配更坏)
178
+ —— 一个被静默忽略的预算配置正是这一位要消灭的形。
179
+ 🔴 **车内异源复审跟修两条**:①**拼接边界的代理对**——一对代理对被上游拆成两帧送来时,按 chunk
180
+ 单算是 3+3=6 而拼起来是 4 字节,每拆一次虚高 2 字节,而段闭合时按拼接后的串扣账 ⇒ 差额永久留在账上,
181
+ 长流里一路把总账推高、最后把还在用的账本淘汰掉;改成只在**拼接边界**上修正一次(O(1),不重扫缓冲)。
182
+ ②**每子代帽不许高过总帽**——两位各自合法、合起来自相矛盾:单条子代能合法地涨到每子代帽,而总帽淘汰
183
+ **不动当期那条**(它正在写),于是那一条整只越过了总帽;配置口补一条关系闸(判的是**落值之后**那一对,
184
+ 只配一位时另一位保持现值,矛盾同样可能是这次配出来的)。
185
+ 常驻门 `scripts/run-subagent-content-budget-test.mjs`。
186
+
187
+ - **③ 座位 lane 补子代两位载体**(`forwardSubagentEvents` / `retainSubagentSessions`,additive)。
188
+ 两位此前是**结构性缺席**:座位 lane 的载荷是一个闭集接口,不在集里 = 「这一轮要不要看子代正文 /
189
+ 要不要保留子代会话」这个决定在这条 lane 上**没有位置可放**(与 `reasoningEffort` 当年同一形)。
190
+ 两位都原样转投同名引擎请求位。🔴 随位写进两条容易踩的语义:①`capabilities.forwardSubagentEvents`
191
+ 说的是「这个键被受理」而**不是**「转发已开着」(它是恒真的受理位)—— 把它读成后者会接出一个永远
192
+ 空着的子代转录面,而能力面一直答「是」;②保留窗口/条数**过大不会被拒、会被引擎悄悄收紧**,
193
+ 渲染端不许把自己请求的值当生效值回显。
194
+ 同批把 `SEND_MESSAGE_REQUEST_KEYS` 升级成有出身的:新增 additive 导出 `SEAT_SEND_MESSAGE_KEY_ORIGINS`
195
+ —— 每个座位位要么写明转投哪个引擎请求位(含 `text → objective` 这种**异名**转投),要么写明为什么
196
+ 不上 wire(`sessionId` 是**同名不同义**的典型:座位那一位是本地会话句柄,引擎会话 id 另有承载)。
197
+ 编译期与闭集双向钉,内容真假由常驻门 `scripts/run-seat-task-request-origin-test.mjs` 对**装在盘上的**
198
+ sdk 型面**直读**核(不手抄键名清单——手抄那一刻门量的就只是两份手抄一不一致)。
199
+
200
+ - **⑦ 呈前封长顺序:同形存量四处收成一只**(`capForDisplay`,additive 导出)。本包每一处「把外部
201
+ 串放上屏之前先消毒 + 封长」的铸点,两个动作的顺序必须是**先转义、后按转义结果封长** —— 消毒器把
202
+ 每个不可见字符改写成 6 字符的可见转义,反过来做(先按原文截、再转义)会让一段纯控制字符的 40
203
+ 字符原文在屏上占 240 个显示字符,承诺的列宽预算成了名义值的**六倍**。四处铸点里 READ 档位读器
204
+ (0.61.0)已是对的,另外三处是反的:写保护姿态行的 `source`、它逐行的 `name`/`kind`、SQL 姿态行的
205
+ 三座。修法不是逐处打补丁,而是把这一只**提到共用底座**(`src/fleetTaskDesc.ts`,与消毒器同居)
206
+ 并让四处一律接它;截点同时避开两种断裂片段(一枚可见转义 token 的中间、一对合法代理对的中间)。
207
+ 🔴 SQL 那一处此前刻意写的是反序,理由是「先清后截会把一个转义序列拦腰截断」—— 那个顾虑成立,
208
+ 但它的解法是**截点避让**而不是把顺序反过来:两个毛病一次同时消掉。对**正常读数**(不含控制字符
209
+ 的引擎/数据库回值)四处逐字节零变化,只有含控制符的病态值渲染形收紧。
210
+ 常驻门 `scripts/run-display-cap-order-test.mjs`。
211
+
52
212
  ## 0.61.0(2026-09-08)
53
213
 
54
214
  > 内容批(隔离树交付):`@sema-agent/sdk` **8.5.0 提货** —— operator 面
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.61.0
38
+ **Version:** 0.62.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
@@ -238,6 +238,12 @@ public-surface guard checks that last one).
238
238
  | `scripts/run-sql-engine-capability-test.mjs` | The SQL-posture read face and the four-state capability reader underneath it. One capability cell here carries **four different things**, and each one points an operator somewhere else: nothing has been observed yet in this process (a one-shot doctor run is always in that state), the response arrived but carries no such key (an older engine), the engine explicitly answered `null` — *this deployment has no SQL backend*, which is a **positive fact** rather than an absence — and a full reading. Fold any two together and the screen states something flatly, confidently, and wrongly, so every positive control here is paired with a control pointing the opposite way, and the four sentences the doctor row can print are checked to be pairwise distinct and non-implying. The reading itself is narrowed no tighter than the mint: `txnMode: null` is a **legal value** — two of the three engines always report it that way, and the upstream type note names reading it as "optimistic" as the error — so treating it as malformed would throw away the entire reading for ordinary deployments, which is the same disease this repo logged when a consumer's domain was narrower than the producer's. A response that cannot be parsed **clears** the cell rather than leaving the previous engine's answer in place, and a separate invalidation port exists for the case the generation latch cannot catch — a same-port respawn whose new probe never succeeded, where the stale reading would otherwise be answered as current fact. Untrusted values (the isolation string is read back from a database server variable) are sanitised and bounded before display, and the bound is applied **before** escaping so a visible escape never gets cut in half. Finally the export names are themselves a guard: the shell still carries a copy that is meant to go red on the package's same-named export and be swapped out, so renaming anything here would silently disarm that lock |
239
239
  | `scripts/run-terminal-cause-projection-test.mjs` | The `7.64.0` wire reshape, projected. A run's ending stopped being eight parallel flat keys and became **one tagged cause** (`completed | failed | blocked | paused`), and a tool call's gate stopped being four orthogonal words and became **one record** (`disposition` / `settlement?` / `origin?`). Both are read in exactly one place in this package, and this guard pins them at **two levels**, because the dangerous seam is "the reader was updated, the consumer was not": each terminal arm is checked on the reader *and* on the `subtype` / `is_error` / `errors[]` the projector actually emits. Two properties carry most of the weight. First, a terminal word this reader does not know is **never** laundered into an empty success — it lands on an `unknown` arm carrying the word verbatim, while a payload with no terminal word at all (the mock lane) keeps the success arm exactly as before, which is the one and only case the reader answers `null`. Second, the three window words (`approval_window_expired`, `denial_limit_window_expired`, `park_sla_expired`) must each be told apart by a different predicate: the previous generation collapsed all three onto one `timeout`, and re-merging them would throw away the discrimination this reshape just restored. Two byte generations are read by one reader, keyed on the discriminator upstream nailed (`"terminal" in result`): the current cause form, and the **flat** form that a current engine still emits on two lanes — replayed persisted bytes, which the service passes through verbatim rather than back-filling, and the service's own rejection envelope. A cause-form payload that also carries stale flat keys must ignore them entirely: keeping one compatibility read is what gives a single fact two sources. The same file also pins the MCP delivery verdict and HTTP status riding the wiring manifest, the four-state write-protection reading (where three of the four states mean *cannot tell*, and none of them may be printed as "there is no table"), and the park-reopen fetch identity: that predicate is asserted through the **real entry point**, since the defect being fixed was precisely a call site wired to a different predicate than the one that routed the row there |
240
240
  | `scripts/run-read-face-posture-projection-test.mjs` | The operator-face `readFace: ReadFacePosture` reader (server >=7.65.0). Three ways of "can't say" are pinned to three different, literal sentences, and none of them may read as "nothing is pinned" — that statement belongs to exactly one case, `face: null`, which is a positive fact reported by the engine, not an absence: not having read an operator response yet, having read one from an engine too old to report the key, and the engine actually saying nothing is pinned are three different next steps for an operator and must not collapse into each other. `source` is read as an open set (the server's closed four words plus an escape hatch) rather than narrowed to an enum, so a new word added upstream is not silently turned into a bad reading. The free-text `note` is sanitized and length-capped before it is ever rendered. A companion pure function flags disagreement between this face and the tenant-facing `capabilities.readFace` — silent only when the two actually agree, honest-absent when either side cannot be read at all, never asserting agreement as a fact. The gate's last leg reads the installed SDK's own `openapi.yaml` directly rather than restating the schema in prose, so the package's leniency cannot quietly drift from the real contract |
241
+ | `scripts/run-display-cap-order-test.mjs` | The order in which untrusted text is sanitised and length-capped, across every mint point that puts an engine- or database-supplied string on a screen. The sanitiser rewrites each invisible character as a six-character escape, so capping the **raw** string first and escaping afterwards hands the screen six times the width that was budgeted — a forty-character allowance becomes two hundred and forty. The guard does not hardcode that allowance, because each mint point wraps its field in different fixed prose and the prose moves: it anchors on the deciding quantity instead, feeding one benign and one control-character input of the same length through the same mint and requiring the second not to come out longer. That criterion is immune to wording changes and stays sensitive to the expansion, and it is `<=` rather than `==` on purpose — a correct escape-then-cap backs the cut off a partially-consumed escape token, so the control-character line is legitimately the shorter of the two, and demanding equality would score that avoidance as a regression. Each mint is bracketed by two positive controls (the input really reaches the screen; the cap really engages) and the expansion predicate is shown to turn red against a deliberately cap-then-escape reference, so an all-green run cannot mean the guard simply measured nothing. The shared mint point is checked directly for the two avoidances it owes — never splitting an escape token in half, which would leave something on screen that looks like the beginning of a complete answer, and never splitting a legal surrogate pair, which would manufacture the very lone surrogate the sanitiser exists to catch |
242
+ | `scripts/run-seat-task-request-origin-test.mjs` | Where every field of the seat lane's send-message payload comes from, and whether it actually lands anywhere. The seat payload is a closed interface this package mints itself, and most of its fields are meant to ride verbatim onto the engine's request body — two facts nothing used to connect, so both directions could drift in silence. A seat field could be named after a request position that does not exist, in which case a client writes to it, the wire carries it, the engine ignores the whole key, and the screen shows a switch that does nothing; conversely a new request position could arrive with no seat to sit in, which is **structural** absence — the closed set *is* the carrier, so a decision missing from it has nowhere to be put at all, the same shape logged when the effort dial had no seat. The guard turns each field's origin into data: either it names the request position it forwards to, or it is declared seat-local with a written reason, and the two are mutually exclusive. Forwarding claims are then checked against the **installed** SDK's type declarations, parsed rather than restated — a hand-copied list of position names would only ever prove that two transcriptions agree. The parser is held to reading top-level positions only, since a nested option object's inner keys would otherwise be mistaken for positions of the request itself, and it proves that discrimination on synthetic input before any verdict is given. The two subagent fields carry a standing regression pin, and the retention window's inner keys are read from the declaration the same way, so a seat that offers a tunable window cannot offer one the wire has no room for |
243
+ | `scripts/run-wire-auth-source-test.mjs` | **When** the outbound credential is read. A literal string is consumed at construction — the transport captures it in a closure and every later request reuses that one copy — so once the engine is replaced by another session and the credential rotates, a long-lived client keeps presenting the old one and the only way out is to rebuild the client along with everything hanging off it. The credential position now also accepts a getter that is called **once per outbound request**. The guard anchors on the deciding quantity, which is not "was the getter called" — reading once at construction and reusing the result would satisfy that too, and is exactly the shape being removed — but *which read produced the value on the wire*: it changes the getter's answer between two requests through the same client and requires the second request to carry the new one, and it requires construction to read the getter **zero** times. The three-state credential semantics are replayed per request rather than assumed: on loopback an unavailable credential sends **no** authorization header at all rather than a fabricated one, off loopback it sends the fail-closed anonymous identity so the deployment answers with an honest 401, and the guard shows a single client moving between those states across successive requests. A getter that throws is fail-soft — the request still goes out under the no-credential branch, because a broken credential port should not take the whole wire down, and the exception may itself carry credential material. The same-origin relay form is checked to stay out of the getter path entirely, and every request is checked to keep the credential in the authorization header only — never in the URL, never in another header |
244
+ | `scripts/run-subagent-durable-divert-test.mjs` | The side-channel that keeps a **sub-agent's** content out of the leader's transcript, on the replay leg. A content frame stamped with a parent tool-call id belongs to a child, and rendering a child's tokens as the leader's own text is the pollution this divert exists to prevent — but the predicate only listed the four **live** frame shapes, while the durable leg replays the same segment in its **aggregated** form. Those frames fell straight through onto the main projection path, which is how a reconnect or a resumed session ended up with the child's answer printed as the leader's. The anchor is unchanged and shared: the parent tool-call id is what says whose frame this is, and whether the frame is an increment or a whole segment has nothing to do with whose it is — judging the two shapes separately is exactly how one of them got missed. Folding the aggregate into a synthetic increment would have been the smaller diff and the wrong one: an increment means *append*, so a segment that already streamed live and then replays whole would be counted **twice**. The two are kept distinct and the aggregate absorbs instead — a whole segment whose prefix is what the buffer already holds replaces it, which also makes a redelivery of the same frame idempotent, and a prefix that does not match falls back to appending both rather than deciding on the engine's behalf which version counts. Segment boundaries stay with the tool frames rather than moving into the aggregate arm, since closing there would turn a second replay of one segment into a second entry, and the increment arm is pinned to keep appending so a token run that happens to be a prefix of the next does not silently lose characters |
245
+ | `scripts/run-subagent-content-budget-test.mjs` | The **byte** budget on the sub-agent transcript ledger. It used to be bounded only by *counts* — so many entries per child, so many children — and a count is not a budget when a single entry has no ceiling of its own: one tool result carrying an inlined attachment, or one long model answer, and a single slot sits on tens of megabytes. The guard anchors on how many bytes are **still held** after over-filling, not on whether truncation fired, because an implementation that flags the overflow without actually dropping anything satisfies the second and not the first. Dropping is required to leave a record — how much went and where the retained content now starts — and that record has to reach the render plan, because content that vanishes with no marker gives the reader a transcript shorter than what happened with nothing to say so; the record is one per child, updated in place, pinned to the front, and excluded from the budget it describes. Order matters and is checked: oldest entries go first and the live tail is trimmed only as a last resort, since taking the text the user is watching stream while older history survives is the wrong end. The total budget evicts a whole least-recently-used child rather than shaving every child, and the configuration surface is fail-loud on zero, negatives, non-finite and non-integer values — a silently ignored budget is the exact failure this exists to remove — with the rejection proven atomic so a bad second field cannot leave half a configuration behind. The defaults are checked to be a magnitude that can really be reached, since a number too large to hit is a field rather than a budget |
246
+ | `scripts/run-result-text-backfill-test.mjs` | What happens when the terminal frame's answer text and the text already on screen do not match. A turn's answer normally streams in and the terminal frame carries the same words again, so the two agree — but when the connection drops mid-answer and the reconnect brings the finished version, "this turn already produced assistant text" is true, the terminal fallback is skipped entirely, and the screen stays permanently short of whatever arrived while the stream was down, with nothing to say so. Four cases are pinned. Nothing on screen yet: render the terminal text whole, byte for byte the previous behaviour. On-screen text is a **prefix** of the terminal text: emit only the missing tail, and the guard measures the deciding quantity — the total bytes that reached the screen must equal the terminal text, which fails both for a missing tail and for a re-render that would print the first half twice; when the two are already equal, nothing is emitted at all. Terminal text is a prefix of what is on screen (an engine-side trim): touch nothing, since there is nothing missing and overwriting with the shorter version would erase what the reader already saw. Neither is a prefix of the other: emit **nothing** and raise a fact instead — which version counts is the engine's to say, and appending the terminal version after the streamed one composes a passage nobody ever wrote. That fact carries lengths rather than text, so a renderer is not handed a third version to choose from, and its declared duty is to *reword* the transcript line, never to render more. A cross-segment case proves the comparison reads the whole committed answer rather than the last segment, and the whole thing is driven through the real two-stage path rather than hand-built messages |
241
247
  | `scripts/run-engine-vocab-floor-test.mjs` | Engine-mirrored vocabularies (structured card whitelist, self-reported tool face, control verbs, recogniser sets) against the *installed* `@sema-agent/core` |
242
248
  | `scripts/run-limits-env-failloud-test.mjs` | `SEMA_HEADLESS_*` env-lane limits reject invalid values as loudly as the flag lane (no silent "no budget" runs) |
243
249
  | `scripts/run-streamjson-timing-honesty-test.mjs` | Stream timing & terminal honesty ([2084]): held errored fs-write results release on model progress; a wall-clock stop maps to `error_during_execution` with a truthful salvage note; the synthetic API-error assistant row carries the `<synthetic>` in-message sentinel. Also ([2489], core 5.8.0): the run-limit `errorCode` -> CC subtype map is pinned code by code (`limits.max_{cost,turns,tokens,walltime}_exceeded`), token/wall-clock stops keep the text the engine already produced, and the `failed` event arm shares the one mapping point. The 5.7 dual-vocabulary legs retired with server 6.0.0 (which bundles core 5.8.0); four **retirement negative controls** stand in their place — the retired `status:'timeout'` and the retired codes must fall to the honest fallback subtype and must never drop back to an empty success, so putting any of them back turns the gate red |
@@ -268,7 +274,7 @@ public-surface guard checks that last one).
268
274
  | `scripts/run-selfheal-reopen-test.mjs` | The 409 active-run self-heal decision chain: `governanceForced` narrows on strict `true` only; triage prefers the wire's `pendingGate.kind` and falls back to the status table (an off-table kind is never guessed into a card arm — hands-off plus the honest wording); a first-sight card makes zero closed/reopened claims and a host presentation receipt of `presented: false` demotes the outcome to reopen-failed; park-row ownership is a fail-closed positive proof (own-run ledger or session id — unprovable is not owned); the three gate-identity key literals live in exactly one mint (`hitl/gateIdentity.ts`, AST string-token scan); the armed-gate presentation ledger is per-session; and the `plan_review` reopen arm shares the arm arm's card body, three-state verdict and delivery pipe, consuming the presentation history once a decision is delivered. The same chain also carries the `running` three-way card: both plan-family gate kinds route to the plan arm and all four ask-family kinds to the ask arm (an off-table kind still never gets guessed into either); the card is offered only for verbs that can actually be honoured and a missing presenter means zero action rather than a silent cancel; a steer is sent **exactly once** with its three delivery outcomes worded apart (a `queued` receipt is the wire correcting the triage input, so the named park word decides which card gets reopened, and an unrecognised park word drives neither arm), and a steer failure is split into *provably not delivered* (4xx) and *delivery unknown*, because telling a user to resend a non-idempotent instruction that may already have landed is how duplicates get made. After a user-chosen cancel, "the session is free" is asserted only from a whitelist of terminal states — park states hold the claim, an unrecognised state word is not a release, a failed read is *unknown* rather than a release, and only a 404 counts as one — and the honest timeout line quotes how long it really waited |
269
275
  | `scripts/run-terminal-identity-copy-test.mjs` | Terminal-state **identity**, in both lanes where a stop gets a name. A run stopped by this deployment's own governance knobs — the open-set `limits.*` family, `output.invalid`, and the `blocked` contract terminal a ReportBlocked agent produces — is not a provider failure, and labelling it `API Error:` sends the reader to check the network, the key and the quota when the handle is the `--max-turns` they passed themselves. Those terminals now render a neutral row; the reverse direction is guarded just as hard, because asserting "this is *not* an API error" on a code the package does not recognise is the same misfiling pointed the other way — a real `gateway HTTP 502`, a `conflict.session_active_run` and any unknown code all keep the `API Error:` prefix, and the row keeps its `isApiErrorMessage` class flag so brief-mode visibility filtering does not silently drop it. The second half is who the rejected submission belonged to: the self-heal copy told every caller "Your message was NOT sent … send it again", which is three separate untruths for a system injection (a plan-review outcome, a cron wake-up, a task notification) — not the user's message, and not re-sendable, since a host queue marks those non-editable and non-recallable. The injected form says so instead, and the one sentence that promises re-delivery is pinned to the single disposition that earns it: `selfHealSubmissionDisposition` is the same function the host consults before putting the item back on its queue, so the promise and the behaviour cannot drift apart, and the arms where no card could be surfaced state plainly that nothing was delivered and nothing will retry |
270
276
  | `scripts/run-additive-key-passthrough-test.mjs` | The one disease shape behind two legs: a **closed whitelist / flattening arm** dropping a fact that is already on the wire, while both sides of the seam look correct. (1) The `task_progress` projection carries a registered **key ledger** — a frame populated with every key the service really projects is pushed through the shipped `eventToSdkMessage`, and the set of wire keys that survive must equal the registered pass-through list **name for name in both directions**, so quietly forwarding one more key is as red as quietly dropping one. `model` (the child run's model id, minted by core as `prepared.model.id` and projected by the server since 7.52.1) is the key this batch adds, with the same conditional the server itself applies: a non-empty string or no key at all — an empty string is neither a model id nor "unknown". The ledger is also checked against the fenced list in `docs/INTEGRATION-CLIENTS.md` §3d, so a doc that still says seven keys while the code forwards eight is red rather than merely stale. (2) The decide-failure arms carry the server's S-02 `currentPending` pointer key from a 409 `approval_stale` refusal onto the outcome the host reads. The reader is structural rather than `instanceof`, because the client is host-injected and the class identity is not this package's to assume; a half triple never mints (half a pointer cannot relocate anything), an empty string is not presence, and `checkpointToken` never transits. Both the allow and the deny leg are driven end to end through the real durable approval path — as is the accept-session leg, where a refusal carrying the pointer key must now re-raise instead of silently re-sending the human's answer for the **old** card as a plain approve (one decide call, pointer preserved), while a legacy 400 still falls back exactly as before — and all three flattening points must call the one shared reader — the same-shape residue check that makes "fixed one arm and left the twin" red instead of invisible |
271
- | `scripts/run-esc-halt-plan-test.mjs` | The Esc stop decision every client shares: fire the **turn-level** halt first, and escalate to a **run-level** cancel in exactly two cases — the engine itself answered with a 409 from the closed code set (it is saying "there is no in-flight turn here; use cancel for a run-level stop"), or that shot came back with no verdict at all *and* the shell can independently prove a permission card was on screen. Everything else does not escalate. The asymmetry is the whole point and every negative control guards the same direction — deciding *not* to escalate costs the user one more choice on a busy-session card (recoverable), deciding to escalate wrongly tears down a run that was alive and takes every in-flight tool with it (not). So: the closed code set is a **frozen** value, not a `ReadonlySet` — type-level immutability does not stop a consumer's `.add()`, and the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift; the escalation gate is the **conjunction** of that closed set and the 409 status, since honouring the code alone lets a 500 that merely quotes it drive a destructive call; `interrupt.not_held` and `steering.not_running` are deliberately outside the set (the first means *this replica* has no live face — the run may be perfectly alive on another); an unreadable code falls to the no-escalation side; a `parked` flag never overrides a verdict the engine did give, and only strict `true` counts when it did not. The first shot is unconditional by construction — it does not consult `parked`, because the 409 it earns is exactly the verdict the gate wants — and the verdict itself is a closed machine-readable reason word, not display copy |
277
+ | `scripts/run-esc-halt-plan-test.mjs` | The Esc stop decision every client shares: fire the **turn-level** halt first, and escalate to a **run-level** cancel in exactly two cases — the engine itself answered with a 409 from the closed code set (it is saying "there is no in-flight turn here; use cancel for a run-level stop"), or that shot came back with no verdict at all *and* the shell can independently prove a permission card was on screen. Everything else does not escalate. The asymmetry is the whole point and every negative control guards the same direction — deciding *not* to escalate costs the user one more choice on a busy-session card (recoverable), deciding to escalate wrongly tears down a run that was alive and takes every in-flight tool with it (not). So: the closed code set is a **frozen** value, not a `ReadonlySet` — type-level immutability does not stop a consumer's `.add()`, and the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift; the escalation gate is the **conjunction** of that closed set and the 409 status, since honouring the code alone lets a 500 that merely quotes it drive a destructive call; `interrupt.not_held` and `steering.not_running` are deliberately outside the set (the first means *this replica* has no live face — the run may be perfectly alive on another); an unreadable code falls to the no-escalation side; a `parked` flag never overrides a verdict the engine did give, and only strict `true` counts when it did not. The first shot is unconditional by construction — it does not consult `parked`, because the 409 it earns is exactly the verdict the gate wants — and the verdict itself is a closed machine-readable reason word, not display copy. A third escalating case was added once tearing the stream stopped reaping the run: with detach armed, a shot that never lands leaves the run going all the way to the end of the turn, so the Esc the user pressed has no effect at all and nothing on screen says so — the old behaviour had a silent backstop (tearing the stream ended the run) and that backstop is gone. The new fact is held to the same three disciplines as `parked`: it is read only where the engine gave no verdict, it is judged **after** `parked` so an existing host's reason word does not change under it, and only strict `true` counts. Absence is proven to be a no-op rather than asserted — the guard carries its own reference implementation of the previous version's table, runs the full grid through both, requires zero divergence when the new field is omitted, and first shows the comparison really does report a difference on the one cell where the two versions are meant to differ |
272
278
  | `scripts/run-peer-frame-projection-test.mjs` | The three engine-injected lanes design/385 puts on the **one** `task_notification` carrier, which are not the same kind of thing at all: a delegated child's uplink (`agentMessage`), another session's message drained from this session's own box (`crossSessionMessage`), and a receipt about one of *this* session's own outbound messages (`crossSessionNotice`). The engine renders none of them inside a `<task-notification>` shell, so a client that projects them as the generic completion card shows "background task finished" while the model read a colleague's sentence — two faces describing different events. The discriminator is pinned to the **typed carrier being present**, never to the `summary` text: those carriers can only be minted by the engine's injection legs (the external `notify()` input is a strict subset of the payload and can wear none of them), while `summary` is filled by every notification there is — so anchoring on text would let any background task impersonate a colleague's message by writing `<agent-message from="…">` into its own summary, and a positive control asserts exactly that payload still projects as the generic card. Fail-closed has two tiers rather than one: a broken **required** field (empty `from`, a non-string `body`, a notice `kind` outside the closed set) returns absence so the caller falls back to the generic card — an honest downgrade where the user still sees the notification — while a broken **optional** field drops only itself, because losing an attribution note and losing a colleague's whole message are not the same magnitude. The provenance side record is **required and must agree on four points** (`kind` matches the lane; `from`/`taskId`/`seq` are present and equal the carrier/payload — each equality is anchored on a core mint site and pinned by the cli wire-anchor A-K24), so a carrier signed with a trusted name but a disagreeing provenance falls back to the generic card; peer bodies pass the same authority-envelope neutralization core applies (`<task-notification>` etc. are defused) so a colleague's text can never seed the resume dedup ledger. Lane precedence copies the engine renderer's own order, because the model already read the frame in that order and a client ordering of its own would put a card on screen that disagrees with the frame the model saw. Rendering and parsing of the transcript line live in the same module and are round-tripped in both directions, including a body carrying a forged closing tag (a parser fooled there hands half a message to the next row) and a quote inside the sender label (which must not forge a second attribute); the notice lane is deliberately kept **out** of the parser, since recognising it would mean anchoring the `[Cross-session …]` prefix and a user typing that same line would be rendered as engine speech. Hostile carriers are read as own **data** descriptors only and accessors are never invoked at all — `catch` catches throwing, not never returning — proven by a counting getter that must stay at zero calls, alongside a revoked proxy and a prototype-only carrier; and four legacy payload shapes assert the no-carrier path is byte-identical to before, which is the executable form of "zero difference for an older host" |
273
279
  | `scripts/run-wiring-manifest-projection-test.mjs` | The two end-user facts carried on the engine's `wiring_manifest` frame (`modelGate`: which tools this run's model gate removed and the verbatim restore hint; `autoMode`: whether auto mode is actually armed and the engine's own reason word). Projection: both sections ride as `_sema_`-prefixed superset keys, verbatim, and no SDK-named key is minted; a frame where neither section is well-formed projects to `none/not_in_slice` (no empty arm); `modelGate` needs all three keys and treats `removed: []` as a bad value rather than a reading; `autoMode` needs a boolean plus a non-empty reason that agrees with it, and the reason word is never mapped onto the capabilities vocabulary; the frame is flat (a nested `manifest:{}` wrapper is not a supply); `eventId` rides like every other arm. Adapter: exactly one chrome event on the main lane, a sub-flow frame (any `parentToolCallId`, `null` included) yields nothing, and an absent `eventId` leaves the key absent. Added at receiving time because the shell-side gate could not see this package's behaviour: two mutations (empty `removed` accepted, sub-flow gate removed) had passed the package suite untouched |
274
280
  | `scripts/run-rule-offers-reader-test.mjs` | The narrowing reader behind the "don't ask again" options, now a public entry point rather than a card-port-only one. Hosts that render the frame themselves (a browser has no three-way terminal card) previously had to rebuild this reader on their side, and what it carries is a **redemption-safety** judgement, not a convenience: the batch arm is redeemed by **index**, so a reader that compacts the array after dropping a malformed entry makes the k-th option a person clicked and the k-th rule the server writes two different rules. So: a bad entry is dropped **on its own** (one bad option must not make a real one disappear) while every surviving entry keeps its **original wire index** — pinned from both ends, with the bad entries leading and trailing. A batch's *members* are the opposite: any malformed member drops the whole batch, because a conjunctive batch is one "yes" to all of them and a batch missing a member is a different grant; its honest-remainder count is a reading, not decoration, so a non-integer or negative value drops the batch rather than rendering a fabricated zero. An empty array, a non-array, an over-cap array and an all-bad array all read as **absence** rather than an empty list, because an empty list renders as "there is an option lane with nothing in it". The two wire generations are ordered by a rule, not a preference: the newer key wins outright, a newer key that is **present but unreadable** does not fall back to the retired key (borrowing the older material would pass someone else's options off as this request's), and a `null` newer key reads as absence so a relaying layer that serialises "missing" as null cannot delete the whole lane on older engines. The public entry is finally reconciled against **both** card-port legs on the same material, byte for byte, so the exported reader and the one the card sees can never become two. Two upstream vocabularies used to be **hand-copied** here, and both had fallen behind: a match word outside the copied pair dropped an otherwise valid option outright, and a batch carrying a directory-read member — a member kind the copy did not know — dropped the whole batch. Both tables now come from one place upstream and are re-exported verbatim, pinned in both directions: every word in the table must be accepted (a narrower copy reds on the words it never learned) and a word constructed to be outside it must still be refused (a reader widened to "any string" reds too), with the retired-key normalising leg sharing the same narrowing so the fix cannot land on one leg only. A member whose kind is genuinely unknown still drops **the whole batch and only that batch** — never one member, because a conjunctive batch one member short renders "yes to N" as "yes to N−1", and never the card, because the honest single beside it is intact — while a member from before the discriminant existed normalises to the historical kind rather than being refused. The additive per-segment reasons ride through verbatim, drop only the row that is malformed, and stay **absent rather than empty** when nothing survives, since an empty list would read as "confirmed nothing uncovered" while the count remains the only source of truth |
@@ -57,6 +57,9 @@ const assistantArm = function* (m, { ctx, idOf, text, cards, inst }) {
57
57
  // #27 段序:先把这张卡之前的散文提交,再提交它之前的思考(思考恒在卡上方)。
58
58
  yield* text.takeAnswerSegment();
59
59
  yield* text.takeThinking();
60
+ // 0.62.0:卡之前的散文已经落账,卡**之后**的散文属于下一条 assistant 消息 —— 划界。
61
+ // (终帧 `result` 在引擎侧只取**最后一条** assistant 消息的文本,补差判据的两边必须是同一种东西。)
62
+ text.beginAssistantMessage();
60
63
  yield transcript({
61
64
  type: 'assistant',
62
65
  // S1:per-RESPONSE 铸 id —— CC 的 grouped-agent 渲染按 `${message.id}:${toolName}`
@@ -125,6 +128,9 @@ const assistantArm = function* (m, { ctx, idOf, text, cards, inst }) {
125
128
  const textBlock = blocks.find(b => b?.type === 'text');
126
129
  if (textBlock && typeof textBlock.text === 'string' && textBlock.text.length > 0) {
127
130
  text.appendAnswer(textBlock.text);
131
+ // 0.62.0:durable 的这一帧**本身就是一整条** assistant 消息 —— 先划界,再把它记成这条消息的全部
132
+ // (`+=` 会把上一条消息的正文算进来,补差判据当场看到一个从不存在的「更长的消息」)。
133
+ text.beginAssistantMessage();
128
134
  yield* text.takeThinking();
129
135
  yield transcript({
130
136
  type: 'assistant',
@@ -133,7 +139,8 @@ const assistantArm = function* (m, { ctx, idOf, text, cards, inst }) {
133
139
  session_id: ctx.sessionId,
134
140
  parent_tool_use_id: null,
135
141
  }, ctx.now());
136
- text.markEmittedText();
142
+ // 0.62.0:把**真上屏的这一段**记进已提交账(终帧补差判据读它;不传会让补差少算一截)
143
+ text.markEmittedText(textBlock.text);
137
144
  }
138
145
  const thinkingBlock = blocks.find(b => b?.type === 'thinking');
139
146
  if (thinkingBlock && typeof thinkingBlock.thinking === 'string') {
@@ -615,6 +622,19 @@ const turnUsageArm = function* (m, { text, flags }) {
615
622
  }
616
623
  };
617
624
  // ══ M0 编排半场 —— result(收口三形之一;与 D7/D8 的次序差异见矩阵 §2.4,不可归一)═══════════════
625
+ /**
626
+ * `whole` 是不是以 `part` 开头 —— 先逐字节比,再容忍**引擎侧的首部修剪**(`result` 出厂 `.trim()` 过,
627
+ * 而屏上那份是原文)。命中时返回**真正对上的那个前缀**(调用方按它的长度切后缀,切点才不会错位)。
628
+ * 两条都不成立 ⇒ `null`(= 不是前缀关系,由调用方落分岔臂)。
629
+ */
630
+ function prefixOf(whole, part) {
631
+ if (whole.startsWith(part))
632
+ return part;
633
+ const trimmed = part.trimStart();
634
+ if (trimmed.length > 0 && trimmed.length !== part.length && whole.startsWith(trimmed))
635
+ return trimmed;
636
+ return null;
637
+ }
618
638
  const resultArm = function* (m, { ctx, idOf, text, cards, panel, flags }) {
619
639
  yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
620
640
  // MF-10 防御 sweep(sweep 点 2/4):结掉那些卡从没关过的行(流被截断)。
@@ -630,19 +650,62 @@ const resultArm = function* (m, { ctx, idOf, text, cards, panel, flags }) {
630
650
  return typeof u?.outputTokens === 'number' ? u.outputTokens : undefined;
631
651
  });
632
652
  yield* text.takeAnswerSegment();
653
+ // 具名成 terminalText:M1 收编后 `text` 是流合并器的名字,原来的同名局部会遮蔽它。
654
+ const terminalText = typeof m.result === 'string' ? m.result : '';
655
+ const committed = text.committedAnswerText;
656
+ const emitTerminal = function* (body, tag) {
657
+ yield transcript({
658
+ type: 'assistant',
659
+ message: { role: 'assistant', content: [{ type: 'text', text: body }] },
660
+ uuid: idOf(m, tag),
661
+ session_id: ctx.sessionId,
662
+ parent_tool_use_id: null,
663
+ }, ctx.now());
664
+ text.markEmittedText(body);
665
+ };
666
+ // ── 终答与已上屏正文的五格(0.62.0 补后四格;修前只有第一格)────────────────────────────────
667
+ // 病形:断线前已经流过一部分正文、重连后终帧带的是**完整**那份 ⇒ `emittedAssistantText` 为真,
668
+ // 于是整条终答兜底被跳过,而屏上永远缺断线期间那一截,且没有任何东西说少了。
669
+ //
670
+ // 🔴 **比较的两边必须是同一种东西**(车内异源复审逼出的真病):终帧 `result` 在引擎侧取的是
671
+ // **最后一条** assistant 消息的文本、且 `.trim()` 过,不是整轮正文的拼接。所以左边读的是
672
+ // `committedAnswerText`(**当前这条消息**已上屏的正文,工具边界重置),不是本 turn 全部正文;
673
+ // 并且相等判定先过一次 `trim()`——否则「散文 → 工具 → 散文」这种再正常不过的多步回答,
674
+ // 以及任何只差首尾空白的回答,都会被判成分岔。
633
675
  if (!text.emittedAssistantText) {
634
- // 具名成 terminalText:M1 收编后 `text` 是流合并器的名字,原来的同名局部会遮蔽它。
635
- const terminalText = typeof m.result === 'string' ? m.result : '';
636
- if (terminalText.length > 0) {
637
- yield transcript({
638
- type: 'assistant',
639
- message: { role: 'assistant', content: [{ type: 'text', text: terminalText }] },
640
- uuid: idOf(m, 'terminal-text'),
641
- session_id: ctx.sessionId,
642
- parent_tool_use_id: null,
643
- }, ctx.now());
644
- text.markEmittedText();
645
- }
676
+ // 一个字都还没上屏 终帧那份就是全部答复,整段渲(修前的唯一行为,逐字保留)。
677
+ if (terminalText.length > 0)
678
+ yield* emitTerminal(terminalText, 'terminal-text');
679
+ }
680
+ else if (terminalText.length === 0) {
681
+ // 终帧没带正文 无事可做(它不是「答复是空的」这句话)。
682
+ }
683
+ else if (committed.trim() === terminalText.trim()) {
684
+ // ③ 两份**是同一段话**(引擎那一位出厂就 `.trim()` 过,所以逐字节比会在纯空白差上假红)⇒ 无事可做。
685
+ // 这一格必须排在前缀两格**之前**:否则一个只差首尾空白的正常 turn 会掉进补差或分岔臂。
686
+ }
687
+ else if (prefixOf(terminalText, committed) !== null) {
688
+ // ④ 已上屏那段是终帧那份的**前缀** ⇒ 只补吐**后缀**那一截。
689
+ // 🔴 只补差不重渲:重渲整段会让屏上出现同一段话两遍(前半还是逐字重复的),那比缺一截更坏。
690
+ const matched = prefixOf(terminalText, committed);
691
+ const suffix = terminalText.slice(matched.length);
692
+ if (suffix.length > 0)
693
+ yield* emitTerminal(suffix, 'terminal-text-suffix');
694
+ }
695
+ else if (committed.startsWith(terminalText)) {
696
+ // ⑤ 终帧那份是已上屏那段的前缀(引擎侧修剪/截断)⇒ **不动**:屏上已经不少任何东西,
697
+ // 补吐没有对象,而拿一份更短的去覆盖更长的会把用户已经读到的内容抹掉。
698
+ }
699
+ else {
700
+ // ⑤ 两份**互相都不是对方的前缀** ⇒ 从某处起就分了岔。库刻意**什么都不补吐**(谁算数只有引擎
701
+ // 说得清;把终帧那份接在屏上那段后面会拼出一段谁都没说过的话),只给宿主一个事实位让转录行
702
+ // 改口。两个位是**长度**不是内容 —— 不复述任何一份正文。
703
+ yield chrome({
704
+ kind: 'result_text_diverged',
705
+ laneProof: MAIN,
706
+ committedLength: committed.length,
707
+ resultLength: terminalText.length,
708
+ });
646
709
  }
647
710
  };
648
711
  // ══ M4 —— 通知族 ═══════════════════════════════════════════════════════════════════════════════
@@ -63,10 +63,39 @@ export interface TextStream {
63
63
  * 正文的人去读段缓冲,别把这个累加器当答案来源。
64
64
  */
65
65
  appendAnswer(text: string): void;
66
- /** 「本 turn 已经产过 assistant 文本」的具名写口(A1 durable / A7 终答兜底两处)。 */
67
- markEmittedText(): void;
66
+ /**
67
+ * 「本 turn 已经产过 assistant 文本」的具名写口(A1 durable / A7 终答兜底两处)
68
+ *
69
+ * `committed` = 这一发**真上屏**的那段正文(计入 {@link TextStream.committedAnswerText})。
70
+ * 缺席 = 只翻位不记账 —— 保留给「翻位但没有正文可记」的调用点;新调用点一律把正文传进来,
71
+ * 不传会让补差判据少算一截、把「已经在屏上了」的内容当成缺的再补一遍。
72
+ */
73
+ markEmittedText(committed?: string): void;
68
74
  /** A7 终答兜底的门 + D8 收口日志读位。 */
69
75
  readonly emittedAssistantText: boolean;
76
+ /**
77
+ * **当前这条 assistant 消息**已提交上屏的正文(工具边界重置;durable 整条直渲时整段替换)。
78
+ *
79
+ * 🔴 **为什么是「当前这条消息」而不是「本 turn 全部正文」**(车内异源复审逼出的真病):终帧
80
+ * `result` 那一位在引擎侧取的是**最后一条 assistant 消息**的文本(装在盘上的引擎产物直证:
81
+ * `assembleResult` 只对 `final` 那一条调 `assistantText`,再 `.trim()`),**不是**整轮的拼接。
82
+ * 拿「本 turn 全部正文」去与它比,一个再正常不过的多步回答(散文 → 工具 → 散文)就会被判成
83
+ * 「两份互相都不是对方前缀」⇒ 对着一条完全正常的 turn 报分岔。⇒ 比较的两边必须是**同一种
84
+ * 东西**:一条 assistant 消息对一条 assistant 消息。
85
+ * 🔴 它与 {@link TextStream.answerLength} 背后那个累加器**不是同一个**,别合并:那一个记的是
86
+ * 「模型这一轮产了多少字」(含还押在缓冲里、尚未提交的、且跨消息累加),只进 D8 的调试行;
87
+ * 这一个记的是「屏上这条消息已经有了哪些字」,是**补差判据的输入**。
88
+ */
89
+ readonly committedAnswerText: string;
90
+ /**
91
+ * **一条新的 assistant 消息从这里开始**(重置 {@link TextStream.committedAnswerText})。
92
+ *
93
+ * 调用点恰两处,两处都是「上一条消息到此为止」这个事实的发生地:
94
+ * · 工具卡臂 —— 卡之前的散文已经段提交,卡之后的散文属于**下一条** assistant 消息;
95
+ * · durable 整条正文臂 —— 那一帧本身就是**一整条**消息(先划界、再把它记成这条消息的全部)。
96
+ * 🔴 它**不动** `emittedAssistantText`:那一位问的是「这一轮产没产过正文」,与消息边界无关。
97
+ */
98
+ beginAssistantMessage(): void;
70
99
  /** D8 收口日志读位(`answer` 本体不出模块)。 */
71
100
  readonly answerLength: number;
72
101
  }
@@ -47,6 +47,8 @@ export function createTextStream(ctx, idOf) {
47
47
  let answerSegment = '';
48
48
  let thinking = '';
49
49
  let emittedAssistantText = false;
50
+ /** **当前这条 assistant 消息**已提交上屏的正文(补差判据的输入;与 `answer` 刻意分家,见接口注)。 */
51
+ let committedText = '';
50
52
  let textPending = '';
51
53
  let thinkingPending = '';
52
54
  let textBlockOpen = false;
@@ -97,6 +99,7 @@ export function createTextStream(ctx, idOf) {
97
99
  session_id: ctx.sessionId,
98
100
  parent_tool_use_id: null,
99
101
  };
102
+ committedText += answerSegment;
100
103
  answerSegment = '';
101
104
  segmentAnchor = null;
102
105
  emittedAssistantText = true;
@@ -194,8 +197,16 @@ export function createTextStream(ctx, idOf) {
194
197
  appendAnswer: (text) => {
195
198
  answer += text;
196
199
  },
197
- markEmittedText: () => {
200
+ markEmittedText: (committed) => {
198
201
  emittedAssistantText = true;
202
+ if (typeof committed === 'string')
203
+ committedText += committed;
204
+ },
205
+ beginAssistantMessage: () => {
206
+ committedText = '';
207
+ },
208
+ get committedAnswerText() {
209
+ return committedText;
199
210
  },
200
211
  get emittedAssistantText() {
201
212
  return emittedAssistantText;
@@ -300,8 +300,17 @@ async function* runStreamInner(events, ctx, handle = {}) {
300
300
  // events) — side-channel it to the 查看态 transcript store and keep it OFF the main pipeline
301
301
  // (a child's tokens rendering as the leader's text is the pollution failure mode). turn_end's
302
302
  // sub-flow guard below stays as-is (usage rollup only).
303
+ //
304
+ // 🔴 0.62.0 —— 判据同时收 durable 腿的**聚合形**(`text` / `reasoning`,整段全文)。
305
+ // 修前判据只列了四个**活体**臂,于是 `runs/:id/events` 重放尾上带 parentToolCallId 的聚合帧
306
+ // **漏过分流**,一路落进下面的通用投影口、渲成 leader 自己的 assistant 正文 —— 也就是本臂
307
+ // 整段头注在防的那个污染形,只是走了另一条腿进来(headless 断线续听 / 交互续听重放尾同踩)。
308
+ // 判据的锚仍是**同一个**:`parentToolCallId` 在场 = 这一帧属于子代;帧是增量还是整段与
309
+ // 「它属于谁」无关,所以两类臂必须共用这一条判据,而不是各判一次。
303
310
  if ((ev.type === 'text_delta' ||
304
311
  ev.type === 'reasoning_delta' ||
312
+ ev.type === 'text' ||
313
+ ev.type === 'reasoning' ||
305
314
  ev.type === 'tool_start' ||
306
315
  ev.type === 'tool_end') &&
307
316
  typeof ev.parentToolCallId === 'string') {
@@ -317,6 +326,13 @@ async function* runStreamInner(events, ctx, handle = {}) {
317
326
  taskId: sub.taskId ?? sub.parentToolCallId,
318
327
  parentToolCallId: sub.parentToolCallId,
319
328
  delta: sub.delta,
329
+ // 聚合两臂的载荷:整段全文。store 侧按**吸收**处置(活体已流过的那一段是它的前缀 ⇒
330
+ // 换掉而不是追加),所以「活体流过 + 重放聚合」不会双份。
331
+ text: sub.text,
332
+ // 聚合帧的**幂等键**:durable 重放会把整轮的段再送一遍,而缝前的事件序号去重只在**同一条**
333
+ // 流内有效(重连后是新的一条流)。把身份交给 store,让跨流的重放也只收一次。
334
+ // (工具两臂不读这一位 —— store 侧按 `toolCallId` 与账本上那张卡判,见 subagentContentStore。)
335
+ eventId: sub.eventId,
320
336
  toolCallId: sub.toolCallId,
321
337
  toolName: sub.toolName,
322
338
  args: sub.args,
@@ -1,4 +1,4 @@
1
- import { type EngineProbeOpts } from './engineWireSdk.js';
1
+ import { type EngineProbeOpts, type WireAuthTokenSource } from './engineWireSdk.js';
2
2
  export declare const MAX_TASK_AGENTS = 32;
3
3
  export declare const MAX_AGENT_NAME_CHARS = 64;
4
4
  export declare const MAX_AGENT_TEXT_CHARS = 4096;
@@ -80,9 +80,13 @@ export declare function projectAgentsForWire(defs: readonly AgentDefLike[], deps
80
80
  * 单机形恒缺席,owner-null)。
81
81
  */
82
82
  export declare function engineSupportsTaskAgents(baseUrl: string, opts?: EngineProbeOpts): Promise<boolean | undefined>;
83
+ /**
84
+ * prepare 腿的连线配置。`authToken` 是**串或取值函数**({@link WireAuthTokenSource},0.62.0 放宽):
85
+ * 函数形每发一次请求读一次 ⇒ 引擎换代、凭证换新之后端只换值,不必重建 client。串形零行为差。
86
+ */
83
87
  export type WireConfig = {
84
88
  baseUrl: string;
85
- authToken?: string;
89
+ authToken?: WireAuthTokenSource;
86
90
  principal?: string;
87
91
  };
88
92
  /** IO 半场注入的产品投影闭包(F5a 定义加载 + model 解析 + skills join)。 */
@@ -25,6 +25,19 @@ import { AgentClient } from '@sema-agent/sdk';
25
25
  export type WireAuthToken = string | {
26
26
  mode: 'loopback-unauthed';
27
27
  };
28
+ /**
29
+ * 凭证**位**的取值形:一份串,或一个**每次发请求时被调用**的取值函数(0.62.0 放宽)。
30
+ *
31
+ * 🔴 为什么需要函数形:串是在**构造那一刻**被读走的 —— 底层客户端把它捕获进闭包,此后每一发请求
32
+ * 都用那一份。引擎被别的会话换代、凭证换新之后,一个长命的 client 会一直拿**旧凭证**发请求,
33
+ * 而端唯一的出路是把整个 client 连同它身上的状态重建一遍。函数形让「换代」只换一个值。
34
+ * 🔴 取值时机是**每发一次请求读一次**,不是构造期读一次然后复用(那只是把快照挪了个地方)。
35
+ * 🔴 取不到值(返回 `undefined` / 空串 / **抛错**)⇒ 按「这一刻没有凭证」走 {@link resolveWireAuth}
36
+ * 的三态:回环出零 Authorization 头(绝不伪造),非回环出 fail-closed 的匿名身份(换一个诚实的
37
+ * 401)。取值口坏了不该让整条 wire 变成不可用,所以抛错是 fail-soft 而不是把异常送进请求路径。
38
+ * 🔴 凭证纪律:本包对这个值只做一件事 —— 放进出站的 Authorization 头。不落盘、不进日志、不进 URL。
39
+ */
40
+ export type WireAuthTokenSource = string | (() => string | undefined);
28
41
  /** loopback 判定(engineTarget.isLoopbackEngineUrl 逐字镜像;解析失败=非 loopback)。 */
29
42
  export declare function isLoopbackWireUrl(url: string): boolean;
30
43
  /**
@@ -62,19 +75,27 @@ export declare function wireAuthTokenFor(baseUrl: string, token?: string): WireA
62
75
  export type EngineProbeOpts = {
63
76
  fetchImpl?: typeof fetch;
64
77
  timeoutMs?: number;
65
- authToken?: string | undefined;
78
+ /** 串或取值函数({@link WireAuthTokenSource};0.62.0 放宽,串形逐字节零行为差)。 */
79
+ authToken?: WireAuthTokenSource | undefined;
66
80
  principal?: string | undefined;
67
81
  };
68
82
  export type EngineWireClientConfig = {
69
83
  baseUrl: string;
70
84
  /** 真 token 串;缺省经 wireAuthTokenFor 三态解析(loopback-unauthed / 'anon')。
85
+ *
86
+ * **取值函数形**({@link WireAuthTokenSource},0.62.0):`() => string | undefined` ——
87
+ * **每发一次请求读一次**,不在构造时快照。给「引擎换代后凭证换新、但 client 要接着用」那条路
88
+ * (端从此只换值,不必重建 client)。三态语义逐格不变,取不到就按无 token 解析。
89
+ *
90
+ * 🔴 中继形与取值函数形**互斥且不同源**:中继是作者的显式声明(「我部署在同源反代后」),
91
+ * 它结构上就没有 token;取值函数是「凭证会变」,两者不会同时出现在一个配置里。
71
92
  *
72
93
  * `{ mode: 'same-origin-relay' }`([C175],0.29.0)= **浏览器同源宿主的显式声明形**:凭证由
73
94
  * 同源反代(cookie/session)承载,出站零 Authorization 头;它是 SDK 浏览器守卫的**唯一**豁免形
74
95
  * (AgentClient 在浏览器宿主拒绝 token/loopback-unauthed/'anon' 三态构造),且该形下 `baseUrl`
75
96
  * 允许相对/同源路径(`''`、`'/api'`)。🔴 这个形**只能显式传入**(作者声明「我部署在同源反代后」),
76
97
  * 绝不由 `resolveWireAuth` 三态解析推导出来 —— 解析口的三态语义一字不动,relay 形直传 SDK。 */
77
- token?: string | {
98
+ token?: WireAuthTokenSource | {
78
99
  mode: 'same-origin-relay';
79
100
  };
80
101
  /** 缺席/undefined=不发 x-agent-principal 头(owner-null;F-011 停发,replEntry live 车道同闸口