From 1a165822acd055d71451cd3a88d439bf4e5aad1f Mon Sep 17 00:00:00 2001 From: evanfang0054 <380616679@qq.com> Date: Tue, 1 Sep 2026 13:02:47 +0800 Subject: [PATCH] fix(feishu): rotate the streaming card before presenting in-turn interactions --- src/channels/feishu/bridge.mjs | 12 +- test/channels/feishu/bridge.test.mjs | 400 +++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 1 deletion(-) diff --git a/src/channels/feishu/bridge.mjs b/src/channels/feishu/bridge.mjs index ba999d9..61d07c1 100644 --- a/src/channels/feishu/bridge.mjs +++ b/src/channels/feishu/bridge.mjs @@ -3352,8 +3352,18 @@ export class FeishuHarnessBridge { stream = await this.#channel.stream(chatId, { markdown: async (controller) => { promptStarted = true; + const baseAskOptions = this.#interactionAskOptions(event, key, message.files); const askOptions = { - ...this.#interactionAskOptions(event, key, message.files), + ...baseAskOptions, + // issue #86:独立交互消息(提问/审批)会落在占位卡下方,呈现前 + // 先换卡,让最终答案落在交互消息之后的新流式卡上。 + onInteraction: async (interaction) => { + if ((interaction?.kind === 'question' || interaction?.kind === 'approval') + && typeof controller?.rotate === 'function') { + await controller.rotate(); + } + await baseAskOptions.onInteraction(interaction); + }, onUpdate: async (update) => { await controller.setContent(this.#progressText(update)); this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1; diff --git a/test/channels/feishu/bridge.test.mjs b/test/channels/feishu/bridge.test.mjs index d3f5fff..8668048 100644 --- a/test/channels/feishu/bridge.test.mjs +++ b/test/channels/feishu/bridge.test.mjs @@ -3353,6 +3353,406 @@ test('Feishu delivers oversized native streams once and retains every card id in assert.equal(status.streamFallbacks ?? 0, 0); }); +// --------------------------------------------------------------------------- +// issue #86(https://github.com/xmanrui/dsh-im/issues/86) +// 飞书流式回复先发占位卡片并全程回写;任务中出现的独立交互消息(ask-user 提问等) +// 只能落在占位卡片下方,而最终结果仍回写最上方的占位卡片,导致阅读顺序错乱: +// 用户从上往下先看到“最终答案”,过程对话反而排在后面。 +// 下面的测试用一条全局 timeline 记录飞书侧所有出站消息的真实先后顺序, +// 并断言期望契约:承载最终答案的卡片必须创建于中途提问消息之后。 +// --------------------------------------------------------------------------- + +function issue86Fixture({ withProgressBeforeQuestion }) { + const fixture = stateFixture([['p2p:ou_user', 'session-issue-86']]); + const timeline = []; + const submitStarted = deferred(); + const answerAccepted = deferred(); + let replySequence = 0; + let cardSequence = 0; + + const client = textClient(async ({ text }) => { + timeline.push({ kind: 'plain-text', text }); + }); + client.cardkit = { v1: { + card: { + create: async () => { + cardSequence += 1; + const cardId = `card-86-${cardSequence}`; + timeline.push({ kind: 'card-created', cardId }); + return { code: 0, data: { card_id: cardId } }; + }, + settings: async () => ({ code: 0 }), + }, + cardElement: { content: async ({ path, data }) => { + timeline.push({ kind: 'card-content', cardId: path.card_id, content: data.content }); + return { code: 0 }; + } }, + } }; + client.im.v1.message.reply = async (request) => { + replySequence += 1; + const messageId = `om-86-${replySequence}`; + const content = JSON.parse(request.data.content); + timeline.push({ + kind: request.data.msg_type === 'text' ? 'text-message' : 'card-message', + messageId, + text: typeof content.text === 'string' ? content.text : '', + }); + return { code: 0, data: { message_id: messageId } }; + }; + client.im.v1.message.delete = async () => ({ code: 0 }); + client.im.v1.messageReaction = { + create: async () => ({ code: 0, data: { reaction_id: 'reaction-86' } }), + delete: async () => ({ code: 0 }), + }; + + const bridge = new FeishuHarnessBridge({ + client, + channel: new VerifiedFeishuChannel({ client }), + harness: { + sessionExists: async () => true, + ask: async (sessionId, _text, options) => { + if (withProgressBeforeQuestion) { + await options.onUpdate({ type: 'text', text: '正在执行第一步…' }); + } + await options.onInteraction({ + kind: 'question', + interactionId: 'question-86', + rpcId: 'question-86', + sessionId, + payload: { + type: 'question/requested', + sessionId, + questions: [{ + id: 'environment', + header: '测试环境', + question: '请选择测试环境', + options: [{ label: '测试环境' }, { label: '生产环境' }], + }], + }, + respond: async (result) => { + submitStarted.resolve(result); + await answerAccepted.promise; + return { accepted: true }; + }, + }); + await answerAccepted.promise; + return '最终回答:选择了测试环境'; + }, + }, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_user']), + }); + + return { bridge, timeline, submitStarted, answerAccepted }; +} + +async function runIssue86Scenario({ bridge, timeline, submitStarted, answerAccepted }) { + bridge.accept(event('om-86-prompt', '请先调用 ask_user_question')); + await eventually( + () => timeline.some((entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境')), + 'the Harness question was not presented in Feishu', + ); + + const questionIndex = timeline.findIndex( + (entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境'), + ); + bridge.accept(event('om-86-answer', '1', { + root_id: 'om-86-prompt', + parent_id: timeline[questionIndex].messageId, + thread_id: 'omt-86', + })); + await Promise.race([ + submitStarted.promise, + new Promise((_, reject) => setTimeout( + () => reject(new Error('the question reply never reached the Harness interaction')), + 1_000, + )), + ]); + answerAccepted.resolve(); + await bridge.waitForIdle(); + return questionIndex; +} + +function issue86FinalCarrierCreatedIndex(timeline) { + const contents = timeline.filter( + (entry) => entry.kind === 'card-content' && entry.content.includes('最终回答:选择了测试环境'), + ); + assert.ok(contents.length > 0, 'the final answer was never written into any streaming card'); + const carrierIds = [...new Set(contents.map((entry) => entry.cardId))]; + return Math.min(...carrierIds.map((cardId) => timeline.findIndex( + (entry) => entry.kind === 'card-created' && entry.cardId === cardId, + ))); +} + +test('issue #86: the final answer must not stay in the card created before a mid-turn question', async () => { + const context = issue86Fixture({ withProgressBeforeQuestion: true }); + const questionIndex = await runIssue86Scenario(context); + const carrierIndex = issue86FinalCarrierCreatedIndex(context.timeline); + + assert.ok( + carrierIndex > questionIndex, + `issue #86 reproduced: the card carrying the final answer was created at timeline #${carrierIndex}, ` + + `before the mid-turn question at #${questionIndex} — Feishu shows the final answer above the question`, + ); +}); + +test('issue #86: even without any progress update, the final answer must not precede the mid-turn question', async () => { + const context = issue86Fixture({ withProgressBeforeQuestion: false }); + const questionIndex = await runIssue86Scenario(context); + const carrierIndex = issue86FinalCarrierCreatedIndex(context.timeline); + + assert.ok( + carrierIndex > questionIndex, + `issue #86 reproduced: the card carrying the final answer was created at timeline #${carrierIndex}, ` + + `before the mid-turn question at #${questionIndex} — Feishu shows the final answer above the question`, + ); +}); + +function issue86RotationFixture({ + postAnswerUpdate = false, + onInteractionOverride = null, + failFinalize = false, + failFinishCardId = null, + skipInteraction = false, +} = {}) { + const fixture = stateFixture([['p2p:ou_user', 'session-issue-86']]); + const timeline = []; + const fallbackTexts = []; + const submitStarted = deferred(); + const answerAccepted = deferred(); + let replySequence = 0; + let cardSequence = 0; + + const client = textClient(async ({ text }) => { + timeline.push({ kind: 'plain-text', text }); + fallbackTexts.push(text); + }); + client.cardkit = { v1: { + card: { + create: async () => { + cardSequence += 1; + const cardId = `card-86-${cardSequence}`; + timeline.push({ kind: 'card-created', cardId }); + return { code: 0, data: { card_id: cardId } }; + }, + settings: async ({ path }) => { + if (failFinishCardId && path.card_id === failFinishCardId) { + throw new Error('settings failed'); + } + timeline.push({ kind: 'card-finished', cardId: path.card_id }); + return { code: 0 }; + }, + }, + cardElement: { content: async ({ path, data }) => { + if (failFinalize && path.card_id === 'card-86-1' && data.content.includes('最终结果见下方')) { + throw new Error('finalize failed'); + } + timeline.push({ kind: 'card-content', cardId: path.card_id, content: data.content }); + return { code: 0 }; + } }, + } }; + client.im.v1.message.reply = async (request) => { + replySequence += 1; + const messageId = `om-86-${replySequence}`; + const content = JSON.parse(request.data.content); + timeline.push({ + kind: request.data.msg_type === 'text' ? 'text-message' : 'card-message', + messageId, + text: typeof content.text === 'string' ? content.text : '', + }); + return { code: 0, data: { message_id: messageId } }; + }; + client.im.v1.message.delete = async () => ({ code: 0 }); + client.im.v1.messageReaction = { + create: async () => ({ code: 0, data: { reaction_id: 'reaction-86' } }), + delete: async () => ({ code: 0 }), + }; + + const bridge = new FeishuHarnessBridge({ + client, + channel: new VerifiedFeishuChannel({ client }), + harness: { + sessionExists: async () => true, + currentWorkspace: () => null, + agentPresetSettings: async () => ({ + agentPreset: null, + agentPresetCatalog: { defaultId: null, items: [] }, + }), + ask: async (sessionId, _text, options) => { + if (onInteractionOverride) { + await onInteractionOverride(sessionId, options, timeline); + await answerAccepted.promise; + return '最终回答:选择了测试环境'; + } + if (!skipInteraction) { + await options.onInteraction({ + kind: 'question', + interactionId: 'question-86', + rpcId: 'question-86', + sessionId, + payload: { + type: 'question/requested', + sessionId, + questions: [{ + id: 'environment', + header: '测试环境', + question: '请选择测试环境', + options: [{ label: '测试环境' }, { label: '生产环境' }], + }], + }, + respond: async (result) => { + submitStarted.resolve(result); + await answerAccepted.promise; + return { accepted: true }; + }, + }); + await answerAccepted.promise; + } + if (postAnswerUpdate) { + await options.onUpdate({ type: 'text', text: '回答后的补充过程' }); + } + return '最终回答:选择了测试环境'; + }, + }, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_user']), + }); + + return { bridge, timeline, fallbackTexts, submitStarted, answerAccepted }; +} + +async function bridge_accept_and_answer({ bridge, timeline, submitStarted, answerAccepted }) { + const turn = bridge.accept(event('om-86-prompt', '请先调用 ask_user_question')); + await eventually( + () => timeline.some((entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境')), + 'the Harness question was not presented in Feishu', + ); + const questionIndex = timeline.findIndex( + (entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境'), + ); + bridge.accept(event('om-86-answer', '1', { + root_id: 'om-86-prompt', + parent_id: timeline[questionIndex].messageId, + thread_id: 'omt-86', + })); + await Promise.race([ + submitStarted.promise, + new Promise((_, reject) => setTimeout( + () => reject(new Error('the question reply never reached the Harness interaction')), + 1_000, + )), + ]); + answerAccepted.resolve(); + const receipt = await turn; + await bridge.waitForIdle(); + return { receipt, questionIndex }; +} + +test('issue #86: post-interaction progress and the final answer land on the rotated card', async () => { + const context = issue86RotationFixture({ postAnswerUpdate: true }); + const { receipt } = await bridge_accept_and_answer(context); + const entries = context.timeline; + const created = entries.filter((entry) => entry.kind === 'card-created'); + assert.deepEqual(created.map((entry) => entry.cardId), ['card-86-1', 'card-86-2']); + // receipt 必须携带新旧两张卡的 provider message id(占位卡 om-86-1、新卡 om-86-3, + // 中间的 om-86-2 是提问文本消息)。 + assert.deepEqual(receipt.providerMessageIds, ['om-86-1', 'om-86-3']); + const card2Contents = entries.filter( + (entry) => entry.kind === 'card-content' && entry.cardId === 'card-86-2', + ); + assert.ok(card2Contents.some((entry) => entry.content.includes('回答后的补充过程'))); + assert.ok(card2Contents.at(-1).content.includes('最终回答:选择了测试环境')); + const card1 = entries.filter((entry) => entry.kind === 'card-content' && entry.cardId === 'card-86-1'); + assert.ok(card1.at(-1).content.includes('最终结果见下方')); + const finished = entries.filter((entry) => entry.kind === 'card-finished').map((entry) => entry.cardId); + assert.ok(finished.includes('card-86-1'), 'old card must be finalized before rotation'); +}); + +test('issue #86: an approval interaction also rotates the stream card', async () => { + const context = issue86RotationFixture({ + onInteractionOverride: async (sessionId, options, timeline) => { + const baseline = timeline.filter( + (entry) => entry.kind === 'text-message' && entry.text.includes('需要你的审批'), + ).length; + await options.onInteraction({ + kind: 'approval', + interactionId: 'approval-86', + rpcId: 'rpc-approval-86', + sessionId, + payload: { + type: 'approval/requested', + sessionId, + approvalId: 'approval-86', + toolName: 'bash', + callId: 'call-86', + reason: '执行构建', + }, + toolCall: { callId: 'call-86', name: 'bash', arguments: JSON.stringify({ operation: '执行构建' }) }, + respond: async () => ({ accepted: true }), + }); + await eventually( + () => timeline.filter( + (entry) => entry.kind === 'text-message' && entry.text.includes('需要你的审批'), + ).length > baseline, + 'the approval message was not presented', + ); + }, + }); + context.answerAccepted.resolve(); + context.bridge.accept(event('om-86-prompt', '请执行构建')); + await context.bridge.waitForIdle(); + const entries = context.timeline; + const created = entries.filter((entry) => entry.kind === 'card-created'); + assert.deepEqual(created.map((entry) => entry.cardId), ['card-86-1', 'card-86-2']); + const firstStreamCardCreated = entries.findIndex((entry) => entry.kind === 'card-created'); + const approvalCardIndex = entries.findIndex( + (entry, index) => entry.kind === 'card-message' && index > firstStreamCardCreated, + ); + const finalCreatedIndex = entries.findIndex( + (entry) => entry.kind === 'card-created' && entry.cardId === 'card-86-2', + ); + assert.ok(approvalCardIndex < finalCreatedIndex, 'approval card must precede the rotated card'); +}); + +test('issue #86: a failure after rotation still falls back to plain text', async () => { + const context = issue86RotationFixture({ failFinishCardId: 'card-86-2' }); + await bridge_accept_and_answer(context); + assert.ok( + context.timeline.some( + (entry) => entry.kind === 'text-message' && entry.text.includes('最终回答:选择了测试环境'), + ), + 'the final answer must be delivered as fallback text', + ); +}); + +test('issue #86: finalize failure degrades without blocking the interaction', async () => { + const context = issue86RotationFixture({ failFinalize: true }); + await bridge_accept_and_answer(context); + assert.ok( + context.timeline.some((entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境')), + 'the question must still be presented', + ); + const created = context.timeline.filter((entry) => entry.kind === 'card-created'); + assert.deepEqual(created.map((entry) => entry.cardId), ['card-86-1', 'card-86-2']); +}); + +test('issue #86: a mid-turn /status command must not rotate the answer card', async () => { + const context = issue86RotationFixture({ skipInteraction: true }); + context.bridge.accept(event('om-86-prompt', '请直接回答')); + await eventually( + () => context.timeline.some((entry) => entry.kind === 'card-finished'), + 'the turn did not finish', + ); + context.bridge.accept(event('om-86-status', '/status')); + await context.bridge.waitForIdle(); + const created = context.timeline.filter((entry) => entry.kind === 'card-created'); + assert.equal(created.length, 1, 'no stream card rotation may happen'); + const contents = context.timeline.filter((entry) => entry.kind === 'card-content'); + assert.ok(contents.at(-1).content.includes('最终回答:选择了测试环境')); +}); + test('a stream finalization failure falls back to text without repeating the prompt', async () => { const seen = new Set(); const sent = [];