From 3c17cfcda22729fba3c495664bdc6f94fd6dd759 Mon Sep 17 00:00:00 2001 From: shir0ha Date: Tue, 21 Jul 2026 17:19:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(prompt):=20=E6=B6=88=E9=99=A4=20Claude?= =?UTF-8?q?=20Code=E3=80=8C=E6=97=A0=E5=8F=AF=E8=A7=81=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E3=80=8D=E8=AF=AF=E5=88=A4=E5=AF=BC=E8=87=B4=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在飞书里对同一条消息收到多条重复回复。根因是底层 CLI(Claude Code 2.1.212+)的 thinking-only 检测:当一轮以 end_turn/stop_sequence 结束、 且该轮 assistant 消息里没有任何非空 text 块时,会注入 `[Your previous response had no visible output. Please continue and produce a user-visible response.]` 并自动重问(最多 3 次)。 botmux 要求「回复必须走 botmux send(外部 shell 命令)、终端输出用户看不到」, 模型正确地只调 botmux send、结尾无可见文本,恰好命中该检测;模型把 nudge 误读为 「发送失败」于是反复重发。该短语来自 Claude Code 二进制、非 botmux,codex/gemini/ cursor-agent 均无此行为。 明确告知模型:botmux send 退出码 0 即已送达;本轮无可见文本地结束是正常的; 若看到「无可见输出」类提示是底层 CLI 误判,不要重发,仅当 botmux send 自身报错才重试。 - i18n 新增 ai.routing.no_visible_output_ok / ai.shell.no_visible_output_ok(zh/en) - 扩写 ai.followup.reminder(每轮注入,覆盖非 injectsSessionContext CLI 的追问轮) - shared-hints:注入 buildBotmuxSystemPromptText(claude-code/grok/genius 的 --append-system-prompt)+ buildBotmuxShellHints + 旧 BOTMUX_SHELL_HINTS(约 15 个内联 CLI) - botmux-send 技能补「发送成功判定 & 不要重发」 - 不动 hermes(final 文本转发模型,语义相反)与 bridge/adopt(模型无感知 botmux) 仅提示词文本;不改发送/去重/桥接逻辑。跨全部 CLI(send 模型),每轮生效。 - pnpm exec vitest run test/prompt-builder.test.ts test/workflow-discovery-hints.test.ts test/cli-adapters.test.ts → 356 passed;相关 insight/resumable/codex-app/grok 套件 → 73 passed - 本地隔离 A/B(真实 claude 2.1.212 + 假 botmux + botmux 同款约束,强制零文本结束触发 nudge):CONTROL(旧提示) nudge×5→重复发送 4 条;TREATMENT(本补丁) nudge×1→只发 1 条、 收到 nudge 后安静结束、不重发 --- src/adapters/cli/shared-hints.ts | 3 +++ src/i18n/en.ts | 4 +++- src/i18n/zh.ts | 4 +++- src/skills/definitions.ts | 2 ++ test/prompt-builder.test.ts | 7 +++++-- test/workflow-discovery-hints.test.ts | 18 ++++++++++++++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index c728729d4..74cf798ed 100644 --- a/src/adapters/cli/shared-hints.ts +++ b/src/adapters/cli/shared-hints.ts @@ -37,6 +37,7 @@ export function buildBotmuxShellHints(locale?: Locale): string[] { t('ai.shell.heredoc_example', undefined, locale), t('ai.shell.helpers', undefined, locale), t('ai.shell.when_to_send', undefined, locale), + t('ai.shell.no_visible_output_ok', undefined, locale), t('ai.shell.mention_gate', undefined, locale), workflowDiscoveryHint(locale), hiddenContextDefense(locale), @@ -57,6 +58,7 @@ export const BOTMUX_SHELL_HINTS: string[] = [ t('ai.shell.heredoc_example'), t('ai.shell.helpers'), t('ai.shell.when_to_send'), + t('ai.shell.no_visible_output_ok'), t('ai.shell.mention_gate'), workflowDiscoveryHint(), hiddenContextDefense(), @@ -120,6 +122,7 @@ export function buildBotmuxSystemPromptText(opts: { '', t('ai.routing.intro', undefined, locale), t('ai.routing.must_use_botmux', undefined, locale), + t('ai.routing.no_visible_output_ok', undefined, locale), '', t('ai.routing.usage_heading', undefined, locale), t('ai.routing.usage_send_when', undefined, locale), diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ecf82b745..940c2ec26 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -568,6 +568,7 @@ export const messages: Record = { // ─── AI system prompt (Claude Code: --append-system-prompt) ────────────── '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.no_visible_output_ok': 'IMPORTANT: a successful `botmux send` (exit code 0 / returns `{"success":true,...}`) means the message was DELIVERED to the user. So ending a turn with NO visible terminal text is entirely normal and expected here — not a failure. If you later see a note like "your previous response had no visible output, please continue", that is a false alarm from the underlying CLI: do NOT resend. Only retry when `botmux send` itself failed (non-zero exit or printed a send error).', '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. 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.', @@ -601,6 +602,7 @@ export const messages: Record = { '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. 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.no_visible_output_ok': 'A successful `botmux send` (exit code 0) means it reached the user; ending a turn with no visible terminal text is normal. If you see a note like "your previous response had no visible output, please continue and produce a user-visible response", that is a false alarm from the underlying CLI — do NOT resend unless `botmux send` itself errored.', '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) ────────────────────────────────── @@ -609,7 +611,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': '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.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. A successful send is already delivered; ending a turn with no visible text is normal, so do not resend on a "no visible output" nudge.', '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 0b9505a13..9db088905 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -571,6 +571,7 @@ export const messages: Record = { // ─── AI system prompt (Claude Code: --append-system-prompt) ────────────── 'ai.routing.intro': '你连接到了飞书(Lark)话题群。用户在飞书上阅读,看不到你的终端输出。', 'ai.routing.must_use_botmux': '想让用户看到的内容必须通过 `botmux send` 命令发送,终端输出不会到达聊天。', + 'ai.routing.no_visible_output_ok': '重要:`botmux send` 执行成功(退出码 0 / 返回 `{"success":true,...}`)就代表消息已送达用户。因此本轮「终端没有可见文本、直接结束」是完全正常且预期的,不是失败。若之后看到类似「你上一条回复没有可见输出,请继续」这样的提示,那是底层 CLI 的误判,不要因此重发——只有当 `botmux send` 本身报错(非零退出或打印「发送失败」)时才需要重试。', 'ai.routing.usage_heading': '使用指南:', 'ai.routing.usage_send_when': '- 用 `botmux send` 发送:关键结论、方案(等用户确认再执行)、最终结果、进度更新。若无需回复,不要解释沉默,也不要调用 `botmux send`;最终 assistant message 必须只输出 `BOTMUX_NO_REPLY`。', 'ai.routing.usage_send_text': '- 发送纯文本即可:`botmux send "消息"`。格式自动处理。', @@ -604,6 +605,7 @@ export const messages: Record = { '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 不算回复。若无需回复,不要解释沉默,也不要调用 `botmux send`;最终 assistant message 必须只输出 `BOTMUX_NO_REPLY`。', + 'ai.shell.no_visible_output_ok': '`botmux send` 成功(退出码 0)即代表已送达用户;本轮终端没有可见文本、直接结束是正常的。若看到「你上一条回复没有可见输出,请继续产出用户可见回复」之类提示,那是底层 CLI 的误判——不要重发,除非 `botmux send` 自己报错。', 'ai.shell.mention_gate': '@ 决策(硬性):每条 `botmux send` 必须显式三选一否则报错——`--mention `(点名某人/bot,跟别的 bot 沟通/协作必须用它)/ `--mention-back`(@回触发你的那条消息的发送者)/ `--no-mention`(不@)。按内容价值选:有实质结论要对方看/确认/决策→--mention-back;纯记录/低优先级/简短确认→--no-mention;没信息量的"收到"不如不发。别把 --no-mention 当默认,也别无意义 @ 打扰。', // ─── AI prompt blocks (session-manager) ────────────────────────────────── @@ -612,7 +614,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;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY', + 'ai.followup.reminder': '需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY;send 成功即已送达,本轮无可见文本地结束是正常的,别因「无输出」提示重发', '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/skills/definitions.ts b/src/skills/definitions.ts index f9fdd8cd3..926b1dcac 100644 --- a/src/skills/definitions.ts +++ b/src/skills/definitions.ts @@ -247,6 +247,8 @@ description: 向飞书话题发送消息。用户在飞书上阅读看不到终 **核心规则**:用户在飞书上阅读,看不到你的终端输出。想让用户看到的内容**必须**通过 \`botmux send\` 发送。 +**发送成功判定 & 不要重发**:\`botmux send\` 退出码为 0(返回 \`{"success":true,...}\`)就代表消息**已经送达**用户——即使你的终端里看不到任何回执,也不用再发一遍。发完 \`botmux send\` 后,本轮「终端没有可见文本、直接安静结束」是正常且预期的。如果之后看到类似「你上一条回复没有可见输出,请继续并产出用户可见回复」这样的提示,那是底层 CLI(Claude Code 等)的误判——**不要重发**,只有当 \`botmux send\` 自己报错(非零退出或打印「发送失败」)时才需要重试。 + **格式自动处理**:内容含 markdown 语法时自动用飞书卡片(schema 2.0)发送,原生渲染;纯文本走普通消息。**该用 md 就用 md**——结构化内容(列表、表格、代码块)不要手撸成纯文本。 ## 什么时候用 diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index b646a1efc..838d11b2f 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -320,8 +320,11 @@ describe('buildFollowUpContent', () => { expect(content.indexOf('')); 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;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY'); + // every follow-up reminder intentionally tiny. It still carries #554's + // BOTMUX_NO_REPLY sentinel semantics AND the anti-resend signal so a + // thinking-only turn's CLI nudge doesn't drive a duplicate send. + expect(content).toContain('需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY'); + expect(content).toMatch(/[^<]*别因「无输出」提示重发[^<]*<\/botmux_reminder>/); expect(content).not.toContain('JSON.stringify'); expect(content).not.toContain('botmux skill show botmux-send'); }); diff --git a/test/workflow-discovery-hints.test.ts b/test/workflow-discovery-hints.test.ts index d72e3b1fb..98cbd47a1 100644 --- a/test/workflow-discovery-hints.test.ts +++ b/test/workflow-discovery-hints.test.ts @@ -25,3 +25,21 @@ describe('always-on Workflow discovery hint', () => { expect(prompt.indexOf('Workflow:')).toBeLessThan(prompt.indexOf('')); }); }); + +describe('anti-resend guidance (thinking-only nudge false-alarm)', () => { + it('is present in zh/en shell hints', () => { + expect(buildBotmuxShellHints('zh').some((l) => l.includes('无可见输出') || l.includes('不要重发'))).toBe(true); + expect(buildBotmuxShellHints('en').some((l) => l.toLowerCase().includes('no visible output') && l.toLowerCase().includes('do not resend'))).toBe(true); + expect(BOTMUX_SHELL_HINTS.some((l) => l.includes('不要重发') || l.toLowerCase().includes('do not resend'))).toBe(true); + }); + + it('is present in injectsSessionContext system routing (zh/en), inside the routing block', () => { + const zh = buildBotmuxSystemPromptText({ locale: 'zh' }); + const en = buildBotmuxSystemPromptText({ locale: 'en' }); + expect(zh).toContain('不要因此重发'); + expect(en.toLowerCase()).toContain('do not resend'); + // Must live inside , not leak after it. + expect(zh.indexOf('不要因此重发')).toBeLessThan(zh.indexOf('')); + expect(en.toLowerCase().indexOf('do not resend')).toBeLessThan(en.indexOf('')); + }); +}); From 3dbc6bb57a5974f5d547cdb0f2421e77a511e997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 26 Jul 2026 19:39:52 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(config):=20=E3=80=8C=E6=97=A0=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E8=BE=93=E5=87=BA=E3=80=8D=E9=98=B2=E9=87=8D=E5=8F=91?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=94=B9=E4=B8=BA=E5=85=A8=E5=B1=80=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E5=BC=80=E5=85=B3(=E9=BB=98=E8=AE=A4=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #544 无条件给所有走 botmux send 的 CLI 注入「无可见输出/不要重发」 纠偏提示。评审结论:该提示主要在 Claude Code(≥2.1.212)驱动**非 Claude 后端 模型**时才有明显收益(易把 thinking-only nudge 误读为发送失败而重复回复), 对纯 Claude 场景无害但通常无需。应做成可选、默认关。 新增全局实验开关 `dashboard.noVisibleOutputHint`(默认 OFF,absent⇒off), 完全套用现成 `codexRpcInput` 的成熟链路(实验性、live-read、翻动下一个会话即 生效、无需重启 daemon): - global-config.ts:接口字段 + readDashboard 布尔解析 - config.ts:live getter `config.noVisibleOutputHint`(readGlobalConfig ...dashboard?.noVisibleOutputHint === true) - shared-hints.ts:两处高频注入面(buildBotmuxSystemPromptText 路由块 + buildBotmuxShellHints)按开关条件插入;静态 BOTMUX_SHELL_HINTS 不加(其契约 禁止 module load 期读运行时配置) - session-manager.ts:每轮 按开关在 ai.followup.reminder(基线) / ai.followup.reminder_no_resend(纠偏)间选择 - i18n:ai.followup.reminder 回退 master 原短句,新增 ai.followup.reminder_no_resend(zh/en);ai.routing/shell.no_visible_output_ok 保留(仅在开关开时被引用) - dashboard:ResolvedDashboardSettings 字段 + resolve + settings-write-applier 校验(invalid_noVisibleOutputHint)+ settings-page.tsx 实验性区块 ToggleRow + i18n label/help(zh/en) 技能文档 botmux-send 里那段「不要重发」按讨论保留不门控(按需查阅、非每轮强注入, 且为它改 installer 幂等/签名不划算)。 仅提示词文本 + 一个默认关的实验开关,不改发送/去重/桥接逻辑。 - 跨 CLI:门控在共用注入面(shared-hints / session-manager),对全部走 botmux send 的 CLI 一致生效;hermes 仍走自己的 hermesFollowupReminder,不受影响 - 默认 OFF 时渲染出的 prompt **逐字回到 pre-feature master**(见验证) - pnpm build 绿 - 相关套件全绿(workflow-discovery-hints / prompt-builder / global-config / settings-write-applier / cli-adapters / dashboard-i18n / codex-rpc-lifecycle / daemon-internal-api 等) - 运行时门控双向验证:OFF⇒路由/shell/reminder 均无纠偏句;ON⇒三处均出现 - **字节级对拍**:当前分支 OFF 状态 vs PR base 父提交(pre-feature master) 的 buildBotmuxSystemPromptText/buildBotmuxShellHints(zh+en)渲染 **完全一致**; ON 状态与 OFF 有差异(纠偏句确实出现) - 全量 pnpm test:9 失败全部落在 scheduler/schedule-card-model/ v3-distillation-runner,在干净 origin/master 同 3 文件跑出**一模一样的 9 失败** =机器级时间敏感/沙盒基线,零回归 Co-Authored-By: Riff --- src/adapters/cli/shared-hints.ts | 17 ++++++++++--- src/config.ts | 8 ++++++ src/core/session-manager.ts | 6 ++++- src/dashboard.ts | 3 +++ src/dashboard/settings-write-applier.ts | 8 ++++++ src/dashboard/web/i18n.ts | 4 +++ src/dashboard/web/settings-page.tsx | 11 +++++++- src/global-config.ts | 8 ++++++ src/i18n/en.ts | 3 ++- src/i18n/zh.ts | 3 ++- test/global-config.test.ts | 14 ++++++++++ test/prompt-builder.test.ts | 24 +++++++++++++---- test/settings-write-applier.test.ts | 16 ++++++++++++ test/workflow-discovery-hints.test.ts | 34 +++++++++++++++++++++---- 14 files changed, 141 insertions(+), 18 deletions(-) diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index 74cf798ed..7e330d9a7 100644 --- a/src/adapters/cli/shared-hints.ts +++ b/src/adapters/cli/shared-hints.ts @@ -14,6 +14,7 @@ */ import { t, type Locale } from '../../i18n/index.js'; import { whiteboardEnabled } from '../../services/whiteboard-store.js'; +import { config } from '../../config.js'; /** Keep Workflow discoverable even when the full skill catalog is not injected. */ function workflowDiscoveryHint(locale?: Locale): string { @@ -37,7 +38,11 @@ export function buildBotmuxShellHints(locale?: Locale): string[] { t('ai.shell.heredoc_example', undefined, locale), t('ai.shell.helpers', undefined, locale), t('ai.shell.when_to_send', undefined, locale), - t('ai.shell.no_visible_output_ok', undefined, locale), + // Experimental anti-resend guidance — opt-in via dashboard Settings + // (dashboard.noVisibleOutputHint). Default OFF, so the rendered hints match + // the pre-feature baseline unless an operator flips it on. Live-read here so + // a toggle takes effect on the next session without a daemon restart. + ...(config.noVisibleOutputHint ? [t('ai.shell.no_visible_output_ok', undefined, locale)] : []), t('ai.shell.mention_gate', undefined, locale), workflowDiscoveryHint(locale), hiddenContextDefense(locale), @@ -49,7 +54,9 @@ export function buildBotmuxShellHints(locale?: Locale): string[] { } /** @deprecated Use `buildBotmuxShellHints(locale)` instead. Kept for any external callers. - * Static legacy value must not read runtime config at module import time. */ + * Static legacy value must not read runtime config at module import time — so the + * experimental `no_visible_output_ok` line (gated on config.noVisibleOutputHint) is + * intentionally absent here; only the live `buildBotmuxShellHints` path carries it. */ export const BOTMUX_SHELL_HINTS: string[] = [ t('ai.shell.intro'), t('ai.shell.commands_are_shell'), @@ -58,7 +65,6 @@ export const BOTMUX_SHELL_HINTS: string[] = [ t('ai.shell.heredoc_example'), t('ai.shell.helpers'), t('ai.shell.when_to_send'), - t('ai.shell.no_visible_output_ok'), t('ai.shell.mention_gate'), workflowDiscoveryHint(), hiddenContextDefense(), @@ -122,7 +128,10 @@ export function buildBotmuxSystemPromptText(opts: { '', t('ai.routing.intro', undefined, locale), t('ai.routing.must_use_botmux', undefined, locale), - t('ai.routing.no_visible_output_ok', undefined, locale), + // Experimental anti-resend guidance — opt-in via dashboard Settings + // (dashboard.noVisibleOutputHint). Default OFF ⇒ this block is byte-for-byte + // the pre-feature baseline. Live-read so a toggle applies to the next session. + ...(config.noVisibleOutputHint ? [t('ai.routing.no_visible_output_ok', undefined, locale)] : []), '', t('ai.routing.usage_heading', undefined, locale), t('ai.routing.usage_send_when', undefined, locale), diff --git a/src/config.ts b/src/config.ts index bb313a088..b62be5bd4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -282,6 +282,14 @@ export const config = { // ON. A per-bot codexRpcInput:true still force-enables; the dashboard toggle // sets this global explicitly. get codexRpcInputDefault(): boolean { return readGlobalConfig().dashboard?.codexRpcInput === true; }, + // Live getter (like codexRpcInputDefault): re-reads the experimental global + // toggle that gates the "no visible output" anti-resend guidance in the botmux + // routing hints, so a Settings change takes effect on the next session without + // a daemon restart. Default OFF (absent ⇒ disabled): the guidance mainly helps + // when Claude Code (≥2.1.212) drives a non-Claude backend model that misreads a + // thinking-only nudge as a send failure; it is harmless but unnecessary for the + // common all-Claude setup, so operators opt in explicitly. + get noVisibleOutputHint(): boolean { return readGlobalConfig().dashboard?.noVisibleOutputHint === true; }, }; // allowedUsers is mutable — daemon resolves email prefixes to open_ids at startup diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 761b4ee1a..2058e4784 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -835,9 +835,13 @@ export function buildFollowUpContent( if (!skipSessionId) parts.push(`${xmlEscape(sessionId)}`); if (roleBlock) parts.push(roleBlock); if (opts?.cliId !== 'mira') { + // Non-hermes CLIs get the anti-resend variant only when the experimental + // dashboard toggle is on (config.noVisibleOutputHint, default OFF); otherwise + // the reminder is byte-for-byte the pre-feature baseline. Live-read so a + // Settings flip applies to the next follow-up turn without a daemon restart. const reminder = opts?.cliId === 'hermes' ? hermesFollowupReminder(opts?.locale) - : t('ai.followup.reminder', undefined, opts?.locale); + : t(config.noVisibleOutputHint ? 'ai.followup.reminder_no_resend' : 'ai.followup.reminder', undefined, opts?.locale); parts.push(`${reminder}`); } if (whiteboardBlock) parts.push(whiteboardBlock); diff --git a/src/dashboard.ts b/src/dashboard.ts index b612de4fd..90bb020b8 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -528,6 +528,8 @@ interface ResolvedDashboardSettings { workerOnline: boolean; lastError: { at: string; message: string; retryAt: string } | null; }; + /** Experimental anti-resend guidance in botmux routing hints. Default OFF. */ + noVisibleOutputHint: boolean; /** Machine-wide VC meeting listener kill-switch. Default ON. */ vcMeetingAgent: { enabled: boolean; @@ -1046,6 +1048,7 @@ function resolveDashboardSettings(): ResolvedDashboardSettings { workerOnline: isCodexNotifierWorkerStateFresh(codexNotifierState), lastError: codexNotifierState?.lastError ?? null, }, + noVisibleOutputHint: dashboard.noVisibleOutputHint === true, // default OFF; opt-in anti-resend guidance vcMeetingAgent: { enabled: global.vcMeetingAgent?.enabled !== false, listenerBotAppId: global.vcMeetingAgent?.listenerBotAppId ?? null, diff --git a/src/dashboard/settings-write-applier.ts b/src/dashboard/settings-write-applier.ts index 8ef615944..2611b8640 100644 --- a/src/dashboard/settings-write-applier.ts +++ b/src/dashboard/settings-write-applier.ts @@ -68,6 +68,7 @@ export interface ResolvedDashboardSettingsView { workerOnline?: boolean; lastError?: { at: string; message: string; retryAt: string } | null; }; + noVisibleOutputHint: boolean; vcMeetingAgent: { enabled: boolean; listenerBotAppId?: string | null; @@ -199,6 +200,7 @@ export type ApplySettingsWriteError = | 'codexNotifier_platform_unsupported' | 'codexNotifier_hook_install_failed' | 'codexNotifier_mixed_patch_unsupported' + | 'invalid_noVisibleOutputHint' | 'invalid_repoPickerMode' | 'invalid_remoteAccess' | 'invalid_vcMeetingAgent' @@ -360,6 +362,12 @@ export async function applySettingsWrite( } patch.codexRpcInput = obj.codexRpcInput; } + if ('noVisibleOutputHint' in obj) { + if (typeof obj.noVisibleOutputHint !== 'boolean') { + return { ok: false, error: 'invalid_noVisibleOutputHint' }; + } + patch.noVisibleOutputHint = obj.noVisibleOutputHint; + } let codexNotifierPatch: import('../global-config.js').CodexNotifierGlobalConfig | undefined; if ('codexNotifier' in obj) { diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 3c0c45d09..5a868eefb 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1288,6 +1288,8 @@ const zh: DashboardMessages = { 'settings.codexNotifierPending': '当前有 {count} 条完成消息等待投递。', 'settings.codexNotifierWorkerPending': 'Hook 已安装,但通知 worker 尚未运行或心跳已过期。', 'settings.codexNotifierLastError': '最近一次投递失败:{error}', + 'settings.noVisibleOutputHint': '「无可见输出」防重发提示', + 'settings.noVisibleOutputHintHelp': '实验性,默认关闭。在 botmux 路由提示里加一段纠偏:告诉模型 botmux send 成功即已送达、本轮无可见文本地结束是正常的、看到「无可见输出请继续」是底层 CLI(Claude Code ≥2.1.212)的误判不要重发。主要用于 Claude Code 驱动非 Claude 后端模型时(易把 thinking-only nudge 误读为发送失败而重复回复);对纯 Claude 场景无害但通常无需开启。开关翻动下一个会话即生效,无需重启。', 'settings.sectionVcMeetingAgent': '会议监听', 'settings.vcMeetingAgent': '允许本机使用会议监听能力', 'settings.vcMeetingAgentHelp': '默认开启。控制本机 bot 是否接收新的会议监听事件。关闭后不再恢复监听;已进行中的会议通常自然结束。', @@ -3286,6 +3288,8 @@ const en: DashboardMessages = { 'settings.codexNotifierPending': '{count} completion message(s) are waiting for delivery.', 'settings.codexNotifierWorkerPending': 'The Hook is installed, but the notification worker is not running or its heartbeat is stale.', 'settings.codexNotifierLastError': 'Most recent delivery failure: {error}', + 'settings.noVisibleOutputHint': '"No visible output" anti-resend hint', + 'settings.noVisibleOutputHintHelp': 'Experimental, off by default. Adds a note to the botmux routing hints telling the model that a successful `botmux send` is already delivered, that ending a turn with no visible terminal text is normal, and that a "no visible output, please continue" prompt is a false alarm from the underlying CLI (Claude Code ≥2.1.212) — do not resend. Mainly helps when Claude Code drives a non-Claude backend model (which tends to misread the thinking-only nudge as a send failure and reply repeatedly); harmless but usually unnecessary for an all-Claude setup. Takes effect on the next session, no restart needed.', 'settings.sectionVcMeetingAgent': 'Meeting Listener', 'settings.vcMeetingAgent': 'Allow meeting listener features on this machine', 'settings.vcMeetingAgentHelp': 'On by default. Controls whether bots on this host accept new meeting listener events. Off also skips listener restore; running meetings usually finish naturally.', diff --git a/src/dashboard/web/settings-page.tsx b/src/dashboard/web/settings-page.tsx index ab8d4cde5..e83dd3399 100644 --- a/src/dashboard/web/settings-page.tsx +++ b/src/dashboard/web/settings-page.tsx @@ -43,6 +43,7 @@ interface DashboardSettings { workerOnline: boolean; lastError: { at: string; message: string; retryAt: string } | null; }; + noVisibleOutputHint: boolean; vcMeetingAgent: { enabled: boolean; listenerBotAppId: string | null; @@ -158,6 +159,7 @@ function parseSettings(s: any): DashboardSettings { ? s.codexNotifier.lastError : null, }, + noVisibleOutputHint: s?.noVisibleOutputHint === true, vcMeetingAgent: { enabled: s?.vcMeetingAgent?.enabled !== false, listenerBotAppId: typeof s?.vcMeetingAgent?.listenerBotAppId === 'string' ? s.vcMeetingAgent.listenerBotAppId : null, @@ -555,7 +557,7 @@ function SettingsBody(props: { const autoUpdateDisabled = !canWrite || settings.localDevInstall || !settings.autoUpdateSupported; const autoRestartDisabled = !canWrite || settings.maintenance.autoUpdate?.enabled !== true; - const saveBoolean = (key: 'publicReadOnly' | 'openTerminalInFeishu' | 'enableLocalCliOpen' | 'chatBotDiscovery' | 'codexRpcInput' | 'remoteAccess', value: boolean) => { + const saveBoolean = (key: 'publicReadOnly' | 'openTerminalInFeishu' | 'enableLocalCliOpen' | 'chatBotDiscovery' | 'codexRpcInput' | 'noVisibleOutputHint' | 'remoteAccess', value: boolean) => { void props.onSave(key, { [key]: value }, s => ({ ...s, [key]: value })); }; const saveHerdrTraexPlugin = (patch: Partial>) => { @@ -699,6 +701,13 @@ function SettingsBody(props: { saving={savingKey === 'codexNotifier'} onSave={saveCodexNotifier} /> + saveBoolean('noVisibleOutputHint', value)} + /> 0 ? out : undefined; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 940c2ec26..77cd77080 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -611,7 +611,8 @@ 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': '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. A successful send is already delivered; ending a turn with no visible text is normal, so do not resend on a "no visible output" nudge.', + '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.followup.reminder_no_resend': '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. A successful send is already delivered; ending a turn with no visible text is normal, so do not resend on a "no visible output" nudge.', '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 9db088905..e9e493450 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -614,7 +614,8 @@ 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;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY;send 成功即已送达,本轮无可见文本地结束是正常的,别因「无输出」提示重发', + 'ai.followup.reminder': '需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY', + 'ai.followup.reminder_no_resend': '需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY;send 成功即已送达,本轮无可见文本地结束是正常的,别因「无输出」提示重发', '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/test/global-config.test.ts b/test/global-config.test.ts index 83ff14de8..9d242deac 100644 --- a/test/global-config.test.ts +++ b/test/global-config.test.ts @@ -65,6 +65,20 @@ describe('global dashboard config', () => { expect(readGlobalConfig().dashboard).toEqual({ chatBotDiscovery: false }); }); + it('reads dashboard.noVisibleOutputHint as a boolean (on)', () => { + writeFileSync(globalConfigPath(), JSON.stringify({ + dashboard: { noVisibleOutputHint: true }, + })); + expect(readGlobalConfig().dashboard).toEqual({ noVisibleOutputHint: true }); + }); + + it('drops non-boolean dashboard.noVisibleOutputHint', () => { + writeFileSync(globalConfigPath(), JSON.stringify({ + dashboard: { noVisibleOutputHint: 'yes' }, + })); + expect(readGlobalConfig().dashboard).toBeUndefined(); + }); + it('reads pinned plugin dashboards as a sanitized machine-wide preference', () => { writeFileSync(globalConfigPath(), JSON.stringify({ dashboard: { pinnedPlugins: ['demo-addon', 'bad/id', 'demo-addon', 'agent-chrome'] }, diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index 838d11b2f..78f0fb1bb 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -85,6 +85,7 @@ vi.mock('../src/core/worker-pool.js', () => ({ // ─── Imports ────────────────────────────────────────────────────────────── import { buildNewTopicPrompt, buildFollowUpContent, buildReforkPrompt, renderSenderTag, renderCursorSenderNote, renderBufferedSenderBlock } from '../src/core/session-manager.js'; +import { config } from '../src/config.js'; import type { DaemonSession } from '../src/core/types.js'; // ─── Tests ──────────────────────────────────────────────────────────────── @@ -320,15 +321,28 @@ describe('buildFollowUpContent', () => { expect(content.indexOf('')); expect(content.indexOf('')).toBeGreaterThan(content.indexOf('')); // Complex send guidance is discoverable once in the opening catalog; keep - // every follow-up reminder intentionally tiny. It still carries #554's - // BOTMUX_NO_REPLY sentinel semantics AND the anti-resend signal so a - // thinking-only turn's CLI nudge doesn't drive a duplicate send. - expect(content).toContain('需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY'); - expect(content).toMatch(/[^<]*别因「无输出」提示重发[^<]*<\/botmux_reminder>/); + // Complex send guidance is discoverable once in the opening catalog; keep + // every follow-up reminder intentionally tiny. By default (experimental + // anti-resend toggle OFF) it is exactly #554's BOTMUX_NO_REPLY sentinel + // baseline — no anti-resend clause appended. + expect(content).toContain('需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY'); + expect(content).not.toContain('别因「无输出」提示重发'); expect(content).not.toContain('JSON.stringify'); expect(content).not.toContain('botmux skill show botmux-send'); }); + it('carries the anti-resend reminder variant when config.noVisibleOutputHint is ON', () => { + (config as { noVisibleOutputHint?: boolean }).noVisibleOutputHint = true; + try { + const content = buildFollowUpContent('hello', SESSION_ID, { cliId: 'codex' }); + // ON variant must inherit #554's sentinel semantics AND add anti-resend. + expect(content).toContain('final 只输出 BOTMUX_NO_REPLY'); + expect(content).toMatch(/[^<]*别因「无输出」提示重发[^<]*<\/botmux_reminder>/); + } finally { + delete (config as { noVisibleOutputHint?: boolean }).noVisibleOutputHint; + } + }); + it('uses final-output reminder for Hermes follow-ups', () => { const content = buildFollowUpContent('hello', SESSION_ID, { cliId: 'hermes' }); diff --git a/test/settings-write-applier.test.ts b/test/settings-write-applier.test.ts index 3e59b3eb3..daabc4772 100644 --- a/test/settings-write-applier.test.ts +++ b/test/settings-write-applier.test.ts @@ -161,6 +161,13 @@ describe('applySettingsWrite happy paths', () => { expect(deps.mergeDashboardConfig).toHaveBeenCalledWith({ chatBotDiscovery: false }); }); + it('writes noVisibleOutputHint toggle (on) through the dashboard segment', async () => { + const deps = makeDeps(); + const r = await applySettingsWrite({ noVisibleOutputHint: true }, deps); + expect(r.ok).toBe(true); + expect(deps.mergeDashboardConfig).toHaveBeenCalledWith({ noVisibleOutputHint: true }); + }); + it('writes herdrTraexPlugin opt-in and trims source/ref through the dashboard segment', async () => { const deps = makeDeps(); const r = await applySettingsWrite({ @@ -392,6 +399,15 @@ describe('applySettingsWrite — validation errors', () => { expect(r.error).toBe('invalid_chatBotDiscovery'); }); + it('rejects non-boolean noVisibleOutputHint → invalid_noVisibleOutputHint', async () => { + const deps = makeDeps(); + const r = await applySettingsWrite({ noVisibleOutputHint: 'yes' }, deps); + expect(r.ok).toBe(false); + if (r.ok) throw new Error('expected failure'); + expect(r.error).toBe('invalid_noVisibleOutputHint'); + expect(deps.mergeDashboardConfig).not.toHaveBeenCalled(); + }); + it('rejects invalid herdrTraexPlugin payloads', async () => { const deps = makeDeps(); const r1 = await applySettingsWrite({ herdrTraexPlugin: 'on' }, deps); diff --git a/test/workflow-discovery-hints.test.ts b/test/workflow-discovery-hints.test.ts index 98cbd47a1..0a937cdda 100644 --- a/test/workflow-discovery-hints.test.ts +++ b/test/workflow-discovery-hints.test.ts @@ -1,10 +1,22 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { BOTMUX_SHELL_HINTS, buildBotmuxShellHints, buildBotmuxSystemPromptText, } from '../src/adapters/cli/shared-hints.js'; +import { config } from '../src/config.js'; + +/** Force the experimental anti-resend toggle (config.noVisibleOutputHint) for a + * test, restoring the real live getter afterwards. Mirrors how the guidance is + * gated in production — default OFF, opt-in via dashboard Settings. */ +function setNoVisibleOutputHint(value: boolean): void { + Object.defineProperty(config, 'noVisibleOutputHint', { get: () => value, configurable: true }); +} +const restoreNoVisibleOutputHint = Object.getOwnPropertyDescriptor(config, 'noVisibleOutputHint'); +afterEach(() => { + if (restoreNoVisibleOutputHint) Object.defineProperty(config, 'noVisibleOutputHint', restoreNoVisibleOutputHint); +}); describe('always-on Workflow discovery hint', () => { it('advertises bounded DAGs and reuse in zh/en shell hints', () => { @@ -26,14 +38,26 @@ describe('always-on Workflow discovery hint', () => { }); }); -describe('anti-resend guidance (thinking-only nudge false-alarm)', () => { - it('is present in zh/en shell hints', () => { +describe('anti-resend guidance (thinking-only nudge false-alarm) — experimental, gated on config.noVisibleOutputHint', () => { + it('is ABSENT by default (toggle OFF) — hints match the pre-feature baseline', () => { + setNoVisibleOutputHint(false); + expect(buildBotmuxShellHints('zh').some((l) => l.includes('无可见输出') || l.includes('不要重发'))).toBe(false); + expect(buildBotmuxShellHints('en').some((l) => l.toLowerCase().includes('no visible output'))).toBe(false); + expect(buildBotmuxSystemPromptText({ locale: 'zh' })).not.toContain('不要因此重发'); + expect(buildBotmuxSystemPromptText({ locale: 'en' }).toLowerCase()).not.toContain('do not resend'); + // The deprecated static array never carries it regardless of the toggle + // (it must not read runtime config at module load). + expect(BOTMUX_SHELL_HINTS.some((l) => l.includes('不要重发') || l.toLowerCase().includes('do not resend'))).toBe(false); + }); + + it('is present in zh/en shell hints when the toggle is ON', () => { + setNoVisibleOutputHint(true); expect(buildBotmuxShellHints('zh').some((l) => l.includes('无可见输出') || l.includes('不要重发'))).toBe(true); expect(buildBotmuxShellHints('en').some((l) => l.toLowerCase().includes('no visible output') && l.toLowerCase().includes('do not resend'))).toBe(true); - expect(BOTMUX_SHELL_HINTS.some((l) => l.includes('不要重发') || l.toLowerCase().includes('do not resend'))).toBe(true); }); - it('is present in injectsSessionContext system routing (zh/en), inside the routing block', () => { + it('is present in injectsSessionContext system routing (zh/en), inside the routing block, when ON', () => { + setNoVisibleOutputHint(true); const zh = buildBotmuxSystemPromptText({ locale: 'zh' }); const en = buildBotmuxSystemPromptText({ locale: 'en' }); expect(zh).toContain('不要因此重发');