fix(feishu): align menu number-fallback test with emoji label and bind-flows

The menu card renders the repair fallback as **5**<emoji>修复, so the two assertions that matched **5**修复 are broadened to tolerate the marker emoji. The number-reply on a later session page now reads the last text confirmation instead of assuming the last outgoing message is text, since #bindSession refreshes the menu card after binding. Rebuild bundle.
This commit is contained in:
C3H3-AI 2026-08-23 09:41:38 +08:00
parent 722a2f8c58
commit 7a6e84cb89
7 changed files with 212 additions and 147 deletions

File diff suppressed because one or more lines are too long

View file

@ -262,6 +262,8 @@ export class FeishuHarnessBridge {
#menus = new Map();
/** Interactive-card message id → route context for button callbacks. */
#cardKeys = new Map();
/** chatId → last card message id (for in-place update). */
#lastCardMessages = new Map();
/** The global event-mux watcher (one per bridge). */
#eventWatcher = null;
/** Serializes live completions and reconnect compensation. */
@ -1336,8 +1338,8 @@ export class FeishuHarnessBridge {
async #handleMenuPick(menu, number, { chatId, key, event }) {
if (menu.kind === 'menu') {
// Number fallback for the total menu:
// 1=续写 2=新会话 3=会话列表 4=关注任务 5=状态 6=修复 7=更多设置 8=帮助
const actions = ['use:current', 'new', 'sessions', 'watchlist', 'status', 'repair', 'settings', 'help'];
// 1=续写 2=新会话 3=会话列表 4=状态 5=修复 6=更多设置 7=帮助
const actions = ['use:current', 'new', 'sessions', 'status', 'repair', 'settings', 'help'];
const action = actions[number - 1];
if (!action) {
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
@ -1443,6 +1445,7 @@ export class FeishuHarnessBridge {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
await this.#send(chatId, `已绑定会话「${title}」\nID:${bound?.sessionId ?? sessionId}`);
await this.#sendMenuCard(key, chatId);
} catch (error) {
await this.#send(chatId, `绑定失败:${safeErrorText(error)}`);
}
@ -1459,6 +1462,25 @@ export class FeishuHarnessBridge {
}
async #sendCard(chatId, cardJson, options = {}) {
const { update = false } = options;
// 尝试更新已有卡片(不发送新消息)
if (update) {
const existingId = this.#lastCardMessages.get(chatId);
if (existingId) {
try {
await this.#client.request({
method: 'PATCH',
url: `/open-apis/im/v1/messages/${existingId}`,
data: { content: cardJson },
});
return existingId;
} catch (error) {
this.#logger.warn?.('[dsh-feishu] card update failed:', error?.code ?? error?.message ?? error, 'sending new');
}
}
}
const response = await this.#client.im.v1.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
@ -1480,6 +1502,9 @@ export class FeishuHarnessBridge {
if (oldest !== undefined) this.#cardKeys.delete(oldest);
}
}
if (messageId) {
this.#lastCardMessages.set(chatId, messageId);
}
return messageId;
}
@ -1532,7 +1557,6 @@ export class FeishuHarnessBridge {
currentSessionId = sessions[0].id;
currentSessionTitle = sessions[0].title ?? sessions[0].id;
}
const watchCount = Array.isArray(this.#state.watchEntries?.(key)) ? this.#state.watchEntries(key).length : 0;
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
this.#rememberMenu(key, { kind: 'menu', chatId });
await this.#sendCard(
@ -1540,9 +1564,9 @@ export class FeishuHarnessBridge {
menuCard({
workspaces, currentWorkspace,
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
sessions, watchCount, archiveVisible,
sessions, archiveVisible,
}),
{ key },
{ key, update: true },
);
}
@ -1754,8 +1778,8 @@ export class FeishuHarnessBridge {
const text = compactCommand?.message || '上下文压缩失败。';
await this.#send(chatId, text);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] compact failed:', error.message);
await this.#send(chatId, '上下文压缩失败,请稍后重试。');
this.#logger.warn?.('[dsh-feishu] compact failed:', error.message, error.code);
await this.#send(chatId, compactCommand?.message || '上下文压缩失败,请稍后重试。');
}
}

