diff --git a/src/channels/feishu/feishu-channel.mjs b/src/channels/feishu/feishu-channel.mjs index 96499c2..f05dc45 100644 --- a/src/channels/feishu/feishu-channel.mjs +++ b/src/channels/feishu/feishu-channel.mjs @@ -205,15 +205,45 @@ export class VerifiedFeishuChannel { } const cards = []; + let activeCard = null; + let rotating = false; try { - const firstCard = await this.#createStreamCard(chatId, options.replyTo); - cards.push(firstCard); + activeCard = await this.#createStreamCard(chatId, options.replyTo); + cards.push(activeCard); 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 = { - 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) => { 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. // Retain the full latest snapshot even when its preview is unchanged. lastContent = next; @@ -224,14 +254,14 @@ export class VerifiedFeishuChannel { const chunks = splitStreamContent(lastContent); for (const [index, chunk] of chunks.entries()) { const card = index === 0 - ? firstCard + ? await ensureActiveCard() : await this.#createStreamCard(chatId, options.replyTo); if (index > 0) cards.push(card); await this.#updateStreamCard(card, chunk); await this.#finishStreamCard(card); } return { - messageId: firstCard.messageId, + messageId: cards[0].messageId, providerMessageIds: cards.map((card) => card.messageId), }; } catch (error) { diff --git a/src/channels/shared/i18n-en/feishu.mjs b/src/channels/shared/i18n-en/feishu.mjs index 43fc6d7..485e45c 100644 --- a/src/channels/shared/i18n-en/feishu.mjs +++ b/src/channels/shared/i18n-en/feishu.mjs @@ -337,6 +337,7 @@ export default { // feishu/feishu-channel.mjs '正在生成…': 'Generating…', + '⤵️ 最终结果见下方': '⤵️ Final result below', '回答完成': 'Answer complete', '内容较长,生成完成后将分段发送完整回答。': 'This response is long. The complete answer will be sent in parts when generation finishes.', diff --git a/test/channels/feishu/feishu-channel.test.mjs b/test/channels/feishu/feishu-channel.test.mjs index a3a77a4..926f64f 100644 --- a/test/channels/feishu/feishu-channel.test.mjs +++ b/test/channels/feishu/feishu-channel.test.mjs @@ -713,3 +713,63 @@ test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', a && !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'); +});