fix(feishu): present Harness questions as threaded replies in topic groups

Regular answers reuse the stream card, which is created through the
reply API targeting the triggering message, so they land in the right
Feishu thread. Pending Harness questions (ask_user_question) were sent
through a fresh message.create with only the chat_id, so in topic
groups they appeared in the group's default area instead of the topic
the conversation belongs to.

Thread question delivery the same way as answers:

- #send accepts { replyTo } and uses im.v1.message.reply, falling back
  to a plain message when the referenced one is gone
- the triggering message id now travels into the interaction context
  and pending state, and #presentInteraction replies to it
- resolved-question notices reply to the user's answer message

- bridge.test.mjs: assert the question is delivered via the reply API
  targeting the triggering message inside a topic group
- lib/index.js: regenerated host bundle
This commit is contained in:
evanfang0054 2026-08-31 17:12:55 +08:00
parent da4114dfe3
commit 3b810fea6b
3 changed files with 156 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -3068,6 +3068,7 @@ export class FeishuHarnessBridge {
actor: senderOpenId(event),
chatId: event.message.chat_id,
requiresMention: event.message.chat_type !== 'p2p',
replyToMessageId: event.message.message_id,
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files,
@ -3368,11 +3369,11 @@ export class FeishuHarnessBridge {
const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) {
if (this.#isResolvedQuestionReply(event, key)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
return;
}
if (claimed && (!pending || pending !== expected)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT());
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id });
return;
}
return this.#enqueueMessage(event, messageId, key, processingReaction, {
@ -3430,7 +3431,7 @@ export class FeishuHarnessBridge {
if (error?.code === 'interaction-not-pending') {
this.#rememberResolvedInteraction(key, pending);
this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
return;
}
pending.submitting = false;
@ -3448,6 +3449,7 @@ export class FeishuHarnessBridge {
actor,
chatId,
requiresMention,
replyToMessageId,
}) {
if (await this.#approvals.handleRequested(interaction, {
key,
@ -3484,6 +3486,7 @@ export class FeishuHarnessBridge {
await this.#send(
chatId,
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
{ replyTo: replyToMessageId },
).catch(() => undefined);
return;
}
@ -3519,6 +3522,7 @@ export class FeishuHarnessBridge {
answers: [],
index: 0,
chatId,
replyToMessageId,
queue: null,
claimedReplyMessageId: null,
submitting: false,
@ -3553,6 +3557,9 @@ export class FeishuHarnessBridge {
pending.questions.length,
{ requiresMention: pending.requiresMention },
),
// Reply to the message that started the turn so the question lands in
// the same Feishu thread/topic instead of the group's default area.
{ replyTo: pending.replyToMessageId },
);
if (messageId) {
pending.questionMessageIds.add(messageId);
@ -3585,7 +3592,7 @@ export class FeishuHarnessBridge {
await this.#state.markSeen(messageId);
this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1;
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
}
#takePendingInteraction(key, interactionId) {
@ -3651,13 +3658,33 @@ export class FeishuHarnessBridge {
else processingReaction.success();
}
async #send(chatId, text) {
async #send(chatId, text, { replyTo } = {}) {
const content = JSON.stringify({ text });
if (replyTo) {
try {
const response = await this.#client.im.v1.message.reply({
path: { message_id: replyTo },
data: { msg_type: 'text', content },
});
if (response?.code && response.code !== 0) {
throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
}
return nonEmptyString(response?.data?.message_id);
} catch (error) {
// The referenced message may be gone (recalled/deleted); keep the
// delivery promise by falling back to a plain chat message.
this.#logger.warn?.(
'[dsh-feishu] threaded reply failed; falling back to a plain message:',
error?.message ?? String(error),
);
}
}
const response = await this.#client.im.v1.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'text',
content: JSON.stringify({ text }),
content,
},
});
if (response?.code && response.code !== 0) {

View file

@ -1238,6 +1238,103 @@ test('a threaded Feishu reply answers a pending Harness question before the orig
assert.equal(status.messagesReplied, 1);
});
test('a Harness question is presented as a threaded reply inside a topic group', async () => {
const sent = [];
const replied = [];
const streamed = [];
const seen = new Set();
const sessions = new Map();
const status = {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: {
create: async (request) => {
sent.push({ text: JSON.parse(request.data.content).text });
return { code: 0, data: { message_id: `om_sent_${sent.length}` } };
},
reply: async (request) => {
replied.push({
to: request.path.message_id,
text: JSON.parse(request.data.content).text,
});
return { code: 0, data: { message_id: `om_replied_${replied.length}` } };
},
} } },
},
channel: {
stream: async (_chatId, input) => {
await input.markdown({
setContent: async (content) => streamed.push(content),
});
return { messageId: 'om_stream' };
},
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => false,
createSession: async () => 'session-topic',
ask: async (_sessionId, _text, options) => {
await options.onUpdate({ type: 'tool', name: 'ask_user_question' });
await options.onInteraction({
kind: 'question',
interactionId: 'question-rpc',
rpcId: 'question-rpc',
sessionId: 'session-topic',
payload: {
type: 'question/requested',
sessionId: 'session-topic',
questions: [{
id: 'environment',
header: '测试环境',
question: '请选择测试环境',
options: [{ label: '测试环境' }, { label: '生产环境' }],
}],
},
respond: async () => ({ accepted: true }),
});
return '已完成';
},
},
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,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_prompt', '请先调用 ask_user_question', {
chat_type: 'group',
chat_id: 'oc_topic_group',
thread_id: 'omt_prompt',
}));
await bridge.waitForIdle();
assert.deepEqual(
replied,
[{ to: 'om_prompt', text: replied[0]?.text }],
'the question must be delivered through the reply API targeting the triggering message',
);
assert.ok(replied[0]?.text.includes('请选择测试环境'));
assert.equal(
sent.some(({ text }) => text.includes('请选择测试环境')),
false,
'the question must not be sent as a plain chat message that lands outside the topic',
);
assert.equal(streamed.at(-1), '已完成');
});
test('pending Harness questions are isolated by Feishu conversation', async () => {
const fixture = stateFixture([
['p2p:ou_a', 'session-a'],