Merge pull request #108 from xmanrui/fix/issue-86-feishu-stream-order

fix(feishu): rotate the streaming card before in-turn interactions (issue #86)
This commit is contained in:
Evan Fang 2026-09-01 17:56:04 +08:00 • committed by GitHub
commit e8c9817598
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 728 additions and 225 deletions

File diff suppressed because one or more lines are too long

View file

@ -3352,8 +3352,18 @@ export class FeishuHarnessBridge {
stream = await this.#channel.stream(chatId, { stream = await this.#channel.stream(chatId, {
markdown: async (controller) => { markdown: async (controller) => {
promptStarted = true; promptStarted = true;
const baseAskOptions = this.#interactionAskOptions(event, key, message.files);
const askOptions = { 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) => { onUpdate: async (update) => {
await controller.setContent(this.#progressText(update)); await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1; this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;

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

@ -3353,6 +3353,406 @@ test('Feishu delivers oversized native streams once and retains every card id in
assert.equal(status.streamFallbacks ?? 0, 0); 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 () => { test('a stream finalization failure falls back to text without repeating the prompt', async () => {
const seen = new Set(); const seen = new Set();
const sent = []; const sent = [];

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');
});