import test from 'node:test'; import assert from 'node:assert/strict'; import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-channel.mjs'; function fakeClient(overrides = {}) { const calls = { cards: [], messages: [], replies: [], updates: [], settings: [], recalls: [], reactionsAdded: [], reactionsRemoved: [], fileUploads: [], imageUploads: [], }; const client = { cardkit: { v1: { card: { 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 }; }, }, cardElement: { content: async (request) => { calls.updates.push(request); return { code: 0 }; }, }, } }, im: { v1: { file: { create: async (request) => { calls.fileUploads.push(request); return { file_key: 'file-key-test' }; }, }, image: { create: async (request) => { calls.imageUploads.push(request); return { data: { image_key: 'image-key-test' } }; }, }, message: { reply: async (request) => { calls.replies.push(request); 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 } }; }, delete: async (request) => { calls.recalls.push(request); return { code: 0 }; }, }, messageReaction: { create: async (request) => { calls.reactionsAdded.push(request); return { code: 0, data: { reaction_id: 'reaction-test' } }; }, delete: async (request) => { calls.reactionsRemoved.push(request); return { code: 0 }; }, }, } }, }; if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent; if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard; if (overrides.uploadFile) client.im.v1.file.create = overrides.uploadFile; if (overrides.uploadImage) client.im.v1.image.create = overrides.uploadImage; if (overrides.replyMessage) client.im.v1.message.reply = overrides.replyMessage; if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage; 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: '正在思考…' }); const result = await channel.stream('oc_chat', { markdown: async (controller) => { await controller.setContent('第一段'); await controller.setContent('第一段和第二段'); }, }, { replyTo: 'om_user' }); 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, sequence: call.data.sequence, })), [ { content: '第一段', sequence: 1 }, { content: '第一段和第二段', sequence: 2 }, ]); assert.equal(calls.settings[0].data.sequence, 3); assert.deepEqual(JSON.parse(calls.settings[0].data.settings), { config: { streaming_mode: false, summary: { content: '第一段和第二段' }, }, }); 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' }), }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects(channel.stream('oc_chat', { markdown: async (controller) => controller.setContent('最终回答'), }, { replyTo: 'om_user' }), /cardElement\.content failed/); assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]); assert.equal(calls.settings.length, 0); }); test('VerifiedFeishuChannel rejects failed finalization and recalls the card', async () => { const { client, calls } = fakeClient({ finishCard: async (request) => { calls.settings.push(request); return { code: 230099, msg: 'card finalization failed' }; }, }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects(channel.stream('oc_chat', { markdown: async (controller) => controller.setContent('已经生成的回答'), }, { replyTo: 'om_user' }), /card\.settings failed/); assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]); assert.equal(calls.settings.length, 1); }); test('VerifiedFeishuChannel checks reaction API results', async () => { const { client, calls } = fakeClient(); const channel = new VerifiedFeishuChannel({ client }); const reactionId = await channel.addReaction('om_user', 'OnIt'); await channel.removeReaction('om_user', reactionId); assert.equal(reactionId, 'reaction-test'); assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt'); assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test'); }); test('VerifiedFeishuChannel uploads a materialized result and replies with a native file message', async () => { const { client, calls } = fakeClient(); const channel = new VerifiedFeishuChannel({ client }); const file = { artifactId: 'artifact-html', deliveryKey: 'delivery-html', fileName: 'result.html', mediaType: 'text/html', size: 19, bytes: Buffer.from('

result

'), }; const receipt = await channel.sendFile('oc_chat', file, { replyTo: 'om_user' }); assert.equal(calls.fileUploads.length, 1); assert.equal(calls.fileUploads[0].data.file_type, 'stream'); assert.equal(calls.fileUploads[0].data.file_name, 'result.html'); assert.equal(calls.fileUploads[0].data.file, file.bytes); assert.equal(calls.replies.length, 1); assert.equal(calls.replies[0].path.message_id, 'om_user'); assert.equal(calls.replies[0].data.msg_type, 'file'); assert.deepEqual(JSON.parse(calls.replies[0].data.content), { file_key: 'file-key-test' }); assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/); assert.deepEqual(receipt, { schemaVersion: 1, deliveryId: 'delivery-html', presentation: 'feishu-file', providerMessageIds: ['om-stream'], artifacts: [{ artifactId: 'artifact-html', outcome: 'sent' }], }); }); test('VerifiedFeishuChannel uploads an image and replies with a native image message', async () => { const { client, calls } = fakeClient(); const channel = new VerifiedFeishuChannel({ client }); const file = { artifactId: 'artifact-image', deliveryKey: 'delivery-image', fileName: 'result.png', mediaType: 'image/png', size: 12, bytes: Buffer.from('png contents'), }; const receipt = await channel.sendImage('oc_chat', file, { replyTo: 'om_user' }); assert.equal(calls.fileUploads.length, 0); assert.equal(calls.imageUploads.length, 1); assert.deepEqual(calls.imageUploads[0].data, { image_type: 'message', image: file.bytes, }); assert.equal(calls.replies.length, 1); assert.equal(calls.replies[0].path.message_id, 'om_user'); assert.equal(calls.replies[0].data.msg_type, 'image'); assert.deepEqual(JSON.parse(calls.replies[0].data.content), { image_key: 'image-key-test' }); assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/); assert.deepEqual(receipt, { schemaVersion: 1, deliveryId: 'delivery-image', presentation: 'feishu-image', providerMessageIds: ['om-stream'], artifacts: [{ artifactId: 'artifact-image', outcome: 'sent' }], }); }); test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => { const { client, calls } = fakeClient({ createMessage: async (request) => { calls.replies.push(request); return { code: 0, data: { message_id: 'om-created-file' } }; }, }); const channel = new VerifiedFeishuChannel({ client }); await channel.sendFile('oc_chat', { artifactId: 'artifact-generic', deliveryKey: 'delivery-generic', fileName: 'result.bin', bytes: Buffer.from('generic'), }); assert.deepEqual(calls.replies[0].params, { receive_id_type: 'chat_id' }); assert.equal(calls.replies[0].data.receive_id, 'oc_chat'); assert.equal(calls.replies[0].data.msg_type, 'file'); }); test('VerifiedFeishuChannel retries uncertain message delivery without uploading twice', async () => { const requests = []; let attempts = 0; const { client, calls } = fakeClient({ replyMessage: async (request) => { requests.push(structuredClone(request)); attempts += 1; return attempts === 1 ? { code: 230049, msg: 'still sending' } : { code: 0, data: { message_id: 'om-retried-file' } }; }, }); const channel = new VerifiedFeishuChannel({ client }); const receipt = await channel.sendFile('oc_chat', { artifactId: 'artifact-retry', deliveryKey: 'delivery-retry', fileName: 'retry.txt', bytes: Buffer.from('retry'), }, { replyTo: 'om_user' }); assert.equal(calls.fileUploads.length, 1); assert.equal(requests.length, 2); assert.deepEqual(requests[1], requests[0]); assert.deepEqual(receipt.providerMessageIds, ['om-retried-file']); }); test('VerifiedFeishuChannel stops after one 230049 retry and never uploads the file twice', async () => { const requests = []; const { client, calls } = fakeClient({ replyMessage: async (request) => { requests.push(structuredClone(request)); return { code: 230049, msg: 'still sending' }; }, }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects(channel.sendFile('oc_chat', { artifactId: 'artifact-still-uncertain', deliveryKey: 'delivery-still-uncertain', fileName: 'uncertain.txt', bytes: Buffer.from('uncertain'), }, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain' && error.providerCode === 230049); assert.equal(calls.fileUploads.length, 1); assert.equal(requests.length, 2); assert.deepEqual(requests[1], requests[0]); }); test('VerifiedFeishuChannel retries an SDK-thrown 230049 with the same file key and UUID', async () => { const requests = []; const { client, calls } = fakeClient({ replyMessage: async (request) => { requests.push(structuredClone(request)); if (requests.length === 1) { const error = new Error('still sending'); error.code = 230049; throw error; } return { code: 0, data: { message_id: 'om-after-thrown-230049' } }; }, }); const channel = new VerifiedFeishuChannel({ client }); const receipt = await channel.sendFile('oc_chat', { artifactId: 'artifact-thrown-retry', deliveryKey: 'delivery-thrown-retry', fileName: 'thrown-retry.txt', bytes: Buffer.from('retry'), }, { replyTo: 'om_user' }); assert.equal(calls.fileUploads.length, 1); assert.equal(requests.length, 2); assert.deepEqual(requests[1], requests[0]); assert.deepEqual(receipt.providerMessageIds, ['om-after-thrown-230049']); }); test('VerifiedFeishuChannel bounds upload and message waits independently', async () => { const uploadFixture = fakeClient({ uploadFile: async () => new Promise(() => {}) }); const uploadChannel = new VerifiedFeishuChannel({ client: uploadFixture.client, fileUploadTimeoutMs: 10, fileMessageTimeoutMs: 100, }); await assert.rejects(uploadChannel.sendFile('oc_chat', { artifactId: 'artifact-upload-timeout', deliveryKey: 'delivery-upload-timeout', fileName: 'upload-timeout.txt', bytes: Buffer.from('timeout'), }, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed' && error.cause?.code === 'provider-timeout'); assert.equal(uploadFixture.calls.replies.length, 0); let messageCalls = 0; const messageFixture = fakeClient({ replyMessage: async () => { messageCalls += 1; return new Promise(() => {}); }, }); const messageChannel = new VerifiedFeishuChannel({ client: messageFixture.client, fileUploadTimeoutMs: 100, fileMessageTimeoutMs: 10, }); await assert.rejects(messageChannel.sendFile('oc_chat', { artifactId: 'artifact-message-timeout', deliveryKey: 'delivery-message-timeout', fileName: 'message-timeout.txt', bytes: Buffer.from('timeout'), }, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain' && error.cause?.code === 'provider-timeout'); assert.equal(messageFixture.calls.fileUploads.length, 1); assert.equal(messageCalls, 1); }); test('VerifiedFeishuChannel rejects waits longer than the 120 second operation timeout', () => { const { client } = fakeClient(); assert.throws( () => new VerifiedFeishuChannel({ client, fileUploadTimeoutMs: 120_001 }), /fileUploadTimeoutMs.*120000/, ); assert.throws( () => new VerifiedFeishuChannel({ client, fileMessageTimeoutMs: 120_001 }), /fileMessageTimeoutMs.*120000/, ); }); test('VerifiedFeishuChannel stops after an in-flight upload when file delivery is cancelled', async () => { let uploadStarted; const started = new Promise((resolve) => { uploadStarted = resolve; }); const { client, calls } = fakeClient({ uploadFile: async () => { uploadStarted(); return new Promise(() => {}); }, }); const channel = new VerifiedFeishuChannel({ client }); const abort = new AbortController(); const reason = new DOMException('runtime stopped', 'AbortError'); const sending = channel.sendFile('oc_chat', { artifactId: 'artifact-cancelled-upload', deliveryKey: 'delivery-cancelled-upload', fileName: 'cancelled.txt', bytes: Buffer.from('cancelled'), }, { replyTo: 'om_user', signal: abort.signal }); await started; abort.abort(reason); await assert.rejects(sending, (error) => error === reason); assert.equal(calls.replies.length, 0); }); test('VerifiedFeishuChannel immediately preserves caller abort during an in-flight message send', async () => { let messageStarted; const started = new Promise((resolve) => { messageStarted = resolve; }); const { client, calls } = fakeClient({ replyMessage: async () => { messageStarted(); return new Promise(() => {}); }, }); const channel = new VerifiedFeishuChannel({ client }); const abort = new AbortController(); const reason = new DOMException('runtime stopped', 'AbortError'); const sending = channel.sendFile('oc_chat', { artifactId: 'artifact-cancelled-message', deliveryKey: 'delivery-cancelled-message', fileName: 'cancelled-message.txt', bytes: Buffer.from('cancelled'), }, { replyTo: 'om_user', signal: abort.signal }); await started; abort.abort(reason); await assert.rejects(sending, (error) => error === reason && error.code !== 'artifact-delivery-uncertain'); assert.equal(calls.fileUploads.length, 1); }); test('VerifiedFeishuChannel does not retry an uncertain file message after cancellation', async () => { const abort = new AbortController(); const reason = new DOMException('runtime stopped', 'AbortError'); const requests = []; const { client } = fakeClient({ replyMessage: async (request) => { requests.push(request); abort.abort(reason); return { code: 230049, msg: 'still sending' }; }, }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects(channel.sendFile('oc_chat', { artifactId: 'artifact-cancelled-retry', deliveryKey: 'delivery-cancelled-retry', fileName: 'cancelled-retry.txt', bytes: Buffer.from('cancelled retry'), }, { replyTo: 'om_user', signal: abort.signal }), (error) => error === reason); assert.equal(requests.length, 1); }); test('VerifiedFeishuChannel lets Feishu decide empty and oversize file outcomes', async () => { const uploads = []; const providerCodes = [234010, 234006, 0]; const { client } = fakeClient({ uploadFile: async (request) => { uploads.push(request); const code = providerCodes.shift(); return code === 0 ? {} : { code }; }, }); const channel = new VerifiedFeishuChannel({ client }); const base = { artifactId: 'artifact-invalid', deliveryKey: 'delivery-invalid', fileName: 'invalid.bin', }; await assert.rejects( channel.sendFile('oc_chat', { ...base, bytes: Buffer.alloc(0) }), (error) => error.code === 'artifact-empty' && error.providerCode === 234010, ); await assert.rejects( channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('provider decides') }), (error) => error.code === 'artifact-too-large' && error.providerCode === 234006, ); await assert.rejects( channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('valid bytes') }), (error) => error.code === 'artifact-provider-failed' && !error.message.includes('undefined'), ); assert.deepEqual(uploads.map((request) => request.data.file.byteLength), [0, 16, 11]); }); test('VerifiedFeishuChannel distinguishes upload failure from uncertain final delivery', async () => { const uploadFixture = fakeClient({ uploadFile: async () => { throw new Error('upload transport closed'); }, }); const uploadChannel = new VerifiedFeishuChannel({ client: uploadFixture.client }); await assert.rejects(uploadChannel.sendFile('oc_chat', { artifactId: 'artifact-upload-transport', deliveryKey: 'delivery-upload-transport', fileName: 'upload.txt', bytes: Buffer.from('upload'), }, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed'); assert.equal(uploadFixture.calls.replies.length, 0); for (const replyMessage of [ async () => { throw new Error('message transport closed'); }, async () => ({ code: 0, data: {} }), ]) { const messageFixture = fakeClient({ replyMessage }); const messageChannel = new VerifiedFeishuChannel({ client: messageFixture.client }); await assert.rejects(messageChannel.sendFile('oc_chat', { artifactId: 'artifact-uncertain-message', deliveryKey: 'delivery-uncertain-message', fileName: 'message.txt', bytes: Buffer.from('message'), }, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'); assert.equal(messageFixture.calls.fileUploads.length, 1); } }); test('VerifiedFeishuChannel keeps explicit final provider codes out of the uncertain bucket', async () => { const { client, calls } = fakeClient({ replyMessage: async () => ({ code: 99991672, msg: 'missing scope' }), }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects(channel.sendFile('oc_chat', { artifactId: 'artifact-message-permission', deliveryKey: 'delivery-message-permission', fileName: 'permission.txt', bytes: Buffer.from('permission'), }, { replyTo: 'om_user' }), (error) => error.code === 'artifact-permission-required' && error.providerCode === 99991672); assert.equal(calls.fileUploads.length, 1); }); test('VerifiedFeishuChannel classifies missing file permission without exposing provider details', async () => { const { client } = fakeClient({ uploadFile: async () => { const error = new Error('raw tenant detail and private diagnostic'); error.code = 99991672; throw error; }, }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects( channel.sendFile('oc_chat', { artifactId: 'artifact-permission', deliveryKey: 'delivery-permission', fileName: 'result.html', bytes: Buffer.from('result'), }), (error) => error.code === 'artifact-permission-required' && error.message.includes('im:resource permission') && !error.message.includes('im:resource:upload') && !error.message.includes('private diagnostic'), ); }); test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', async () => { const { client } = fakeClient({ uploadFile: async () => { const transport = new Error('raw transport diagnostic'); transport.code = 'ERR_BAD_REQUEST'; throw [transport, { code: 99991672, msg: 'raw provider permission URL', }]; }, }); const channel = new VerifiedFeishuChannel({ client }); await assert.rejects( channel.sendFile('oc_chat', { artifactId: 'artifact-array-permission', deliveryKey: 'delivery-array-permission', fileName: 'result.txt', bytes: Buffer.from('result'), }), (error) => error.code === 'artifact-permission-required' && error.providerCode === 99991672 && !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'); });