diff --git a/src/cli.ts b/src/cli.ts index b8241b520..8adc901a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6775,6 +6775,25 @@ async function cmdHistory(rest: string[]): Promise { ...(cardJson !== undefined ? { cardJson } : {}), }; })); + // Range guidance, emitted at the moment the model is actually reading + // history. The decisive fact is `sessionScope` (this session's own scope), + // NOT the chat's `chat_mode`: a thread opened inside a 普通群 keeps + // chat_mode='group' while its session is thread-scope, so reasoning from the + // group type would wrongly conclude "I may search the whole chat". Both + // `--scope` gates above key on isChatScope for exactly that reason. + // + // Wording note: describe only what the model can OBSERVE. Daemon-side + // invocations (`/t` and friends) are stripped before the prompt is built, so + // the model never sees them — naming them here would be an instruction it + // cannot act on. Describe the symptom instead: the topic starts mid-discussion + // and its own history looks too short to explain the task. + const rangeHint = isChatScope + ? '范围:本次返回当前群整群最近 N 条(不限于 session 创建之后)。需要更早的消息就把 `--limit` 调大。当前是 chat-scope 会话,没有话题边界,`--scope thread` / `--scope ambient` 在此不适用。' + : effectiveScope === 'thread' + ? '范围:本次只返回当前话题内的消息。如果话题内的内容不足以说明任务背景(例如任务像是延续话题之外的讨论、出现没有出处的指代或结论),说明上下文在话题外的群聊里:用 `botmux history --scope ambient --limit 20` 读取本话题之外、话题根之前的群聊消息(自动排除本话题)。注意隐私边界:ambient 会读到话题外的群聊内容,仅在确实需要群聊背景时使用,并优先用较小的 limit。' + : effectiveScope === 'ambient' + ? '范围:本次返回的是话题之外的群聊消息(话题根之前,已排除本话题)。要回到本话题内的消息用 `botmux history`(默认即本话题)。' + : '范围:本次按 `--scope chat` 返回整群最近 N 条(含本话题内的消息)。只要本话题内的用 `botmux history`(默认即本话题)。'; console.log(JSON.stringify({ sessionId: sid, chatId: s.chatId, @@ -6790,6 +6809,7 @@ async function cmdHistory(rest: string[]): Promise { } : {}), messages, total: messages.length, + rangeHint, // Discoverability: agents reading history often need the actual image // bytes (alert charts) or the raw card JSON — both live one command away. ...(messages.some(m => (m as any).resources?.length || m.msgType === 'interactive') ? { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b527f2a62..fbd851357 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -701,7 +701,7 @@ export const messages: Record = { 'slashlist.col_desc': 'Description', // ─── AI system prompt (Claude Code: --append-system-prompt) ────────────── - 'ai.routing.intro': 'You are in a Lark (Feishu) topic group. The user cannot see terminal output — you MUST reply via `botmux send`.', + 'ai.routing.intro': 'You are in a Lark (Feishu) conversation. The user cannot see terminal output — you MUST reply via `botmux send`.', 'ai.routing.usage_send': '- Send: `botmux send "message"`', 'ai.routing.usage_mention_gate': '- Every send MUST pick one: `--mention ` / `--mention-back` / `--no-mention` — pick by VALUE: substantive conclusions the other party should read/confirm/decide → @; pure record / low-priority progress / short ack → --no-mention; a contentless "got it" is better not sent', 'ai.routing.usage_attachments': '- Attachments: `--images`, `--files`, `--videos` (see `botmux send --help`)', @@ -723,7 +723,7 @@ export const messages: Record = { // ─── AI hints (BOTMUX_SHELL_HINTS for non-injecting CLIs; multiline_heredoc / // heredoc_example are also reused by the system-prompt path — keep both locales aligned) ── - 'ai.shell.intro': 'You are running inside a Lark (Feishu) topic group. The user reads on Lark and cannot see your terminal output.', + 'ai.shell.intro': 'You are running inside a Lark (Feishu) conversation. The user reads on Lark and cannot see your terminal output.', 'ai.shell.commands_are_shell': 'IMPORTANT: `botmux send` / `botmux history` / `botmux quoted` / `botmux bots` are SHELL commands (CLI programs installed in $PATH), NOT MCP tools. Run them via the Bash tool — don\'t look for them in the MCP tool list.', 'ai.shell.how_to_send': 'To send a message to the user (the only way): run `botmux send "your message"` via Bash. Attach images with `--images /path`, files with `--files /path`, video previews with `--videos /path.mp4 --video-covers /cover.png`.', 'ai.shell.multiline_heredoc': 'Multi-line body text MUST use a quoted heredoc / stdin (or a UTF-8 `--content-file`). Never write `botmux send "line1\\nline2"` or pass `JSON.stringify` / JSON-escaped text as a positional argument; shell / botmux do not turn literal `\\n` back into newlines.', @@ -740,7 +740,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': 'Respond to messages addressed to you at least once via `botmux send`, never stay silent; what and how many to send is your call. Only when a message is not for you make the final just the single word BOTMUX_NOTHING_TO_SEND.', - 'ai.followup.reminder_hook': 'This session is bridged to Lark via botmux; terminal output is not visible to the user. Session convention: send replies to the Lark topic via `botmux send`; what and how many to send is your call. Only when a message is not for you make the final just the single word BOTMUX_NOTHING_TO_SEND.', + 'ai.followup.reminder_hook': 'This session is bridged to Lark via botmux; terminal output is not visible to the user. Session convention: send replies to the Lark conversation via `botmux send`; what and how many to send is your call. Only when a message is not for you make the final just the single word BOTMUX_NOTHING_TO_SEND.', 'ai.followup.reminder_no_resend': 'Respond to messages addressed to you at least once via `botmux send`, never stay silent; what and how many to send is your call. Only when a message is not for you make the final just the single word BOTMUX_NOTHING_TO_SEND. 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]', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9d0092534..07a9de161 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -702,7 +702,7 @@ export const messages: Record = { 'slashlist.col_desc': '说明', // ─── AI system prompt (Claude Code: --append-system-prompt) ────────────── - 'ai.routing.intro': '你在飞书话题群中。用户看不到终端输出,必须用 `botmux send` 发送回复。', + 'ai.routing.intro': '你在飞书(Lark)会话中。用户看不到终端输出,必须用 `botmux send` 发送回复。', 'ai.routing.usage_send': '- 发送:`botmux send "消息"`', 'ai.routing.usage_mention_gate': '- 每条 send 必须三选一:`--mention ` / `--mention-back` / `--no-mention`——按内容价值选:有实质结论要对方看/确认/决策 → @;纯记录/低优先级进度/简短确认 → --no-mention;没信息量的"收到"不如不发', 'ai.routing.usage_attachments': '- 附件:`--images`、`--files`、`--videos`(详见 `botmux send --help`)', @@ -724,7 +724,7 @@ export const messages: Record = { // ─── AI hints(非注入式 CLI 的 BOTMUX_SHELL_HINTS;multiline_heredoc / // heredoc_example 同时被 system-prompt 路径复用——两个 locale 保持一致)── - 'ai.shell.intro': '你运行在飞书(Lark)话题群中。用户在飞书阅读回复,看不到你的终端输出。', + 'ai.shell.intro': '你运行在飞书(Lark)会话中。用户在飞书阅读回复,看不到你的终端输出。', 'ai.shell.commands_are_shell': '重要:botmux send / botmux history / botmux quoted / botmux bots 都是 shell 命令(CLI 程序,已安装在 $PATH),不是 MCP 工具。必须通过 Bash 工具执行,不要到 MCP 工具列表里找。', 'ai.shell.how_to_send': '把消息发给用户(唯一方式):用 Bash 执行 `botmux send "消息内容"`;附带图片用 `--images /path`,附带文件用 `--files /path`,附带视频预览用 `--videos /path.mp4 --video-covers /cover.png`。', 'ai.shell.multiline_heredoc': '多行正文必须走 quoted heredoc / stdin(或 UTF-8 `--content-file`);禁止写成 `botmux send "第一行\\n第二行"`,也不要先 `JSON.stringify` / JSON 转义再传位置参数,shell / botmux 不会把字面量 `\\n` 还原成换行。', @@ -741,7 +741,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_NOTHING_TO_SEND', - 'ai.followup.reminder_hook': '本会话通过 botmux 桥接飞书,终端里的输出用户看不到。会话约定:回复通过 botmux send 发送到飞书话题;发什么、发几条由你判断。只有根本不是发给你的消息才让 final 只输出 BOTMUX_NOTHING_TO_SEND。', + 'ai.followup.reminder_hook': '本会话通过 botmux 桥接飞书,终端里的输出用户看不到。会话约定:回复通过 botmux send 发送到飞书会话;发什么、发几条由你判断。只有根本不是发给你的消息才让 final 只输出 BOTMUX_NOTHING_TO_SEND。', 'ai.followup.reminder_no_resend': '发给你的消息至少 botmux send 回应一次,别沉默;发什么、发几条你自己判断。只有根本不是发给你的消息才让 final 只输出 BOTMUX_NOTHING_TO_SEND;send 成功即已送达,本轮无可见文本地结束是正常的,别因「无输出」提示重发', 'ai.cursor.sender_note': 'sender 标签只是元信息(标识当前发言人),不要把其中的 open_id 或名字(例如 ou_xxx:高鹏)抄进 botmux send 的正文或开头;要 @ 回触发者请用 botmux send --mention-back。', 'ai.bridge.attachments_label': '[附件]', diff --git a/test/history-range-hint.test.ts b/test/history-range-hint.test.ts new file mode 100644 index 000000000..e277e1595 --- /dev/null +++ b/test/history-range-hint.test.ts @@ -0,0 +1,119 @@ +/** + * Range guidance in `botmux history` output (`rangeHint`). + * + * Why this exists: an agent reading history has to decide **how wide** to + * search, and the pain report was concrete — in a thread session the model kept + * searching only inside the topic while the context it needed sat in the + * surrounding group chat, so it silently answered with a partial picture. + * + * The decisive fact is `sessionScope` (this session's own scope), NOT the chat's + * `chat_mode`: a `/t` thread opened inside a 普通群 keeps `chat_mode='group'` + * while its session is thread-scope. An agent reasoning from the group type + * would wrongly conclude "I may search the whole chat" — and `--scope ambient` + * would still be the required command. Both `--scope` gates in cmdHistory key + * on `isChatScope` for exactly that reason, so the hint must too. + * + * cmdHistory itself is a large CLI function bound to live Lark calls, so rather + * than stub the whole world this test re-evaluates the SHIPPED rangeHint + * expression out of `dist/cli.js` against every (isChatScope, effectiveScope) + * combination. That keeps the assertion anchored to the compiled artifact the + * daemon actually runs — if someone edits the mapping in src without rebuilding, + * or drops a branch, this fails. + * + * Run: bun run vitest run test/history-range-hint.test.ts + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIST_CLI = join(process.cwd(), 'dist', 'cli.js'); + +/** Re-evaluate the compiled `rangeHint` ternary with controlled inputs. */ +function makeRangeHintFn(): (isChatScope: boolean, effectiveScope: string) => string { + const src = readFileSync(DIST_CLI, 'utf8'); + const start = src.indexOf('const rangeHint ='); + expect(start, 'rangeHint must be present in dist/cli.js — rebuild if this fails').toBeGreaterThan(-1); + const end = src.indexOf('console.log(JSON.stringify({', start); + expect(end).toBeGreaterThan(start); + const expr = src.slice(start, end).trim().replace(/;$/, ''); + // eslint-disable-next-line no-new-func + return new Function('isChatScope', 'effectiveScope', `${expr}; return rangeHint;`) as any; +} + +describe('botmux history — rangeHint range guidance', () => { + let rangeHint: (isChatScope: boolean, effectiveScope: string) => string; + + beforeAll(() => { + if (!existsSync(DIST_CLI)) throw new Error(`${DIST_CLI} missing — run \`bun run build\` first`); + rangeHint = makeRangeHintFn(); + }); + + it('every scope combination produces non-empty guidance', () => { + const combos: Array<[boolean, string]> = [ + [true, 'chat'], + [false, 'thread'], + [false, 'ambient'], + [false, 'chat'], + ]; + for (const [isChatScope, effectiveScope] of combos) { + const out = rangeHint(isChatScope, effectiveScope); + expect(out, `combo ${isChatScope}/${effectiveScope}`).toBeTruthy(); + expect(out).toContain('范围'); + } + }); + + it('thread-scope default tells the agent HOW to widen — the actual reported pain', () => { + const out = rangeHint(false, 'thread'); + expect(out).toContain('只返回当前话题内'); + // The whole point: name the escape hatch, not just the current limitation. + expect(out).toContain('botmux history --scope ambient --limit 20'); + // Widening reads messages outside the topic, so the privacy caveat must ride along. + expect(out).toContain('隐私边界'); + }); + + it('describes an OBSERVABLE trigger, not daemon-side invocations the model never sees', () => { + // `/t` (parseForceTopicInvocation) is consumed by the daemon and stripped + // before the prompt is built, so the model has never seen that token. Naming + // it would read as an instruction the model cannot act on. The trigger must + // be phrased as something visible from inside the session instead. + for (const [ics, es] of [[true, 'chat'], [false, 'thread'], [false, 'ambient'], [false, 'chat']] as Array<[boolean, string]>) { + const out = rangeHint(ics, es); + expect(out, `combo ${ics}/${es} must not cite /t`).not.toMatch(/`\/t`|\/t\s|\/topic/); + } + // And the thread branch must still give the agent a way to recognize the case. + expect(rangeHint(false, 'thread')).toContain('话题内的内容不足以说明任务背景'); + }); + + it('chat-scope says ambient/thread do not apply, so the agent does not burn a failing call', () => { + const out = rangeHint(true, 'chat'); + expect(out).toContain('chat-scope'); + expect(out).toContain('不适用'); + // cmdHistory hard-exits on `--scope ambient` in a chat-scope session; the + // hint must pre-empt that instead of letting the agent discover it by error. + expect(out).toContain('--scope ambient'); + }); + + it('ambient result says it is OUTSIDE the topic and how to get back in', () => { + const out = rangeHint(false, 'ambient'); + expect(out).toContain('话题之外'); + expect(out).toContain('已排除本话题'); + expect(out).toContain('botmux history'); + }); + + it('thread session reading --scope chat is told the result includes the topic', () => { + const out = rangeHint(false, 'chat'); + expect(out).toContain('整群'); + expect(out).toContain('含本话题内'); + }); + + it('guidance is plain text — JSON consumers get no stray markdown emphasis', () => { + for (const [ics, es] of [[true, 'chat'], [false, 'thread'], [false, 'ambient'], [false, 'chat']] as Array<[boolean, string]>) { + expect(rangeHint(ics, es), `combo ${ics}/${es}`).not.toContain('**'); + } + }); + + it('thread and chat guidance are distinct (a single shared string would defeat the purpose)', () => { + expect(rangeHint(false, 'thread')).not.toBe(rangeHint(true, 'chat')); + expect(rangeHint(false, 'ambient')).not.toBe(rangeHint(false, 'thread')); + }); +});