feat(feishu): add stream card rotation to keep answers below mid-turn interactions

This commit is contained in:
evanfang0054 2026-09-01 12:57:21 +08:00
parent 24f4a1e05b
commit bf03b7e696
3 changed files with 97 additions and 6 deletions

View file

@ -205,15 +205,45 @@ export class VerifiedFeishuChannel {
} }
const cards = []; const cards = [];
let activeCard = null;
let rotating = false;
try { try {
const firstCard = await this.#createStreamCard(chatId, options.replyTo); activeCard = await this.#createStreamCard(chatId, options.replyTo);
cards.push(firstCard); cards.push(activeCard);
let lastContent = this.#initialText; let lastContent = this.#initialText;
// issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
// 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
// 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
// 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
const ensureActiveCard = async () => {
if (!rotating) return activeCard;
activeCard = await this.#createStreamCard(chatId, options.replyTo);
cards.push(activeCard);
rotating = false;
return activeCard;
};
const controller = { const controller = {
messageId: firstCard.messageId, get messageId() {
return activeCard.messageId;
},
rotate: async () => {
if (rotating) return;
rotating = true;
try {
await this.#updateStreamCard(
activeCard,
`${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
);
await this.#finishStreamCard(activeCard);
} catch (error) {
// 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
}
},
setContent: async (content) => { setContent: async (content) => {
const next = String(content ?? '') || '…'; const next = String(content ?? '') || '…';
await this.#updateStreamCard(firstCard, streamPreview(next)); const card = await ensureActiveCard();
await this.#updateStreamCard(card, streamPreview(next));
// Updates are replaceable snapshots, including progress/tool text. // Updates are replaceable snapshots, including progress/tool text.
// Retain the full latest snapshot even when its preview is unchanged. // Retain the full latest snapshot even when its preview is unchanged.
lastContent = next; lastContent = next;
@ -224,14 +254,14 @@ export class VerifiedFeishuChannel {
const chunks = splitStreamContent(lastContent); const chunks = splitStreamContent(lastContent);
for (const [index, chunk] of chunks.entries()) { for (const [index, chunk] of chunks.entries()) {
const card = index === 0 const card = index === 0
? firstCard ? await ensureActiveCard()
: await this.#createStreamCard(chatId, options.replyTo); : await this.#createStreamCard(chatId, options.replyTo);
if (index > 0) cards.push(card); if (index > 0) cards.push(card);
await this.#updateStreamCard(card, chunk); await this.#updateStreamCard(card, chunk);
await this.#finishStreamCard(card); await this.#finishStreamCard(card);
} }
return { return {
messageId: firstCard.messageId, messageId: cards[0].messageId,
providerMessageIds: cards.map((card) => card.messageId), providerMessageIds: cards.map((card) => card.messageId),
}; };
} catch (error) { } catch (error) {

View file

@ -337,6 +337,7 @@ export default {
// feishu/feishu-channel.mjs // feishu/feishu-channel.mjs
'正在生成…': 'Generating…', '正在生成…': 'Generating…',
'⤵️ 最终结果见下方': '⤵️ Final result below',
'回答完成': 'Answer complete', '回答完成': 'Answer complete',
'内容较长,生成完成后将分段发送完整回答。': '内容较长,生成完成后将分段发送完整回答。':
'This response is long. The complete answer will be sent in parts when generation finishes.', 'This response is long. The complete answer will be sent in parts when generation finishes.',

View file

@ -713,3 +713,63 @@ test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', a
&& !error.message.includes('provider permission URL'), && !error.message.includes('provider permission URL'),
); );
}); });
test('rotate() finalizes the old card and carries the final answer into a new card', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
const result = await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent('第一步进行中');
await controller.rotate();
// 此间隙 bridge 发出独立交互消息(此处不需要模拟)
await controller.setContent('最终回答');
},
});
assert.deepEqual(result.providerMessageIds, ['om-stream', 'om-stream-2']);
const card1 = calls.updates.filter((u) => u.path.card_id === 'card-test');
assert.ok(card1.at(-1).data.content.includes('最终结果见下方'), 'old card must carry the pointer notice');
const card2 = calls.updates.filter((u) => u.path.card_id === 'card-test-2');
assert.ok(card2.at(-1).data.content.includes('最终回答'), 'new card must carry the final answer');
assert.equal(calls.settings.length, 2, 'both cards must be finished');
});
test('rotate() degrades gracefully when finalizing the old card fails', async () => {
let cardTestUpdates = 0;
const { client, calls } = fakeClient({
updateContent: async (request) => {
if (request.path.card_id === 'card-test') {
cardTestUpdates += 1;
if (cardTestUpdates === 2) throw new Error('transient finalize failure');
}
calls.updates.push(request);
return { code: 0 };
},
});
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
const result = await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent('第一步进行中');
await controller.rotate();
await controller.setContent('最终回答');
},
});
assert.deepEqual(result.providerMessageIds, ['om-stream', 'om-stream-2']);
const card2 = calls.updates.filter((u) => u.path.card_id === 'card-test-2');
assert.ok(card2.at(-1).data.content.includes('最终回答'));
});
test('rotate() keeps oversized chunked delivery on the rotated card chain', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
const huge = `${'A'.repeat(27990)}\nB`.repeat(2);
const result = await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent('过程');
await controller.rotate();
await controller.setContent(huge);
},
});
assert.equal(result.providerMessageIds.length >= 3, true);
const settingsByCard = calls.settings.map((s) => s.path.card_id);
assert.deepEqual([...new Set(settingsByCard)].length, settingsByCard.length, 'each card finishes exactly once');
});