Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6775,6 +6775,25 @@ async function cmdHistory(rest: string[]): Promise<void> {
...(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,
Expand All @@ -6790,6 +6809,7 @@ async function cmdHistory(rest: string[]): Promise<void> {
} : {}),
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') ? {
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ export const messages: Record<string, string> = {
'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 <open_id>` / `--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`)',
Expand All @@ -723,7 +723,7 @@ export const messages: Record<string, string> = {

// ─── 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.',
Expand All @@ -740,7 +740,7 @@ export const messages: Record<string, string> = {
'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]',
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ export const messages: Record<string, string> = {
'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 <open_id>` / `--mention-back` / `--no-mention`——按内容价值选:有实质结论要对方看/确认/决策 → @;纯记录/低优先级进度/简短确认 → --no-mention;没信息量的"收到"不如不发',
'ai.routing.usage_attachments': '- 附件:`--images`、`--files`、`--videos`(详见 `botmux send --help`)',
Expand All @@ -724,7 +724,7 @@ export const messages: Record<string, string> = {

// ─── 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` 还原成换行。',
Expand All @@ -741,7 +741,7 @@ export const messages: Record<string, string> = {
'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': '[附件]',
Expand Down
119 changes: 119 additions & 0 deletions test/history-range-hint.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});