mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
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:
parent
71ca552113
commit
6c6d4af936
7 changed files with 522 additions and 239 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed / 修复
|
||||
|
||||
- 飞书流式回复超过单卡长度限制时保留预览,生成结束后分段发送完整回答,不再因超限抛错而撤回卡片;分段保留 Unicode 字符、空白和所有消息 ID,解决 Issue #78。
|
||||
Feishu streaming replies now keep a bounded preview and deliver the complete final answer across multiple cards instead of throwing and recalling the card when its length limit is exceeded. Splitting preserves Unicode characters, whitespace, and every message ID, resolving Issue #78.
|
||||
|
||||
## [3.1.0] - 2026-08-28
|
||||
|
||||
### Added / 新增
|
||||
|
|
|
|||
383
lib/index.js
383
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -49,6 +49,7 @@ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
|
|
@ -3315,7 +3316,7 @@ export class FeishuHarnessBridge {
|
|||
createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-cardkit',
|
||||
providerMessageIds: stream?.messageId ? [stream.messageId] : [],
|
||||
providerMessageIds: providerMessageIdsFor(stream),
|
||||
}),
|
||||
);
|
||||
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
||||
|
|
|
|||
|
|
@ -126,7 +126,37 @@ function deliveryUuid(file, chatId, messageType) {
|
|||
|
||||
function summaryOf(text) {
|
||||
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
|
||||
return summary.length <= 50 ? summary : `${streamTextPrefix(summary, 49)}…`;
|
||||
}
|
||||
|
||||
function streamTextPrefix(text, maxChars) {
|
||||
let end = Math.min(text.length, maxChars);
|
||||
// Keep a UTF-16 surrogate pair together when the limit lands inside an emoji.
|
||||
const before = text.charCodeAt(end - 1);
|
||||
const after = text.charCodeAt(end);
|
||||
if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1;
|
||||
return text.slice(0, end);
|
||||
}
|
||||
|
||||
function streamPreview(text) {
|
||||
if (text.length <= MAX_STREAM_CHARS) return text;
|
||||
const notice = `\n\n${t('内容较长,生成完成后将分段发送完整回答。')}`;
|
||||
return streamTextPrefix(text, MAX_STREAM_CHARS - notice.length) + notice;
|
||||
}
|
||||
|
||||
function splitStreamContent(text) {
|
||||
const chunks = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > MAX_STREAM_CHARS) {
|
||||
const prefix = streamTextPrefix(remaining, MAX_STREAM_CHARS);
|
||||
let end = prefix.lastIndexOf('\n') + 1;
|
||||
if (end < MAX_STREAM_CHARS * 0.6) end = prefix.length;
|
||||
chunks.push(remaining.slice(0, end));
|
||||
// Unlike plain-text fallback splitting, keep all whitespace in the answer.
|
||||
remaining = remaining.slice(end);
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function streamingCard(initialText) {
|
||||
|
|
@ -164,7 +194,7 @@ export class VerifiedFeishuChannel {
|
|||
fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
||||
}) {
|
||||
this.#client = client;
|
||||
this.#initialText = initialText ?? t(DEFAULT_INITIAL_TEXT);
|
||||
this.#initialText = String(initialText ?? t(DEFAULT_INITIAL_TEXT)) || '…';
|
||||
this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
|
||||
}
|
||||
|
|
@ -174,59 +204,40 @@ export class VerifiedFeishuChannel {
|
|||
throw new Error('Feishu stream requires a markdown producer');
|
||||
}
|
||||
|
||||
let messageId = null;
|
||||
const cardResponse = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
|
||||
data: {
|
||||
type: 'card_json',
|
||||
data: JSON.stringify(streamingCard(this.#initialText)),
|
||||
},
|
||||
}));
|
||||
const cardId = cardResponse?.data?.card_id;
|
||||
if (!cardId) throw new Error('Feishu card.create returned no card_id');
|
||||
|
||||
const cards = [];
|
||||
try {
|
||||
messageId = await this.#sendCard(chatId, cardId, options.replyTo);
|
||||
let sequence = 0;
|
||||
const firstCard = await this.#createStreamCard(chatId, options.replyTo);
|
||||
cards.push(firstCard);
|
||||
let lastContent = this.#initialText;
|
||||
const controller = {
|
||||
messageId,
|
||||
messageId: firstCard.messageId,
|
||||
setContent: async (content) => {
|
||||
const next = String(content ?? '') || '…';
|
||||
if (next === lastContent) return;
|
||||
if (next.length > MAX_STREAM_CHARS) {
|
||||
throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`);
|
||||
}
|
||||
const response = await this.#client.cardkit.v1.cardElement.content({
|
||||
path: { card_id: cardId, element_id: STREAM_ELEMENT_ID },
|
||||
data: {
|
||||
content: next,
|
||||
sequence: ++sequence,
|
||||
uuid: `content_${cardId}_${sequence}`,
|
||||
},
|
||||
});
|
||||
assertApiSuccess('Feishu cardElement.content', response);
|
||||
await this.#updateStreamCard(firstCard, streamPreview(next));
|
||||
// Updates are replaceable snapshots, including progress/tool text.
|
||||
// Retain the full latest snapshot even when its preview is unchanged.
|
||||
lastContent = next;
|
||||
},
|
||||
};
|
||||
|
||||
await input.markdown(controller);
|
||||
const finishResponse = await this.#client.cardkit.v1.card.settings({
|
||||
path: { card_id: cardId },
|
||||
data: {
|
||||
settings: JSON.stringify({
|
||||
config: {
|
||||
streaming_mode: false,
|
||||
summary: { content: summaryOf(lastContent) || t('回答完成') },
|
||||
},
|
||||
}),
|
||||
sequence: ++sequence,
|
||||
uuid: `settings_${cardId}_${sequence}`,
|
||||
},
|
||||
});
|
||||
assertApiSuccess('Feishu card.settings', finishResponse);
|
||||
return { messageId };
|
||||
const chunks = splitStreamContent(lastContent);
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const card = index === 0
|
||||
? firstCard
|
||||
: await this.#createStreamCard(chatId, options.replyTo);
|
||||
if (index > 0) cards.push(card);
|
||||
await this.#updateStreamCard(card, chunk);
|
||||
await this.#finishStreamCard(card);
|
||||
}
|
||||
return {
|
||||
messageId: firstCard.messageId,
|
||||
providerMessageIds: cards.map((card) => card.messageId),
|
||||
};
|
||||
} catch (error) {
|
||||
if (messageId) await this.#recall(messageId);
|
||||
// Preserve the existing provider-error fallback contract: the bridge
|
||||
// resends the completed answer, so remove any cards it would duplicate.
|
||||
for (const card of cards) await this.#recall(card.messageId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -369,6 +380,51 @@ export class VerifiedFeishuChannel {
|
|||
});
|
||||
}
|
||||
|
||||
async #createStreamCard(chatId, replyTo) {
|
||||
const content = streamPreview(this.#initialText);
|
||||
const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
|
||||
data: {
|
||||
type: 'card_json',
|
||||
data: JSON.stringify(streamingCard(content)),
|
||||
},
|
||||
}));
|
||||
const cardId = response?.data?.card_id;
|
||||
if (!cardId) throw new Error('Feishu card.create returned no card_id');
|
||||
const messageId = await this.#sendCard(chatId, cardId, replyTo);
|
||||
return { cardId, messageId, content, sequence: 0 };
|
||||
}
|
||||
|
||||
async #updateStreamCard(card, content) {
|
||||
if (content === card.content) return;
|
||||
const response = await this.#client.cardkit.v1.cardElement.content({
|
||||
path: { card_id: card.cardId, element_id: STREAM_ELEMENT_ID },
|
||||
data: {
|
||||
content,
|
||||
sequence: ++card.sequence,
|
||||
uuid: `content_${card.cardId}_${card.sequence}`,
|
||||
},
|
||||
});
|
||||
assertApiSuccess('Feishu cardElement.content', response);
|
||||
card.content = content;
|
||||
}
|
||||
|
||||
async #finishStreamCard(card) {
|
||||
const response = await this.#client.cardkit.v1.card.settings({
|
||||
path: { card_id: card.cardId },
|
||||
data: {
|
||||
settings: JSON.stringify({
|
||||
config: {
|
||||
streaming_mode: false,
|
||||
summary: { content: summaryOf(card.content) || t('回答完成') },
|
||||
},
|
||||
}),
|
||||
sequence: ++card.sequence,
|
||||
uuid: `settings_${card.cardId}_${card.sequence}`,
|
||||
},
|
||||
});
|
||||
assertApiSuccess('Feishu card.settings', response);
|
||||
}
|
||||
|
||||
async #sendCard(chatId, cardId, replyTo) {
|
||||
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
||||
const response = replyTo
|
||||
|
|
|
|||
|
|
@ -335,6 +335,8 @@ export default {
|
|||
// feishu/feishu-channel.mjs
|
||||
'正在生成…': 'Generating…',
|
||||
'回答完成': 'Answer complete',
|
||||
'内容较长,生成完成后将分段发送完整回答。':
|
||||
'This response is long. The complete answer will be sent in parts when generation finishes.',
|
||||
'飞书机器人': 'Feishu bot',
|
||||
|
||||
// feishu/message-utils.mjs
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue