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(''));
+ });
+});