Merge pull request #104 from evanfang0054/fix/feishu-thread-card-actions

fix(feishu): thread card-action confirmations to the card message
This commit is contained in:
Evan Fang 2026-09-01 10:05:40 +08:00 • committed by GitHub
commit 075d2713c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 79 deletions

File diff suppressed because one or more lines are too long

View file

@ -774,7 +774,7 @@ export class FeishuHarnessBridge {
await this.#state.markSeen(messageId);
this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1;
if (result?.message) await this.#send(event.message.chat_id, result.message);
if (result?.message) await this.#send(event.message.chat_id, result.message, { replyTo: event.message.message_id });
this.#status.lastError = null;
})
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
@ -843,7 +843,7 @@ export class FeishuHarnessBridge {
const text = options.appendMessage
? `${messageFailureText(failure)}\n\n${options.appendMessage}`
: messageFailureText(failure);
await this.#send(chatId, text).catch(() => undefined);
await this.#send(chatId, text, { replyTo: options.replyTo }).catch(() => undefined);
return failure;
}
@ -874,6 +874,7 @@ export class FeishuHarnessBridge {
await this.#send(
event.message.chat_id,
failureText,
{ replyTo: event.message.message_id },
).catch(() => undefined);
}
@ -1537,7 +1538,7 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.open_chat_id)
?? nonEmptyString(event?.chat_id);
if (chatId) {
this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。'), { replyTo: messageId }).catch(() => undefined);
}
return Promise.resolve();
}
@ -1590,7 +1591,7 @@ export class FeishuHarnessBridge {
await this.#sendSteer(entry, formText);
return;
}
await this.#send(entry.chatId, t('请输入补充指令后再提交。'));
await this.#send(entry.chatId, t('请输入补充指令后再提交。'), { replyTo: entry.messageId ?? null });
return;
}
}
@ -1630,7 +1631,7 @@ export class FeishuHarnessBridge {
}
this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks');
let tracked;
tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'))
tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'), { replyTo: entry.messageId ?? null })
.catch(() => undefined)
.finally(() => {
this.#cardActionTasks.delete(tracked);
@ -1711,7 +1712,7 @@ export class FeishuHarnessBridge {
})
.catch(async (error) => {
if (this.#signal?.aborted) return;
await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' });
await this.#sendFailure(entry.chatId, error, { logLabel: 'card action', replyTo: entry.messageId });
})
.finally(() => {
if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
@ -1752,10 +1753,13 @@ export class FeishuHarnessBridge {
sessionPage = 0,
selections = [],
}) {
// Confirmations triggered by a card interaction stay anchored to the
// card's message so they land inside the same Feishu topic.
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
await this.#showSessions(
{ chatId, key },
{ chatId, key, replyTo: messageId },
sessionWorkspace,
page,
{ updateMessageId: messageId },
@ -1763,17 +1767,17 @@ export class FeishuHarnessBridge {
return;
}
if (action === 'workspaces') {
await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId });
await this.#showWorkspaces({ chatId, key, replyTo: messageId }, { updateMessageId: messageId });
return;
}
if (action === 'watchlist') {
await this.#showWatchList(key, chatId, { updateMessageId: messageId });
await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return;
}
// 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
if (action === 'watch_add' || action === 'watch_remove') {
if (selections.length === 0) {
await this.#send(chatId, t('请先选择至少一个会话。'));
await reply(t('请先选择至少一个会话。'));
return;
}
let changed = 0;
@ -1794,6 +1798,7 @@ export class FeishuHarnessBridge {
const result = await this.#runWatch(key, chatId, sessionId, {
notify: false,
validatedTarget,
replyTo: messageId,
});
if (result.changed) changed += 1;
else if (!result.ok) failed += 1;
@ -1819,54 +1824,53 @@ export class FeishuHarnessBridge {
? t('所选会话已在关注列表中。')
: t('所选会话已不在关注列表中。');
try {
await this.#showWatchList(key, chatId, { updateMessageId: messageId });
await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message);
}
await this.#send(chatId, summary).catch((error) => {
await reply(summary).catch((error) => {
this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message);
});
return;
}
if (action === 'new') {
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
return;
}
await this.#state.clearSession(key);
await this.#send(chatId, t('已开启全新 Harness 会话。'));
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
await reply(t('已开启全新 Harness 会话。'));
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return;
}
if (action === 'use:current') {
const sessionId = this.#state.sessionFor(key);
if (typeof sessionId !== 'string' || !sessionId) {
await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。'));
await reply(t('当前没有绑定的会话,请先从会话列表选择。'));
return;
}
await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。'));
await reply(t('已就绪,直接发消息即可继续当前会话。'));
return;
}
if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
await this.#state?.setIncludeArchivedSessions?.(next);
await this.#send(
chatId,
await reply(
next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
);
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return;
}
if (action === 'repair') {
await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
await reply(t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
return;
}
if (action === 'compact') {
await this.#handleCompact(key, chatId);
await this.#handleCompact(key, chatId, messageId);
return;
}
if (action === 'stop') {
await this.#handleStop(key, chatId);
await this.#handleStop(key, chatId, messageId);
return;
}
if (action === 'steer') {
@ -1877,10 +1881,10 @@ export class FeishuHarnessBridge {
if (action.startsWith('steer:')) {
const raw = action.slice('steer:'.length);
if (raw === 'custom') {
await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId });
await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
return;
}
await this.#sendSteer({ key, chatId }, raw);
await this.#sendSteer({ key, chatId, messageId }, raw);
return;
}
if (action === 'presets') {
@ -1900,7 +1904,7 @@ export class FeishuHarnessBridge {
return;
}
if (action === 'back_to_menu') {
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return;
}
if (action === 'preset_default') {
@ -1923,18 +1927,18 @@ export class FeishuHarnessBridge {
return;
}
if (action.startsWith('use:')) {
await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId });
await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId, replyTo: messageId });
return;
}
if (action.startsWith('workspace:')) {
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId });
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId, replyTo: messageId });
return;
}
if (action.startsWith('unwatch:')) {
const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length), { replyTo: messageId });
if (result.ok && messageId) {
await this.#showSessions(
{ chatId, key },
{ chatId, key, replyTo: messageId },
sessionWorkspace,
sessionPage,
{ updateMessageId: messageId },
@ -1946,10 +1950,11 @@ export class FeishuHarnessBridge {
const sessionId = action.slice('watch:'.length);
const result = await this.#runWatch(key, chatId, sessionId, {
workspaceHint: sessionWorkspace,
replyTo: messageId,
});
if (result.ok && messageId) {
await this.#showSessions(
{ chatId, key },
{ chatId, key, replyTo: messageId },
sessionWorkspace,
sessionPage,
{ updateMessageId: messageId },
@ -1978,48 +1983,50 @@ export class FeishuHarnessBridge {
}
async #handleMenuPick(menu, number, { chatId, key, event }) {
const replyTo = event?.message?.message_id ?? null;
const reply = (text) => this.#send(chatId, text, { replyTo });
if (menu.kind === 'menu') {
// Number fallback for the total menu:
// 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
const action = actions[number - 1];
if (!action) {
await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
await reply(t('菜单没有这个编号,回复 /m 重新打开。'));
return;
}
if (action === 'repair') {
await this.#handleRepairCommand(event, '/repair');
return;
}
await this.#handleCardAction(action, { chatId, key });
await this.#handleCardAction(action, { chatId, key, messageId: replyTo });
return;
}
if (menu.kind === 'sessions') {
const session = menu.sessions[number - 1];
if (!session?.sessionId) {
await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
await reply(t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
return;
}
// The number label sits on the session (bind) button of the row.
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key, messageId: replyTo });
return;
}
if (menu.kind === 'workspaces') {
const workspace = menu.paths[number - 1];
if (!workspace) {
await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
await reply(t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
return;
}
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key, messageId: replyTo });
return;
}
if (menu.kind === 'watches') {
const entry = menu.entries[number - 1];
if (!entry?.sessionId) {
await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
await reply(t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
return;
}
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key, messageId: replyTo });
}
}
@ -2075,7 +2082,7 @@ export class FeishuHarnessBridge {
},
);
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'session list' });
await this.#sendFailure(chatId, error, { logLabel: 'session list', replyTo });
}
}
@ -2092,35 +2099,37 @@ export class FeishuHarnessBridge {
{ key, updateMessageId, replyTo },
);
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
await this.#sendFailure(chatId, error, { logLabel: 'workspace list', replyTo });
}
}
async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) {
async #bindSession(key, chatId, sessionId, { updateMessageId = null, replyTo = null } = {}) {
try {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
await this.#send(chatId, [
t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
t('发送 /history 查看最近对话。'),
].join('\n'));
await this.#sendMenuCard(key, chatId, { updateMessageId });
].join('\n'), { replyTo });
await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) {
await this.#sendFailure(chatId, error, {
logLabel: 'session binding',
replyTo,
userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
});
}
}
async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) {
async #switchWorkspace(key, chatId, workspace, { updateMessageId = null, replyTo = null } = {}) {
try {
const current = await this.#harness.switchWorkspace(workspace);
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
await this.#sendMenuCard(key, chatId, { updateMessageId });
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }), { replyTo });
await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) {
await this.#sendFailure(chatId, error, {
logLabel: 'workspace switch',
replyTo,
userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
});
}
@ -2344,7 +2353,7 @@ export class FeishuHarnessBridge {
catalog._currentId = settings.agentPreset;
await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'preset card' });
await this.#sendFailure(chatId, error, { logLabel: 'preset card', replyTo: updateMessageId });
}
}
@ -2369,7 +2378,7 @@ export class FeishuHarnessBridge {
}
await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'model card' });
await this.#sendFailure(chatId, error, { logLabel: 'model card', replyTo: updateMessageId });
}
}
@ -2397,7 +2406,7 @@ export class FeishuHarnessBridge {
}
await this.#send(chatId, lines.join('\n'), { replyTo });
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'status text' });
await this.#sendFailure(chatId, error, { logLabel: 'status text', replyTo });
}
}
@ -2449,7 +2458,7 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'status card' });
await this.#sendFailure(chatId, error, { logLabel: 'status card', replyTo: updateMessageId });
}
}
@ -2467,21 +2476,21 @@ export class FeishuHarnessBridge {
/**
* Run the /compact command and show the result.
*/
async #handleCompact(key, chatId) {
async #handleCompact(key, chatId, replyTo = null) {
try {
const result = await runCompactCommand(
'/compact', this.#harness, this.#state, key, { signal: this.#signal },
);
await this.#send(chatId, result?.message || t('上下文压缩失败。'));
await this.#send(chatId, result?.message || t('上下文压缩失败。'), { replyTo });
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'compact' });
await this.#sendFailure(chatId, error, { logLabel: 'compact', replyTo });
}
}
/**
* Stop the running task in the bound session (mirrors `/stop`).
*/
async #handleStop(key, chatId) {
async #handleStop(key, chatId, replyTo = null) {
try {
const result = await runControlCommand(
'/stop', this.#harness, this.#state, key, {
@ -2495,9 +2504,9 @@ export class FeishuHarnessBridge {
this.#approvals.closeRoute(key),
]);
}
await this.#send(chatId, result?.message || t('/stop 执行完成。'));
await this.#send(chatId, result?.message || t('/stop 执行完成。'), { replyTo });
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'stop' });
await this.#sendFailure(chatId, error, { logLabel: 'stop', replyTo });
}
}
@ -2522,26 +2531,26 @@ export class FeishuHarnessBridge {
control: { owner: this, key },
},
);
await this.#send(chatId, result?.message || t('已提交补充指令。'));
await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
}
/**
* Reset the preset to follow the Host default.
*/
async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) {
async #handlePresetDefault(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
try {
const result = await runPresetCommand(
'/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
);
for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(chatId, reply);
if (reply) await this.#send(chatId, reply, { replyTo });
}
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'preset reset' });
await this.#sendFailure(chatId, error, { logLabel: 'preset reset', replyTo: updateMessageId });
return;
}
try {
await this.#sendMenuCard(key, chatId, { updateMessageId });
await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
}
@ -2550,21 +2559,21 @@ export class FeishuHarnessBridge {
/**
* Handle preset selection from the preset dropdown.
*/
async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) {
async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null, replyTo = null } = {}) {
try {
const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
const result = await runPresetCommand(
`/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
);
for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(chatId, reply);
if (reply) await this.#send(chatId, reply, { replyTo });
}
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'preset selection' });
await this.#sendFailure(chatId, error, { logLabel: 'preset selection', replyTo: updateMessageId });
return;
}
try {
await this.#sendMenuCard(key, chatId, { updateMessageId });
await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
}
@ -2579,7 +2588,7 @@ export class FeishuHarnessBridge {
* catalog validation, busy checks, pending-interaction checks and the
* session binding lock stay in one place.
*/
async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) {
async #handleModelSelect(key, chatId, modelId, { updateMessageId = null, replyTo = null } = {}) {
try {
const result = await runModelCommand(
`/model ${modelId}`, this.#harness, this.#state, key, {
@ -2589,14 +2598,14 @@ export class FeishuHarnessBridge {
},
);
for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(chatId, reply);
if (reply) await this.#send(chatId, reply, { replyTo });
}
} catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'model selection' });
await this.#sendFailure(chatId, error, { logLabel: 'model selection', replyTo: updateMessageId });
return;
}
try {
await this.#sendMenuCard(key, chatId, { updateMessageId });
await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
}
@ -2867,6 +2876,13 @@ export class FeishuHarnessBridge {
chatId,
lastSeq,
...(watchStartedAt !== null ? { watchStartedAt } : {}),
// Remember where the watch was created so completion pushes can be
// delivered as replies inside the same Feishu topic.
...(replyTo
? { replyToMessageId: replyTo }
: existingEntry?.replyToMessageId
? { replyToMessageId: existingEntry.replyToMessageId }
: {}),
});
} catch (error) {
await reply(t('关注失败:{message}', { message: safeErrorText(error) }));
@ -2992,7 +3008,7 @@ export class FeishuHarnessBridge {
await this.#sendCard(
entry.chatId,
completionCard(sessionId, entry.title, reason),
{ key },
{ key, replyTo: entry.replyToMessageId ?? null },
);
const current = this.#state.watchEntry?.(key, sessionId);
if (!current

View file

@ -1412,6 +1412,124 @@ test('command replies in a topic group are threaded to the triggering message',
);
});
test('bind confirmations from card actions are threaded to the card message', async (t) => {
const created = [];
const replied = [];
const seen = new Set();
const sessions = new Map([['group:oc_topic:thread:omt_cmd', 'session-cmd']]);
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: {
create: async (request) => {
created.push({
type: request.data.msg_type,
text: request.data.msg_type === 'text'
? JSON.parse(request.data.content).text
: null,
});
return { code: 0, data: { message_id: `om_created_${created.length}` } };
},
reply: async (request) => {
const sentMessageId = `om_replied_${replied.length + 1}`;
replied.push({
id: sentMessageId,
to: request.path.message_id,
type: request.data.msg_type,
text: request.data.msg_type === 'text'
? JSON.parse(request.data.content).text
: null,
});
return { code: 0, data: { message_id: sentMessageId } };
},
} } },
},
harness: {
ensureRunning: async () => true,
bindWorkspaceSession: async (key, sessionId) => ({ sessionId, title: 'Test Session' }),
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
},
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
await bridge.accept(event('om_cmd', '/new', {
chat_type: 'group',
chat_id: 'oc_topic',
thread_id: 'omt_cmd',
}));
await eventually(() => replied.length >= 1, 'the menu card was not created');
const menuCardMessageId = replied.filter((item) => item.type === 'interactive').at(-1).id;
await bridge.onCardAction(cardActionEvent(menuCardMessageId, 'use:session-1', 'ou_user'));
await eventually(
() => replied.some(({ to, text }) => to === menuCardMessageId && text?.includes('已绑定会话')),
'the bind confirmation must be threaded to the card message',
);
assert.equal(
created.some(({ text }) => text?.includes('已绑定会话')),
false,
'the bind confirmation must not be sent as a fresh chat message outside the topic',
);
});
test('watch completion pushes are threaded to the message that created the watch', async (t) => {
const { state } = await watchStoreFixture();
const harness = watchHarness({
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'race-session', title: 'Race Session' }] },
});
const completionReplies = [];
const completionCreates = [];
const client = {
im: { v1: { message: {
create: async (request) => {
completionCreates.push(request.data.msg_type);
return { code: 0, data: { message_id: `om_created_${completionCreates.length}` } };
},
reply: async (request) => {
completionReplies.push({ to: request.path.message_id, type: request.data.msg_type });
return { code: 0, data: { message_id: `om_replied_${completionReplies.length}` } };
},
} } },
};
const bridge = new FeishuHarnessBridge({
client,
channel: {},
harness,
state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('watch-anchor', '/watch 1', { senderOpenId: 'ou_owner' }));
const entry = state.watchEntry('p2p:ou_owner', 'race-session');
assert.equal(entry?.replyToMessageId, 'watch-anchor', 'the watch entry must remember the anchor message');
harness._listeners[0].onSessionEvent({
sessionId: 'race-session',
event: { type: 'turn/end', seq: 10, time: entry.watchStartedAt, data: { reason: { kind: 'completed' } } },
});
await eventually(
() => completionReplies.some(({ type }) => type === 'interactive'),
'the completion card was not delivered',
);
assert.equal(
completionReplies.at(-1).to,
'watch-anchor',
'the completion push must be threaded to the /watch command message',
);
assert.equal(
completionCreates.length,
0,
'the completion push must not be sent as a fresh message outside the topic',
);
});
test('pending Harness questions are isolated by Feishu conversation', async () => {
const fixture = stateFixture([
['p2p:ou_a', 'session-a'],