cc-haha/adapters/feishu/__tests__/flush-controller.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

291 lines
8.5 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.

/**
* FlushController 单元测试
*
* 覆盖:
* - 基础: cardMessageReady gate, complete() 锁死
* - 节流窗口: 立即 flush / 延迟 flush
* - Mutex: 进行中的 flush 重复调用标记 needsReflush
* - Conflict reflush: API 结束后自动补一次
* - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush
* - waitForFlush: 等当前 flush 结束
*/
import { describe, it, expect } from 'bun:test'
import { FlushController, THROTTLE } from '../flush-controller.js'
// 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve
function makeControllableFlush() {
const calls: string[] = []
let resolveCurrent: (() => void) | null = null
let latch: Promise<void> | null = null
const doFlush = async () => {
calls.push('flush-start')
if (latch) {
await latch
latch = null
}
calls.push('flush-end')
}
const blockNext = () => {
latch = new Promise<void>((resolve) => {
resolveCurrent = resolve
})
}
const unblock = () => {
if (resolveCurrent) {
const r = resolveCurrent
resolveCurrent = null
r()
}
}
return { doFlush, calls, blockNext, unblock }
}
async function sleep(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms))
}
// ---------------------------------------------------------------------------
// Basic gating
// ---------------------------------------------------------------------------
describe('FlushController: cardMessageReady gate', () => {
it('在 cardMessageReady=false 时不 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
await fc.flush()
await fc.throttledUpdate(50)
expect(count).toBe(0)
})
it('setCardMessageReady(true) 后 flush 可执行', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
await fc.flush()
expect(count).toBe(1)
})
it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
// 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支
await fc.throttledUpdate(500)
// 同步阶段还没到 500ms不应触发 flush
expect(count).toBe(0)
// 500+ms 后延迟 timer 触发
await sleep(600)
expect(count).toBe(1)
})
})
describe('FlushController: complete()', () => {
it('complete() 后拒绝新 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
fc.complete()
await fc.flush()
await fc.throttledUpdate(50)
expect(count).toBe(0)
})
})
// ---------------------------------------------------------------------------
// Throttle window
// ---------------------------------------------------------------------------
describe('FlushController: 节流窗口', () => {
it('超过窗口立即 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
// 手动把 lastUpdateTime 挪远(等同于已过了节流窗口)
await sleep(150)
await fc.throttledUpdate(100)
expect(count).toBe(1)
})
it('在窗口内首次调用安排延迟 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true) // lastUpdateTime = now
await fc.throttledUpdate(200)
expect(count).toBe(0) // 延迟中,还没触发
await sleep(300)
expect(count).toBe(1) // 200ms 后延迟 timer 触发
})
it('窗口内多次调用复用同一个延迟 timer不重复 flush', async () => {
let count = 0
const fc = new FlushController(async () => {
count += 1
})
fc.setCardMessageReady(true)
await fc.throttledUpdate(200)
await fc.throttledUpdate(200)
await fc.throttledUpdate(200)
await fc.throttledUpdate(200)
await sleep(300)
expect(count).toBe(1)
})
})
// ---------------------------------------------------------------------------
// Mutex + conflict reflush
// ---------------------------------------------------------------------------
describe('FlushController: mutex + 冲突重刷', () => {
it('flush 进行中的重复调用不并发执行', async () => {
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
const fc = new FlushController(doFlush)
fc.setCardMessageReady(true)
blockNext()
const p1 = fc.flush() // flush-start 后被 latch 卡住
// 让事件循环走一轮,确保第一次 flush 进入 body
await sleep(10)
// 第二次调用时第一次还没结束 —— 应被 mutex 挡住
const p2 = fc.flush()
// 两次 Promise 都已登记,但都还没 end
expect(calls).toEqual(['flush-start'])
unblock()
await p1
await p2
// 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷
// conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完)
await sleep(20)
// 第一次 flush-start + flush-end然后冲突补刷再一次 start + end
expect(calls).toEqual([
'flush-start', 'flush-end',
'flush-start', 'flush-end',
])
})
it('flush 进行中的 throttledUpdate 也会触发补刷', async () => {
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
const fc = new FlushController(doFlush)
fc.setCardMessageReady(true)
blockNext()
const p1 = fc.flush()
await sleep(10)
// API 进行中收到新的 update 请求
await fc.throttledUpdate(10)
unblock()
await p1
await sleep(30)
// 第一次 flush + 冲突补刷
expect(calls.filter((c) => c === 'flush-end').length).toBe(2)
})
})
// ---------------------------------------------------------------------------
// Long gap batching
// ---------------------------------------------------------------------------
describe('FlushController: 长间隔批量', () => {
it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => {
let count = 0
let flushAtMs = 0
const start = Date.now()
const fc = new FlushController(async () => {
count += 1
flushAtMs = Date.now() - start
})
fc.setCardMessageReady(true)
// 等到 elapsed > 2000ms
await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50)
const callAt = Date.now() - start
await fc.throttledUpdate(THROTTLE.CARDKIT_MS)
// throttledUpdate 同步阶段不应立即 flush因为走批量分支
expect(count).toBe(0)
// 等 BATCH_AFTER_GAP_MS + 余量
await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50)
expect(count).toBe(1)
// 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms
expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20)
})
})
// ---------------------------------------------------------------------------
// waitForFlush
// ---------------------------------------------------------------------------
describe('FlushController: waitForFlush', () => {
it('没在 flush 时立即返回', async () => {
const fc = new FlushController(async () => {})
fc.setCardMessageReady(true)
const start = Date.now()
await fc.waitForFlush()
expect(Date.now() - start).toBeLessThan(10)
})
it('有 flush 在跑时等它结束', async () => {
const { doFlush, blockNext, unblock } = makeControllableFlush()
const fc = new FlushController(doFlush)
fc.setCardMessageReady(true)
blockNext()
const p1 = fc.flush()
await sleep(10)
let resolved = false
const waiter = fc.waitForFlush().then(() => {
resolved = true
})
await sleep(20)
expect(resolved).toBe(false)
unblock()
await p1
await waiter
expect(resolved).toBe(true)
})
})
// ---------------------------------------------------------------------------
// 常量合理性
// ---------------------------------------------------------------------------
describe('FlushController: THROTTLE 常量', () => {
it('CARDKIT_MS=100, PATCH_MS=1500', () => {
expect(THROTTLE.CARDKIT_MS).toBe(100)
expect(THROTTLE.PATCH_MS).toBe(1500)
})
it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => {
expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000)
expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300)
})
})