@openclaw/feishu 2026.3.11 → 2026.3.13

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.
@@ -25,44 +25,33 @@ vi.mock("./typing.js", () => ({
25
25
  addTypingIndicator: addTypingIndicatorMock,
26
26
  removeTypingIndicator: removeTypingIndicatorMock,
27
27
  }));
28
- vi.mock("./streaming-card.js", () => ({
29
- mergeStreamingText: (previousText: string | undefined, nextText: string | undefined) => {
30
- const previous = typeof previousText === "string" ? previousText : "";
31
- const next = typeof nextText === "string" ? nextText : "";
32
- if (!next) {
33
- return previous;
34
- }
35
- if (!previous || next === previous) {
36
- return next;
37
- }
38
- if (next.startsWith(previous)) {
39
- return next;
40
- }
41
- if (previous.startsWith(next)) {
42
- return previous;
43
- }
44
- return `${previous}${next}`;
45
- },
46
- FeishuStreamingSession: class {
47
- active = false;
48
- start = vi.fn(async () => {
49
- this.active = true;
50
- });
51
- update = vi.fn(async () => {});
52
- close = vi.fn(async () => {
53
- this.active = false;
54
- });
55
- isActive = vi.fn(() => this.active);
56
-
57
- constructor() {
58
- streamingInstances.push(this);
59
- }
60
- },
61
- }));
28
+ vi.mock("./streaming-card.js", async () => {
29
+ const actual = await vi.importActual<typeof import("./streaming-card.js")>("./streaming-card.js");
30
+ return {
31
+ mergeStreamingText: actual.mergeStreamingText,
32
+ FeishuStreamingSession: class {
33
+ active = false;
34
+ start = vi.fn(async () => {
35
+ this.active = true;
36
+ });
37
+ update = vi.fn(async () => {});
38
+ close = vi.fn(async () => {
39
+ this.active = false;
40
+ });
41
+ isActive = vi.fn(() => this.active);
42
+
43
+ constructor() {
44
+ streamingInstances.push(this);
45
+ }
46
+ },
47
+ };
48
+ });
62
49
 
63
50
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
64
51
 
