From b52376b085c5a5107ed3db1cfdd2ce421698359c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Tue, 28 Jul 2026 20:55:05 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(skills):=20=E8=BD=AC=E4=B9=89=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E6=8A=80=E8=83=BD=E6=8F=90=E7=A4=BA=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skills/injection-mode.ts | 14 +++++++++--- test/skill-injection-mode.test.ts | 37 +++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/skills/injection-mode.ts b/src/skills/injection-mode.ts index 08b689d18..b2d1aa9c2 100644 --- a/src/skills/injection-mode.ts +++ b/src/skills/injection-mode.ts @@ -140,6 +140,14 @@ function frontmatterDescription(content: string): string { return line ? line.slice('description:'.length).trim() : ''; } +/** Escape prose rendered as text inside an XML-like prompt block. */ +function escapeXmlText(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + /** * The built-in skills the model should be told about in `prompt` mode. Mirrors * exactly what `global` mode would install: the unconditional BUILTIN_SKILLS, @@ -182,11 +190,11 @@ export function buildBuiltinSkillCatalogBlock(entries: BuiltinSkillEntry[], loca const intro = en ? ' covers basic communication only. These supplementary botmux skills are available in this session. Match the task against a description, then run `botmux skill show ` to read that skill\'s full instructions before acting — do not guess the commands.' : ' 只覆盖基础通信用法。当前 botmux 会话还有下面这些可按需读取的内置技能。先按描述判断该用哪个,再用 `botmux skill show ` 读取完整说明后再执行——不要凭空猜命令。'; - const lines = entries.map((e) => `- ${e.name}: ${promptCatalogDescription(e, locale)}`); + const lines = entries.map((e) => escapeXmlText(`- ${e.name}: ${promptCatalogDescription(e, locale)}`)); // Distinct tag from the user-registered skill catalog (``, injected only in the worker via prepareSessionSkillPrompt) so // the two never collide and can co-exist in one prompt. - return ['', intro, ...lines, ''].join('\n'); + return ['', escapeXmlText(intro), ...lines, ''].join('\n'); } /** `off` mode nudge: no catalog, just point the model at the CLI's own help. @@ -196,7 +204,7 @@ export function builtinSkillHelpPointer(locale?: Locale): string { const inner = locale === 'en' ? 'Beyond the commands in , more botmux capabilities (ask / schedule / workflow / …) are shell subcommands — run `botmux --help`, and `botmux --help` for a specific one, to discover them.' : '除了 里的命令,botmux 还有更多能力(ask / schedule / workflow 等),都是 shell 子命令——用 `botmux --help` 查全部,`botmux <子命令> --help` 查单个用法。'; - return `\n${inner}\n`; + return `\n${escapeXmlText(inner)}\n`; } /** diff --git a/test/skill-injection-mode.test.ts b/test/skill-injection-mode.test.ts index efdb89b42..37b0b64cd 100644 --- a/test/skill-injection-mode.test.ts +++ b/test/skill-injection-mode.test.ts @@ -143,6 +143,12 @@ describe('resolveSkillInjectionSupport (dashboard control class)', () => { }); describe('built-in skill catalog', () => { + function innerText(block: string): string { + const match = block.match(/^\n([\s\S]*)\n<\/botmux_builtin_skills>$/); + expect(match).not.toBeNull(); + return match![1]; + } + it('lists the unconditional built-ins plus ask when the CLI has no hook', () => { const entries = builtinSkillEntries({ asksViaHook: false, whiteboardEnabled: false }); const names = entries.map((e) => e.name); @@ -179,7 +185,7 @@ describe('built-in skill catalog', () => { const block = buildBuiltinSkillCatalogBlock(entries); expect(block.startsWith('')).toBe(true); expect(block.trimEnd().endsWith('')).toBe(true); - expect(block).toContain('botmux skill show '); + expect(block).toContain('botmux skill show <name>'); expect(block).toContain('- botmux-send:'); expect(block).toContain('首次复杂飞书发送前读取'); expect(block).toContain('JSON.stringify'); @@ -188,6 +194,21 @@ describe('built-in skill catalog', () => { expect(entries.find((e) => e.name === 'botmux-send')?.description).toContain('向飞书话题发送消息'); }); + it('keeps catalog prose as escaped text instead of nested XML-like tags', () => { + for (const locale of [undefined, 'en'] as const) { + const block = buildBuiltinSkillCatalogBlock([{ + name: 'botmux-fixture', + description: 'Use & keep going.', + content: '', + }], locale); + + expect(innerText(block)).not.toMatch(/[<>]/); + expect(block).toContain('<botmux_routing>'); + expect(block).toContain('botmux skill show <name>'); + expect(block).toContain('Use <fixture> & keep going.'); + } + }); + it('renders an empty block for no entries', () => { expect(buildBuiltinSkillCatalogBlock([])).toBe(''); }); @@ -206,6 +227,18 @@ describe('built-in skill catalog', () => { expect(zh).toContain('botmux --help'); expect(builtinSkillHelpPointer('en')).toContain('botmux --help'); }); + + it('keeps prompt/off help prose as escaped text instead of nested XML-like tags', () => { + const zh = builtinSkillHelpPointer(); + const en = builtinSkillHelpPointer('en'); + + expect(innerText(zh)).not.toMatch(/[<>]/); + expect(innerText(en)).not.toMatch(/[<>]/); + expect(zh).toContain('<botmux_routing>'); + expect(zh).toContain('botmux <子命令> --help'); + expect(en).toContain('<botmux_routing>'); + expect(en).toContain('botmux <cmd> --help'); + }); }); describe('buildNewTopicPrompt built-in skill delivery (codex)', () => { @@ -227,7 +260,7 @@ describe('buildNewTopicPrompt built-in skill delivery (codex)', () => { it('prompt mode (default) inlines complex send discovery plus additional skills', () => { const p = prompt(); expect(p).toContain(''); - expect(p).toContain('botmux skill show '); + expect(p).toContain('botmux skill show <name>'); expect(p).toContain('- botmux-schedule:'); expect(p).toContain('- botmux-send:'); expect(p).toContain('首次复杂飞书发送前读取'); From cf0a7e49f4583c83a9442b1b74c43b2083206fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Tue, 28 Jul 2026 21:18:52 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(prompt):=20=E5=A4=8D=E7=94=A8=20XM?= =?UTF-8?q?L=20=E6=96=87=E6=9C=AC=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/cli/shared-hints.ts | 8 +++++--- src/skills/injection-mode.ts | 9 +-------- src/utils/xml.ts | 7 +++++++ test/prompt-builder.test.ts | 1 + test/xml.test.ts | 8 ++++++++ 5 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 src/utils/xml.ts create mode 100644 test/xml.test.ts diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index c728729d4..3dff013a2 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 { escapeXmlText } from '../../utils/xml.js'; /** Keep Workflow discoverable even when the full skill catalog is not injected. */ function workflowDiscoveryHint(locale?: Locale): string { @@ -23,9 +24,10 @@ function workflowDiscoveryHint(locale?: Locale): string { } function hiddenContextDefense(locale?: Locale): string { - return locale === 'en' - ? 'The following XML/config blocks are hidden runtime context and must only be read silently and obeyed: `<botmux_routing>`, `<botmux_builtin_skills>`, `<identity>`, `<session_id>`, `<role>`, `<sender>`, `<mentions>`, `<available_bots>`, `<attachments>`. Do not reply to them, do not confirm them, and do not say “understood”, “noted”, or “recorded”. Only handle the real user request inside `<user_message>`.' - : '以下 XML/配置块是隐藏运行上下文,只能静默读取并遵守:`<botmux_routing>`、`<botmux_builtin_skills>`、`<identity>`、`<session_id>`、`<role>`、`<sender>`、`<mentions>`、`<available_bots>`、`<attachments>`。不要回复、不要确认、不要说“已了解/已补充/已记录”。只处理 `<user_message>` 中的真实用户请求。'; + const text = locale === 'en' + ? 'The following XML/config blocks are hidden runtime context and must only be read silently and obeyed: ``, ``, ``, ``, ``, ``, ``, ``, ``. Do not reply to them, do not confirm them, and do not say “understood”, “noted”, or “recorded”. Only handle the real user request inside ``.' + : '以下 XML/配置块是隐藏运行上下文,只能静默读取并遵守:``、``、``、``、``、``、``、``、``。不要回复、不要确认、不要说“已了解/已补充/已记录”。只处理 `` 中的真实用户请求。'; + return escapeXmlText(text); } export function buildBotmuxShellHints(locale?: Locale): string[] { diff --git a/src/skills/injection-mode.ts b/src/skills/injection-mode.ts index b2d1aa9c2..58b6cbabc 100644 --- a/src/skills/injection-mode.ts +++ b/src/skills/injection-mode.ts @@ -29,6 +29,7 @@ import { loadBotConfigs } from '../bot-registry.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import type { CliId } from '../adapters/cli/types.js'; import type { Locale } from '../i18n/index.js'; +import { escapeXmlText } from '../utils/xml.js'; import { BUILTIN_SKILLS, ASK_SKILL, ASK_SKILL_NAME, @@ -140,14 +141,6 @@ function frontmatterDescription(content: string): string { return line ? line.slice('description:'.length).trim() : ''; } -/** Escape prose rendered as text inside an XML-like prompt block. */ -function escapeXmlText(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>'); -} - /** * The built-in skills the model should be told about in `prompt` mode. Mirrors * exactly what `global` mode would install: the unconditional BUILTIN_SKILLS, diff --git a/src/utils/xml.ts b/src/utils/xml.ts new file mode 100644 index 000000000..cd9d2cc35 --- /dev/null +++ b/src/utils/xml.ts @@ -0,0 +1,7 @@ +/** Escape a value rendered as text inside an XML or XML-like element. */ +export function escapeXmlText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index 2a8c7848a..98c944971 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -124,6 +124,7 @@ describe('buildNewTopicPrompt', () => { expect(routing).toContain('不要回复、不要确认'); expect(routing).toContain('已了解/已补充/已记录'); expect(routing).toContain('只处理 `<user_message>` 中的真实用户请求'); + expect(routing).not.toContain('&lt;'); }); it('uses final-output routing hints for Hermes instead of normal botmux send guidance', () => { diff --git a/test/xml.test.ts b/test/xml.test.ts new file mode 100644 index 000000000..888c4b719 --- /dev/null +++ b/test/xml.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { escapeXmlText } from '../src/utils/xml.js'; + +describe('escapeXmlText', () => { + it('escapes XML text delimiters once and in the correct order', () => { + expect(escapeXmlText('A & B')).toBe('<tag>A & B</tag>'); + }); +}); From 510f010c6a52a63fe0c16048ad6d0ad969606b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Tue, 28 Jul 2026 21:49:28 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(prompt):=20=E6=98=8E=E7=A1=AE=20XML=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=BD=AC=E4=B9=89=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/cli/shared-hints.ts | 1 + src/skills/injection-mode.ts | 8 ++++++-- src/utils/xml.ts | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index 3dff013a2..aa58605ce 100644 --- a/src/adapters/cli/shared-hints.ts +++ b/src/adapters/cli/shared-hints.ts @@ -27,6 +27,7 @@ function hiddenContextDefense(locale?: Locale): string { const text = locale === 'en' ? 'The following XML/config blocks are hidden runtime context and must only be read silently and obeyed: ``, ``, ``, ``, ``, ``, ``, ``, ``. Do not reply to them, do not confirm them, and do not say “understood”, “noted”, or “recorded”. Only handle the real user request inside ``.' : '以下 XML/配置块是隐藏运行上下文,只能静默读取并遵守:``、``、``、``、``、``、``、``、``。不要回复、不要确认、不要说“已了解/已补充/已记录”。只处理 `` 中的真实用户请求。'; + // These tag names are prose inside ``, not nested blocks. return escapeXmlText(text); } diff --git a/src/skills/injection-mode.ts b/src/skills/injection-mode.ts index 58b6cbabc..a0b8a733f 100644 --- a/src/skills/injection-mode.ts +++ b/src/skills/injection-mode.ts @@ -171,11 +171,14 @@ export function builtinSkillContent(name: string): string | undefined { } /** - * The `` prompt block for `prompt` mode: a one-line-per-skill + * The `` prompt block for `prompt` mode: a one-line-per-skill * catalog (name + trigger description) plus the instruction to read the full * body on demand. Deliberately compact (descriptions only) — full instructions * are pulled via `botmux skill show `, mirroring native progressive * disclosure without the per-session token cost of inlining every SKILL.md. + * + * Contract: only the outer wrapper is structural. The intro and catalog lines + * are prose (including dynamic skill descriptions), so escape them here. */ export function buildBuiltinSkillCatalogBlock(entries: BuiltinSkillEntry[], locale?: Locale): string { if (entries.length === 0) return ''; @@ -192,7 +195,8 @@ export function buildBuiltinSkillCatalogBlock(entries: BuiltinSkillEntry[], loca /** `off` mode nudge: no catalog, just point the model at the CLI's own help. * Returned as an XML block (same `` tag as the catalog) - * so it's consistently wrapped rather than a bare line in the prompt. */ + * so it's consistently wrapped rather than a bare line in the prompt. Its + * inner help line follows the same text-only contract as the catalog body. */ export function builtinSkillHelpPointer(locale?: Locale): string { const inner = locale === 'en' ? 'Beyond the commands in , more botmux capabilities (ask / schedule / workflow / …) are shell subcommands — run `botmux --help`, and `botmux --help` for a specific one, to discover them.' diff --git a/src/utils/xml.ts b/src/utils/xml.ts index cd9d2cc35..fa69c1a1a 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -1,4 +1,7 @@ -/** Escape a value rendered as text inside an XML or XML-like element. */ +/** + * Escape raw text rendered inside an XML or XML-like element. + * Call once at the render boundary; pre-escaped entities would be double-escaped. + */ export function escapeXmlText(value: string): string { return value .replace(/&/g, '&') From 4308407535f183a58d284a3ae43ac4a23bae22a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 29 Jul 2026 11:12:02 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(prompt):=20=E9=80=89=E6=8B=A9=E6=80=A7?= =?UTF-8?q?=E8=BD=AC=E4=B9=89=E8=B7=AF=E7=94=B1=E6=AD=A3=E6=96=87=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/cli/shared-hints.ts | 67 ++++++++++++++++-------------- src/core/session-manager.ts | 3 +- src/utils/xml.ts | 12 ++++++ test/prompt-builder.test.ts | 71 ++++++++++++++++++++++++++++++++ test/xml.test.ts | 12 +++++- 5 files changed, 131 insertions(+), 34 deletions(-) diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index aa58605ce..fd7399d3b 100644 --- a/src/adapters/cli/shared-hints.ts +++ b/src/adapters/cli/shared-hints.ts @@ -14,7 +14,7 @@ */ import { t, type Locale } from '../../i18n/index.js'; import { whiteboardEnabled } from '../../services/whiteboard-store.js'; -import { escapeXmlText } from '../../utils/xml.js'; +import { escapeXmlTagLikeTokens, escapeXmlText } from '../../utils/xml.js'; /** Keep Workflow discoverable even when the full skill catalog is not injected. */ function workflowDiscoveryHint(locale?: Locale): string { @@ -43,9 +43,9 @@ export function buildBotmuxShellHints(locale?: Locale): string[] { t('ai.shell.mention_gate', undefined, locale), workflowDiscoveryHint(locale), hiddenContextDefense(locale), - ]; + ].map(escapeXmlTagLikeTokens); if (whiteboardEnabled()) { - hints.push('出现 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论仍用 `botmux send`;不要写密钥/隐私;更新默认用中文。'); + hints.push(escapeXmlTagLikeTokens('出现 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论仍用 `botmux send`;不要写密钥/隐私;更新默认用中文。')); } return hints; } @@ -63,7 +63,7 @@ export const BOTMUX_SHELL_HINTS: string[] = [ t('ai.shell.mention_gate'), workflowDiscoveryHint(), hiddenContextDefense(), -]; +].map(escapeXmlTagLikeTokens); /** * Build the `` (+ optional ``) text injected via a @@ -73,9 +73,10 @@ export const BOTMUX_SHELL_HINTS: string[] = [ * session-manager omits these blocks from the per-message envelope for such * adapters, so this is the only place the model learns the routing rules. * - * Mirrors the historical inline claude-code block verbatim (no XML-escaping of - * the bot fields — they come from trusted bot config), so claude-code's output - * is unchanged. + * Real envelope tags stay structural, while complete `<...>` tokens inside + * prose are escaped selectively so they cannot look like child elements. + * Shell heredoc operators remain copyable, and bot fields are still rendered + * from trusted bot config without changing their historical handling. */ export function buildBotmuxSystemPromptText(opts: { locale?: Locale; @@ -89,6 +90,8 @@ export function buildBotmuxSystemPromptText(opts: { }): string { const { locale, botName, botOpenId, builtinSkillBlock } = opts; const unknown = t('ai.identity.unknown', undefined, locale); + const prose = (key: string): string => + escapeXmlTagLikeTokens(t(key, undefined, locale)); const identityBlock = botName || botOpenId ? [ @@ -97,18 +100,18 @@ export function buildBotmuxSystemPromptText(opts: { ` ${botName ?? unknown}`, ` ${botOpenId ?? unknown}`, ' ', - ` ${t('ai.identity.routing_intro', undefined, locale)}`, - ` ${t('ai.identity.rule_own_part', undefined, locale)}`, - ` ${t('ai.identity.rule_silent_when_other', undefined, locale)}`, - ` ${t('ai.identity.rule_no_proactive_pull', undefined, locale)}`, + ` ${prose('ai.identity.routing_intro')}`, + ` ${prose('ai.identity.rule_own_part')}`, + ` ${prose('ai.identity.rule_silent_when_other')}`, + ` ${prose('ai.identity.rule_no_proactive_pull')}`, '', - ` ${t('ai.identity.mention_intro', undefined, locale)}`, - ` ${t('ai.identity.mention_must', undefined, locale)}`, - ` ${t('ai.identity.mention_partners', undefined, locale)}`, - ` ${t('ai.identity.mention_usage', undefined, locale)}`, - ` ${t('ai.identity.mention_when_to', undefined, locale)}`, - ` ${t('ai.identity.mention_when_not', undefined, locale)}`, - ` ${t('ai.identity.mention_gate', undefined, locale)}`, + ` ${prose('ai.identity.mention_intro')}`, + ` ${prose('ai.identity.mention_must')}`, + ` ${prose('ai.identity.mention_partners')}`, + ` ${prose('ai.identity.mention_usage')}`, + ` ${prose('ai.identity.mention_when_to')}`, + ` ${prose('ai.identity.mention_when_not')}`, + ` ${prose('ai.identity.mention_gate')}`, ' ', '', ] @@ -116,25 +119,25 @@ export function buildBotmuxSystemPromptText(opts: { const whiteboardRouting = whiteboardEnabled() ? [ '', - '出现 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论仍必须`botmux send`。', + escapeXmlTagLikeTokens('出现 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论仍必须`botmux send`。'), ] : []; return [ '', - t('ai.routing.intro', undefined, locale), - t('ai.routing.must_use_botmux', undefined, locale), + prose('ai.routing.intro'), + prose('ai.routing.must_use_botmux'), '', - t('ai.routing.usage_heading', undefined, locale), - t('ai.routing.usage_send_when', undefined, locale), - t('ai.routing.usage_send_text', undefined, locale), - t('ai.routing.usage_heredoc', undefined, locale), - t('ai.routing.heredoc_example', undefined, locale), - t('ai.routing.usage_images', undefined, locale), - t('ai.routing.usage_files', undefined, locale), - t('ai.routing.usage_videos', undefined, locale), - t('ai.routing.usage_history', undefined, locale), - t('ai.routing.usage_bots_list', undefined, locale), - workflowDiscoveryHint(locale), + prose('ai.routing.usage_heading'), + prose('ai.routing.usage_send_when'), + prose('ai.routing.usage_send_text'), + prose('ai.routing.usage_heredoc'), + prose('ai.routing.heredoc_example'), + prose('ai.routing.usage_images'), + prose('ai.routing.usage_files'), + prose('ai.routing.usage_videos'), + prose('ai.routing.usage_history'), + prose('ai.routing.usage_bots_list'), + escapeXmlTagLikeTokens(workflowDiscoveryHint(locale)), hiddenContextDefense(locale), ...whiteboardRouting, '', diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 761b4ee1a..83c2dc62c 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -65,6 +65,7 @@ import { getAttachmentsDir } from './attachment-path.js'; import { resolveRegularGroupMode } from '../services/chat-reply-mode-store.js'; import { beginReplyTargetTurn } from './reply-target.js'; import { readDeferredTopicBinding, removeDeferredTopicBinding } from './deferred-topic-binding.js'; +import { escapeXmlTagLikeTokens } from '../utils/xml.js'; export { getAttachmentsDir } from './attachment-path.js'; @@ -683,7 +684,7 @@ export function buildNewTopicPrompt( '', ` ${xmlEscape(botIdentity.name ?? unknown)}`, ` ${xmlEscape(botIdentity.openId ?? unknown)}`, - ` ${t('ai.identity.short_routing', undefined, locale)}`, + ` ${escapeXmlTagLikeTokens(t('ai.identity.short_routing', undefined, locale))}`, '', ].join('\n'); } diff --git a/src/utils/xml.ts b/src/utils/xml.ts index fa69c1a1a..b5dbc3128 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -8,3 +8,15 @@ export function escapeXmlText(value: string): string { .replace(//g, '>'); } + +/** + * Escape complete angle-bracket tokens embedded in XML prompt prose. + * + * This is intentionally narrower than `escapeXmlText`: shell operators such + * as the `<<'EOF'` heredoc marker have no closing `>` and must remain copyable. + * Apply it to prose fields before rendering them, never to a serialized block + * whose real structural tags must stay intact. + */ +export function escapeXmlTagLikeTokens(value: string): string { + return value.replace(/<[^<>\r\n]+>/g, token => escapeXmlText(token)); +} diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index 98c944971..590c44a36 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 { BOTMUX_SHELL_HINTS, buildBotmuxShellHints, buildBotmuxSystemPromptText } from '../src/adapters/cli/shared-hints.js'; import type { DaemonSession } from '../src/core/types.js'; // ─── Tests ──────────────────────────────────────────────────────────────── @@ -105,6 +106,7 @@ describe('buildNewTopicPrompt', () => { it('should include heredoc guidance for non-Claude CLIs', () => { const prompt = buildNewTopicPrompt('hello', SESSION_ID, 'codex'); expect(prompt).toContain("botmux send <<'EOF'"); + expect(prompt).not.toContain("botmux send <<'EOF'"); expect(prompt).toContain('第一行'); expect(prompt).toContain('第二行'); expect(prompt).toContain('botmux send "第一行\\n第二行"'); @@ -228,6 +230,29 @@ describe('buildNewTopicPrompt', () => { expect(prompt.indexOf(`${SESSION_ID}`)).toBeLessThan(prompt.indexOf('')); }); + it.each([ + ['zh', '<对方 open_id>'], + ['en', '<their open_id>'], + ] as const)('escapes tag-like placeholders in the %s inline identity prose', (locale, expectedPlaceholder) => { + const prompt = buildNewTopicPrompt( + 'hello', + SESSION_ID, + 'codex', + undefined, + undefined, + undefined, + undefined, + undefined, + { name: 'Codex Bot', openId: 'ou_bot' }, + locale, + ); + const identity = prompt.slice(prompt.indexOf(''), prompt.indexOf('') + ''.length); + const prose = identity.replace(/<\/?(?:identity|name|open_id|routing_rules)>/g, ''); + + expect(identity).toContain(expectedPlaceholder); + expect(prose.match(/<[^<>\r\n]+>/g) ?? []).toEqual([]); + }); + it('keeps per-turn sender and mentions after the first user message', () => { const prompt = buildNewTopicPrompt( 'hello', @@ -248,6 +273,52 @@ describe('buildNewTopicPrompt', () => { }); }); +describe('botmux routing prose XML boundaries', () => { + it.each([ + ['zh', '<open_id:名字>'], + ['en', '<open_id:name>'], + ] as const)('escapes tag-like placeholders in %s inline shell hints while preserving heredoc syntax', (locale, mentionPlaceholder) => { + const hints = buildBotmuxShellHints(locale).join('\n'); + + expect(hints).toContain('<message_id>'); + expect(hints).toContain(mentionPlaceholder); + expect(hints).toContain('<whiteboard>'); + expect(hints).toContain("botmux send <<'EOF'"); + expect(hints).not.toContain("botmux send <<'EOF'"); + expect(hints.match(/<[^<>\r\n]+>/g) ?? []).toEqual([]); + }); + + it('keeps the legacy static shell hints on the same selective-escaping boundary', () => { + const hints = BOTMUX_SHELL_HINTS.join('\n'); + + expect(hints).toContain('<message_id>'); + expect(hints).toContain("botmux send <<'EOF'"); + expect(hints).not.toContain("botmux send <<'EOF'"); + expect(hints.match(/<[^<>\r\n]+>/g) ?? []).toEqual([]); + }); + + it.each([ + ['zh', '<对方 bot 的 open_id>'], + ['en', '<other-bot-open-id>'], + ] as const)('escapes tag-like placeholders in the %s system-prompt prose while preserving real structure and heredoc syntax', (locale, mentionPlaceholder) => { + const prompt = buildBotmuxSystemPromptText({ + locale, + botName: 'Codex Bot', + botOpenId: 'ou_bot', + }); + const prose = prompt.replace(/<\/?(?:botmux_routing|identity|name|open_id|routing_rules)>/g, ''); + + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(mentionPlaceholder); + expect(prompt).toContain('<available_bots>'); + expect(prompt).toContain('<whiteboard>'); + expect(prompt).toContain("botmux send <<'EOF'"); + expect(prompt).not.toContain("botmux send <<'EOF'"); + expect(prose.match(/<[^<>\r\n]+>/g) ?? []).toEqual([]); + }); +}); + describe('buildFollowUpContent', () => { const SESSION_ID = 'follow-up-session-456'; diff --git a/test/xml.test.ts b/test/xml.test.ts index 888c4b719..f0607d5a6 100644 --- a/test/xml.test.ts +++ b/test/xml.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { escapeXmlText } from '../src/utils/xml.js'; +import { escapeXmlTagLikeTokens, escapeXmlText } from '../src/utils/xml.js'; describe('escapeXmlText', () => { it('escapes XML text delimiters once and in the correct order', () => { expect(escapeXmlText('A & B')).toBe('<tag>A & B</tag>'); }); }); + +describe('escapeXmlTagLikeTokens', () => { + it('escapes complete tag-like prose tokens without rewriting shell heredocs', () => { + const input = "botmux quoted ; botmux send <<'EOF'; --mention <对方 bot 的 open_id>"; + + expect(escapeXmlTagLikeTokens(input)).toBe( + "botmux quoted <message_id>; botmux send <<'EOF'; --mention <对方 bot 的 open_id>", + ); + }); +}); From f71c13f6ffe45287a3cc56fdc4ebdba53dfd45a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 29 Jul 2026 12:59:55 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(prompt):=20=E8=A1=A5=E9=BD=90=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=AD=A3=E6=96=87=E8=BD=AC=E4=B9=89=E6=97=81=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/backend/riff-backend.ts | 5 +++-- src/core/session-manager.ts | 2 +- src/utils/xml.ts | 4 ++++ test/prompt-builder.test.ts | 8 ++++++++ test/riff-backend.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/adapters/backend/riff-backend.ts b/src/adapters/backend/riff-backend.ts index 4eb793a65..0ce60cc4e 100644 --- a/src/adapters/backend/riff-backend.ts +++ b/src/adapters/backend/riff-backend.ts @@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import type { SessionBackend, SpawnOpts } from './types.js'; import { logger } from '../../utils/logger.js'; +import { escapeXmlTagLikeTokens } from '../../utils/xml.js'; /** * Fallback system prompt injected into every riff task when no explicit @@ -27,9 +28,9 @@ const DEFAULT_RIFF_SYSTEM_PROMPT = [ 'Multi-line messages MUST use a heredoc — never `botmux send "line1\\nline2"`, since `\\n` may appear literally in Lark.', "Correct multi-line example:\n botmux send <<'EOF'\n line 1\n line 2\n EOF", '', - 'Helpers: `botmux history` (read this session\'s history), `botmux quoted ` (fetch a quoted message), `botmux bots list` (list other bots in the group).', + escapeXmlTagLikeTokens('Helpers: `botmux history` (read this session\'s history), `botmux quoted ` (fetch a quoted message), `botmux bots list` (list other bots in the group).'), '', - '@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention ` (use the open_id from the tag of the CURRENT message you are answering) / `--no-mention` (low-priority notes). NEVER use `--mention-back` in this sandbox: the session-recorded sender is frozen at task creation, so on follow-up turns it would @ the wrong person (it is disabled here and will error).', + escapeXmlTagLikeTokens('@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention ` (use the open_id from the tag of the CURRENT message you are answering) / `--no-mention` (low-priority notes). NEVER use `--mention-back` in this sandbox: the session-recorded sender is frozen at task creation, so on follow-up turns it would @ the wrong person (it is disabled here and will error).'), '', '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.', 'COMPLETION CONTRACT: a turn is complete ONLY after `botmux send` actually ran and printed ✓ success. Writing the answer solely in your final report/output does NOT reach the user — always run `botmux send` first, then summarize in the report.', diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 83c2dc62c..f683f8e6f 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -525,7 +525,7 @@ function renderWhiteboardBlock(opts?: { whiteboardId?: string }): string { return [ ``, '本地项目上下文;读取:`botmux whiteboard read --id ' + id + ' --json`(拿到 content 与 updatedAt)。', - '更新状态:`botmux whiteboard update --id ' + id + ' --expected-updated-at <上次 read 的 updatedAt> <内容>`。', + escapeXmlTagLikeTokens('更新状态:`botmux whiteboard update --id ' + id + ' --expected-updated-at <上次 read 的 updatedAt> <内容>`。'), '更新前先用 `read --json` 拿到当前内容与 updatedAt,融合新信息后整体重写为一份完整的当前状态(默认中文;代码标识/命令/错误信息可保留原文),并用 `--expected-updated-at` 回传 read 到的版本号做并发冲突检测。', '若更新报 `whiteboard_cas_mismatch`,说明期间有其它 agent 改过白板——重新 `read --json` 拿最新内容与 updatedAt,再次融合重写。', '不要直接读写本地文件;不要写密钥/隐私;用户可见结论仍必须 `botmux send`。', diff --git a/src/utils/xml.ts b/src/utils/xml.ts index b5dbc3128..8fef371ca 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -16,6 +16,10 @@ export function escapeXmlText(value: string): string { * as the `<<'EOF'` heredoc marker have no closing `>` and must remain copyable. * Apply it to prose fields before rendering them, never to a serialized block * whose real structural tags must stay intact. + * + * Any complete `<...>` span is treated as a tag-like token. Do not apply this + * helper to arbitrary shell/math prose where unrelated `<` and `>` operators + * may occur on the same line (for example, `cmd < input > output`). */ export function escapeXmlTagLikeTokens(value: string): string { return value.replace(/<[^<>\r\n]+>/g, token => escapeXmlText(token)); diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index 590c44a36..497fc0f1c 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -184,6 +184,14 @@ describe('buildNewTopicPrompt', () => { expect(prompt).toContain(''); expect(prompt).toContain('读取:`botmux whiteboard read --id wb_test --json`'); + expect(prompt).toContain('<上次 read 的 updatedAt>'); + expect(prompt).toContain('<内容>'); + const whiteboard = prompt.slice( + prompt.indexOf('') + ''.length, + ); + const whiteboardProse = whiteboard.replace(/<\/?whiteboard(?:\s[^>]*)?>/g, ''); + expect(whiteboardProse.match(/<[^<>\r\n]+>/g) ?? []).toEqual([]); // The CAS flow: update carries --expected-updated-at, and a mismatch tells // the agent to re-read. Pin both so the prompt keeps guiding agents to CAS. expect(prompt).toContain('update --id wb_test --expected-updated-at'); diff --git a/test/riff-backend.test.ts b/test/riff-backend.test.ts index 26494d483..102e0bf0a 100644 --- a/test/riff-backend.test.ts +++ b/test/riff-backend.test.ts @@ -633,6 +633,29 @@ describe('RiffBackend', () => { }); describe('prompt single @-rule (finding K/2)', () => { + it('escapes tag-like tokens in the built-in system prose without rewriting the heredoc', async () => { + const be = makeBackend({ injectStatusLines: false }); + be.spawn('', [], {} as any); + be.write('hi'); + await flush(); + resolvers.shift()!(taskResponse('task-1')); + await flush(); + const exec = calls.find(c => c.url.includes('/api/task-execute'))!; + const prompt = String(JSON.parse(String(exec.init?.body)).config.userPrompt); + const system = prompt.slice( + prompt.indexOf(''), + prompt.indexOf('') + ''.length, + ); + const systemProse = system.replace(/<\/?system>/g, ''); + + expect(system).toContain('<message_id>'); + expect(system).toContain('<open_id>'); + expect(system).toContain('<sender>'); + expect(system).toContain("botmux send <<'EOF'"); + expect(system).not.toContain("botmux send <<'EOF'"); + expect(systemProse.match(/<[^<>\r\n]+>/g) ?? []).toEqual([]); + }); + it('payload prompt forbids mention-back and keeps mandatory routing under a custom systemPrompt', async () => { const be = makeBackend({ injectStatusLines: false, systemPrompt: '你是 QA 专家,回答尽量简短。' }); be.spawn('', [], {} as any);