Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions src/services/bridge-fallback-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
// - `<prose>\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 {
Expand Down
24 changes: 23 additions & 1 deletion test/bridge-fallback-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 },
Expand Down