mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
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:
commit
e8c9817598
6 changed files with 728 additions and 225 deletions
438
lib/index.js
438
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue