From e6a66e6f4204de5742dbdbaf3478253ebdacdf99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 29 Jul 2026 02:51:22 -0700 Subject: [PATCH] =?UTF-8?q?fix(bridge):=20=E6=97=A0=E5=9B=9E=E5=A4=8D=20se?= =?UTF-8?q?ntinel=20=E6=94=B9=E4=B8=BA=E6=9C=AB=E8=A1=8C=E7=BB=88=E6=AD=A2?= =?UTF-8?q?=E7=AC=A6=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #554 的 gate 用 `final.trim() === BOTMUX_NO_REPLY` 精确全等,要求整条 final 只有这一个 token。实测模型几乎总是先解释一句"无需回复"再把 token 另起一行附在结尾(prose + 空行 + token),不命中全等→整条含字面量 token 被兜底原样转发到飞书,泄漏 sentinel。 改为判「final 的最后一个非空行」是否精确等于 BOTMUX_NO_REPLY: - 纯 token / prose+空行+token(结尾独立行)→ 命中,整条 turn 静默吞掉 - token 出现在句中、或 token 之后还有正文 → 不命中,照常转发 取舍(已确认):命中即整条丢弃(方案 B,激进),彻底静音无回复回合; 风险=真答复若恰好以独立 sentinel 行结尾会被整条丢掉,产品选择接受此 风险以换取完全静音。用「末行」而非 substring/endsWith,把误吞限定在 模型主动以 sentinel 收尾的 final。 - 与 #553 空完成兜底组合:sentinel 终止的 final 非空,不触发空完成诊断 - adopt 模式不解释 sentinel(不变) - 测试:新增末行终止/句中 token/token 后接正文/CRLF/尾部标点等用例; 目标 6 文件 480 + 组合对抗 11 全过;pnpm build 通过 Co-Authored-By: Riff --- src/services/bridge-fallback-gate.ts | 35 ++++++++++++++++++++++++---- test/bridge-fallback-gate.test.ts | 24 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/services/bridge-fallback-gate.ts b/src/services/bridge-fallback-gate.ts index 056a88bec..df23a52cb 100644 --- a/src/services/bridge-fallback-gate.ts +++ b/src/services/bridge-fallback-gate.ts @@ -8,10 +8,14 @@ * disk and threads them through here. * * Rules: - * - Non-adopt + exact no-reply sentinel: suppress. Botmux-aware models use - * this explicit protocol when a turn genuinely needs no chat response. - * Exact matching is intentional: prose such as "I should stay silent" - * is still a normal final answer and must not be guessed away. + * - Non-adopt + no-reply sentinel terminator: suppress the whole turn. + * Botmux-aware models use this explicit protocol when a turn genuinely + * needs no chat response. The signal is the LAST non-empty line of the + * final being exactly `BOTMUX_NO_REPLY` — models almost always explain + * the silence first and then append the token on its own line, so a + * full-string exact match leaked the literal token into Lark. A token + * that only appears inline (mid-sentence, or with prose after it) is + * still a normal answer and is NOT guessed away. See isBridgeNoReplyFinal. * - Adopt mode never suppresses: in /adopt the model in the adopted * session is unaware of botmux, so transcript drain is the ONLY * channel from model to Lark. There's no `botmux send` to compete @@ -42,7 +46,28 @@ const MATERIAL_FINAL_MIN_EXTRA_CHARS = 120; export const BRIDGE_NO_REPLY_SENTINEL = 'BOTMUX_NO_REPLY'; export function isBridgeNoReplyFinal(finalText: string | undefined): boolean { - return finalText?.trim() === BRIDGE_NO_REPLY_SENTINEL; + if (finalText === undefined) return false; + // Suppress the whole turn when the model's final ENDS WITH a standalone + // no-reply sentinel line. We look at the LAST non-empty line only: + // - pure `BOTMUX_NO_REPLY` → suppress + // - `\n\nBOTMUX_NO_REPLY` → suppress the whole turn + // - a final whose last non-empty line is prose → NOT a no-reply signal + // (the token inline in a sentence, or followed by more prose, still posts) + // Full-string exact match was too brittle: botmux-aware models almost always + // explain the silence first ("...no reply needed.") and then append the token + // on its own line, which exact match let leak the literal token into Lark. + // Trade-off (accepted): a genuine answer that happens to end with a bare + // sentinel line is dropped WHOLE — the product wants a fully silent no-reply + // turn over the safer strip-and-forward. The last-non-empty-line rule (not a + // substring / endsWith test) keeps that risk to finals the model deliberately + // terminated with the sentinel. + const lines = finalText.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.length === 0) continue; + return line === BRIDGE_NO_REPLY_SENTINEL; + } + return false; } export interface BridgeSendMarker { diff --git a/test/bridge-fallback-gate.test.ts b/test/bridge-fallback-gate.test.ts index 569bd5a83..5aca2ca36 100644 --- a/test/bridge-fallback-gate.test.ts +++ b/test/bridge-fallback-gate.test.ts @@ -28,7 +28,20 @@ describe('shouldSuppressBridgeEmit', () => { )).toBe(true); }); - it('non-adopt: prose about staying silent is not guessed away', () => { + it('non-adopt: prose then a standalone sentinel LINE suppresses the whole turn', () => { + // The real-world shape: the model explains the silence, then appends the + // token on its own trailing line. Full-string exact match let this leak. + expect(shouldSuppressBridgeEmit( + { ...turn(100), finalText: `Codex acknowledged and is reviewing. Nothing for me to do — no reply needed.\n\n${BRIDGE_NO_REPLY_SENTINEL}` }, + undefined, + [], + false, + )).toBe(true); + }); + + it('non-adopt: token inline in a prose sentence is not guessed away', () => { + // Last non-empty line is a full sentence (token mid-line), not a bare + // sentinel — a normal answer that merely mentions the token. expect(shouldSuppressBridgeEmit( { ...turn(100), finalText: `I will stay silent instead of replying. ${BRIDGE_NO_REPLY_SENTINEL}` }, undefined, @@ -37,6 +50,15 @@ describe('shouldSuppressBridgeEmit', () => { )).toBe(false); }); + it('non-adopt: sentinel followed by more prose still posts (not a terminator)', () => { + expect(shouldSuppressBridgeEmit( + { ...turn(100), finalText: `${BRIDGE_NO_REPLY_SENTINEL}\n\nActually, here is the answer you asked for.` }, + undefined, + [], + false, + )).toBe(false); + }); + it('adopt mode does not interpret the no-reply sentinel', () => { expect(shouldSuppressBridgeEmit( { ...turn(100), finalText: BRIDGE_NO_REPLY_SENTINEL },