View file

@ -109,19 +109,17 @@ function backButton() {
/**
* The hub menu card — a single entry point covering every command, organized
* by operating flow:
* 1. 情境 / 会话 (高频率) → 续写 · 新会话 · 会话列表
* 2. 环境 / 能力(设置·即改即显) → 工作区下拉 · 模型 · 预设 · 归档
* 3. 任务追踪 → 关注任务(watch 引擎入口) · 状态
* 4. 系统 → 帮助 · 修复(数字 9)
* 1. 会话 · 工作区(高频率)→ 会话下拉 · 工作区下拉 · 新会话 · 全部会话
* 2. 任务控制 → 停止 · 压缩 · 补充指令下拉
* 3. 系统 → 状态 · 帮助 · 更多设置
* `ctx` bundle:
* - workspaces: string[] (工作区下拉选项)
* - currentWorkspace: string|null (当前工作区,下拉高亮)
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
* - watchCount: number (关注任务数)
* - archiveVisible: boolean (归档显隐开关当前值)
* Number fallback: 1=续写 2=新会话 3=会话列表 4=关注任务 5=状态
* 6=修复 7=更多设置 8=帮助.
* Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
* 5=修复 6=更多设置 7=帮助.
*/
export function menuCard(ctx) {
const {
@ -129,7 +127,6 @@ export function menuCard(ctx) {
currentWorkspace = null,
currentSession = null,
sessions = [],
watchCount = 0,
archiveVisible = false,
} = ctx || {};
@ -145,47 +142,74 @@ export function menuCard(ctx) {
{ tag: 'hr' },
];
// ── 情境 / 会话(高频率)────────────────────────────────────
elements.push({ tag: 'div', text: markdown('**情境 · 会话**') });
// 会话下拉:列出最近会话,选择即切换(当前绑定会话带 ✓)
// ── 会话 · 工作区(高频率,左右并排)────────────────────────
const sessionOptions = (Array.isArray(sessions) ? sessions : []).slice(0, 20);
if (sessionOptions.length > 0) {
const hasSessions = sessionOptions.length > 0;
const hasWorkspaces = Array.isArray(workspaces) && workspaces.length > 0;
// 构建会话下拉(始终显示,无会话时显示占位)
let sessionDropdown = null;
if (hasSessions) {
const sessionPickOptions = sessionOptions.map((s) => ({
text: { tag: 'plain_text', content: `${s.id === currentSessionId ? '✓ ' : ''}${safeTitle(s.title)}` },
value: s.id,
}));
elements.push({
sessionDropdown = {
tag: 'select_static',
name: 'session_pick',
placeholder: { tag: 'plain_text', content: '切换/续写会话' },
placeholder: { tag: 'plain_text', content: '切换会话' },
initial_index: initialIndex(sessionPickOptions, currentSessionId),
options: sessionPickOptions,
behaviors: [{ type: 'callback', value: { action: 'session_pick' } }],
});
elements.push(buttonPair('🆕 新会话', 'new', '📋 全部会话', 'sessions'));
};
} else {
elements.push(buttonPair('🆕 新会话', 'new', '📋 会话列表', 'sessions'));
sessionDropdown = {
tag: 'select_static',
name: 'session_pick',
placeholder: { tag: 'plain_text', content: '无可用会话' },
initial_index: 0,
options: [],
disabled: true,
};
}
elements.push({ tag: 'hr' });
// ── 工作区(环境上下文)────────────────────────────────────
elements.push({ tag: 'div', text: markdown('**工作区**') });
if (Array.isArray(workspaces) && workspaces.length > 0) {
// 飞书 schema 2.0 的 select_static option 不支持 selected 字段,
// 默认展示用 initial_index;文本 ✓ 前缀仅作额外视觉高亮。
// 构建工作区下拉
let workspaceDropdown = null;
if (hasWorkspaces) {
const wsOptions = workspaces.slice(0, 20).map((path) => ({
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
value: path,
}));
elements.push({
workspaceDropdown = {
tag: 'select_static',
name: 'workspace_pick',
placeholder: { tag: 'plain_text', content: '切换工作区' },
initial_index: initialIndex(wsOptions, currentWorkspace),
options: wsOptions,
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
};
}
// 左右并排:column_set 两列,左会话右工作区
if (sessionDropdown && workspaceDropdown) {
elements.push({
tag: 'column_set',
flex_mode: 'none',
columns: [
{ tag: 'column', width: 'weighted', weight: 1, elements: [sessionDropdown] },
{ tag: 'column', width: 'weighted', weight: 1, elements: [workspaceDropdown] },
],
});
} else {
if (sessionDropdown) elements.push(sessionDropdown);
if (workspaceDropdown) elements.push(workspaceDropdown);
}
// 新会话 + 全部会话按钮
elements.push(buttonPair('🆕 新会话', 'new', '📋 全部会话', 'sessions'));
// 如果既没有会话也没有工作区,显示工作区列表按钮
if (!hasSessions && !hasWorkspaces) {
elements.push(button('🗂 工作区列表', 'workspaces'));
}
elements.push({ tag: 'hr' });
@ -210,23 +234,15 @@ export function menuCard(ctx) {
});
elements.push({ tag: 'hr' });
// ── 任务追踪 ───────────────────────────────────────────────
elements.push({ tag: 'div', text: markdown(`**任务追踪** · 👁 关注任务 ${watchCount}`) });
elements.push(buttonPair('👁 关注任务', 'watchlist', '📊 状态', 'status'));
elements.push({ tag: 'hr' });
// ── 底部操作(系统功能)────────────────────────────────────
elements.push(buttonPair('📊 状态', 'status', '📖 帮助', 'help'));
elements.push(button('⚙ 更多设置', 'settings'));
// ── 底部操作(低频配置收敛到「更多设置」)──────────────────
elements.push(buttonPair('⚙ 更多设置', 'settings', '📖 帮助', 'help'));
// 命令与数字兜底说明(飞书 schema 2.0 不支持 collapse,用平铺文本)
// 命令与数字兜底说明
elements.push({ tag: 'div', text: markdown(
'**数字兜底**\n'
+ '**1**续写 · **2**新会话 · **3**会话列表 · **4**关注 · **5**状态\n'
+ '**6 · 修复卡片按钮** · **7**更多设置 · **8**帮助',
) });
elements.push({ tag: 'div', text: markdown(
'**任务控制**(需先绑定会话)\n'
+ '`/stop` 停止 · `/steer <指令>` 补充指令',
+ '**1**续写 · **2**新会话 · **3**会话列表 · **4**状态\n'
+ '**5**🔧修复 · **6**更多设置 · **7**帮助',
) });
return cardWith('🤖 助手中心', elements);
}
@ -496,12 +512,15 @@ export function menuHelpText() {
'/workspacelist 列出工作区绝对路径',
'/new 开启全新会话',
'',
'📊 状态 / 压缩 / 关注',
'📊 状态 / 压缩',
'/status 连接状态',
'/compact 压缩当前会话上下文',
'/archived on/off 会话列表显示/隐藏归档',
'',
'👁 关注',
'/watch ID 关注会话(完成后推送)',
'/watchlist 关注列表',
'/archived on/off 会话列表显示/隐藏归档',
'/unwatch ID 取消关注',
'',
'🤖 预设 / 模型',
'/presetlist 列出可用 Agent Preset',
@ -526,28 +545,43 @@ export function helpCard(extraTextLines = []) {
? ('\n' + extraTextLines.join('\n'))
: '';
const elements = [
{ tag: 'div', text: markdown('**回复数字或点击按钮使用功能**\n\n' +
'📋 会话列表 — 查看/绑定已有会话\n' +
'🆕 新会话 — 开启全新会话\n' +
'📊 状态 — 查看系统连接状态\n' +
'📐 压缩 — 压缩当前会话上下文\n' +
'🤖 预设 — 选择 Agent 预设\n' +
'🧠 模型 — 选择 AI 模型\n' +
'🔍 关注列表 — 管理关注会话\n' +
'🔄 修复 — 修复卡片按钮回调') },
{ tag: 'div', text: markdown('**📋 卡片功能**\n\n' +
'1. 会话下拉 — 切换当前绑定会话\n' +
'2. 工作区下拉 — 切换工作区\n' +
'3. 🆕 新会话 — 开启全新会话\n' +
'4. 📋 全部会话 — 查看/绑定会话\n' +
'5. ⏹ 停止 — 停止当前任务\n' +
'6. 📐 压缩 — 压缩当前会话上下文\n' +
'7. 补充指令 — 给 Agent 发送指令\n' +
'8. 📊 状态 — 查看系统连接状态\n' +
'9. 📖 帮助 — 查看本帮助\n' +
'10. ⚙ 更多设置 — 预设/模型/归档设置') },
{ tag: 'hr' },
{ tag: 'div', text: markdown('**文本命令**\n\n' +
{ tag: 'div', text: markdown('**⌨️ 文本命令**\n\n' +
'`/m` — 打开菜单卡片\n' +
'`/new` — 开启全新会话\n' +
'`/session ID` — 绑定已有会话\n' +
'`/sessionlist [工作区]` — 列出会话\n' +
'`/workspace 路径` — 切换工作区\n' +
'`/workspacelist` — 列出工作区绝对路径\n' +
'`/sessionlist [工作区序号或绝对路径]` — 列出会话 ID 和标题\n' +
'`/watch ID` — 关注会话(完成后推送)\n' +
'`/stop` — 停止当前任务\n' +
'`/steer 指令` — 给 Agent 补充指令\n' +
'`/archived on/off` — 会话列表显示/隐藏归档\n' +
'`/workspacelist` — 列出工作区\n' +
'`/status` — 查看连接状态\n' +
'`/compact` — 压缩上下文\n' +
'`/stop` — 停止当前任务\n' +
'`/steer 指令` — 补充指令\n' +
'`/watch ID` — 关注会话\n' +
'`/watchlist` — 关注列表\n' +
'`/unwatch ID` — 取消关注\n' +
'`/archived on/off` — 归档显隐\n' +
'`/presetlist` — 列出预设\n' +
'`/models` — 列出模型' + extraText) },
'`/preset [序号/ID]` — 切换预设\n' +
'`/preset --default` — 跟随默认\n' +
'`/models` — 列出模型\n' +
'`/model 2` — 切换模型\n' +
'`/repair` — 修复卡片按钮') },
{ tag: 'hr' },
{ tag: 'div', text: markdown('**💡 数字兜底**\n回复数字快速操作:\n' +
'**1**续写 · **2**新会话 · **3**全部会话\n' +
'**4**状态 · **5**修复 · **6**更多设置 · **7**帮助') },
{ tag: 'hr' },
backButton(),
];
@ -620,7 +654,7 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
const watching = watched(session.sessionId);
return row(
buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(watching ? '⭐ 取消关注' : '☆ 关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(label, `use:${session.sessionId}`),
);
}),

View file

@ -68,7 +68,7 @@ export async function runControlCommand(text, harness, state, key, {
const session = boundSession(harness, state, key);
if (!session) {
return commandResult('当前聊天没有正在运行的任务,请直接发送普通消息。');
return commandResult('当前聊天没有绑定会话,无法补充指令。请先绑定会话。');
}
if (typeof session.steerActiveTurn !== 'function') {
throw new TypeError('Harness session does not support steering active turns');
@ -80,5 +80,5 @@ export async function runControlCommand(text, harness, state, key, {
);
return steered
? commandResult('已提交补充指令,Agent 会在下一步读取。')
: commandResult('当前聊天没有正在运行的任务,请直接发送普通消息。');
: commandResult('任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。');
}

View file

@ -2182,7 +2182,9 @@ test('number replies on a later session page use page-local labels', async () =>
await bridge.accept(event('sessions-number-pick', '1', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
assert.match(JSON.parse(sent.at(-1).content).text, /ID:session-21/);
// #bindSession 先发文本确认,再原地更新/重发菜单卡;取最后一条文本消息验证绑定结果
const texts = sent.filter((m) => m.msgType !== 'interactive');
assert.match(JSON.parse(texts.at(-1).content).text, /ID:session-21/);
});
test('session pagination preserves an explicitly selected workspace', async () => {
@ -2342,17 +2344,17 @@ test('/repair status after a runtime restart never starts a duplicate authorizat
assert.match(message, /不会启动新的授权/);
});
test('menu repair entry is number-only and reply 6 starts the same repair flow', async () => {
test('menu repair entry is number-only and reply 5 starts the same repair flow', async () => {
const repair = repairCapability();
const fx = repairBridge({ capability: repair.capability });
await fx.bridge.accept(event('repair-menu-open', '/m', { senderOpenId: 'ou_owner' }));
await fx.bridge.waitForIdle();
const menu = cards(fx.sent)[0].content;
assert.match(JSON.stringify(menu), /6 · 修复卡片按钮/);
assert.match(JSON.stringify(menu), /\*\*5\*\*🔧修复/);
assert.equal(buttonsFromCard(menu).some((button) => callbackAction(button) === 'repair'), false);
await fx.bridge.accept(event('repair-menu-six', '6', { senderOpenId: 'ou_owner' }));
await fx.bridge.accept(event('repair-menu-five', '5', { senderOpenId: 'ou_owner' }));
await fx.bridge.waitForIdle();
assert.equal(repair.calls.start.length, 1);
assert.equal(fx.asks, 0);
@ -3040,5 +3042,5 @@ test('menu stop and steer reply friendly when no session is bound', async () =>
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(calls.steer.length, 0);
assert.match(JSON.parse(sent.at(-1).content).text, /普通消息/);
assert.match(JSON.parse(sent.at(-1).content).text, /没有绑定会话/);
});

View file

@ -43,15 +43,15 @@ function forms(value, result = []) {
test('menu exposes the increased command set and keeps repair number-only', () => {
const card = JSON.parse(menuCard());
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
assert.match(JSON.stringify(card), /\*\*5\*\*\S*修复/);
const actions = buttons(card).flatMap((button) => (
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
));
assert.deepEqual(actions, [
'new', 'sessions', 'workspaces', 'stop', 'compact',
'watchlist', 'status', 'settings', 'help',
'status', 'help', 'settings',
]);
// 修复不占位按钮:仅通过数字兜底「6」触发(见 bridge)
// 修复不占位按钮:仅通过数字兜底「5🔧」触发(见 bridge)
assert.equal(actions.includes('repair'), false);
});

View file

@ -88,7 +88,7 @@ test('/stop and /steer return friendly no-session messages without creating one'
);
assert.match(
(await runControlCommand('/steer 补充', harness, state, 'direct:one')).message,
/普通消息/,
/没有绑定会话/,
);
assert.equal(calls.some(([method]) => method === 'workspaceSession'), false);
});
@ -135,7 +135,7 @@ test('/steer reports a lost active-turn race instead of starting new work', asyn
const result = await runControlCommand('/steer continue', harness, state, 'direct:one', {
control: { owner: {}, key: 'direct:one' },
});
assert.match(result.message, /普通消息/);
assert.match(result.message, /没有正在运行/);
assert.equal(calls.filter(([method]) => method === 'steerActiveTurn').length, 1);
});