fix(feishu): preserve long streaming replies

Keep a bounded streaming preview, then deliver the complete final answer across multiple cards. Preserve all provider message IDs and cover long snapshots and Unicode boundaries with regression tests.

Fixes #78
This commit is contained in:
xmanrui 2026-08-28 10:50:35 +08:00
parent 71ca552113
commit 6c6d4af936
7 changed files with 522 additions and 239 deletions

View file

@ -6,6 +6,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-channel.mjs';
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
@ -2910,6 +2911,79 @@ test('Feishu does not repeat finalized card text when cancellation happens befor
assert.deepEqual(fallbackTexts, []);
});
test('Feishu delivers oversized native streams once and retains every card id in the receipt', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-long-answer']]);
const status = bridgeStatus();
const cards = new Map();
const replyIds = [];
const recalls = [];
const fallbackTexts = [];
const answer = `${'完整回答'.repeat(15000)}\n完成。`;
let askCount = 0;
const client = textClient(async ({ text }) => fallbackTexts.push(text));
client.cardkit = { v1: {
card: {
create: async (request) => {
const cardId = `card-${cards.size + 1}`;
cards.set(cardId, { content: JSON.parse(request.data.data).body.elements[0].content });
return { code: 0, data: { card_id: cardId } };
},
settings: async ({ path, data }) => {
cards.get(path.card_id).finished = !JSON.parse(data.settings).config.streaming_mode;
return { code: 0 };
},
},
cardElement: { content: async ({ path, data }) => {
assert.ok(data.content.length <= 28000);
cards.get(path.card_id).content = data.content;
return { code: 0 };
} },
} };
client.im.v1.message.reply = async ({ path }) => {
assert.equal(path.message_id, 'om-long-answer');
const messageId = `om-card-${replyIds.length + 1}`;
replyIds.push(messageId);
return { code: 0, data: { message_id: messageId } };
};
client.im.v1.message.delete = async ({ path }) => {
recalls.push(path.message_id);
return { code: 0 };
};
client.im.v1.messageReaction = {
create: async () => ({ code: 0, data: { reaction_id: 'reaction-long-answer' } }),
delete: async () => ({ code: 0 }),
};
const bridge = new FeishuHarnessBridge({
client,
channel: new VerifiedFeishuChannel({ client }),
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
askCount += 1;
await options.onUpdate({ type: 'text', text: '中间过程'.repeat(8000) });
await options.onUpdate({ type: 'tool', name: 'web_search' });
await options.onUpdate({ type: 'text', text: answer.slice(0, 28001) });
return answer;
},
},
state: fixture.state,
status,
allowedSenderOpenIds: new Set(['ou_user']),
});
const receipt = await bridge.accept(event('om-long-answer', '请生成长回答'));
assert.equal(askCount, 1);
assert.equal([...cards.values()].map(({ content }) => content).join(''), answer);
assert.ok([...cards.values()].every(({ finished }) => finished));
assert.deepEqual(receipt.providerMessageIds, ['om-card-1', 'om-card-2', 'om-card-3']);
assert.deepEqual(recalls, []);
assert.deepEqual(fallbackTexts, []);
assert.equal(status.streamResponses, 1);
assert.equal(status.streamErrors ?? 0, 0);
assert.equal(status.streamFallbacks ?? 0, 0);
});
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
const seen = new Set();
const sent = [];

View file

