fix(feishu): rotate the streaming card before presenting in-turn interactions

This commit is contained in:
evanfang0054 2026-09-01 13:02:47 +08:00
parent bf03b7e696
commit 1a165822ac
2 changed files with 411 additions and 1 deletions

View file

@ -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;

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);
});
// ---------------------------------------------------------------------------
// 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 = [];