bingocode 1.0.26 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/config/bingo-defaults/settings.json +2 -1
  2. package/package.json +1 -2
  3. package/src/server/services/providerService.ts +104 -0
  4. package/src/utils/managedEnv.ts +1 -17
  5. package/.github/FUNDING.yml +0 -1
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -44
  7. package/.github/ISSUE_TEMPLATE/config.yml +0 -1
  8. package/.github/ISSUE_TEMPLATE/question.md +0 -40
  9. package/.github/workflows/build-desktop-dev.yml +0 -210
  10. package/.github/workflows/deploy-docs.yml +0 -59
  11. package/.github/workflows/release-desktop.yml +0 -162
  12. package/.spine/user.yaml +0 -5
  13. package/.spine/workspace.yaml +0 -1
  14. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  15. package/adapters/common/__tests__/format.test.ts +0 -148
  16. package/adapters/common/__tests__/http-client.test.ts +0 -105
  17. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  18. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  19. package/adapters/common/__tests__/session-store.test.ts +0 -62
  20. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  21. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  22. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  23. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  24. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  25. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  26. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  27. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  28. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  29. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  30. package/adapters/feishu/__tests__/media.test.ts +0 -120
  31. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  32. package/adapters/telegram/__tests__/media.test.ts +0 -86
  33. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  34. package/adapters/tsconfig.json +0 -18
  35. package/bunfig.toml +0 -1
  36. package/preload.ts +0 -30
  37. package/scripts/count-app-loc.ts +0 -256
  38. package/scripts/release.ts +0 -130
  39. package/src/server/__tests__/conversation-service.test.ts +0 -173
  40. package/src/server/__tests__/conversations.test.ts +0 -458
  41. package/src/server/__tests__/cron-scheduler.test.ts +0 -575
  42. package/src/server/__tests__/e2e/business-flow.test.ts +0 -841
  43. package/src/server/__tests__/e2e/full-flow.test.ts +0 -357
  44. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +0 -123
  45. package/src/server/__tests__/haha-oauth-api.test.ts +0 -146
  46. package/src/server/__tests__/haha-oauth-service.test.ts +0 -185
  47. package/src/server/__tests__/providers-real.test.ts +0 -244
  48. package/src/server/__tests__/providers.test.ts +0 -579
  49. package/src/server/__tests__/proxy-streaming.test.ts +0 -317
  50. package/src/server/__tests__/proxy-transform.test.ts +0 -469
  51. package/src/server/__tests__/real-llm-test.ts +0 -526
  52. package/src/server/__tests__/scheduled-tasks.test.ts +0 -371
  53. package/src/server/__tests__/sessions.test.ts +0 -786
  54. package/src/server/__tests__/settings.test.ts +0 -376
  55. package/src/server/__tests__/skills.test.ts +0 -125
  56. package/src/server/__tests__/tasks.test.ts +0 -171
  57. package/src/server/__tests__/team-watcher.test.ts +0 -400
  58. package/src/server/__tests__/teams.test.ts +0 -627
  59. package/src/server/middleware/cors.test.ts +0 -27
  60. package/src/utils/__tests__/cronFrequency.test.ts +0 -153
  61. package/src/utils/__tests__/cronTasks.test.ts +0 -204
  62. package/src/utils/computerUse/permissions.test.ts +0 -44
  63. package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
  64. package/stubs/color-diff-napi.ts +0 -45
  65. package/tsconfig.json +0 -24
