cc-haha/adapters/feishu/__tests__/streaming-card.test.ts
程序员阿江(Relakkes) ae586b0d9b feat(feishu): rewrite streaming to CardKit Schema 2.0 with proper markdown
Replace legacy im.message.patch streaming path with a Feishu CardKit
Schema 2.0 pipeline mirroring openclaw-lark's implementation. Desktop
now renders real formatted markdown (headings, tables, code blocks)
instead of literal text, and streaming output updates per chat without
cross-talk.

New modules:
- card-errors.ts: structured Lark SDK error parsing for code 230020
  (rate limit -> skip frame) and 230099/11310 (table limit -> disable
  CardKit streaming and fall back to settings+update at finalize)
- flush-controller.ts: mutex + needsReflush + long-gap batching
  throttler for per-chat flush coordination
- cardkit.ts: thin wrapper over client.cardkit.v1.* (card.create +
  im.message.{create,reply} + cardElement.content + card.settings +
  card.update), with monotonic sequence and CardKitApiError
- streaming-card.ts: per-chat StreamingCard state machine covering
  idle -> creating -> streaming -> finalizing -> completed/aborted,
  including patch fallback when CardKit create/send fails

index.ts rewrite:
- replaces MessageBuffer/chatStates/buffers maps with a single
  streamingCards Map<chatId, StreamingCard>
- content_start{text}/content_delta create or reuse the card; finalize
  runs on message_complete; abort renders a red error card
- /clear and other silent commands stay out of the streaming-card path
  so they don't leave empty cards
- all command handlers + normal chat are wrapped in the per-chat
  enqueue() serial queue, preventing reply reordering when commands
  are fired rapidly
- createSessionForChat() now first resets any stale WS session before
  calling connectSession, fixing /projects pick_project leaving the
  old session bound to the previous workDir
- normal messages pre-create the card before sendUserMessage so users
  see a "☁️ 正在思考中..." loading indicator immediately
- content_start{tool_use} no longer finalizes the current card, letting
  one user turn's full text stay in a single card

markdown-style.ts:
- default cardVersion changed from 1 to 2 (Schema 2.0)
- headings H2~H6 demoted to H5, H1 to H4, only when source has H1~H3
- Schema 2.0 <br> padding around headings, tables, code blocks
- stripInvalidImageKeys() drops non-img_* image references
- FEISHU_CARD_TABLE_LIMIT=3 + sanitizeTextForCard() wraps 4th+ table
  in a fenced code block to avoid 230099/11310

Tests: 182 pass, 0 fail, 383 expect() calls across 6 files. tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:04:22 +08:00

