@sema-agent/client-core 0.42.0 → 0.43.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
@@ -28,6 +28,131 @@
28
28
  > 不许悄悄漂:豁免登记的 `releasedAt` 与 `FROZEN` 账上 0.36.0 那一行逐字相等;本段(点名版本号
29
29
  > `0.36.0` + 关键字「勘误」)必须还在这份头注里 —— 删掉本段而不同批把门侧豁免一起处理,门当场红。
30
30
 
31
+ ## 0.43.1(2026-08-25)
32
+
33
+ **批面**:`readCcImportRedeemCounts` —— cc-import redeem 200 体的兑付计数**双代归一读口**
34
+ (server 7.46 未声明 BREAKING #2 的消费半场;姊妹案=0.43.0 的 `ruleOffers`)。
35
+
36
+ ### 背景(server ≥7.46.0 未声明换形)
37
+
38
+ server 7.46 把 `POST /v1/rules/cc-import/redeem` 的 200 体从三桶形
39
+ `{ result: { persisted[], deduped[], skippedAtRedeem[], rev } }` 换成判别联合形
40
+ `{ result: { members[], rev } }`(member 带 `status: "persisted" | "deduped"`;refused 不再上
41
+ 200 体 —— server 侧折进 404/503)。CHANGELOG 零声明,npm 最新 SDK 7.2.0 `rules.d.ts` 仍是旧形
42
+ (engine 7.46.0 fixture dist 直证 `rules-consent.js` / `http/routes/rules.js`)。旧读法
43
+ `result.persisted.length` 在 7.46 上恒 undefined ⇒ 导入计数恒 0、成功 banner 永不渲。
44
+
45
+ ### 端要改的一处
46
+
47
+ 兑付计数一律走 `readCcImportRedeemCounts(redeemed.result)`:
48
+ - 新形 `members[]` 在场恒按新键读(全 0 也**绝不回落**旧键;与 0.43.0 窄读器同款纪律);
49
+ - 旧形(≤7.45)`persisted[]/deduped[]` 桶兜底;
50
+ - 两形都缺 ⇒ `null`(诚实缺席 —— 渲 unknown,绝不渲 0/成功)。
51
+
52
+ **测试**:rules-side 门 G7 六断言(新形/空批/新键恒赢/旧形/双缺/未知 status 开集)。
53
+
54
+ ## 0.43.0(2026-08-25)
55
+
56
+ > 🔴 **两阶段协议提醒(发包批的义务,见本档头注)**:本段发布前须由发包批做**阶段一** ——
57
+ > `package.json` bump `0.42.0 → 0.43.0` + 本段标题转日期 + README `Version` 行跟版 +
58
+ > `run-integration-doc-freshness-test.mjs` 的 `FROZEN` 账**追加**一行 `pending: true`;发布后
59
+ > **阶段二**补 `releasedAt` 与段 `sha256` 并删 `pending`。**施工批不做 bump、不动 `FROZEN`**。
60
+
61
+ **批面**:①#323 两件呈现面修复(clay 三裁 08-25,已落 `4c364c1`)②#334/[5223] `ruleSuggestions`
62
+ → `ruleOffers` 判别联合换形三腿消费(**BREAKING**)③#341/[5214]③ `inputHasBidi` 过境
63
+ ④`requiresRealApproval` 过境复核 + 帧键镜像同形族扫。
64
+
65
+ ### 🔴 BREAKING(#334/[5223];server ≥7.46.0 / core 5.58.0 design/375)
66
+
67
+ 上游把审批卡的「不再询问」候选从 `ruleSuggestions` 整体换成判别联合 `ruleOffers`
68
+ (`kind: "single" | "batch"`),**live `tool_approval` 帧 / `card_json` 呈卡帧 / durable
69
+ `PendingCheckpoint` 三腿同换**;7.46 引擎上旧键在这三腿一个都不发(engine 7.46.0 fixture 直证)。
70
+
71
+ **端要改的三处**:
72
+
73
+ | 面 | 0.42.0 及更早 | 0.43.0 |
74
+ |---|---|---|
75
+ | 卡入参(活卡帧腿) | `ApprovalCardRequest.ruleSuggestions?: RuleSuggestion[]` | **`ruleOffers?: RuleOffer[]`** |
76
+ | 卡入参(durable 行腿,只读) | `ruleSuggestionsReadOnly?: RuleSuggestion[]` | **`ruleOffersReadOnly?: RuleOffer[]`** |
77
+ | 卡决断(兑付) | `persistRule` / `persistRuleEdited`(**字节不变**) | 两位不动 + 新增 **`persistRuleBatchOfferIndex?: number`** |
78
+
79
+ - **两代 wire 键经同一把窄读器归一成包内单一形**:新键优先、旧键(≤7.45)归一成 `kind:'single'`,
80
+ **新键在场即定局绝不回落**(`null` 与缺席同视,理由见 `readOfferSupply` 顶注的代价不对称段)⇒
81
+ 混舰队部署上端**不需要任何版本分支**;
82
+ - **`RuleOffer.offerIndex` 是本包铸的座**(上游形上没有):batch 臂的兑付键就是**原始 wire 下标**,
83
+ 窄读器逐条丢坏形但**下标不前移**(压紧 = 人点的第 k 个与服务端兑的第 k 个指向两条不同规则)。
84
+ 🔴 **两条腿下标语义不同**:活卡帧腿恒等于 core 的 offer index(**是**选择键);durable 行腿
85
+ server 已压紧过且无兑付口(**只是展示座**);
86
+ - **入参型与出参型分开**:`ToolApprovalFrame.ruleOffers` 收的是 `readonly WireRuleOffer[]`
87
+ (与上游逐字同形、**无** `offerIndex`),归一后的 `RuleOffer` 只出现在卡入参上;
88
+ - **批臂的兑付**:`persistRuleBatchOfferIndex` 与 `persistRule`/`persistRuleEdited` **三臂互斥**
89
+ (server 对同场组合响亮 400 且**连决断一起拒** ⇒ 包边界整条丢持久臂 + 留痕,决断照送);
90
+ 下标必须在本帧 offers 里真指向一条 `kind:'batch'`,越界/指向 single/负数/非整数一律丢键;
91
+ - 🔴 **批臂要能力位**:新增 `ToolApprovalFrameLaneOpts.respondBatchRuleOffersCapable`
92
+ (`capabilities.respondBatchRuleOffers`,server ≥7.46.0)。**宿主漏供 ⇒ 现代引擎上每一次批臂勾选
93
+ 都被丢**。只闸兑付不闸展示(offer 照旧上卡,端可走本地落规则那条路);
94
+ - **ack 超集视图** `ToolApprovalRespondAckView`(sdk 7.2.0 锚尚无这两位):`persistedRule`(编辑臂
95
+ 落盘的**规范化后**文本 —— 界面回显这一份而不是输入框那份)· `persistedRules`(批臂全体成员规范
96
+ 文本,展示序)。两位过**三道包内闸**:单复数同场 ⇒ 两位一起丢 · `rulePersisted !== true` ⇒ 丢 ·
97
+ 臂相关性(单数只属编辑臂;复数条数须等于所选 batch 的成员数)。⚠️ 逐条归属今天 wire 上证不出,
98
+ 端渲成「**引擎报告**落盘的规则」而不是「你选的那批」——在册 **P-39**,已向 server 索要 additive 锚。
99
+
100
+ ### 新增(additive)
101
+
102
+ - **`inputHasBidi` 过境**(#341/[5214]③;server ≥7.46.0 E-14 Trojan Source 族):帧顶层 `?: true`
103
+ → `ApprovalCardRequest.inputHasBidi`,条件 stamp **只认严格 true**。🔴 **披露位不是清洗位,本包
104
+ 字节零改**(清洗会让卡上显示的与真跑的不是同一个东西,比不披露更坏);显形归端。
105
+ **缺席禁读成「已确认干净」**(同时覆盖「真的没有」与「args 序列化不了所以扫不了」)。**单腿**:
106
+ durable 行腿今天无对偶(与 `requiresRealApproval` 同族,§7 P-32);
107
+ - **持久臂被丢时的两条诚实告知**(公开导出 772 → **776**;与 `surfaceRememberNotApplied` 同族,
108
+ 要看得见得先装 `HitlHostSurface` 口):
109
+ · `surfaceRuleArmNotSent()` / `RULE_NOT_SENT_WARN_TEXT` —— **能力位未确认**导致持久臂整条没发
110
+ (编辑臂与批臂**共用一条**,同形存量一并清 —— 编辑臂此前也只写 debug);
111
+ · `surfaceRuleArmRejected()` / `RULE_NOT_SENT_REJECTED_WARN_TEXT` —— 这次选择**没过包内表核/互斥核**
112
+ (表外文本 / 坏下标 / 两臂同场;候选臂那条表核自 0.26.0 起也只写 error 日志,同批一起清)。
113
+ 🔴 **两条刻意分开**:对用户的下一步建议不同(换台引擎/等探测 vs 换引擎也不会变),折成一条会
114
+ 谎报原因。共用两条口径纪律:文案**不说「这台引擎不支持」**(能力位缺席三种同形成因);通知
115
+ **只在 respond 真成功之后**发(文案里有「审批本身过了」,respond 抛错那条路上那句是假的)。
116
+ 🔴 **同一次 respond 的通知顺序契约**(`showNotice` 直接顶替 current、不排队 ⇒ 最后发的才看得见):
117
+ 三条按**严重度升序**发、最重的压轴 —— 规则没存 < `allow_session` 没记住 < **编辑没转发(工具正在
118
+ 用原始入参跑,唯一一条 [high])**。端自己重排/合并时必须保住这条不变量;
119
+ - **帧键镜像同形族扫**(21 → **24** 键):补 `ruleOffers` / `inputHasBidi`,并把族扫捞出的**存量**
120
+ 漏键 `parked`(#329,server 7.44.0 及更早就在,`tool_approval_complete` only)一并补进 ——
121
+ 镜像补齐,消费面另立项(§7 **P-37**);
122
+ - **`run-approval-frame-keys-test.mjs` 新增 server fixture 腿**:直接解析
123
+ `@sema-agent/server/dist/tool-approval.d.ts` 与本仓镜像做**机器对表**。SDK 运行期锚按构造滞后于
124
+ server,只对 SDK 锚比对的门对「server 已发、SDK 与本仓同时没有」的键是**盲的** —— 那正是 [5223]
125
+ 这次静默换键的成因形。`SEMA_CC_SERVER_FIXTURE=<dir>` 开;**提货/抬 pin/发包批必须另加
126
+ `SEMA_CC_REQUIRE_SERVER_FIXTURE=1`** 把它变成硬门(缺 fixture 当场红)。
127
+
128
+ ### 呈现面修复(#323,clay 三裁 2026-08-25;已落 `4c364c1`)
129
+
130
+ - **idle-flush 段提交双准入**(每段一刀 + 句末边界):长工具参数生成期的 assistant 文本不再被切成
131
+ 碎片(实测碎片 19 → 2 / 5 → 1);
132
+ - **用户中断的 `tool_result` 归一 CC 形**:双模判据 fail-safe,整帧只改 `output` 机读位全过境
133
+ (`isError` / `errorCode` / `_sema_collateral_abort` 一个都不动),五条排水出口统一 + 结构门。
134
+ ⇒ **端做归因的 `output` 三分**(§7 P-30 已订正):`'Operation aborted'` = 非中断原因的 fail-soft ·
135
+ CC 中断串 `[Request interrupted by user for tool use]` = 用户中断 · CC REJECT 串 = 用户真拒绝;
136
+ - 同批捡修两真缺陷:中途出口绕过 · 剥尾不判换行;新登记 known-gap **P-36**(中断文案归一只覆盖
137
+ `Operation aborted` 这一串,另两族刻意不并入 —— core 显式拒绝合并两串向)。
138
+
139
+ ### 已知局限(本版新增;完整台账见 `docs/INTEGRATION-CLIENTS.md` §6e/§7)
140
+
141
+ - **P-37**:`parked` 已进键镜像但本包零消费(只长在 `tool_approval_complete` 上,那种帧不进卡口);
142
+ - **P-38**(**存量、非本批引入**,基线 `4c364c1` 逐字相同):`frameRouter` 的标准帧路由**只解构
143
+ `decision`**,`ack` 与 `respondRefusal` 整段丢掉 ⇒ 走标准集成面的端读不到规则回显与 400 拒句
144
+ (⚠️ `rememberApplied` / `updatedInputForwarded` 两条更重的告知**不在此列** —— 它们经
145
+ `HitlHostSurface` 就地投递,不经本路由;端自己直调 `surfaceToolApprovalFrameAndRespond` 也拿得到);
146
+ - **P-39**:批臂回执 `persistedRules` 的**逐条归属** wire 上证不出(只核基数),端的呈现口径见上;
147
+ - **P-40**(**系统性、非本批引入**):三个能力位是 **per-baseUrl 的布尔缓存,不与「收 respond 的那个
148
+ 副本」绑定**。引擎温切 / 滚动升级 / 多副本代理下,一个陈旧的 `true` 仍会把新形送到老副本上,而
149
+ 编辑臂/批臂在老解析器上是**响亮 400 且连决断一起拒**。
150
+ ⚠️ ⇒ **「能力位闸让老引擎零受迫」这句话有前提**:能力读数必须与实际 responder 一致。
151
+ 0.42.0 段第 4 条里那句无前提的「老引擎零受迫」按本条订正(⚠️ 那一段是**冻结面**,按 #252 宪法
152
+ **不回改**,订正落在本段)。**宿主义务**:引擎 respawn/restart 后调
153
+ `invalidateEngineCaps(baseUrl, probe?)`(推荐两参形);滚动升级窗口内宁可把两个规则能力位按
154
+ `false` 供(丢持久臂 + 一条诚实通知,远好于把人按下的决断打掉)。
155
+
31
156
  ## 0.42.0(2026-08-24)
32
157
 
33
158
  > 🔴 **两阶段协议提醒(发包批的义务,见本档头注)**:本段发布前须由发包批做**阶段一** ——
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.42.0
38
+ **Version:** 0.43.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
@@ -31,8 +31,18 @@ export interface TextStream {
31
31
  drainLive(): Generator<AdapterOutput>;
32
32
  /** 提交累积的思考块。 */
33
33
  takeThinking(): Generator<AdapterOutput>;
34
- /** 提交累积的文本段(#27 段序铁律)。 */
34
+ /** 提交累积的文本段(#27 段序铁律)。**正常边界**专用(工具卡前 / turn 收口)。 */
35
35
  takeAnswerSegment(): Generator<AdapterOutput>;
36
+ /**
37
+ * T40 IDLE-FLUSH 专用的段提交口(#323 症状①,2026-08-25 clay 裁 (a)+(b) **合取**)。
38
+ *
39
+ * 与 {@link takeAnswerSegment} 的差别只在**准入**,提交本体逐字同一份实现:
40
+ * (a) **每段至多一刀**:idle 提交过一次之后,该段后续 idle 一律跳过,直到一次**正常边界**
41
+ * (工具卡前 / turn 收口的 `takeAnswerSegment`)重开新段;
42
+ * (b) **只在句末/段末边界提交**:段尾不是句末字符或换行 ⇒ 跳过本拍,等下一拍。
43
+ * 两条都不满足时**什么都不做**(缓冲原样押着,活体预览照常由 `drainLive` 走)。
44
+ */
45
+ takeAnswerSegmentOnIdle(): Generator<AdapterOutput>;
36
46
  /** D3 IDLE-FLUSH 竞速判据:四个缓冲有任何一个非空。 */
37
47
  hasPendingContent(): boolean;
38
48
  /** D5 攒批节拍:一个间隔至多一次 flush(判据与 `lastFlushAt` 一起收在本模块内)。 */
@@ -1,5 +1,46 @@
1
1
  import { chrome, MAIN, transcript } from './ids.js';
2
2
  import { estimateCjkTokens, FLUSH_INTERVAL_MS } from './wireShapes.js';
3
+ /**
4
+ * 件 A(#323 症状①)判据 (b) 的句末字符集 —— 中英文各一套,**闭集**(开集匹配会把「代码里的点」
5
+ * 之外的一切标点都当句末,等于没收紧)。换行单独在 {@link endsAtSentenceBoundary} 里判(段末)。
6
+ */
7
+ const SENTENCE_END_CHARS = new Set(['。', '!', '?', '…', '.', '!', '?']);
8
+ /**
9
+ * 句末标点**之后**还可以合法跟着的收尾符(闭集):引号 / 括号 / markdown 强调与行内代码标记。
10
+ * 🔴 有它的理由(异源复审 finding 采纳):真句末很常见地**不以句末标点结尾** ——
11
+ * `他说:“完成了。”` 的末字符是右引号、`**Done.**` 的末字符是 `*`。不剥这一层,判据 (b) 会把
12
+ * 这些**真句末**永久判否(每一拍 idle 都返回 false),等于对带引号/强调的回复整条腿失效。
13
+ * 🔴 只剥**收尾**符,不做括号配对分析:未闭合的行内代码 `` `a.b `` 末字符是 `b`,本来就判否;
14
+ * 而 `` `a.b` `` 剥掉反引号后是 `b`,同样判否 —— 代码跨度里的点不会被误当句末。
15
+ */
16
+ const SENTENCE_CLOSING_CHARS = new Set([
17
+ '"', "'", '”', '’', '』', '」', '》', '〉', '】', '〕', ')', ')', ']', ']', '}', '}', '*', '_', '~', '`',
18
+ ]);
19
+ /** 收尾符最多剥这么多层 —— 剥的是收尾符,不是「一直回溯到找得着句号为止」。 */
20
+ const MAX_TRAILING_CLOSERS = 8;
21
+ /**
22
+ * 段尾是不是句末/段末边界(件 A 判据 (b))。
23
+ * 行末空格/制表符先剥掉再看最后一个字符 —— 流式 delta 常把 `". "` 拆成两拍,不剥就恒判否。
24
+ * 然后剥一层 {@link SENTENCE_CLOSING_CHARS} 收尾符,最后一次性判「换行(段末)∪ 句末标点」。
25
+ *
26
+ * 🔴 **换行判在剥尾之后**(异源复审第四轮 finding 采纳):换行检查若只做在剥尾**之前**,
27
+ * `Done.\n**`、整块围栏代码块(```` ```\nx\n``` ````)、`“完成了\n”` 这些**剥掉收尾符才露出换行**
28
+ * 的形会全部判否 —— 恰好是「已经写完一段」最常见的三种收尾,判否等于把它们永久押到工具边界/
29
+ * turn 收口。换行本身不在收尾符集里,所以「先剥后判」对「换行就在最外层」那些形是恒等的。
30
+ */
31
+ function endsAtSentenceBoundary(s) {
32
+ let t = s.replace(/[ \t]+$/u, '');
33
+ if (t.length === 0)
34
+ return false;
35
+ let last = t[t.length - 1];
36
+ for (let i = 0; i < MAX_TRAILING_CLOSERS && SENTENCE_CLOSING_CHARS.has(last); i++) {
37
+ t = t.slice(0, -1).replace(/[ \t]+$/u, '');
38
+ if (t.length === 0)
39
+ return false;
40
+ last = t[t.length - 1];
41
+ }
42
+ return last === '\n' || last === '\r' || SENTENCE_END_CHARS.has(last);
43
+ }
3
44
  export function createTextStream(ctx, idOf) {
4
45
  // ── turn 级状态(cli generator 局部变量的等价;矩阵 §1.1 逐行对位)──────────────────────────
5
46
  let answer = '';
@@ -11,6 +52,12 @@ export function createTextStream(ctx, idOf) {
11
52
  let textBlockOpen = false;
12
53
  let thinkingBlockOpen = false;
13
54
  let lastFlushAt = 0;
55
+ /**
56
+ * 件 A 判据 (a):**本段已经用掉 idle 的那一刀**。写于 `takeAnswerSegmentOnIdle` 真提交那一拍,
57
+ * 复位于 `takeAnswerSegment`(= 正常边界到达 ⇒ 重开新段)——**空段的正常边界也复位**:
58
+ * 「工具帧来过」本身就是重开新段的事实,与那一拍有没有散文可提交无关。
59
+ */
60
+ let idleFlushUsedInSegment = false;
14
61
  /** T29 攒批节拍:宿主策略优先(桌面/web 可传 16ms 对齐 CC 桌面端),缺席=207 CLI 口径 100ms。 */
15
62
  const flushEveryMs = typeof ctx.coalesceIntervalMs === 'number' && ctx.coalesceIntervalMs >= 0
16
63
  ? ctx.coalesceIntervalMs
@@ -37,8 +84,9 @@ export function createTextStream(ctx, idOf) {
37
84
  yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: false });
38
85
  yield transcript(msg, ctx.now());
39
86
  }
40
- /** 提交累积的文本段(cli takeAnswerSegment:#27 段序铁律——工具卡前/turn 末各 flush 一次)。 */
41
- function* takeAnswerSegment() {
87
+ /** 段提交的**唯一本体**(两个入口 `takeAnswerSegment` / `takeAnswerSegmentOnIdle` 共用;
88
+ * 两个入口的差别只在准入判据,提交出来的消息一个字节都不分叉) */
89
+ function* emitAnswerSegment() {
42
90
  if (answerSegment.length === 0)
43
91
  return;
44
92
  const anchor = segmentAnchor ?? {};
@@ -55,6 +103,23 @@ export function createTextStream(ctx, idOf) {
55
103
  textBlockOpen = false;
56
104
  yield transcript(msg, ctx.now());
57
105
  }
106
+ /** 提交累积的文本段(cli takeAnswerSegment:#27 段序铁律——工具卡前/turn 末各 flush 一次)。
107
+ * 🔴 **正常边界**入口:除了复位件 A 的 idle 配额,行为与拆分前逐字一致(零改动面)。 */
108
+ function* takeAnswerSegment() {
109
+ idleFlushUsedInSegment = false;
110
+ yield* emitAnswerSegment();
111
+ }
112
+ /** 件 A:IDLE-FLUSH 专用入口(准入 = 每段一刀 + 句末/段末边界;两条都过才提交)。 */
113
+ function* takeAnswerSegmentOnIdle() {
114
+ if (answerSegment.length === 0)
115
+ return;
116
+ if (idleFlushUsedInSegment)
117
+ return;
118
+ if (!endsAtSentenceBoundary(answerSegment))
119
+ return;
120
+ idleFlushUsedInSegment = true;
121
+ yield* emitAnswerSegment();
122
+ }
58
123
  /** 合并后的活体增量下泄(cli drainLive:思考先于文本 = 持久臂序)。 */
59
124
  function* drainLive() {
60
125
  if (thinkingPending.length > 0) {
@@ -114,6 +179,7 @@ export function createTextStream(ctx, idOf) {
114
179
  drainLive,
115
180
  takeThinking,
116
181
  takeAnswerSegment,
182
+ takeAnswerSegmentOnIdle,
117
183
  hasPendingContent: () => thinking.length > 0 ||
118
184
  answerSegment.length > 0 ||
119
185
  thinkingPending.length > 0 ||
@@ -41,6 +41,14 @@ export declare const FLUSH_INTERVAL_MS = 100;
41
41
  * 🔴 这是**过渡修**,正解在 core [817](wire 侧给事件);且**需要宿主定时器**:
42
42
  * `ctx.setTimer` 缺席 ⇒ 整条竞速不启用(纯等下一帧),与 0.4.0 逐字同行为。
43
43
  *
44
+ * 🔴 **适用域收紧**(#323 症状①,2026-08-25 clay 裁 (a)+(b) 合取):上面这条「缓冲非空就提交」
45
+ * 的原文**外溢**到了它没打算管的场景 —— 慢模型的正常吐字节奏本身就带 >1.5s 的卡顿,于是每卡一次
46
+ * 切一刀,一轮回复碎成 N 条 assistant 消息(屏上 N 个 ⏺;离线复现:1600ms 间隔 ⇒ 19 条)。
47
+ * 段提交因此改走 `textStream.takeAnswerSegmentOnIdle` 的**两条准入**:
48
+ * (a) 每个 answer segment 至多一刀(用掉之后要等一次正常边界重开新段);
49
+ * (b) 只在段尾是句末字符(`。!?…` / `.!?`)或换行时提交,否则跳过本拍等下一拍。
50
+ * 原意(大 Write 静默窗:开场白句末收尾 + 长静默 ⇒ 开场白先上屏)在两条准入下**原样成立**。
51
+ *
44
52
  * 公面。
45
53
  */
46
54
  export declare const IDLE_FLUSH_MS = 1500;
@@ -26,6 +26,14 @@ export const FLUSH_INTERVAL_MS = 100;
26
26
  * 🔴 这是**过渡修**,正解在 core [817](wire 侧给事件);且**需要宿主定时器**:
27
27
  * `ctx.setTimer` 缺席 ⇒ 整条竞速不启用(纯等下一帧),与 0.4.0 逐字同行为。
28
28
  *
29
+ * 🔴 **适用域收紧**(#323 症状①,2026-08-25 clay 裁 (a)+(b) 合取):上面这条「缓冲非空就提交」
30
+ * 的原文**外溢**到了它没打算管的场景 —— 慢模型的正常吐字节奏本身就带 >1.5s 的卡顿,于是每卡一次
31
+ * 切一刀,一轮回复碎成 N 条 assistant 消息(屏上 N 个 ⏺;离线复现:1600ms 间隔 ⇒ 19 条)。
32
+ * 段提交因此改走 `textStream.takeAnswerSegmentOnIdle` 的**两条准入**:
33
+ * (a) 每个 answer segment 至多一刀(用掉之后要等一次正常边界重开新段);
34
+ * (b) 只在段尾是句末字符(`。!?…` / `.!?`)或换行时提交,否则跳过本拍等下一拍。
35
+ * 原意(大 Write 静默窗:开场白句末收尾 + 长静默 ⇒ 开场白先上屏)在两条准入下**原样成立**。
36
+ *
29
37
  * 公面。
30
38
  */
31
39
  export const IDLE_FLUSH_MS = 1500;
package/dist/adapt.js CHANGED
@@ -228,6 +228,12 @@ class WireToCcAdapterImpl {
228
228
  for (;;) {
229
229
  const nextPromise = iterator.next();
230
230
  let step;
231
+ // 🔴 件 A 跟修的**留钉**(2026-08-25,一次证伪留档,免得下一棒再走一遍):判据收紧后同一个
232
+ // `nextPromise` 会被 race **多圈**(判据没过就让位),曾疑心「每圈现造的派生 promise 没人
233
+ // await ⇒ 上游 reject 时 unhandledRejection」。**实测证伪**:`Promise.race` 会给每个入参
234
+ // 挂反应,让位那些圈的派生 promise 因此都是 handled 的;上游的错照常经这一圈 await 抛出去。
235
+ // 对应常驻断言 = pure 门 B3 T40「上游 reject 照常抛给调用方」。所以这里**不改**(改成
236
+ // 只派生一次是纯分配面的微优化,零行为差,不值一处 diff)。
231
237
  while (step === undefined) {
232
238
  // 缓冲全空 ⇒ 零开销路径;宿主没给定时器 ⇒ 竞速整条不启用(绝不 setTimeout 兜底)。
233
239
  // 判据本体(四项 or)已随 12 行状态收进 M1 —— 这里读的是它的具名形。
@@ -254,8 +260,13 @@ class WireToCcAdapterImpl {
254
260
  // committed 序 —— 与工具卡前的边界 flush 逐字同序,只是提前发生)。
255
261
  yield* text.drainLive();
256
262
  yield* text.takeThinking();
257
- yield* text.takeAnswerSegment();
258
- // 缓冲已空 下一圈直接 plain-await。
263
+ // 🔴 件 A(#323 症状①,2026-08-25):文本段走**专用入口** `takeAnswerSegmentOnIdle`
264
+ // (每段一刀 + 只在句末/段末提交),不是通用的 `takeAnswerSegment`。
265
+ // 病:慢模型每 1.5s 一卡顿 = 一刀,一轮回复被切成 N 条 assistant 消息(屏上 N 个 ⏺)。
266
+ // 适用域:本竞速的原意只是「大 Write 的静默窗别把已生成的开场白押着」,不是
267
+ // 「每停 1.5s 就切一段」。终局 flush(工具卡前 / turn 收口)那条路**零改动**。
268
+ yield* text.takeAnswerSegmentOnIdle();
269
+ // 缓冲(可能)已空 ⇒ 下一圈直接 plain-await;判据没过时段缓冲还在,下一拍再race。
259
270
  }
260
271
  }
261
272
  if (step === undefined)
@@ -73,6 +73,7 @@
73
73
  import type { AgentEvent } from '@sema-agent/sdk';
74
74
  import { type AskGateWireDeps } from './frameRouter.js';
75
75
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
76
+ export { HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE } from './frameRouter.js';
76
77
  export type { AskGateWireDeps } from './frameRouter.js';
77
78
  export type { AskAnsweredOutput } from './gateLedger.js';
78
79
  export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
@@ -1,5 +1,5 @@
1
1
  import { createGateLedger } from './gateLedger.js';
2
- import { isHostProgressFrame, routeFrame, } from './frameRouter.js';
2
+ import { flushHeldWithInterruptRewrite, isHostProgressFrame, routeFrame, } from './frameRouter.js';
3
3
  import { resolvePark } from './parkResolver.js';
4
4
  // ── 宿主面口(搬迁差分 3)────────────────────────────────────────────────────────────────────
5
5
  //
@@ -19,6 +19,9 @@ import { resolvePark } from './parkResolver.js';
19
19
  // `toAnsweredOutput` / 下方 `bridgeAskUserQuestionGates`)、类型四个。定义搬去了实现所在的
20
20
  // 那一刀,本文件是它们对外的**唯一门牌**——下游 import 路径不变,public-export 基线不变。
21
21
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
22
+ // 件 B(#323 症状②,2026-08-25):CC 中断形的**单一真源**导出(与 SEMA_COLLATERAL_ABORT_KEY 同款
23
+ // 理由 —— 三端各自手抄同一句 CC 文案 = 漂移温床)。壳/web/desktop 认这个常量。
24
+ export { HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE } from './frameRouter.js';
22
25
  // #324 / [4907]:连坐 abort 机读位的**单一真源**导出([C93] `toolEndOutputText` 同款理由 ——
23
26
  // 三端各自手抄键名 = 漂移温床)。壳/web/desktop 认这个常量,不认文案。
24
27
  export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
@@ -58,8 +61,14 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
58
61
  led.noteSeq(ev);
59
62
  // HOLD 只护 park 窗口:host lane 的模型推进帧一到就放行扣留帧(触发集的来龙去脉见
60
63
  // `frameRouter.isHostProgressFrame` 上方长注)。
61
- if (led.heldCount() > 0 && isHostProgressFrame(ev))
62
- yield* led.flushHeld();
64
+ // 件 B(异源复审 finding 采纳):这个中途出口**也**要走中断感知的那一个。病形 = 用户按了
65
+ // Esc(signal 已 aborted)之后,源流里还有一条**已缓冲**的推进帧到达 ⇒ 扣留帧从这里放行,
66
+ // 绕开收口出口的改写 ⇒ 用户照旧看到红 `Error: Operation aborted`。
67
+ // 🔴 这里**没有终帧**可读,所以只有宿主腿(signal)能命中;引擎还在推进而 signal 没 abort 的
68
+ // 正常情形下,判据两腿全不命中 ⇒ 与本件之前逐字节相同。
69
+ if (led.heldCount() > 0 && isHostProgressFrame(ev)) {
70
+ yield* flushHeldWithInterruptRewrite(led, { signal: opts?.signal });
71
+ }
63
72
  const action = await routeFrame(ev, ctx);
64
73
  if (action.kind === 'skip')
65
74
  continue;
@@ -83,7 +92,9 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
83
92
  }
84
93
  if (!park) {
85
94
  // 源流走完没终帧(disconnect/abort)——flush 后结束,与现状一致。
86
- yield* led.flushHeld();
95
+ // 件 B(#323 症状②):没有终帧 ⇒ 出口证据只剩宿主腿(`signal.aborted` = 用户按了 Esc);
96
+ // 缺席时包装恒走原路,与本行修前逐字节相同。
97
+ yield* flushHeldWithInterruptRewrite(led, { signal: opts?.signal });
87
98
  return;
88
99
  }
89
100
  hops++;
@@ -24,6 +24,20 @@ import type { GateLedger } from './gateLedger.js';
24
24
  * 🔴 pure 门 B7 段:包内冻结字面量(无条件)+ 壳树全树扫描「每一处声明都逐字节相同」(壳树缺席=跳过)。
25
25
  */
26
26
  export declare const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
27
+ /**
28
+ * CC `utils/messages.ts` 的 `INTERRUPT_MESSAGE_FOR_TOOL_USE` **逐字**(件 B,#323 症状②,2026-08-25)。
29
+ *
30
+ * 🔴 CC 语料直证的两条事实(壳侧勘察,cli `src/utils/messages.ts:216-217` 逐字节同):
31
+ * ① `"Operation aborted"` 在 CC 只在 transport 层 throw,**从不上屏** —— 它是我们这条引擎链
32
+ * 特有的产物,渲成红 `Error: Operation aborted` 是 CC 里根本不存在的形;
33
+ * ② CC 的**唯一**中断呈现形就是本串:壳的 `UserToolErrorMessage` 对
34
+ * `content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)` 渲 dim `Interrupted`(CC 2.1.223 逐字判据)。
35
+ * ⇒ 用户中断批的 tool_result 文案在**呈现向**归一到本串,三端就天然拿到 CC 的 Interrupted 形,
36
+ * 不必各自再抄一份「Operation aborted 也算中断」的手工判据(那正是漂移温床)。
37
+ * 🔴 **呈现向,不是模型面**:本包这条链是转录/渲染链;喂回模型的那一份由引擎自己的 wire 承载,
38
+ * 这里一个字节都不碰它。
39
+ */
40
+ export declare const HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE = "[Request interrupted by user for tool use]";
27
41
  /**
28
42
  * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
29
43
  * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
@@ -60,7 +74,14 @@ export interface AskGateWireDeps {
60
74
  respondToolApproval?: RespondToolApprovalFn;
61
75
  /** #229 respond-note 供给链(0.29.0 发包扫描门修,2026-08-12):帧腿车道参数(能力位读数),
62
76
  * 宿主从自己的 caps 缓存供给。缺席 = note 门 fail-closed 到「不发」侧(决断照常,现状字节
63
- * 不变)—— 此前包内唯一生产调用点(routeFrame 的 tool_approval 臂)无此位,note 恒不发。 */
77
+ * 不变)—— 此前包内唯一生产调用点(routeFrame 的 tool_approval 臂)无此位,note 恒不发。
78
+ * ⚠️ **本位早已不只管 note**(0.43.0 订正,异源对抗复审五轮 [medium]:上句的「note 门」措辞
79
+ * 是 0.29.0 的历史射程,再留着会让宿主以为不接这个字段只损失一条备注)。`ToolApprovalFrameLaneOpts`
80
+ * 今天携带**四位**,缺席各有各的后果:`approvalDecisionNoteCapable`(备注不发)·
81
+ * `respondFreeFormRulesCapable`(**编辑臂**整条不发)· `respondBatchRuleOffersCapable`
82
+ * (**批臂**整条不发)· `windowIsCurrent`(窗三键不过境 ⇒ 卡上无倒计时)。后两位缺席时,人在卡上
83
+ * 按下的「不再询问」会被整条丢弃(决断照送 + 一条 `surfaceRuleArmNotSent` 通知)—— 逐位语义见
84
+ * 各自的 JSDoc 与 `docs/INTEGRATION-CLIENTS.md` §5b。 */
64
85
  approvalLane?: ToolApprovalFrameLaneOpts;
65
86
  }
66
87
  /** 两族 gate:AskUserQuestion 走问答 overlay(原路);其余(fs 写三件 / Bash / 一等
@@ -113,6 +134,43 @@ export interface FrameRouterCtx {
113
134
  export declare function isAskTool(toolName: string | undefined): boolean;
114
135
  /** 这一帧是不是「host lane 的模型推进」= 扣留帧的放行信号。 */
115
136
  export declare function isHostProgressFrame(ev: AgentEvent): boolean;
137
+ /** {@link flushHeldWithInterruptRewrite} 的出口证据(三腿,全是结构事实;缺席即不改写)。 */
138
+ export interface HeldFlushInterruptEvidence {
139
+ /** 本出口手上的终帧(有就读它的 cancel 码);中途出口/无终帧路径传 `undefined`。 */
140
+ terminal?: AgentEvent | undefined;
141
+ /** 宿主的 turn 中断信号。 */
142
+ signal?: AbortSignal | undefined;
143
+ /**
144
+ * **这张 park 是被用户中断掉的**(`GateOutcome.kind === 'aborted'`)—— 唯一写者 =
145
+ * `parkResolver` 的 fail-soft 出口。该 outcome 在包内的语义就是逐字的「turn 被中断(Esc/Ctrl+C)」
146
+ * (见 `toolApprovalWire` 同臂:它顺带用一次 TOOL 级 deny `reason:'Interrupted by user'` 把
147
+ * 挂着的门结算掉),所以它是**关于这张 park 本身**的一手中断证据,不是推断。
148
+ */
149
+ gateAbortedByUser?: boolean | undefined;
150
+ }
151
+ /**
152
+ * `led.flushHeld()` 的**唯一**排水出口包装:用户中断批把毒化帧的 `output` 归一成 CC 的中断形
153
+ * ({@link HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE}),其余一切原样。
154
+ *
155
+ * ── 判词(两模,别合并;方向一律 fail-safe:证据不足就走原路)──────────────────────────────
156
+ * · **模 A —— 纯用户中断批**(证据 = cancel 终帧码 / 宿主 `signal.aborted`):要求 **本批零 park
157
+ * 登记**(`hasBatchPark() === false`),且帧上 `errorCode !== "gate.parked"`。两道门都是同一件事
158
+ * 的两个面 ——「这一批背后没有任何门」才谈得上纯中断;有门而我们只看见 abort 帧 = 判不出,不改。
159
+ * · **模 B —— 门被用户中断**(证据 = {@link HeldFlushInterruptEvidence.gateAbortedByUser}):
160
+ * park 在场是**前提**而不是障碍,所以模 A 的两道门在这一模下**都让位** —— 包括
161
+ * `gate.parked` 那一票。理由:该码回答的是「这次调用为什么中止 =被门 park 了」,回答不了
162
+ * 「这一批为什么收场 = 用户按了 Esc」,两者正交;拿它做否决会把这一格里**最该显形的那张
163
+ * 主角卡**留成红 `Error: Operation aborted`(真机复现:审批卡挂着按 Esc,主角与连坐帧同文案)。
164
+ * 🔴 **gate deny 语义零变**:deny 走的是 `decided` 分支(→ 续流重放 → `denied-call`/`deny-stamp-next`
165
+ * 两臂 stamp CC REJECT 文案),那两臂排在 hold-poison **之前**、帧根本不进扣留表,本包装够不着。
166
+ * fail-soft 的其余原因(no_pending / 传输失败 / hop 用尽)三腿全不命中 ⇒ 诚实红照旧。
167
+ *
168
+ * 🔴 **只改 `output` 一个键**:`isError` / `errorCode` / `toolCallId` / `toolName` / `settledBy` /
169
+ * `approver` / `resolution` / `structured` 以及 `flushHeld` 自己盖的
170
+ * `_sema_collateral_abort` 机读位全部原样过境(展开赋值只覆盖 `output`)—— 端侧的连坐折叠锚的是
171
+ * 机读位不是文案,所以折叠语义不受影响,只是被折那一行的正文换成了 CC 中断形。
172
+ */
173
+ export declare function flushHeldWithInterruptRewrite(led: GateLedger, evidence: HeldFlushInterruptEvidence): Generator<AgentEvent>;
116
174
  /**
117
175
  * 一帧 → 一个处置。手柄的**先后**与原文逐条同序:tool_approval 帧 → tool_start → tool_end →
118
176
  * suspended → done → failed → 透传。
@@ -11,6 +11,26 @@ export const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this t
11
11
  function rejectMessageForRender() {
12
12
  return HITL_REJECT_MESSAGE;
13
13
  }
14
+ /**
15
+ * CC `utils/messages.ts` 的 `INTERRUPT_MESSAGE_FOR_TOOL_USE` **逐字**(件 B,#323 症状②,2026-08-25)。
16
+ *
17
+ * 🔴 CC 语料直证的两条事实(壳侧勘察,cli `src/utils/messages.ts:216-217` 逐字节同):
18
+ * ① `"Operation aborted"` 在 CC 只在 transport 层 throw,**从不上屏** —— 它是我们这条引擎链
19
+ * 特有的产物,渲成红 `Error: Operation aborted` 是 CC 里根本不存在的形;
20
+ * ② CC 的**唯一**中断呈现形就是本串:壳的 `UserToolErrorMessage` 对
21
+ * `content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)` 渲 dim `Interrupted`(CC 2.1.223 逐字判据)。
22
+ * ⇒ 用户中断批的 tool_result 文案在**呈现向**归一到本串,三端就天然拿到 CC 的 Interrupted 形,
23
+ * 不必各自再抄一份「Operation aborted 也算中断」的手工判据(那正是漂移温床)。
24
+ * 🔴 **呈现向,不是模型面**:本包这条链是转录/渲染链;喂回模型的那一份由引擎自己的 wire 承载,
25
+ * 这里一个字节都不碰它。
26
+ */
27
+ export const HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE = '[Request interrupted by user for tool use]';
28
+ /**
29
+ * run 被**取消**的终态短码(server cancel settle 契约:`runs.cancel` → run SETTLES to `failed` +
30
+ * `errorCode:"cancelled"`;成文见本包 `controlRouter.ts` cancel 段与 `docs/INTEGRATION-CLIENTS.md`
31
+ * 的 cancel/deny 对照行 ——「ack 带 `errorCode:"cancelled"`」)。
32
+ */
33
+ const RUN_CANCELLED_ERROR_CODE = 'cancelled';
14
34
  /**
15
35
  * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
16
36
  * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
@@ -247,6 +267,87 @@ export function isHostProgressFrame(ev) {
247
267
  return ((ev.type === 'text_delta' || ev.type === 'reasoning_delta' || ev.type === 'tool_start') &&
248
268
  ev.parentToolCallId === undefined);
249
269
  }
270
+ // ── 件 B(#323 症状②):用户中断批的呈现向文案归一 ────────────────────────────────────────────
271
+ /**
272
+ * 这次 flush 的**出口**带不带「用户要停」的证据。两条腿各自是**结构事实**,都不是猜:
273
+ * ① **wire 终帧腿**:`failed.errorCode === "cancelled"`(server cancel settle 契约)/ durable 腿
274
+ * 同码骑在 `done.result.errorCode` 上(终态投影表 `terminalToSdkResult` 头注的同一格)。
275
+ * 壳侧实证:Esc 的默认臂之外还有一发 best-effort `POST /v1/runs/:id/cancel`,run 就是这么
276
+ * 结算的 —— 所以「这一批 abort 帧属于一次取消」在 wire 上有据可查。
277
+ * ② **宿主腿**:`ctx.signal.aborted`。这个 signal 是宿主的 turn 中断信号,壳侧唯一 abort 源是
278
+ * 用户按 Esc(`abortController.abort('user-cancel')`),web/desktop 同位。它是**宿主事实**
279
+ * 而非帧事实,所以只在**收口出口**读(源流已经走完/终帧已到),不在流中途读。
280
+ * 🔴 两腿都不命中 ⇒ 没有中断证据 ⇒ 文案一个字节不改(诚实缺席,不靠猜)。
281
+ */
282
+ function isUserInterruptTerminalOrSignal(ev, signal) {
283
+ if (signal?.aborted === true)
284
+ return true;
285
+ if (ev === undefined)
286
+ return false;
287
+ if (ev.type === 'failed') {
288
+ return ev.errorCode === RUN_CANCELLED_ERROR_CODE;
289
+ }
290
+ if (ev.type === 'done') {
291
+ const r = ev.result;
292
+ return !!r && r.errorCode === RUN_CANCELLED_ERROR_CODE;
293
+ }
294
+ return false;
295
+ }
296
+ /**
297
+ * 呈现向改写的**帧级**准入(与出口级证据合取)。三条,全是不放宽的精确判据:
298
+ * ① 文案**精确等于** {@link ENGINE_ABORT_TOOL_RESULT}(6.0.0 的块数组形先经 `toolEndOutputText`
299
+ * 归一)—— `"operation aborted before execution"`(连坐旁观者:从未执行)与
300
+ * `interrupted_never_started` 族**刻意不进本臂**:core 显式拒绝合并两串向([4973]),
301
+ * 两串各承真语义,它们各自的文案/折叠语义已在别处成立,并进来 = 把两个语义压成一个。
302
+ * ② `errorCode !== "gate.parked"`:机读位在场时,**门语义帧一票否决** —— 那是「门把这次调用
303
+ * park 了」,不是「用户按了 Esc」。⚠️ 这一票**只在 ①/② 两腿证据下投**:见
304
+ * `allowGateParked` 与 {@link flushHeldWithInterruptRewrite} 头注的两模判词。
305
+ * ③ `isError === true`:呈现向的中断形长在报错卡那一路(壳 `UserToolErrorMessage` 的判据位)。
306
+ */
307
+ function isUserInterruptRewritable(ev, allowGateParked) {
308
+ if (ev.isError !== true)
309
+ return false;
310
+ if (!allowGateParked && ev.errorCode === ENGINE_GATE_PARKED_ERROR_CODE) {
311
+ return false;
312
+ }
313
+ return toolEndOutputText(ev.output) === ENGINE_ABORT_TOOL_RESULT;
314
+ }
315
+ /**
316
+ * `led.flushHeld()` 的**唯一**排水出口包装:用户中断批把毒化帧的 `output` 归一成 CC 的中断形
317
+ * ({@link HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE}),其余一切原样。
318
+ *
319
+ * ── 判词(两模,别合并;方向一律 fail-safe:证据不足就走原路)──────────────────────────────
320
+ * · **模 A —— 纯用户中断批**(证据 = cancel 终帧码 / 宿主 `signal.aborted`):要求 **本批零 park
321
+ * 登记**(`hasBatchPark() === false`),且帧上 `errorCode !== "gate.parked"`。两道门都是同一件事
322
+ * 的两个面 ——「这一批背后没有任何门」才谈得上纯中断;有门而我们只看见 abort 帧 = 判不出,不改。
323
+ * · **模 B —— 门被用户中断**(证据 = {@link HeldFlushInterruptEvidence.gateAbortedByUser}):
324
+ * park 在场是**前提**而不是障碍,所以模 A 的两道门在这一模下**都让位** —— 包括
325
+ * `gate.parked` 那一票。理由:该码回答的是「这次调用为什么中止 =被门 park 了」,回答不了
326
+ * 「这一批为什么收场 = 用户按了 Esc」,两者正交;拿它做否决会把这一格里**最该显形的那张
327
+ * 主角卡**留成红 `Error: Operation aborted`(真机复现:审批卡挂着按 Esc,主角与连坐帧同文案)。
328
+ * 🔴 **gate deny 语义零变**:deny 走的是 `decided` 分支(→ 续流重放 → `denied-call`/`deny-stamp-next`
329
+ * 两臂 stamp CC REJECT 文案),那两臂排在 hold-poison **之前**、帧根本不进扣留表,本包装够不着。
330
+ * fail-soft 的其余原因(no_pending / 传输失败 / hop 用尽)三腿全不命中 ⇒ 诚实红照旧。
331
+ *
332
+ * 🔴 **只改 `output` 一个键**:`isError` / `errorCode` / `toolCallId` / `toolName` / `settledBy` /
333
+ * `approver` / `resolution` / `structured` 以及 `flushHeld` 自己盖的
334
+ * `_sema_collateral_abort` 机读位全部原样过境(展开赋值只覆盖 `output`)—— 端侧的连坐折叠锚的是
335
+ * 机读位不是文案,所以折叠语义不受影响,只是被折那一行的正文换成了 CC 中断形。
336
+ */
337
+ export function* flushHeldWithInterruptRewrite(led, evidence) {
338
+ // 🔴 读位必须在 flushHeld **之前**求值:那一拍尾部 `retireBatchState()` 会把批级计数清零。
339
+ const gateAborted = evidence.gateAbortedByUser === true;
340
+ const rewrite = gateAborted
341
+ ? true
342
+ : !led.hasBatchPark() && isUserInterruptTerminalOrSignal(evidence.terminal, evidence.signal);
343
+ for (const ev of led.flushHeld()) {
344
+ if (!rewrite || ev.type !== 'tool_end' || !isUserInterruptRewritable(ev, gateAborted)) {
345
+ yield ev;
346
+ continue;
347
+ }
348
+ yield { ...ev, output: HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE };
349
+ }
350
+ }
250
351
  /**
251
352
  * 🔴 **有序**(承重):分类器批3([907])的注释原文 = 「结构性识别(core 机器签名)优先于 fs
252
353
  * HOLD/REJECT 分支(签名比"fs 写报错"更特定;分类器 deny 从不产 tool_approval 帧,denied /
@@ -496,7 +597,8 @@ function routeDone(ev, ctx) {
496
597
  led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.result.checkpointGate?.toolName) });
497
598
  return { kind: 'park', gate: 'fs', pendingDone: ev, ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
498
599
  }
499
- const events = [...led.flushHeld()]; // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
600
+ // 真错(非 gate)的 Ask tool_end 此刻诚实渲染;件 B:纯用户中断批在这个出口把文案归一成 CC 中断形。
601
+ const events = [...flushHeldWithInterruptRewrite(led, { terminal: ev, signal: ctx.signal })];
500
602
  // durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
501
603
  // rewind 绑定 —— 用 sync leg 捕的真 handle 修正回去。
502
604
  const r = ev.result;
@@ -532,7 +634,9 @@ export async function routeFrame(ev, ctx) {
532
634
  return routeSuspended(ev, ctx);
533
635
  if (ev.type === 'done')
534
636
  return routeDone(ev, ctx);
535
- if (ev.type === 'failed')
536
- return { kind: 'end', events: [...led.flushHeld(), ev] };
637
+ // B:cancel settle 的终帧(`failed{errorCode:"cancelled"}`)就是「用户要停」的 wire 证据。
638
+ if (ev.type === 'failed') {
639
+ return { kind: 'end', events: [...flushHeldWithInterruptRewrite(led, { terminal: ev, signal: ctx.signal }), ev] };
640
+ }
537
641
  return { kind: 'yield', events: [ev] };
538
642
  }
@@ -173,6 +173,15 @@ export interface GateLedger {
173
173
  noteParkGate(evidence: {
174
174
  fsOrShellFamily: boolean;
175
175
  }): void;
176
+ /**
177
+ * 「**本批**有没有登记过 park」——件 B(#323 症状②,2026-08-25)的硬门读位。
178
+ *
179
+ * 🔴 与 {@link flushHeld} 的主角判别是**两件事**,别合并:那条要的是「恰好一张」(归属可证),
180
+ * 本条要的是「**零张**」(这批 abort 帧背后没有任何 gate ⇒ 才谈得上「纯用户中断批」)。
181
+ * 判词方向同样 fail-safe:有 park = 门语义,呈现向一个字节都不改。
182
+ * 🔴 读位必须在 `flushHeld` 之前取(那一拍尾部会 `retireBatchState()` 把计数清零)。
183
+ */
184
+ hasBatchPark(): boolean;
176
185
  /** 续流重放的解答 tool_end 无 output,不补就会渲成结果不可用;这里记下真实答案供该帧 stamp
177
186
  * `structured` 让卡片渲真实选择。 */
178
187
  rememberAnswer(callId: string, answered: AskAnsweredOutput): void;
@@ -245,6 +245,9 @@ export function createGateLedger() {
245
245
  fsOrShellFamilyGate = evidence.fsOrShellFamily;
246
246
  heldSinceLastPark = false;
247
247
  },
248
+ hasBatchPark() {
249
+ return batchParkCount > 0;
250
+ },
248
251
  rememberAnswer(callId, answered) {
249
252
  resolvedAnswers.set(callId, answered);
250
253
  },