diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c955ad042..68c180bd9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -565,7 +565,7 @@ export const messages: Record = { 'ai.routing.intro': 'You are connected to a Lark (Feishu) topic group. The user is reading on Lark and CANNOT see your terminal output.', 'ai.routing.must_use_botmux': 'To make the user see something, you MUST send it via the `botmux send` command. Terminal output does NOT reach the chat.', 'ai.routing.usage_heading': 'How to use it:', - 'ai.routing.usage_send_when': '- Use `botmux send` for: key conclusions, plans (wait for user approval before acting), final results, progress updates.', + 'ai.routing.usage_send_when': '- Use `botmux send` for: key conclusions, plans (wait for user approval before acting), final results, progress updates. If no reply is needed, do not explain the silence and do not call `botmux send`; the final assistant message must be exactly `BOTMUX_NO_REPLY`.', 'ai.routing.usage_send_text': '- Plain text is fine: `botmux send "your message"`. Formatting is auto-handled.', 'ai.routing.usage_heredoc': '- Multi-line body text MUST use a quoted heredoc / stdin (or a UTF-8 `--content-file`). Never write `botmux send "line1\\nline2"` or pass `JSON.stringify` / JSON-escaped text as a positional argument; shell / botmux do not turn literal `\\n` back into newlines.', 'ai.routing.heredoc_example': " Correct multi-line example:\n```bash\nbotmux send <<'EOF'\nline 1\nline 2\nEOF\n```", @@ -596,7 +596,7 @@ export const messages: Record = { 'ai.shell.multiline_heredoc': 'Multi-line body text MUST use a quoted heredoc / stdin (or a UTF-8 `--content-file`). Never write `botmux send "line1\\nline2"` or pass `JSON.stringify` / JSON-escaped text as a positional argument; shell / botmux do not turn literal `\\n` back into newlines.', 'ai.shell.heredoc_example': "Correct multi-line example:\n```bash\nbotmux send <<'EOF'\nline 1\nline 2\nEOF\n```", 'ai.shell.helpers': 'Helpers: `botmux history` (read this session\'s history — thread/topic sessions are topic-scoped; regular-group chat-scope sessions are group-wide), `botmux quoted ` (fetch a quoted message — only use it when the prompt header shows `[user quoted message ...]`), `botmux bots list` (list other bots in the group).', - 'ai.shell.when_to_send': 'When to send: key conclusions, plans (wait for user approval before acting), final results, progress updates. A bare `print`/`echo` does NOT count as a reply.', + 'ai.shell.when_to_send': 'When to send: key conclusions, plans (wait for user approval before acting), final results, progress updates. A bare `print`/`echo` does NOT count as a reply. If no reply is needed, do not explain the silence and do not call `botmux send`; the final assistant message must be exactly `BOTMUX_NO_REPLY`.', 'ai.shell.mention_gate': '@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention ` (name a person/bot; REQUIRED to communicate or collaborate with another bot) / `--mention-back` (@ the sender of the message you are replying to) / `--no-mention` (none). Choose by VALUE: substantive conclusion the other party should read/confirm/decide → --mention-back; pure record / low-priority / short ack → --no-mention; a contentless "got it" is better not sent. Do not default to --no-mention, and do not @ people for nothing.', // ─── AI prompt blocks (session-manager) ────────────────────────────────── @@ -605,7 +605,7 @@ export const messages: Record = { 'ai.available_bots.hint': 'To communicate or collaborate with a bot listed here you MUST --mention its open_id (botmux send --mention ou_xxx ...). Without --mention the other bot receives nothing.', 'ai.available_bots.hint_collapsed': 'To communicate or collaborate with another bot, first run `botmux bots list` to get its open_id, then --mention it. Without --mention the other bot receives nothing.', 'ai.available_bots.collapsed_line': 'There are {count} collaborator bots in this chat: {names}.', - 'ai.followup.reminder': 'Replies must go via `botmux send` — terminal output does not reach the user.', + 'ai.followup.reminder': 'When a reply is needed, use `botmux send`; when none is needed, do not explain the silence and make the final exactly BOTMUX_NO_REPLY.', 'ai.cursor.sender_note': 'The sender tag is metadata identifying the current speaker — never copy its open_id or name (e.g. ou_xxx:Alice) into your botmux send body or opening line; to @ the triggerer use botmux send --mention-back.', 'ai.bridge.attachments_label': '[Attachments]', 'ai.bridge.mentions_label': '[@Mentions]', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index cc51e5e8b..ea2f5e6aa 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -568,7 +568,7 @@ export const messages: Record = { 'ai.routing.intro': '你连接到了飞书(Lark)话题群。用户在飞书上阅读,看不到你的终端输出。', 'ai.routing.must_use_botmux': '想让用户看到的内容必须通过 `botmux send` 命令发送,终端输出不会到达聊天。', 'ai.routing.usage_heading': '使用指南:', - 'ai.routing.usage_send_when': '- 用 `botmux send` 发送:关键结论、方案(等用户确认再执行)、最终结果、进度更新。', + 'ai.routing.usage_send_when': '- 用 `botmux send` 发送:关键结论、方案(等用户确认再执行)、最终结果、进度更新。若无需回复,不要解释沉默,也不要调用 `botmux send`;最终 assistant message 必须只输出 `BOTMUX_NO_REPLY`。', 'ai.routing.usage_send_text': '- 发送纯文本即可:`botmux send "消息"`。格式自动处理。', 'ai.routing.usage_heredoc': '- 多行正文必须走 quoted heredoc / stdin(或 UTF-8 `--content-file`);禁止写成 `botmux send "第一行\\n第二行"`,也不要先 `JSON.stringify` / JSON 转义再传位置参数,shell / botmux 不会把字面量 `\\n` 还原成换行。', 'ai.routing.heredoc_example': " 正确多行示例:\n```bash\nbotmux send <<'EOF'\n第一行\n第二行\nEOF\n```", @@ -599,7 +599,7 @@ export const messages: Record = { 'ai.shell.multiline_heredoc': '多行正文必须走 quoted heredoc / stdin(或 UTF-8 `--content-file`);禁止写成 `botmux send "第一行\\n第二行"`,也不要先 `JSON.stringify` / JSON 转义再传位置参数,shell / botmux 不会把字面量 `\\n` 还原成换行。', 'ai.shell.heredoc_example': "正确多行示例:\n```bash\nbotmux send <<'EOF'\n第一行\n第二行\nEOF\n```", 'ai.shell.helpers': '辅助命令:`botmux history`(读此会话历史;thread/话题会话拉话题内,普通群 chat-scope 会话拉整群)、`botmux quoted `(按需读取被引用的消息,仅在 prompt 头部出现 `[用户引用了消息 ...]` 提示时使用)、`botmux bots list`(查群内其他机器人)。', - 'ai.shell.when_to_send': '发送时机:关键结论、方案(等用户确认再动手)、最终结果、进度更新。只 print/echo 不算回复。', + 'ai.shell.when_to_send': '发送时机:关键结论、方案(等用户确认再动手)、最终结果、进度更新。只 print/echo 不算回复。若无需回复,不要解释沉默,也不要调用 `botmux send`;最终 assistant message 必须只输出 `BOTMUX_NO_REPLY`。', 'ai.shell.mention_gate': '@ 决策(硬性):每条 `botmux send` 必须显式三选一否则报错——`--mention `(点名某人/bot,跟别的 bot 沟通/协作必须用它)/ `--mention-back`(@回触发你的那条消息的发送者)/ `--no-mention`(不@)。按内容价值选:有实质结论要对方看/确认/决策→--mention-back;纯记录/低优先级/简短确认→--no-mention;没信息量的"收到"不如不发。别把 --no-mention 当默认,也别无意义 @ 打扰。', // ─── AI prompt blocks (session-manager) ────────────────────────────────── @@ -608,7 +608,7 @@ export const messages: Record = { 'ai.available_bots.hint': '要跟这里的某个 bot 沟通或协作必须 --mention 它的 open_id(botmux send --mention ou_xxx ...),不 --mention 对方 bot 完全收不到消息', 'ai.available_bots.hint_collapsed': '要跟别的 bot 沟通或协作先 `botmux bots list` 查 open_id 再 --mention,不 --mention 对方收不到', 'ai.available_bots.collapsed_line': '群里有 {count} 个可协作 bot:{names}。', - 'ai.followup.reminder': '回复必须 botmux send,终端输出用户看不到', + 'ai.followup.reminder': '需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY', 'ai.cursor.sender_note': 'sender 标签只是元信息(标识当前发言人),不要把其中的 open_id 或名字(例如 ou_xxx:高鹏)抄进 botmux send 的正文或开头;要 @ 回触发者请用 botmux send --mention-back。', 'ai.bridge.attachments_label': '[附件]', 'ai.bridge.mentions_label': '[@提及]', diff --git a/src/services/bridge-fallback-gate.ts b/src/services/bridge-fallback-gate.ts index 4747660e7..056a88bec 100644 --- a/src/services/bridge-fallback-gate.ts +++ b/src/services/bridge-fallback-gate.ts @@ -8,6 +8,10 @@ * 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. * - 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 @@ -35,6 +39,12 @@ import { normaliseForFingerprint } from './bridge-turn-queue.js'; const MATERIAL_FINAL_LENGTH_RATIO = 2; 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; +} + export interface BridgeSendMarker { sentAtMs: number; messageId?: string; @@ -98,6 +108,7 @@ export function shouldSuppressBridgeEmit( adoptMode: boolean, ): boolean { if (adoptMode) return false; + if (isBridgeNoReplyFinal(turn.finalText)) return true; if (turn.isLocal) return true; if (turn.markTimeMs === undefined) return false; const lower = turn.markTimeMs; diff --git a/test/bridge-fallback-gate.test.ts b/test/bridge-fallback-gate.test.ts index 2cf2ee887..569bd5a83 100644 --- a/test/bridge-fallback-gate.test.ts +++ b/test/bridge-fallback-gate.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + BRIDGE_NO_REPLY_SENTINEL, shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit, type BridgeSendMarker, @@ -18,6 +19,33 @@ const markerForContent = (sentAtMs: number, content: string): BridgeSendMarker = }; describe('shouldSuppressBridgeEmit', () => { + it('non-adopt: exact no-reply sentinel suppresses without a send marker', () => { + expect(shouldSuppressBridgeEmit( + { ...turn(100), finalText: ` ${BRIDGE_NO_REPLY_SENTINEL}\n` }, + undefined, + [], + false, + )).toBe(true); + }); + + it('non-adopt: prose about staying silent is not guessed away', () => { + expect(shouldSuppressBridgeEmit( + { ...turn(100), finalText: `I will stay silent instead of replying. ${BRIDGE_NO_REPLY_SENTINEL}` }, + undefined, + [], + false, + )).toBe(false); + }); + + it('adopt mode does not interpret the no-reply sentinel', () => { + expect(shouldSuppressBridgeEmit( + { ...turn(100), finalText: BRIDGE_NO_REPLY_SENTINEL }, + undefined, + [], + true, + )).toBe(false); + }); + it('adopt mode never suppresses, even with markers in window', () => { const markers: BridgeSendMarker[] = [{ sentAtMs: 150 }]; expect(shouldSuppressBridgeEmit(turn(100), 200, markers, true)).toBe(false); diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index 00a2ef660..41bacde73 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -1421,6 +1421,10 @@ describe('systemHints', () => { expect(hints.length).toBeGreaterThan(0); expect(hints.some(h => h.includes('botmux send'))).toBe(true); }); + + it('traex systemHints declare the exact no-reply protocol', () => { + expect(createTraexAdapter('/bin/traex').systemHints.join('\n')).toContain('BOTMUX_NO_REPLY'); + }); }); // --------------------------------------------------------------------------- diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index 2a8c7848a..b646a1efc 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -321,7 +321,7 @@ describe('buildFollowUpContent', () => { expect(content.indexOf('')).toBeGreaterThan(content.indexOf('')); // Complex send guidance is discoverable once in the opening catalog; keep // every follow-up reminder intentionally tiny. - expect(content).toContain('回复必须 botmux send,终端输出用户看不到'); + expect(content).toContain('需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY'); expect(content).not.toContain('JSON.stringify'); expect(content).not.toContain('botmux skill show botmux-send'); });