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 },