65
52
  describe("createFeishuReplyDispatcher streaming behavior", () => {
53
+ type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
54
+
66
55
  beforeEach(() => {
67
56
  vi.clearAllMocks();
68
57
  streamingInstances.length = 0;
@@ -128,6 +117,25 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
128
117
  return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
129
118
  }
130
119
 
120
+ function createRuntimeLogger() {
121
+ return { log: vi.fn(), error: vi.fn() } as never;
122
+ }
123
+
124
+ function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
125
+ const result = createFeishuReplyDispatcher({
126
+ cfg: {} as never,
127
+ agentId: "agent",
128
+ runtime: {} as never,
129
+ chatId: "oc_chat",
130
+ ...overrides,
131
+ });
132
+
133
+ return {
134
+ result,
135
+ options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
136
+ };
137
+ }
138
+
131
139
  it("skips typing indicator when account typingIndicator is disabled", async () => {
132
140
  resolveFeishuAccountMock.mockReturnValue({
133
141
  accountId: "main",
@@ -209,14 +217,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
209
217
  });
210
218
 
211
219
  it("keeps auto mode plain text on non-streaming send path", async () => {
212
- createFeishuReplyDispatcher({
213
- cfg: {} as never,
214
- agentId: "agent",
215
- runtime: {} as never,
216
- chatId: "oc_chat",
217
- });
218
-
219
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
220
+ const { options } = createDispatcherHarness();
220
221
  await options.deliver({ text: "plain text" }, { kind: "final" });
221
222
 
222
223
  expect(streamingInstances).toHaveLength(0);
@@ -225,14 +226,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
225
226
  });
226
227
 
227
228
  it("suppresses internal block payload delivery", async () => {
228
- createFeishuReplyDispatcher({
229
- cfg: {} as never,
230
- agentId: "agent",
231
- runtime: {} as never,
232
- chatId: "oc_chat",
233
- });
234
-
235
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
229
+ const { options } = createDispatcherHarness();
236
230
  await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
237
231
 
238
232
  expect(streamingInstances).toHaveLength(0);
@@ -253,15 +247,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
253
247
  });
254
248
 
255
249
  it("uses streaming session for auto mode markdown payloads", async () => {
256
- createFeishuReplyDispatcher({
257
- cfg: {} as never,
258
- agentId: "agent",
259
- runtime: { log: vi.fn(), error: vi.fn() } as never,
260
- chatId: "oc_chat",
250
+ const { options } = createDispatcherHarness({
251
+ runtime: createRuntimeLogger(),
261
252
  rootId: "om_root_topic",
262
253
  });
263
-
264
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
265
254
  await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
266
255
 
267
256
  expect(streamingInstances).toHaveLength(1);
@@ -277,14 +266,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
277
266
  });
278
267
 
279
268
  it("closes streaming with block text when final reply is missing", async () => {
280
- createFeishuReplyDispatcher({
281
- cfg: {} as never,
282
- agentId: "agent",
283
- runtime: { log: vi.fn(), error: vi.fn() } as never,
284
- chatId: "oc_chat",
269
+ const { options } = createDispatcherHarness({
270
+ runtime: createRuntimeLogger(),
285
271
  });
286
-
287
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
288
272
  await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
289
273
  await options.onIdle?.();
290
274
 
@@ -295,14 +279,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
295
279
  });
296
280
 
297
281
  it("delivers distinct final payloads after streaming close", async () => {
298
- createFeishuReplyDispatcher({
299
- cfg: {} as never,
300
- agentId: "agent",
301
- runtime: { log: vi.fn(), error: vi.fn() } as never,
302
- chatId: "oc_chat",
282
+ const { options } = createDispatcherHarness({
283
+ runtime: createRuntimeLogger(),
303
284
  });
304
-
305
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
306
285
  await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
307
286
  await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
308
287
 
@@ -316,14 +295,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
316
295
  });
317
296
 
318
297
  it("skips exact duplicate final text after streaming close", async () => {
319
- createFeishuReplyDispatcher({
320
- cfg: {} as never,
321
- agentId: "agent",
322
- runtime: { log: vi.fn(), error: vi.fn() } as never,
323
- chatId: "oc_chat",
298
+ const { options } = createDispatcherHarness({
299
+ runtime: createRuntimeLogger(),
324
300
  });
325
-
326
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
327
301
  await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
328
302
  await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
329
303
 
@@ -383,14 +357,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
383
357
  },
384
358
  });
385
359
 
386
- const result = createFeishuReplyDispatcher({
387
- cfg: {} as never,
388
- agentId: "agent",
389
- runtime: { log: vi.fn(), error: vi.fn() } as never,
390
- chatId: "oc_chat",
360
+ const { result, options } = createDispatcherHarness({
361
+ runtime: createRuntimeLogger(),
391
362
  });
392
-
393
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
394
363
  await options.onReplyStart?.();
395
364
  await result.replyOptions.onPartialReply?.({ text: "hello" });
396
365
  await options.deliver({ text: "lo world" }, { kind: "block" });
@@ -402,14 +371,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
402
371
  });
403
372
 
404
373
  it("sends media-only payloads as attachments", async () => {
405
- createFeishuReplyDispatcher({
406
- cfg: {} as never,
407
- agentId: "agent",
408
- runtime: {} as never,
409
- chatId: "oc_chat",
410
- });
411
-
412
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
374
+ const { options } = createDispatcherHarness();
413
375
  await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
414
376
 
415
377
  expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
@@ -424,14 +386,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
424
386
  });
425
387
 
426
388
  it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
427
- createFeishuReplyDispatcher({
428
- cfg: {} as never,
429
- agentId: "agent",
430
- runtime: {} as never,
431
- chatId: "oc_chat",
432
- });
433
-
434
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
389
+ const { options } = createDispatcherHarness();
435
390
  await options.deliver(
436
391
  { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
437
392
  { kind: "final" },
@@ -447,14 +402,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
447
402
  });
448
403
 
449
404
  it("sends attachments after streaming final markdown replies", async () => {
450
- createFeishuReplyDispatcher({
451
- cfg: {} as never,
452
- agentId: "agent",
453
- runtime: { log: vi.fn(), error: vi.fn() } as never,
454
- chatId: "oc_chat",
405
+ const { options } = createDispatcherHarness({
406
+ runtime: createRuntimeLogger(),
455
407
  });
456
-
457
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
458
408
  await options.deliver(
459
409
  { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
460
410
  { kind: "final" },
@@ -472,16 +422,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
472
422
  });
473
423
 
474
424
  it("passes replyInThread to sendMessageFeishu for plain text", async () => {
475
- createFeishuReplyDispatcher({
476
- cfg: {} as never,
477
- agentId: "agent",
478
- runtime: {} as never,
479
- chatId: "oc_chat",
425
+ const { options } = createDispatcherHarness({
480
426
  replyToMessageId: "om_msg",
481
427
  replyInThread: true,
482
428
  });
483
-
484
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
485
429
  await options.deliver({ text: "plain text" }, { kind: "final" });
486
430
 
487
431
  expect(sendMessageFeishuMock).toHaveBeenCalledWith(
@@ -504,16 +448,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
504
448
  },
505
449
  });
506
450
 
507
- createFeishuReplyDispatcher({
508
- cfg: {} as never,
509
- agentId: "agent",
510
- runtime: {} as never,
511
- chatId: "oc_chat",
451
+ const { options } = createDispatcherHarness({
512
452
  replyToMessageId: "om_msg",
513
453
  replyInThread: true,
514
454
  });
515
-
516
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
517
455
  await options.deliver({ text: "card text" }, { kind: "final" });
518
456
 
519
457
  expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
@@ -525,16 +463,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
525
463
  });
526
464
 
527
465
  it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
528
- createFeishuReplyDispatcher({
529
- cfg: {} as never,
530
- agentId: "agent",
531
- runtime: { log: vi.fn(), error: vi.fn() } as never,
532
- chatId: "oc_chat",
466
+ const { options } = createDispatcherHarness({
467
+ runtime: createRuntimeLogger(),
533
468
  replyToMessageId: "om_msg",
534
469
  replyInThread: true,
535
470
  });
536
-
537
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
538
471
  await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
539
472
 
540
473
  expect(streamingInstances).toHaveLength(1);
@@ -545,18 +478,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
545
478
  });
546
479
 
547
480
  it("disables streaming for thread replies and keeps reply metadata", async () => {
548
- createFeishuReplyDispatcher({
549
- cfg: {} as never,
550
- agentId: "agent",
551
- runtime: { log: vi.fn(), error: vi.fn() } as never,
552
- chatId: "oc_chat",
481
+ const { options } = createDispatcherHarness({
482
+ runtime: createRuntimeLogger(),
553
483
  replyToMessageId: "om_msg",
554
484
  replyInThread: false,
555
485
  threadReply: true,
556
486
  rootId: "om_root_topic",
557
487
  });
558
-
559
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
560
488
  await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
561
489
 
562
490
  expect(streamingInstances).toHaveLength(0);
@@ -569,16 +497,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
569
497
  });
570
498
 
571
499
  it("passes replyInThread to media attachments", async () => {
572
- createFeishuReplyDispatcher({
573
- cfg: {} as never,
574
- agentId: "agent",
575
- runtime: {} as never,
576
- chatId: "oc_chat",
500
+ const { options } = createDispatcherHarness({
577
501
  replyToMessageId: "om_msg",
578
502
  replyInThread: true,
579
503
  });
580
-
581
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
582
504
  await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
583
505
 
584
506
  expect(sendMediaFeishuMock).toHaveBeenCalledWith(
@@ -224,6 +224,41 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
224
224
  lastPartial = "";
225
225
  };
226
226
 
227
+ const sendChunkedTextReply = async (params: {
228
+ text: string;
229
+ useCard: boolean;
230
+ infoKind?: string;
231
+ }) => {
232
+ let first = true;
233
+ const chunkSource = params.useCard
234
+ ? params.text
235
+ : core.channel.text.convertMarkdownTables(params.text, tableMode);
236
+ for (const chunk of core.channel.text.chunkTextWithMode(
237
+ chunkSource,
238
+ textChunkLimit,
239
+ chunkMode,
240
+ )) {
241
+ const message = {
242
+ cfg,
243
+ to: chatId,
244
+ text: chunk,
245
+ replyToMessageId: sendReplyToMessageId,
246
+ replyInThread: effectiveReplyInThread,
247
+ mentions: first ? mentionTargets : undefined,
248
+ accountId,
249
+ };
250
+ if (params.useCard) {
251
+ await sendMarkdownCardFeishu(message);
252
+ } else {
253
+ await sendMessageFeishu(message);
254
+ }
255
+ first = false;
256
+ }
257
+ if (params.infoKind === "final") {
258
+ deliveredFinalTexts.add(params.text);
259
+ }
260
+ };
261
+
227
262
  const { dispatcher, replyOptions, markDispatchIdle } =
228
263
  core.channel.reply.createReplyDispatcherWithTyping({
229
264
  responsePrefix: prefixContext.responsePrefix,
@@ -303,48 +338,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
303
338
  return;
304
339
  }
305
340
 
306
- let first = true;
307
341
  if (useCard) {
308
- for (const chunk of core.channel.text.chunkTextWithMode(
309
- text,
310
- textChunkLimit,
311
- chunkMode,
312
- )) {
313
- await sendMarkdownCardFeishu({
314
- cfg,
315
- to: chatId,
316
- text: chunk,
317
- replyToMessageId: sendReplyToMessageId,
318
- replyInThread: effectiveReplyInThread,
319
- mentions: first ? mentionTargets : undefined,
320
- accountId,
321
- });
322
- first = false;
323
- }
324
- if (info?.kind === "final") {
325
- deliveredFinalTexts.add(text);
326
- }
342
+ await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
327
343
  } else {
328
- const converted = core.channel.text.convertMarkdownTables(text, tableMode);
329
- for (const chunk of core.channel.text.chunkTextWithMode(
330
- converted,
331
- textChunkLimit,
332
- chunkMode,
333
- )) {
334
- await sendMessageFeishu({
335
- cfg,
336
- to: chatId,
337
- text: chunk,
338
- replyToMessageId: sendReplyToMessageId,
339
- replyInThread: effectiveReplyInThread,
340
- mentions: first ? mentionTargets : undefined,
341
- accountId,
342
- });
343
- first = false;
344
- }
345
- if (info?.kind === "final") {
346
- deliveredFinalTexts.add(text);
347
- }
344
+ await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
348
345
  }
349
346
  }
350
347
 
@@ -25,6 +25,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
25
25
  const replyMock = vi.fn();
26
26
  const createMock = vi.fn();
27
27
 
28
+ async function expectFallbackResult(
29
+ send: () => Promise<{ messageId?: string }>,
30
+ expectedMessageId: string,
31
+ ) {
32
+ const result = await send();
33
+ expect(replyMock).toHaveBeenCalledTimes(1);
34
+ expect(createMock).toHaveBeenCalledTimes(1);
35
+ expect(result.messageId).toBe(expectedMessageId);
36
+ }
37
+
28
38
  beforeEach(() => {
29
39
  vi.clearAllMocks();
30
40
  resolveFeishuSendTargetMock.mockReturnValue({
@@ -51,16 +61,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
51
61
  data: { message_id: "om_new" },
52
62
  });
53
63
 
54
- const result = await sendMessageFeishu({
55
- cfg: {} as never,
56
- to: "user:ou_target",
57
- text: "hello",
58
- replyToMessageId: "om_parent",
59
- });
60
-
61
- expect(replyMock).toHaveBeenCalledTimes(1);
62
- expect(createMock).toHaveBeenCalledTimes(1);
63
- expect(result.messageId).toBe("om_new");
64
+ await expectFallbackResult(
65
+ () =>
66
+ sendMessageFeishu({
67
+ cfg: {} as never,
68
+ to: "user:ou_target",
69
+ text: "hello",
70
+ replyToMessageId: "om_parent",
71
+ }),
72
+ "om_new",
73
+ );
64
74
  });
65
75
 
66
76
  it("falls back to create for withdrawn card replies", async () => {
@@ -73,16 +83,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
73
83
  data: { message_id: "om_card_new" },
74
84
  });
75
85
 
76
- const result = await sendCardFeishu({
77
- cfg: {} as never,
78
- to: "user:ou_target",
79
- card: { schema: "2.0" },
80
- replyToMessageId: "om_parent",
81
- });
82
-
83
- expect(replyMock).toHaveBeenCalledTimes(1);
84
- expect(createMock).toHaveBeenCalledTimes(1);
85
- expect(result.messageId).toBe("om_card_new");
86
+ await expectFallbackResult(
87
+ () =>
88
+ sendCardFeishu({
89
+ cfg: {} as never,
90
+ to: "user:ou_target",
91
+ card: { schema: "2.0" },
92
+ replyToMessageId: "om_parent",
93
+ }),
94
+ "om_card_new",
95
+ );
86
96
  });
87
97
 
88
98
  it("still throws for non-withdrawn reply failures", async () => {
@@ -111,16 +121,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
111
121
  data: { message_id: "om_thrown_fallback" },
112
122
  });
113
123
 
114
- const result = await sendMessageFeishu({
115
- cfg: {} as never,
116
- to: "user:ou_target",
117
- text: "hello",
118
- replyToMessageId: "om_parent",
119
- });
120
-
121
- expect(replyMock).toHaveBeenCalledTimes(1);
122
- expect(createMock).toHaveBeenCalledTimes(1);
123
- expect(result.messageId).toBe("om_thrown_fallback");
124
+ await expectFallbackResult(
125
+ () =>
126
+ sendMessageFeishu({
127
+ cfg: {} as never,
128
+ to: "user:ou_target",
129
+ text: "hello",
130
+ replyToMessageId: "om_parent",
131
+ }),
132
+ "om_thrown_fallback",
133
+ );
124
134
  });
125
135
 
126
136
  it("falls back to create when card reply throws a not-found AxiosError", async () => {
@@ -133,16 +143,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
133
143
  data: { message_id: "om_axios_fallback" },
134
144
  });
135
145
 
136
- const result = await sendCardFeishu({
137
- cfg: {} as never,
138
- to: "user:ou_target",
139
- card: { schema: "2.0" },
140
- replyToMessageId: "om_parent",
141
- });
142
-
143
- expect(replyMock).toHaveBeenCalledTimes(1);
144
- expect(createMock).toHaveBeenCalledTimes(1);
145
- expect(result.messageId).toBe("om_axios_fallback");
146
+ await expectFallbackResult(
147
+ () =>
148
+ sendCardFeishu({
149
+ cfg: {} as never,
150
+ to: "user:ou_target",
151
+ card: { schema: "2.0" },
152
+ replyToMessageId: "om_parent",
153
+ }),
154
+ "om_axios_fallback",
155
+ );
146
156
  });
147
157
 
148
158
  it("re-throws non-withdrawn thrown errors for text messages", async () => {