524 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* StreamingCard 生命周期测试
*
* 用 mock Lark client 覆盖:
* - ensureCreated: 成功路径 / 降级路径
* - appendText: 累积 + 触发 throttled flush
* - finalize: settings(false) + update 顺序、sequence 单调递增
* - abort: 渲染错误卡片
* - 230020 → 跳帧
* - 230099 table limit → 禁用流式finalize 时仍走 CardKit
* - 纯 patch fallback 路径
*/
import { describe, it, expect, beforeEach } from 'bun:test'
import {
StreamingCard,
buildInitialStreamingCard,
buildRenderedCard,
buildErrorCard,
} from '../streaming-card.js'
import { STREAMING_ELEMENT_ID } from '../cardkit.js'
// ---------------------------------------------------------------------------
// Mock client
// ---------------------------------------------------------------------------
type ApiCall = { api: string; args: any }
type MockBehavior = {
'card.create'?: any | ((args: any) => any)
'card.settings'?: any | ((args: any) => any)
'card.update'?: any | ((args: any) => any)
'cardElement.content'?: any | ((args: any, callIdx: number) => any)
'im.message.create'?: any | ((args: any) => any)
'im.message.reply'?: any | ((args: any) => any)
'im.message.patch'?: any | ((args: any, callIdx: number) => any)
}
function makeMockClient(behavior: MockBehavior = {}) {
const calls: ApiCall[] = []
let contentCallIdx = 0
let patchCallIdx = 0
function handle(api: string, resp: any, args: any, idx?: number): any {
calls.push({ api, args })
if (typeof resp === 'function') return resp(args, idx ?? 0)
return resp
}
const client: any = {
cardkit: {
v1: {
card: {
create: async (args: any) =>
handle('cardkit.v1.card.create', behavior['card.create'] ?? {
code: 0, data: { card_id: 'ck_default' },
}, args),
settings: async (args: any) =>
handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
update: async (args: any) =>
handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
},
cardElement: {
content: async (args: any) => {
const idx = contentCallIdx++
return handle('cardkit.v1.cardElement.content',
behavior['cardElement.content'] ?? { code: 0 }, args, idx)
},
},
},
},
im: {
message: {
create: async (args: any) =>
handle('im.message.create', behavior['im.message.create'] ?? {
data: { message_id: 'om_default' },
}, args),
reply: async (args: any) =>
handle('im.message.reply', behavior['im.message.reply'] ?? {
data: { message_id: 'om_reply_default' },
}, args),
patch: async (args: any) => {
const idx = patchCallIdx++
return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
},
},
},
}
return { client, calls }
}
async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms))
}
// ---------------------------------------------------------------------------
// Card JSON builders
// ---------------------------------------------------------------------------
describe('buildInitialStreamingCard', () => {
it('Schema 2.0 + streaming_mode + element_id', () => {
const card = buildInitialStreamingCard() as any
expect(card.schema).toBe('2.0')
expect(card.config.streaming_mode).toBe(true)
// 第二个元素是空的 streaming_content 目标loading 在首位以避免顶部空 padding
const streaming = card.body.elements[1]
expect(streaming.tag).toBe('markdown')
expect(streaming.content).toBe('')
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
})
it('loading 提示元素在首位(避免空 streaming_content 挤出顶部 padding', () => {
const card = buildInitialStreamingCard() as any
const elements = card.body.elements as any[]
expect(elements.length).toBe(2)
const loading = elements[0]
expect(loading.tag).toBe('markdown')
expect(loading.content).toContain('正在思考中')
expect(loading.text_size).toBe('notation')
// loading 元素不能有 element_id那是给 streaming_content 独占的)
expect(loading.element_id).toBeUndefined()
})
})
describe('buildRenderedCard', () => {
it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
const card = buildRenderedCard('hello world') as any
expect(card.schema).toBe('2.0')
expect(card.config.streaming_mode).toBeUndefined()
expect(card.body.elements.length).toBe(1)
const el = card.body.elements[0]
expect(el.tag).toBe('markdown')
expect(el.content).toBe('hello world')
// 最终卡无需 element_id
expect(el.element_id).toBeUndefined()
})
it('空字符串保底为单空格', () => {
const card = buildRenderedCard('') as any
expect(card.body.elements[0].content).toBe(' ')
})
})
describe('buildErrorCard', () => {
it('红色 header + markdown body', () => {
const card = buildErrorCard('oops') as any
expect((card.header as any).template).toBe('red')
expect((card.header as any).title.content).toContain('出错')
expect(card.body.elements[0].content).toBe('oops')
})
})
// ---------------------------------------------------------------------------
// StreamingCard lifecycle
// ---------------------------------------------------------------------------
describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
it('依次调用 card.create + im.message.createsequence=1', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
'im.message.create': { data: { message_id: 'om_main_1' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
await sc.ensureCreated()
expect(sc._getPhase()).toBe('streaming')
expect(sc._getCardId()).toBe('ck_main_1')
expect(sc._getMessageId()).toBe('om_main_1')
expect(sc._getSequence()).toBe(1)
expect(sc._isCardKitStreamActive()).toBe(true)
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
expect(calls[1]!.api).toBe('im.message.create')
// 初始卡 JSON 包含 streaming_mode 和 element_id
const cardJson = JSON.parse(calls[0]!.args.data.data)
expect(cardJson.schema).toBe('2.0')
expect(cardJson.config.streaming_mode).toBe(true)
// loading 元素在首位streaming_content 占位在第二
expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID)
// IM message 引用 card_id
const content = JSON.parse(calls[1]!.args.data.content)
expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
})
it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_1' } },
'im.message.create': { data: { message_id: 'om_1' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
await sc.ensureCreated()
await sc.ensureCreated()
// 只一次 create + 一次 send
const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
const sendCalls = calls.filter((c) => c.api === 'im.message.create')
expect(createCalls.length).toBe(1)
expect(sendCalls.length).toBe(1)
})
it('replyToMessageId 走 im.message.reply 而非 create', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.reply': { data: { message_id: 'om_reply' } },
})
const sc = new StreamingCard({
larkClient: client,
chatId: 'c',
replyToMessageId: 'om_parent',
})
await sc.ensureCreated()
expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
expect(sc._getMessageId()).toBe('om_reply')
})
})
describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 99991672, msg: 'permission denied' },
'im.message.create': { data: { message_id: 'om_fb' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
expect(sc._getPhase()).toBe('streaming')
expect(sc._getCardId()).toBeNull()
expect(sc._getMessageId()).toBe('om_fb')
expect(sc._isCardKitStreamActive()).toBe(false)
// fallback 发送的是 Schema 2.0 interactive 卡
const createCall = calls.find((c) => c.api === 'im.message.create')
expect(createCall).toBeDefined()
expect(createCall!.args.data.msg_type).toBe('interactive')
const cardContent = JSON.parse(createCall!.args.data.content)
expect(cardContent.schema).toBe('2.0')
})
it('CardKit send 失败create 成功但 im.message.create 失败)也能降级', async () => {
let sendCallCount = 0
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': () => {
sendCallCount++
if (sendCallCount === 1) throw new Error('send failed')
return { data: { message_id: 'om_fb2' } }
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
expect(sc._getPhase()).toBe('streaming')
expect(sc._getCardId()).toBeNull()
expect(sc._getMessageId()).toBe('om_fb2')
})
it('降级发送也失败 → aborted + throw', async () => {
const { client } = makeMockClient({
'card.create': { code: 99991672 },
'im.message.create': () => {
throw new Error('really broken')
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await expect(sc.ensureCreated()).rejects.toThrow()
expect(sc._getPhase()).toBe('aborted')
})
})
// ---------------------------------------------------------------------------
// appendText + flush
// ---------------------------------------------------------------------------
describe('StreamingCard: appendText + flush', () => {
it('accumulated 文本写入 cardElement.contentsequence 单调递增', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_stream' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
// 第一次 appendText 进入节流窗口(刚 readylastUpdateTime 还新)
sc.appendText('Hello ')
sc.appendText('world')
// 节流窗口 100ms + 余量
await sleep(150)
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThan(0)
// 最后一次 flush 的内容应包含完整累积文本
const lastCall = contentCalls[contentCalls.length - 1]!
expect(lastCall.args.data.content).toContain('Hello world')
expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
// sequence 严格单调递增
const seqs = contentCalls.map((c) => c.args.data.sequence)
for (let i = 1; i < seqs.length; i++) {
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
}
})
it('内容未变化时不重复 flush基于 lastFlushedText 对比)', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('same')
await sleep(150)
// 强制再跑一次 flush无新文本
await sc._getFlushController().flush()
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
// 应该只有一次 content 调用
expect(contentCalls.length).toBe(1)
})
it('completed 之后的 appendText 被忽略', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
await sc.finalize()
sc.appendText('ignored')
expect(sc._getAccumulatedText()).toBe('')
})
})
// ---------------------------------------------------------------------------
// finalize
// ---------------------------------------------------------------------------
describe('StreamingCard: finalize', () => {
it('CardKit 路径: settings(false) + card.updatesequence 连续递增', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_final' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('# Title\n\nBody')
await sleep(150)
const contentSeqs = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.map((c) => c.args.data.sequence)
const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
expect(settingsCalls.length).toBe(1)
expect(updateCalls.length).toBe(1)
const settingsSeq = settingsCalls[0]!.args.data.sequence
const updateSeq = updateCalls[0]!.args.data.sequence
expect(settingsSeq).toBeGreaterThan(lastContentSeq)
expect(updateSeq).toBeGreaterThan(settingsSeq)
// settings 关闭 streaming_mode
const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
expect(settings.streaming_mode).toBe(false)
// update 卡内容是预处理后的 markdown
const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
const finalContent = finalCardJson.body.elements[0].content
// H1 被降级为 H4
expect(finalContent).toContain('#### Title')
expect(finalContent).toContain('Body')
})
it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 99991672 },
'im.message.create': { data: { message_id: 'om_fb' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('## Heading\n\nContent')
await sleep(1600) // 等 PATCH_MS 窗口
await sc.finalize()
const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
expect(patchCalls.length).toBeGreaterThan(0)
// 最后一次 patch 是 finalize 的full final card
const lastPatch = patchCalls[patchCalls.length - 1]!
const finalCard = JSON.parse(lastPatch.args.data.content)
const finalContent = finalCard.body.elements[0].content
// ## → ##### 降级
expect(finalContent).toContain('##### Heading')
})
it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => {
const { client } = makeMockClient()
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
})
it('finalize 失败不抛出', async () => {
const { client } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
'card.settings': () => {
throw new Error('settings exploded')
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('text')
await sleep(150)
// finalize 内部捕获错误不 rethrow
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
})
})
// ---------------------------------------------------------------------------
// Rate limit + table limit
// ---------------------------------------------------------------------------
describe('StreamingCard: 错误处理', () => {
it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
let callIdx = 0
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.create': { data: { message_id: 'om' } },
'cardElement.content': () => {
const i = callIdx++
if (i === 0) {
const err: any = new Error('rate limit')
err.code = 230020
throw err
}
return { code: 0 }
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('first')
await sleep(150)
// 第一次被限流
sc.appendText(' second')
await sleep(150)
// 第二次应能成功
// CardKit 仍然 active没降级
expect(sc._isCardKitStreamActive()).toBe(true)
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
})
it('230099 table limit → 禁用流式但 cardId 保留finalize 仍走 CardKit', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
'im.message.create': { data: { message_id: 'om' } },
'cardElement.content': () => {
const err: any = new Error('content failed')
err.code = 230099
err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
throw err
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('some content')
await sleep(150)
expect(sc._isCardKitStreamActive()).toBe(false)
expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
await sc.finalize()
// finalize 仍然走 CardKit 的 settings + updatecardId 还在)
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
// 不走 patch
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// abort
// ---------------------------------------------------------------------------
describe('StreamingCard: abort', () => {
it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_err' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendText('partial...')
await sleep(150)
await sc.abort(new Error('something went wrong'))
expect(sc._getPhase()).toBe('aborted')
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
expect(updateCalls.length).toBeGreaterThan(0)
const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
expect(errCard.header.template).toBe('red')
expect(errCard.body.elements[0].content).toContain('something went wrong')
// 保留已累积的部分文本
expect(errCard.body.elements[0].content).toContain('partial...')
})
it('idle 阶段 abort 不抛错', async () => {
const { client } = makeMockClient()
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.abort(new Error('before any card'))
expect(sc._getPhase()).toBe('aborted')
})
})