@@ -1,914 +0,0 @@
1
- /**
2
- * StreamingCard 生命周期测试
3
- *
4
- * 用 mock Lark client 覆盖:
5
- * - ensureCreated: 成功路径 / 降级路径
6
- * - appendText: 累积 + 触发 throttled flush
7
- * - finalize: settings(false) + update 顺序、sequence 单调递增
8
- * - abort: 渲染错误卡片
9
- * - 230020 → 跳帧
10
- * - 230099 table limit → 禁用流式,finalize 时仍走 CardKit
11
- * - 纯 patch fallback 路径
12
- */
13
-
14
- import { describe, it, expect, beforeEach } from 'bun:test'
15
- import {
16
- StreamingCard,
17
- buildInitialStreamingCard,
18
- buildRenderedCard,
19
- buildErrorCard,
20
- } from '../streaming-card.js'
21
- import { STREAMING_ELEMENT_ID } from '../cardkit.js'
22
-
23
- // ---------------------------------------------------------------------------
24
- // Mock client
25
- // ---------------------------------------------------------------------------
26
-
27
- type ApiCall = { api: string; args: any }
28
-
29
- type MockBehavior = {
30
- 'card.create'?: any | ((args: any) => any)
31
- 'card.settings'?: any | ((args: any) => any)
32
- 'card.update'?: any | ((args: any) => any)
33
- 'cardElement.content'?: any | ((args: any, callIdx: number) => any)
34
- 'im.message.create'?: any | ((args: any) => any)
35
- 'im.message.reply'?: any | ((args: any) => any)
36
- 'im.message.patch'?: any | ((args: any, callIdx: number) => any)
37
- }
38
-
39
- function makeMockClient(behavior: MockBehavior = {}) {
40
- const calls: ApiCall[] = []
41
- let contentCallIdx = 0
42
- let patchCallIdx = 0
43
-
44
- function handle(api: string, resp: any, args: any, idx?: number): any {
45
- calls.push({ api, args })
46
- if (typeof resp === 'function') return resp(args, idx ?? 0)
47
- return resp
48
- }
49
-
50
- const client: any = {
51
- cardkit: {
52
- v1: {
53
- card: {
54
- create: async (args: any) =>
55
- handle('cardkit.v1.card.create', behavior['card.create'] ?? {
56
- code: 0, data: { card_id: 'ck_default' },
57
- }, args),
58
- settings: async (args: any) =>
59
- handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
60
- update: async (args: any) =>
61
- handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
62
- },
63
- cardElement: {
64
- content: async (args: any) => {
65
- const idx = contentCallIdx++
66
- return handle('cardkit.v1.cardElement.content',
67
- behavior['cardElement.content'] ?? { code: 0 }, args, idx)
68
- },
69
- },
70
- },
71
- },
72
- im: {
73
- message: {
74
- create: async (args: any) =>
75
- handle('im.message.create', behavior['im.message.create'] ?? {
76
- data: { message_id: 'om_default' },
77
- }, args),
78
- reply: async (args: any) =>
79
- handle('im.message.reply', behavior['im.message.reply'] ?? {
80
- data: { message_id: 'om_reply_default' },
81
- }, args),
82
- patch: async (args: any) => {
83
- const idx = patchCallIdx++
84
- return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
85
- },
86
- },
87
- },
88
- }
89
- return { client, calls }
90
- }
91
-
92
- async function sleep(ms: number) {
93
- await new Promise((r) => setTimeout(r, ms))
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // Card JSON builders
98
- // ---------------------------------------------------------------------------
99
-
100
- describe('buildInitialStreamingCard', () => {
101
- it('Schema 2.0 + streaming_mode + element_id', () => {
102
- const card = buildInitialStreamingCard() as any
103
- expect(card.schema).toBe('2.0')
104
- expect(card.config.streaming_mode).toBe(true)
105
- // 唯一元素:streaming_content,初始内容为 loading 提示
106
- const elements = card.body.elements as any[]
107
- expect(elements.length).toBe(1)
108
- const streaming = elements[0]
109
- expect(streaming.tag).toBe('markdown')
110
- expect(streaming.content).toContain('正在思考中')
111
- expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
112
- })
113
- })
114
-
115
- describe('buildRenderedCard', () => {
116
- it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
117
- const card = buildRenderedCard('hello world') as any
118
- expect(card.schema).toBe('2.0')
119
- expect(card.config.streaming_mode).toBeUndefined()
120
- expect(card.body.elements.length).toBe(1)
121
- const el = card.body.elements[0]
122
- expect(el.tag).toBe('markdown')
123
- expect(el.content).toBe('hello world')
124
- // 最终卡无需 element_id
125
- expect(el.element_id).toBeUndefined()
126
- })
127
-
128
- it('空字符串保底为单空格', () => {
129
- const card = buildRenderedCard('') as any
130
- expect(card.body.elements[0].content).toBe(' ')
131
- })
132
- })
133
-
134
- describe('buildErrorCard', () => {
135
- it('红色 header + markdown body', () => {
136
- const card = buildErrorCard('oops') as any
137
- expect((card.header as any).template).toBe('red')
138
- expect((card.header as any).title.content).toContain('出错')
139
- expect(card.body.elements[0].content).toBe('oops')
140
- })
141
- })
142
-
143
- // ---------------------------------------------------------------------------
144
- // StreamingCard lifecycle
145
- // ---------------------------------------------------------------------------
146
-
147
- describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
148
- it('依次调用 card.create + im.message.create,sequence=1', async () => {
149
- const { client, calls } = makeMockClient({
150
- 'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
151
- 'im.message.create': { data: { message_id: 'om_main_1' } },
152
- })
153
- const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
154
- await sc.ensureCreated()
155
-
156
- expect(sc._getPhase()).toBe('streaming')
157
- expect(sc._getCardId()).toBe('ck_main_1')
158
- expect(sc._getMessageId()).toBe('om_main_1')
159
- expect(sc._getSequence()).toBe(1)
160
- expect(sc._isCardKitStreamActive()).toBe(true)
161
-
162
- expect(calls[0]!.api).toBe('cardkit.v1.card.create')
163
- expect(calls[1]!.api).toBe('im.message.create')
164
-
165
- // 初始卡 JSON 包含 streaming_mode 和 element_id
166
- const cardJson = JSON.parse(calls[0]!.args.data.data)
167
- expect(cardJson.schema).toBe('2.0')
168
- expect(cardJson.config.streaming_mode).toBe(true)
169
- // 唯一元素即 streaming_content
170
- expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
171
-
172
- // IM message 引用 card_id
173
- const content = JSON.parse(calls[1]!.args.data.content)
174
- expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
175
- })
176
-
177
- it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
178
- const { client, calls } = makeMockClient({
179
- 'card.create': { code: 0, data: { card_id: 'ck_1' } },
180
- 'im.message.create': { data: { message_id: 'om_1' } },
181
- })
182
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
183
- await sc.ensureCreated()
184
- await sc.ensureCreated()
185
- await sc.ensureCreated()
186
- // 只一次 create + 一次 send
187
- const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
188
- const sendCalls = calls.filter((c) => c.api === 'im.message.create')
189
- expect(createCalls.length).toBe(1)
190
- expect(sendCalls.length).toBe(1)
191
- })
192
-
193
- it('replyToMessageId 走 im.message.reply 而非 create', async () => {
194
- const { client, calls } = makeMockClient({
195
- 'card.create': { code: 0, data: { card_id: 'ck' } },
196
- 'im.message.reply': { data: { message_id: 'om_reply' } },
197
- })
198
- const sc = new StreamingCard({
199
- larkClient: client,
200
- chatId: 'c',
201
- replyToMessageId: 'om_parent',
202
- })
203
- await sc.ensureCreated()
204
- expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
205
- expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
206
- expect(sc._getMessageId()).toBe('om_reply')
207
- })
208
- })
209
-
210
- describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
211
- it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
212
- const { client, calls } = makeMockClient({
213
- 'card.create': { code: 99991672, msg: 'permission denied' },
214
- 'im.message.create': { data: { message_id: 'om_fb' } },
215
- })
216
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
217
- await sc.ensureCreated()
218
-
219
- expect(sc._getPhase()).toBe('streaming')
220
- expect(sc._getCardId()).toBeNull()
221
- expect(sc._getMessageId()).toBe('om_fb')
222
- expect(sc._isCardKitStreamActive()).toBe(false)
223
-
224
- // fallback 发送的是 Schema 2.0 interactive 卡
225
- const createCall = calls.find((c) => c.api === 'im.message.create')
226
- expect(createCall).toBeDefined()
227
- expect(createCall!.args.data.msg_type).toBe('interactive')
228
- const cardContent = JSON.parse(createCall!.args.data.content)
229
- expect(cardContent.schema).toBe('2.0')
230
- })
231
-
232
- it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
233
- let sendCallCount = 0
234
- const { client } = makeMockClient({
235
- 'card.create': { code: 0, data: { card_id: 'ck' } },
236
- 'im.message.create': () => {
237
- sendCallCount++
238
- if (sendCallCount === 1) throw new Error('send failed')
239
- return { data: { message_id: 'om_fb2' } }
240
- },
241
- })
242
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
243
- await sc.ensureCreated()
244
- expect(sc._getPhase()).toBe('streaming')
245
- expect(sc._getCardId()).toBeNull()
246
- expect(sc._getMessageId()).toBe('om_fb2')
247
- })
248
-
249
- it('降级发送也失败 → aborted + throw', async () => {
250
- const { client } = makeMockClient({
251
- 'card.create': { code: 99991672 },
252
- 'im.message.create': () => {
253
- throw new Error('really broken')
254
- },
255
- })
256
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
257
- await expect(sc.ensureCreated()).rejects.toThrow()
258
- expect(sc._getPhase()).toBe('aborted')
259
- })
260
- })
261
-
262
- // ---------------------------------------------------------------------------
263
- // appendText + flush
264
- // ---------------------------------------------------------------------------
265
-
266
- describe('StreamingCard: appendText + flush', () => {
267
- it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
268
- const { client, calls } = makeMockClient({
269
- 'card.create': { code: 0, data: { card_id: 'ck_stream' } },
270
- 'im.message.create': { data: { message_id: 'om' } },
271
- })
272
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
273
- await sc.ensureCreated()
274
-
275
- // 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
276
- sc.appendText('Hello ')
277
- sc.appendText('world')
278
-
279
- // 节流窗口 100ms + 余量
280
- await sleep(150)
281
-
282
- const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
283
- expect(contentCalls.length).toBeGreaterThan(0)
284
- // 最后一次 flush 的内容应包含完整累积文本
285
- const lastCall = contentCalls[contentCalls.length - 1]!
286
- expect(lastCall.args.data.content).toContain('Hello world')
287
- expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
288
- // sequence 严格单调递增
289
- const seqs = contentCalls.map((c) => c.args.data.sequence)
290
- for (let i = 1; i < seqs.length; i++) {
291
- expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
292
- }
293
- })
294
-
295
- it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
296
- const { client, calls } = makeMockClient({
297
- 'card.create': { code: 0, data: { card_id: 'ck' } },
298
- 'im.message.create': { data: { message_id: 'om' } },
299
- })
300
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
301
- await sc.ensureCreated()
302
-
303
- sc.appendText('same')
304
- await sleep(150)
305
-
306
- // 强制再跑一次 flush(无新文本)
307
- await sc._getFlushController().flush()
308
-
309
- const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
310
- // 应该只有一次 content 调用
311
- expect(contentCalls.length).toBe(1)
312
- })
313
-
314
- it('completed 之后的 appendText 被忽略', async () => {
315
- const { client } = makeMockClient({
316
- 'card.create': { code: 0, data: { card_id: 'ck' } },
317
- 'im.message.create': { data: { message_id: 'om' } },
318
- })
319
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
320
- await sc.ensureCreated()
321
- await sc.finalize()
322
- sc.appendText('ignored')
323
- expect(sc._getAccumulatedText()).toBe('')
324
- })
325
- })
326
-
327
- // ---------------------------------------------------------------------------
328
- // finalize
329
- // ---------------------------------------------------------------------------
330
-
331
- describe('StreamingCard: finalize', () => {
332
- it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
333
- const { client, calls } = makeMockClient({
334
- 'card.create': { code: 0, data: { card_id: 'ck_final' } },
335
- 'im.message.create': { data: { message_id: 'om' } },
336
- })
337
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
338
- await sc.ensureCreated()
339
- sc.appendText('# Title\n\nBody')
340
- await sleep(150)
341
- const contentSeqs = calls
342
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
343
- .map((c) => c.args.data.sequence)
344
- const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
345
-
346
- await sc.finalize()
347
-
348
- expect(sc._getPhase()).toBe('completed')
349
-
350
- const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
351
- const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
352
- expect(settingsCalls.length).toBe(1)
353
- expect(updateCalls.length).toBe(1)
354
-
355
- const settingsSeq = settingsCalls[0]!.args.data.sequence
356
- const updateSeq = updateCalls[0]!.args.data.sequence
357
- expect(settingsSeq).toBeGreaterThan(lastContentSeq)
358
- expect(updateSeq).toBeGreaterThan(settingsSeq)
359
-
360
- // settings 关闭 streaming_mode
361
- const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
362
- expect(settings.streaming_mode).toBe(false)
363
-
364
- // update 卡内容是预处理后的 markdown
365
- const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
366
- const finalContent = finalCardJson.body.elements[0].content
367
- // H1 被降级为 H4
368
- expect(finalContent).toContain('#### Title')
369
- expect(finalContent).toContain('Body')
370
- })
371
-
372
- it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
373
- const { client, calls } = makeMockClient({
374
- 'card.create': { code: 99991672 },
375
- 'im.message.create': { data: { message_id: 'om_fb' } },
376
- })
377
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
378
- await sc.ensureCreated()
379
- sc.appendText('## Heading\n\nContent')
380
- await sleep(1600) // 等 PATCH_MS 窗口
381
- await sc.finalize()
382
-
383
- const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
384
- expect(patchCalls.length).toBeGreaterThan(0)
385
- // 最后一次 patch 是 finalize 的(full final card)
386
- const lastPatch = patchCalls[patchCalls.length - 1]!
387
- const finalCard = JSON.parse(lastPatch.args.data.content)
388
- const finalContent = finalCard.body.elements[0].content
389
- // ## → ##### 降级
390
- expect(finalContent).toContain('##### Heading')
391
- })
392
-
393
- it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => {
394
- const { client } = makeMockClient()
395
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
396
- await sc.finalize()
397
- expect(sc._getPhase()).toBe('completed')
398
- })
399
-
400
- it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => {
401
- const { client, calls } = makeMockClient({
402
- 'card.create': { code: 0, data: { card_id: 'ck_term' } },
403
- 'im.message.create': { data: { message_id: 'om' } },
404
- })
405
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
406
- await sc.ensureCreated()
407
-
408
- // 同时塞入三种内容
409
- sc.appendReasoning('Let me think about this problem carefully...')
410
- sc.startTool('tu_1', 'Read')
411
- sc.completeTool('tu_1', 'Read')
412
- sc.appendText('## 答复\n\n这是最终答复正文。')
413
- await sleep(150)
414
-
415
- // 流式中间帧应该包含 reasoning + tools + answer 全套
416
- const lastMidFrame = calls
417
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
418
- .pop()!.args.data.content as string
419
- expect(lastMidFrame).toContain('思考中')
420
- expect(lastMidFrame).toContain('Read')
421
- expect(lastMidFrame).toContain('最终答复正文')
422
-
423
- await sc.finalize()
424
-
425
- // finalize 用的是 card.update,把整张卡换成只有 answer 的版本
426
- const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
427
- const finalCardJson = JSON.parse(updateCall.args.data.card.data)
428
- const finalContent = finalCardJson.body.elements[0].content as string
429
-
430
- expect(finalContent).toContain('最终答复正文')
431
- // H2 → 降级 H5
432
- expect(finalContent).toContain('##### 答复')
433
- // reasoning + tools 都不应该出现在终态
434
- expect(finalContent).not.toContain('思考中')
435
- expect(finalContent).not.toContain('think about this problem')
436
- expect(finalContent).not.toContain('Read')
437
- expect(finalContent).not.toContain('🛠️')
438
- expect(finalContent).not.toContain('💭')
439
- })
440
-
441
- it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => {
442
- const { client, calls } = makeMockClient({
443
- 'card.create': { code: 0, data: { card_id: 'ck_no_answer' } },
444
- 'im.message.create': { data: { message_id: 'om' } },
445
- })
446
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
447
- await sc.ensureCreated()
448
-
449
- // 只有推理,没有 appendText —— 异常 case 但要可控降级
450
- sc.appendReasoning('I was thinking but never produced an answer.')
451
- await sleep(150)
452
-
453
- await sc.finalize()
454
- const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
455
- const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string
456
- // 至少能看到推理内容
457
- expect(finalContent).toContain('thinking')
458
- })
459
-
460
- it('finalize 失败不抛出', async () => {
461
- const { client } = makeMockClient({
462
- 'card.create': { code: 0, data: { card_id: 'ck' } },
463
- 'im.message.create': { data: { message_id: 'om' } },
464
- 'card.settings': () => {
465
- throw new Error('settings exploded')
466
- },
467
- })
468
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
469
- await sc.ensureCreated()
470
- sc.appendText('text')
471
- await sleep(150)
472
- // finalize 内部捕获错误不 rethrow
473
- await sc.finalize()
474
- expect(sc._getPhase()).toBe('completed')
475
- })
476
- })
477
-
478
- // ---------------------------------------------------------------------------
479
- // Rate limit + table limit
480
- // ---------------------------------------------------------------------------
481
-
482
- describe('StreamingCard: 错误处理', () => {
483
- it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
484
- let callIdx = 0
485
- const { client, calls } = makeMockClient({
486
- 'card.create': { code: 0, data: { card_id: 'ck' } },
487
- 'im.message.create': { data: { message_id: 'om' } },
488
- 'cardElement.content': () => {
489
- const i = callIdx++
490
- if (i === 0) {
491
- const err: any = new Error('rate limit')
492
- err.code = 230020
493
- throw err
494
- }
495
- return { code: 0 }
496
- },
497
- })
498
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
499
- await sc.ensureCreated()
500
- sc.appendText('first')
501
- await sleep(150)
502
- // 第一次被限流
503
- sc.appendText(' second')
504
- await sleep(150)
505
- // 第二次应能成功
506
-
507
- // CardKit 仍然 active(没降级)
508
- expect(sc._isCardKitStreamActive()).toBe(true)
509
- const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
510
- expect(contentCalls.length).toBeGreaterThanOrEqual(2)
511
- })
512
-
513
- it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
514
- const { client, calls } = makeMockClient({
515
- 'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
516
- 'im.message.create': { data: { message_id: 'om' } },
517
- 'cardElement.content': () => {
518
- const err: any = new Error('content failed')
519
- err.code = 230099
520
- err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
521
- throw err
522
- },
523
- })
524
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
525
- await sc.ensureCreated()
526
- sc.appendText('some content')
527
- await sleep(150)
528
- expect(sc._isCardKitStreamActive()).toBe(false)
529
- expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
530
-
531
- await sc.finalize()
532
- // finalize 仍然走 CardKit 的 settings + update(cardId 还在)
533
- expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
534
- expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
535
- // 不走 patch
536
- expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
537
- })
538
- })
539
-
540
- // ---------------------------------------------------------------------------
541
- // abort
542
- // ---------------------------------------------------------------------------
543
-
544
- describe('StreamingCard: abort', () => {
545
- it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
546
- const { client, calls } = makeMockClient({
547
- 'card.create': { code: 0, data: { card_id: 'ck_err' } },
548
- 'im.message.create': { data: { message_id: 'om' } },
549
- })
550
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
551
- await sc.ensureCreated()
552
- sc.appendText('partial...')
553
- await sleep(150)
554
-
555
- await sc.abort(new Error('something went wrong'))
556
- expect(sc._getPhase()).toBe('aborted')
557
-
558
- const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
559
- expect(updateCalls.length).toBeGreaterThan(0)
560
- const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
561
- expect(errCard.header.template).toBe('red')
562
- expect(errCard.body.elements[0].content).toContain('something went wrong')
563
- // 保留已累积的部分文本
564
- expect(errCard.body.elements[0].content).toContain('partial...')
565
- })
566
-
567
- it('idle 阶段 abort 不抛错', async () => {
568
- const { client } = makeMockClient()
569
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
570
- await sc.abort(new Error('before any card'))
571
- expect(sc._getPhase()).toBe('aborted')
572
- })
573
- })
574
-
575
- // ---------------------------------------------------------------------------
576
- // Reasoning / tool use rendering
577
- // ---------------------------------------------------------------------------
578
-
579
- describe('StreamingCard: appendReasoning', () => {
580
- it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => {
581
- const { client, calls } = makeMockClient({
582
- 'card.create': { code: 0, data: { card_id: 'ck_think' } },
583
- 'im.message.create': { data: { message_id: 'om' } },
584
- })
585
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
586
- await sc.ensureCreated()
587
-
588
- sc.appendReasoning('Analyzing the problem. ')
589
- sc.appendReasoning('Let me check file A.')
590
- await sleep(150)
591
-
592
- const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
593
- expect(contentCalls.length).toBeGreaterThan(0)
594
- const last = contentCalls[contentCalls.length - 1]!
595
- expect(last.args.data.content).toContain('💭')
596
- expect(last.args.data.content).toContain('思考中')
597
- expect(last.args.data.content).toContain('Analyzing the problem.')
598
- expect(last.args.data.content).toContain('Let me check file A.')
599
- // 没有 blockquote `>` 前缀 —— 这是新格式的关键
600
- expect(last.args.data.content).not.toContain('> Analyzing')
601
- // 没有 appendText → 不应有普通正文
602
- expect(sc._getAccumulatedReasoning()).toContain('Analyzing')
603
- expect(sc._getAccumulatedText()).toBe('')
604
- })
605
-
606
- it('completed 之后 appendReasoning 被忽略', async () => {
607
- const { client } = makeMockClient({
608
- 'card.create': { code: 0, data: { card_id: 'ck' } },
609
- 'im.message.create': { data: { message_id: 'om' } },
610
- })
611
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
612
- await sc.ensureCreated()
613
- await sc.finalize()
614
- sc.appendReasoning('too late')
615
- expect(sc._getAccumulatedReasoning()).toBe('')
616
- })
617
- })
618
-
619
- describe('StreamingCard: startTool / completeTool', () => {
620
- it('startTool 压入 running 步骤,completeTool 翻到 done', async () => {
621
- const { client, calls } = makeMockClient({
622
- 'card.create': { code: 0, data: { card_id: 'ck_tool' } },
623
- 'im.message.create': { data: { message_id: 'om' } },
624
- })
625
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
626
- await sc.ensureCreated()
627
-
628
- sc.startTool('tu_1', 'Read')
629
- await sleep(150)
630
- let steps = sc._getToolSteps()
631
- expect(steps.length).toBe(1)
632
- expect(steps[0]!.name).toBe('Read')
633
- expect(steps[0]!.status).toBe('running')
634
-
635
- // 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式)
636
- const runningContent = calls
637
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
638
- .map((c) => c.args.data.content)
639
- .join('\n')
640
- expect(runningContent).toContain('⚙️')
641
- expect(runningContent).toContain('Read')
642
- expect(runningContent).toContain('🛠️')
643
-
644
- sc.completeTool('tu_1', 'Read')
645
- await sleep(150)
646
- steps = sc._getToolSteps()
647
- expect(steps[0]!.status).toBe('done')
648
-
649
- // 最新 flush 应显示 "✅ Read" 不再有 "⚙️"
650
- const lastContent = calls
651
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
652
- .pop()!.args.data.content as string
653
- expect(lastContent).toContain('✅')
654
- expect(lastContent).toContain('Read')
655
- // 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标
656
- expect(lastContent).not.toContain('⚙️')
657
- })
658
-
659
- it('按 toolUseId 去重: 同一 id 不重复压入', async () => {
660
- const { client } = makeMockClient({
661
- 'card.create': { code: 0, data: { card_id: 'ck' } },
662
- 'im.message.create': { data: { message_id: 'om' } },
663
- })
664
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
665
- await sc.ensureCreated()
666
-
667
- sc.startTool('tu_1', 'Read')
668
- sc.startTool('tu_1', 'Read')
669
- sc.startTool('tu_1', 'Read')
670
- expect(sc._getToolSteps().length).toBe(1)
671
- })
672
-
673
- it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => {
674
- const { client } = makeMockClient({
675
- 'card.create': { code: 0, data: { card_id: 'ck' } },
676
- 'im.message.create': { data: { message_id: 'om' } },
677
- })
678
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
679
- await sc.ensureCreated()
680
-
681
- sc.startTool(undefined, 'Read')
682
- sc.startTool(undefined, 'Read')
683
- // 合成 id 不同 → 两个独立步骤
684
- expect(sc._getToolSteps().length).toBe(2)
685
- })
686
-
687
- it('completeTool 只匹配最近的 running 同名步骤', async () => {
688
- const { client } = makeMockClient({
689
- 'card.create': { code: 0, data: { card_id: 'ck' } },
690
- 'im.message.create': { data: { message_id: 'om' } },
691
- })
692
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
693
- await sc.ensureCreated()
694
-
695
- sc.startTool('tu_1', 'Bash')
696
- sc.startTool('tu_2', 'Bash')
697
- sc.completeTool(undefined, 'Bash')
698
- const steps = sc._getToolSteps()
699
- // 更晚的 tu_2 被标记 done
700
- expect(steps[0]!.status).toBe('running')
701
- expect(steps[1]!.status).toBe('done')
702
- })
703
-
704
- it('空 toolName 忽略', async () => {
705
- const { client } = makeMockClient({
706
- 'card.create': { code: 0, data: { card_id: 'ck' } },
707
- 'im.message.create': { data: { message_id: 'om' } },
708
- })
709
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
710
- await sc.ensureCreated()
711
-
712
- sc.startTool('tu_1', undefined)
713
- sc.startTool('tu_1', '')
714
- expect(sc._getToolSteps().length).toBe(0)
715
- })
716
- })
717
-
718
- // 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。
719
- // 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase
720
- // gate / 等任何东西吃掉)。
721
- describe('StreamingCard: 真实事件流(用户场景回归)', () => {
722
- it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => {
723
- const { client, calls } = makeMockClient({
724
- 'card.create': { code: 0, data: { card_id: 'ck_real' } },
725
- 'im.message.create': { data: { message_id: 'om_real' } },
726
- })
727
- const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' })
728
-
729
- // 1. 用户发消息 → handleMessage 预建卡(fire-and-forget)
730
- const creating = sc.ensureCreated()
731
- await creating // 等卡可写
732
-
733
- // 2. 服务端: status streaming + content_start{text} (thinking block)
734
- // feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op)
735
- // (no direct call here — 等同于 no-op)
736
-
737
- // 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式)
738
- sc.appendReasoning('Analyzing the latest commits to find ')
739
- await sleep(30)
740
- sc.appendReasoning('breaking changes. Need to look at ')
741
- await sleep(30)
742
- sc.appendReasoning('the public API surface, the schema files, ')
743
- await sleep(30)
744
- sc.appendReasoning('and any removed exports. Let me check the ')
745
- await sleep(30)
746
- sc.appendReasoning('git log first.')
747
-
748
- // 等节流窗口结束
749
- await sleep(200)
750
-
751
- const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length
752
- expect(flushesAfterReasoning).toBeGreaterThan(0)
753
-
754
- const lastReasoningContent = calls
755
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
756
- .pop()!.args.data.content as string
757
- // 应该包含 reasoning 累积内容
758
- expect(lastReasoningContent).toContain('breaking changes')
759
- expect(lastReasoningContent).toContain('git log first')
760
-
761
- // 4. 服务端: content_start{tool_use, name: 'Bash'}
762
- sc.startTool('tu_bash_1', 'Bash')
763
- await sleep(150)
764
-
765
- const lastWithTool = calls
766
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
767
- .pop()!.args.data.content as string
768
- expect(lastWithTool).toContain('Bash')
769
- expect(lastWithTool).toContain('⚙️')
770
- expect(lastWithTool).toContain('🛠️')
771
-
772
- // 5. 服务端: tool_use_complete
773
- sc.completeTool('tu_bash_1', 'Bash')
774
- await sleep(150)
775
-
776
- const lastAfterToolDone = calls
777
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
778
- .pop()!.args.data.content as string
779
- expect(lastAfterToolDone).toContain('Bash')
780
- // ⚙️ 切到 ✅ —— 当前唯一一步已完成
781
- expect(lastAfterToolDone).toContain('✅')
782
- expect(lastAfterToolDone).not.toContain('⚙️')
783
-
784
- // 6. 第二个 tool 序列
785
- sc.startTool('tu_read_1', 'Read')
786
- await sleep(150)
787
- sc.completeTool('tu_read_1', 'Read')
788
- await sleep(150)
789
-
790
- // 7. 最终 text 输出
791
- sc.appendText('## 破坏性变更分析\n\n')
792
- await sleep(120)
793
- sc.appendText('1. **API 重命名**: foo → bar\n')
794
- await sleep(120)
795
- sc.appendText('2. **删除导出**: baz')
796
- await sleep(200)
797
-
798
- const lastWithText = calls
799
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
800
- .pop()!.args.data.content as string
801
- // 应该同时包含 reasoning, tools, answer
802
- expect(lastWithText).toContain('git log first') // reasoning
803
- expect(lastWithText).toContain('Bash') // tool
804
- expect(lastWithText).toContain('Read') // tool
805
- expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5)
806
- expect(lastWithText).toContain('API 重命名')
807
-
808
- // 8. message_complete → finalize
809
- await sc.finalize()
810
- expect(sc._getPhase()).toBe('completed')
811
-
812
- // 验证有 settings + update 收尾
813
- expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
814
- expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
815
- })
816
-
817
- it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => {
818
- let firstFrameRejected = false
819
- const { client, calls } = makeMockClient({
820
- 'card.create': { code: 0, data: { card_id: 'ck_recover' } },
821
- 'im.message.create': { data: { message_id: 'om' } },
822
- 'cardElement.content': () => {
823
- if (!firstFrameRejected) {
824
- firstFrameRejected = true
825
- // 模拟一个 *非* rate-limit、*非* table-limit 错误
826
- // 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题
827
- const err: any = new Error('mystery cardkit error')
828
- err.code = 999999
829
- throw err
830
- }
831
- return { code: 0 }
832
- },
833
- })
834
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
835
- await sc.ensureCreated()
836
-
837
- sc.appendReasoning('first thought')
838
- await sleep(150)
839
- // 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续
840
- sc.appendReasoning(' second thought')
841
- await sleep(150)
842
- // 验证: 至少尝试了 2 次 cardElement.content 调用
843
- const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
844
- expect(contentCalls.length).toBeGreaterThanOrEqual(2)
845
- // 而且 streaming 仍是 active
846
- expect(sc._isCardKitStreamActive()).toBe(true)
847
- })
848
- })
849
-
850
- describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => {
851
- it('三个 section 按顺序 tools → reasoning → answer 组合', async () => {
852
- const { client, calls } = makeMockClient({
853
- 'card.create': { code: 0, data: { card_id: 'ck_all' } },
854
- 'im.message.create': { data: { message_id: 'om' } },
855
- })
856
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
857
- await sc.ensureCreated()
858
-
859
- sc.appendReasoning('Should I read file A first?')
860
- sc.startTool('tu_1', 'Read')
861
- sc.appendText('Here is the answer.')
862
- await sleep(150)
863
-
864
- const lastContent = calls
865
- .filter((c) => c.api === 'cardkit.v1.cardElement.content')
866
- .pop()!.args.data.content as string
867
-
868
- const idxTools = lastContent.indexOf('🛠️')
869
- const idxReasoning = lastContent.indexOf('思考中')
870
- const idxAnswer = lastContent.indexOf('Here is the answer')
871
-
872
- expect(idxTools).toBeGreaterThan(-1)
873
- expect(idxReasoning).toBeGreaterThan(-1)
874
- expect(idxAnswer).toBeGreaterThan(-1)
875
- // tools 在最顶部 → reasoning 居中 → answer 在底部
876
- expect(idxTools).toBeLessThan(idxReasoning)
877
- expect(idxReasoning).toBeLessThan(idxAnswer)
878
- })
879
-
880
- it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => {
881
- let resolveCreate: (() => void) | null = null
882
- const createLatch = new Promise<void>((r) => { resolveCreate = r })
883
-
884
- const { client, calls } = makeMockClient({
885
- 'card.create': async () => {
886
- await createLatch
887
- return { code: 0, data: { card_id: 'ck_slow' } }
888
- },
889
- 'im.message.create': { data: { message_id: 'om' } },
890
- })
891
- const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
892
-
893
- // 不 await: 在 create 还没 resolve 之前,先压入一个 tool step
894
- const creating = sc.ensureCreated()
895
- // 让事件循环推进到 create 被 await
896
- await sleep(10)
897
- sc.startTool('tu_1', 'Glob')
898
-
899
- // 此时 cardMessageReady 仍是 false —— 没有任何 flush
900
- const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
901
- expect(contentBefore.length).toBe(0)
902
-
903
- // 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush
904
- resolveCreate!()
905
- await creating
906
- await sleep(150)
907
-
908
- const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
909
- expect(contentAfter.length).toBeGreaterThan(0)
910
- const last = contentAfter[contentAfter.length - 1]!
911
- expect(last.args.data.content).toContain('Glob')
912
- expect(last.args.data.content).toContain('🛠️')
913
- })
914
- })