cc-haha/adapters/feishu/__tests__/streaming-card.test.ts
程序员阿江(Relakkes) bfafd26fb6 fix: batch resolve 5 issues from Feishu bug tracker
- fix(desktop): Settings Agents/Skills page layout overflow at narrow widths
  Shift header grid breakpoint md→xl, summary cards sm→3col, add min-w-0/truncate

- fix(desktop): TabBar tab style — remove border-r separators, use bg-only active state
  Drop ::after indicator and inter-tab borders; active tab distinguished by surface bg

- feat(desktop): IM Adapters page — horizontal tabs with Feishu first, Telegram second
  Replace vertical stack with tablist; add ImTabButton with aria-selected + focus-visible

- fix(server): bundled IM session cwd resolves to / instead of selected project
  Root cause: preload.ts chdir(CALLER_DIR) inherits '/' from Tauri-spawned sidecar.
  Fix: pin CALLER_DIR and PWD to session workDir in conversationService spawn env.
  Add diagnostic logs at sessionService, ws/handler, and conversationService.

- fix(feishu): StreamingCard shows duplicate "thinking" indicators
  Merge static loading element into streaming_content; renderedText() controls state.

- test(adapters): add 5 ImageBlockWatcher edge-case unit tests (reset, relative path,
  multi-image chunk, malformed data URI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:36:14 +08:00

915 lines
35 KiB
TypeScript
Raw Permalink 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 提示
const elements = card.body.elements as any[]
expect(elements.length).toBe(1)
const streaming = elements[0]
expect(streaming.tag).toBe('markdown')
expect(streaming.content).toContain('正在思考中')
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
})
})
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)
// 唯一元素即 streaming_content
expect(cardJson.body.elements[0].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 只保留 answerText丢弃 reasoning + toolSteps', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_term' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
// 同时塞入三种内容
sc.appendReasoning('Let me think about this problem carefully...')
sc.startTool('tu_1', 'Read')
sc.completeTool('tu_1', 'Read')
sc.appendText('## 答复\n\n这是最终答复正文。')
await sleep(150)
// 流式中间帧应该包含 reasoning + tools + answer 全套
const lastMidFrame = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastMidFrame).toContain('思考中')
expect(lastMidFrame).toContain('Read')
expect(lastMidFrame).toContain('最终答复正文')
await sc.finalize()
// finalize 用的是 card.update把整张卡换成只有 answer 的版本
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
const finalCardJson = JSON.parse(updateCall.args.data.card.data)
const finalContent = finalCardJson.body.elements[0].content as string
expect(finalContent).toContain('最终答复正文')
// H2 → 降级 H5
expect(finalContent).toContain('##### 答复')
// reasoning + tools 都不应该出现在终态
expect(finalContent).not.toContain('思考中')
expect(finalContent).not.toContain('think about this problem')
expect(finalContent).not.toContain('Read')
expect(finalContent).not.toContain('🛠️')
expect(finalContent).not.toContain('💭')
})
it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_no_answer' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
// 只有推理,没有 appendText —— 异常 case 但要可控降级
sc.appendReasoning('I was thinking but never produced an answer.')
await sleep(150)
await sc.finalize()
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string
// 至少能看到推理内容
expect(finalContent).toContain('thinking')
})
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')
})
})
// ---------------------------------------------------------------------------
// Reasoning / tool use rendering
// ---------------------------------------------------------------------------
describe('StreamingCard: appendReasoning', () => {
it('累积 thinking delta 并渲染在卡片中plain markdown不用 blockquote', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_think' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendReasoning('Analyzing the problem. ')
sc.appendReasoning('Let me check file A.')
await sleep(150)
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThan(0)
const last = contentCalls[contentCalls.length - 1]!
expect(last.args.data.content).toContain('💭')
expect(last.args.data.content).toContain('思考中')
expect(last.args.data.content).toContain('Analyzing the problem.')
expect(last.args.data.content).toContain('Let me check file A.')
// 没有 blockquote `>` 前缀 —— 这是新格式的关键
expect(last.args.data.content).not.toContain('> Analyzing')
// 没有 appendText → 不应有普通正文
expect(sc._getAccumulatedReasoning()).toContain('Analyzing')
expect(sc._getAccumulatedText()).toBe('')
})
it('completed 之后 appendReasoning 被忽略', 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.appendReasoning('too late')
expect(sc._getAccumulatedReasoning()).toBe('')
})
})
describe('StreamingCard: startTool / completeTool', () => {
it('startTool 压入 running 步骤completeTool 翻到 done', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_tool' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.startTool('tu_1', 'Read')
await sleep(150)
let steps = sc._getToolSteps()
expect(steps.length).toBe(1)
expect(steps[0]!.name).toBe('Read')
expect(steps[0]!.status).toBe('running')
// 卡片也应显示 "🛠️ ⚙️ Read"inline 形式)
const runningContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.map((c) => c.args.data.content)
.join('\n')
expect(runningContent).toContain('⚙️')
expect(runningContent).toContain('Read')
expect(runningContent).toContain('🛠️')
sc.completeTool('tu_1', 'Read')
await sleep(150)
steps = sc._getToolSteps()
expect(steps[0]!.status).toBe('done')
// 最新 flush 应显示 "✅ Read" 不再有 "⚙️"
const lastContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastContent).toContain('✅')
expect(lastContent).toContain('Read')
// 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标
expect(lastContent).not.toContain('⚙️')
})
it('按 toolUseId 去重: 同一 id 不重复压入', 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()
sc.startTool('tu_1', 'Read')
sc.startTool('tu_1', 'Read')
sc.startTool('tu_1', 'Read')
expect(sc._getToolSteps().length).toBe(1)
})
it('缺省 toolUseId 时按 name + index 合成 id不同步骤可并存', 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()
sc.startTool(undefined, 'Read')
sc.startTool(undefined, 'Read')
// 合成 id 不同 → 两个独立步骤
expect(sc._getToolSteps().length).toBe(2)
})
it('completeTool 只匹配最近的 running 同名步骤', 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()
sc.startTool('tu_1', 'Bash')
sc.startTool('tu_2', 'Bash')
sc.completeTool(undefined, 'Bash')
const steps = sc._getToolSteps()
// 更晚的 tu_2 被标记 done
expect(steps[0]!.status).toBe('running')
expect(steps[1]!.status).toBe('done')
})
it('空 toolName 忽略', 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()
sc.startTool('tu_1', undefined)
sc.startTool('tu_1', '')
expect(sc._getToolSteps().length).toBe(0)
})
})
// 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。
// 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase
// gate / 等任何东西吃掉)。
describe('StreamingCard: 真实事件流(用户场景回归)', () => {
it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_real' } },
'im.message.create': { data: { message_id: 'om_real' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' })
// 1. 用户发消息 → handleMessage 预建卡fire-and-forget
const creating = sc.ensureCreated()
await creating // 等卡可写
// 2. 服务端: status streaming + content_start{text} (thinking block)
// feishu/index.ts 的 content_start text 分支会再 await ensureCreatedno-op
// (no direct call here — 等同于 no-op)
// 3. 服务端: thinking deltas5 个增量,间隔 30ms 模拟流式)
sc.appendReasoning('Analyzing the latest commits to find ')
await sleep(30)
sc.appendReasoning('breaking changes. Need to look at ')
await sleep(30)
sc.appendReasoning('the public API surface, the schema files, ')
await sleep(30)
sc.appendReasoning('and any removed exports. Let me check the ')
await sleep(30)
sc.appendReasoning('git log first.')
// 等节流窗口结束
await sleep(200)
const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length
expect(flushesAfterReasoning).toBeGreaterThan(0)
const lastReasoningContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
// 应该包含 reasoning 累积内容
expect(lastReasoningContent).toContain('breaking changes')
expect(lastReasoningContent).toContain('git log first')
// 4. 服务端: content_start{tool_use, name: 'Bash'}
sc.startTool('tu_bash_1', 'Bash')
await sleep(150)
const lastWithTool = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastWithTool).toContain('Bash')
expect(lastWithTool).toContain('⚙️')
expect(lastWithTool).toContain('🛠️')
// 5. 服务端: tool_use_complete
sc.completeTool('tu_bash_1', 'Bash')
await sleep(150)
const lastAfterToolDone = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
expect(lastAfterToolDone).toContain('Bash')
// ⚙️ 切到 ✅ —— 当前唯一一步已完成
expect(lastAfterToolDone).toContain('✅')
expect(lastAfterToolDone).not.toContain('⚙️')
// 6. 第二个 tool 序列
sc.startTool('tu_read_1', 'Read')
await sleep(150)
sc.completeTool('tu_read_1', 'Read')
await sleep(150)
// 7. 最终 text 输出
sc.appendText('## 破坏性变更分析\n\n')
await sleep(120)
sc.appendText('1. **API 重命名**: foo → bar\n')
await sleep(120)
sc.appendText('2. **删除导出**: baz')
await sleep(200)
const lastWithText = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
// 应该同时包含 reasoning, tools, answer
expect(lastWithText).toContain('git log first') // reasoning
expect(lastWithText).toContain('Bash') // tool
expect(lastWithText).toContain('Read') // tool
expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5)
expect(lastWithText).toContain('API 重命名')
// 8. message_complete → finalize
await sc.finalize()
expect(sc._getPhase()).toBe('completed')
// 验证有 settings + update 收尾
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
})
it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => {
let firstFrameRejected = false
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_recover' } },
'im.message.create': { data: { message_id: 'om' } },
'cardElement.content': () => {
if (!firstFrameRejected) {
firstFrameRejected = true
// 模拟一个 *非* rate-limit、*非* table-limit 错误
// 当前实现会把 cardKitStreamActive 设 false本测试就是要发现这个问题
const err: any = new Error('mystery cardkit error')
err.code = 999999
throw err
}
return { code: 0 }
},
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendReasoning('first thought')
await sleep(150)
// 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续
sc.appendReasoning(' second thought')
await sleep(150)
// 验证: 至少尝试了 2 次 cardElement.content 调用
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
// 而且 streaming 仍是 active
expect(sc._isCardKitStreamActive()).toBe(true)
})
})
describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => {
it('三个 section 按顺序 tools → reasoning → answer 组合', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck_all' } },
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
await sc.ensureCreated()
sc.appendReasoning('Should I read file A first?')
sc.startTool('tu_1', 'Read')
sc.appendText('Here is the answer.')
await sleep(150)
const lastContent = calls
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
.pop()!.args.data.content as string
const idxTools = lastContent.indexOf('🛠️')
const idxReasoning = lastContent.indexOf('思考中')
const idxAnswer = lastContent.indexOf('Here is the answer')
expect(idxTools).toBeGreaterThan(-1)
expect(idxReasoning).toBeGreaterThan(-1)
expect(idxAnswer).toBeGreaterThan(-1)
// tools 在最顶部 → reasoning 居中 → answer 在底部
expect(idxTools).toBeLessThan(idxReasoning)
expect(idxReasoning).toBeLessThan(idxAnswer)
})
it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => {
let resolveCreate: (() => void) | null = null
const createLatch = new Promise<void>((r) => { resolveCreate = r })
const { client, calls } = makeMockClient({
'card.create': async () => {
await createLatch
return { code: 0, data: { card_id: 'ck_slow' } }
},
'im.message.create': { data: { message_id: 'om' } },
})
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
// 不 await: 在 create 还没 resolve 之前,先压入一个 tool step
const creating = sc.ensureCreated()
// 让事件循环推进到 create 被 await
await sleep(10)
sc.startTool('tu_1', 'Glob')
// 此时 cardMessageReady 仍是 false —— 没有任何 flush
const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentBefore.length).toBe(0)
// 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush
resolveCreate!()
await creating
await sleep(150)
const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
expect(contentAfter.length).toBeGreaterThan(0)
const last = contentAfter[contentAfter.length - 1]!
expect(last.args.data.content).toContain('Glob')
expect(last.args.data.content).toContain('🛠️')
})
})