diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index c728729d4..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,6 +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), + // 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), @@ -48,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'), @@ -120,6 +128,10 @@ export function buildBotmuxSystemPromptText(opts: { '', t('ai.routing.intro', undefined, locale), t('ai.routing.must_use_botmux', 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 ecf82b745..77cd77080 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) ────────────────────────────────── @@ -610,6 +612,7 @@ export const messages: Record = { '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_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 0b9505a13..e9e493450 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) ────────────────────────────────── @@ -613,6 +615,7 @@ export const messages: Record = { '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_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/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/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 b646a1efc..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,12 +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. + // 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 d72e3b1fb..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', () => { @@ -25,3 +37,33 @@ describe('always-on Workflow discovery hint', () => { expect(prompt.indexOf('Workflow:')).toBeLessThan(prompt.indexOf('')); }); }); + +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); + }); + + 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('不要因此重发'); + 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('')); + }); +});