@ -4,6 +4,8 @@ import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-chann
function fakeClient(overrides = {}) {
const calls = {
cards: [],
messages: [],
replies: [],
updates: [],
settings: [],
@ -16,7 +18,11 @@ function fakeClient(overrides = {}) {
const client = {
cardkit: { v1: {
card: {
create: async () => ({ code: 0, data: { card_id: 'card-test' } }),
create: async (request) => {
calls.cards.push(request);
const cardId = calls.cards.length === 1 ? 'card-test' : `card-test-${calls.cards.length}`;
return { code: 0, data: { card_id: cardId } };
},
settings: async (request) => {
calls.settings.push(request);
return { code: 0 };
@ -45,9 +51,14 @@ function fakeClient(overrides = {}) {
message: {
reply: async (request) => {
calls.replies.push(request);
return { code: 0, data: { message_id: 'om-stream' } };
const messageId = calls.replies.length === 1 ? 'om-stream' : `om-stream-${calls.replies.length}`;
return { code: 0, data: { message_id: messageId } };
},
create: async (request) => {
calls.messages.push(request);
const messageId = calls.messages.length === 1 ? 'om-stream' : `om-stream-${calls.messages.length}`;
return { code: 0, data: { message_id: messageId } };
},
create: async () => ({ code: 0, data: { message_id: 'om-stream' } }),
delete: async (request) => {
calls.recalls.push(request);
return { code: 0 };
@ -75,6 +86,14 @@ function fakeClient(overrides = {}) {
return { client, calls };
}
function finalCardContents(calls) {
return calls.cards.map((request, index) => {
const cardId = index === 0 ? 'card-test' : `card-test-${index + 1}`;
return calls.updates.findLast((update) => update.path.card_id === cardId)?.data.content
?? JSON.parse(request.data.data).body.elements[0].content;
});
}
test('VerifiedFeishuChannel streams content and verifies terminal settings', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
@ -86,7 +105,7 @@ test('VerifiedFeishuChannel streams content and verifies terminal settings', asy
},
}, { replyTo: 'om_user' });
assert.deepEqual(result, { messageId: 'om-stream' });
assert.deepEqual(result, { messageId: 'om-stream', providerMessageIds: ['om-stream'] });
assert.equal(calls.replies[0].path.message_id, 'om_user');
assert.deepEqual(calls.updates.map((call) => ({
content: call.data.content,
@ -105,6 +124,129 @@ test('VerifiedFeishuChannel streams content and verifies terminal settings', asy
assert.equal(calls.recalls.length, 0);
});
test('VerifiedFeishuChannel previews long snapshots and delivers the entire final answer in cards', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client });
const answer = `${'A'.repeat(28000)}\n\n ${'中'.repeat(28000)}😀\n尾声 `;
const result = await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent(answer.slice(0, 28001));
const preview = calls.updates.at(-1).data.content;
assert.ok(preview.length <= 28000);
assert.match(preview, /生成完成后将分段发送完整回答/);
const updateCount = calls.updates.length;
await controller.setContent(answer);
assert.equal(calls.updates.length, updateCount, 'unchanged previews need no provider update');
assert.equal(calls.cards.length, 1, 'only the final snapshot is split into permanent cards');
},
}, { replyTo: 'om_user' });
const contents = finalCardContents(calls);
assert.equal(contents.join(''), answer, 'preserve the latest tail, newlines, and whitespace');
assert.ok(contents.every((content) => content.length <= 28000 && content.isWellFormed()));
assert.ok(calls.updates.every(({ data }) => data.content.length <= 28000));
assert.equal(result.providerMessageIds.length, contents.length);
assert.deepEqual(result.providerMessageIds, ['om-stream', 'om-stream-2', 'om-stream-3']);
assert.ok(calls.replies.every(({ path }) => path.message_id === 'om_user'));
assert.equal(calls.settings.length, contents.length);
assert.ok(calls.settings.every(({ data }) => (
JSON.parse(data.settings).config.streaming_mode === false
)));
assert.equal(calls.recalls.length, 0);
});
test('VerifiedFeishuChannel handles exact limits and Unicode without a reply target', async (t) => {
const previewLimit = 28000 - '\n\n内容较长,生成完成后将分段发送完整回答。'.length;
for (const answer of [
'a'.repeat(27999),
'a'.repeat(28000),
'a'.repeat(28001),
`${'中'.repeat(27999)}😀尾声`,
`${'a'.repeat(20000)}\n\n${'b'.repeat(12000)}`,
`${'a'.repeat(48)}😀${'b'.repeat(30000)}`,
`${'a'.repeat(previewLimit - 1)}😀${'b'.repeat(100)}`,
]) {
await t.test(`${answer.length} UTF-16 units`, async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client });
const result = await channel.stream('oc_chat', {
markdown: async (controller) => controller.setContent(answer),
});
const contents = finalCardContents(calls);
assert.equal(contents.join(''), answer);
assert.ok(contents.every((content) => content.length <= 28000 && content.isWellFormed()));
assert.ok(calls.updates.every(({ data }) => (
data.content.length <= 28000 && data.content.isWellFormed()
)));
assert.ok(calls.settings.every(({ data }) => (
JSON.parse(data.settings).config.summary.content.isWellFormed()
)));
assert.equal(contents.length, answer.length <= 28000 ? 1 : 2);
assert.equal(result.providerMessageIds.length, contents.length);
assert.ok(calls.messages.every(({ params, data }) => (
params.receive_id_type === 'chat_id' && data.receive_id === 'oc_chat'
)));
assert.equal(calls.replies.length, 0);
assert.equal(calls.recalls.length, 0);
});
}
});
test('VerifiedFeishuChannel replaces oversized progress with a shorter final answer', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client });
await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent('中间过程'.repeat(10000));
await controller.setContent('最终回答');
},
}, { replyTo: 'om_user' });
assert.deepEqual(finalCardContents(calls), ['最终回答']);
assert.equal(calls.replies.length, 1);
assert.equal(calls.recalls.length, 0);
});
test('VerifiedFeishuChannel finishes an empty producer and skips duplicate content updates', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client, initialText: '' });
await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent(null);
await controller.setContent('');
},
});
assert.deepEqual(finalCardContents(calls), ['…']);
assert.equal(calls.updates.length, 0);
assert.equal(calls.settings.length, 1);
});
test('VerifiedFeishuChannel cleans up every split card on a real provider failure for text fallback', async () => {
const { client, calls } = fakeClient({
updateContent: async (request) => {
calls.updates.push(request);
return request.path.card_id === 'card-test-3'
? { code: 230099, msg: 'element update failed' }
: { code: 0 };
},
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(channel.stream('oc_chat', {
markdown: async (controller) => controller.setContent('a'.repeat(56001)),
}, { replyTo: 'om_user' }), /cardElement\.content failed/);
assert.equal(calls.settings.length, 2);
assert.deepEqual(calls.recalls.map(({ path }) => path.message_id), [
'om-stream', 'om-stream-2', 'om-stream-3',
]);
});
test('VerifiedFeishuChannel rejects failed updates and recalls the partial card', async () => {
const { client, calls } = fakeClient({
updateContent: async () => ({ code: 230099, msg: 'element update failed' }),