@nextclaw/channel-plugin-feishu 0.2.18 → 0.2.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/channel-plugin-feishu",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "private": false,
5
5
  "description": "NextClaw Feishu/Lark channel plugin with doc/wiki/drive tools.",
6
6
  "type": "module",
@@ -0,0 +1,218 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
4
+ const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
5
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
6
+ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7
+ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
8
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
9
+ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
10
+ const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
11
+ const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
12
+ const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
13
+ const streamingInstances = vi.hoisted(() => [] as any[]);
14
+
15
+ vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
16
+ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
17
+ vi.mock("./send.js", () => ({
18
+ sendMessageFeishu: sendMessageFeishuMock,
19
+ sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
20
+ }));
21
+ vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
22
+ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
23
+ vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
24
+ vi.mock("./typing.js", () => ({
25
+ addTypingIndicator: addTypingIndicatorMock,
26
+ removeTypingIndicator: removeTypingIndicatorMock,
27
+ }));
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
+ });
49
+
50
+ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
51
+
52
+ describe("createFeishuReplyDispatcher finalization behavior", () => {
53
+ type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
54
+
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ streamingInstances.length = 0;
58
+ sendMediaFeishuMock.mockResolvedValue(undefined);
59
+
60
+ resolveFeishuAccountMock.mockReturnValue({
61
+ accountId: "main",
62
+ appId: "app_id",
63
+ appSecret: "app_secret",
64
+ domain: "feishu",
65
+ config: {
66
+ renderMode: "auto",
67
+ streaming: true,
68
+ },
69
+ });
70
+
71
+ resolveReceiveIdTypeMock.mockReturnValue("chat_id");
72
+ createFeishuClientMock.mockReturnValue({});
73
+
74
+ createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
75
+ dispatcher: {},
76
+ replyOptions: {},
77
+ markDispatchIdle: vi.fn(),
78
+ _opts: opts,
79
+ }));
80
+
81
+ getFeishuRuntimeMock.mockReturnValue({
82
+ channel: {
83
+ text: {
84
+ resolveTextChunkLimit: vi.fn(() => 4000),
85
+ resolveChunkMode: vi.fn(() => "line"),
86
+ resolveMarkdownTableMode: vi.fn(() => "preserve"),
87
+ convertMarkdownTables: vi.fn((text) => text),
88
+ chunkTextWithMode: vi.fn((text) => [text]),
89
+ },
90
+ reply: {
91
+ createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
92
+ resolveHumanDelayConfig: vi.fn(() => undefined),
93
+ },
94
+ },
95
+ });
96
+ });
97
+
98
+ function setupNonStreamingAutoDispatcher() {
99
+ resolveFeishuAccountMock.mockReturnValue({
100
+ accountId: "main",
101
+ appId: "app_id",
102
+ appSecret: "app_secret",
103
+ domain: "feishu",
104
+ config: {
105
+ renderMode: "auto",
106
+ streaming: false,
107
+ },
108
+ });
109
+
110
+ createFeishuReplyDispatcher({
111
+ cfg: {} as never,
112
+ agentId: "agent",
113
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
114
+ chatId: "oc_chat",
115
+ });
116
+
117
+ return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
118
+ }
119
+
120
+ function createRuntimeLogger() {
121
+ return { log: vi.fn(), error: vi.fn() } as never;
122
+ }
123
+
124
+ function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
125
+ createFeishuReplyDispatcher({
126
+ cfg: {} as never,
127
+ agentId: "agent",
128
+ runtime: {} as never,
129
+ chatId: "oc_chat",
130
+ ...overrides,
131
+ });
132
+
133
+ return {
134
+ options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
135
+ };
136
+ }
137
+
138
+ it("closes streaming with block text when final reply is missing", async () => {
139
+ const { options } = createDispatcherHarness({
140
+ runtime: createRuntimeLogger(),
141
+ });
142
+ await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
143
+ await options.onIdle?.();
144
+
145
+ expect(streamingInstances).toHaveLength(1);
146
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
147
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
148
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
149
+ });
150
+
151
+ it("delivers distinct final payloads after streaming close", async () => {
152
+ const { options } = createDispatcherHarness({
153
+ runtime: createRuntimeLogger(),
154
+ });
155
+ await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
156
+ await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
157
+
158
+ expect(streamingInstances).toHaveLength(2);
159
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
160
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
161
+ expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
162
+ expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
163
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
164
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it("skips exact duplicate final text after streaming close", async () => {
168
+ const { options } = createDispatcherHarness({
169
+ runtime: createRuntimeLogger(),
170
+ });
171
+ await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
172
+ await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
173
+
174
+ expect(streamingInstances).toHaveLength(1);
175
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
176
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
177
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
178
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("suppresses duplicate final text while still sending media", async () => {
182
+ const options = setupNonStreamingAutoDispatcher();
183
+ await options.deliver({ text: "plain final" }, { kind: "final" });
184
+ await options.deliver(
185
+ { text: "plain final", mediaUrl: "https://example.com/a.png" },
186
+ { kind: "final" },
187
+ );
188
+
189
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
190
+ expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
191
+ expect.objectContaining({
192
+ text: "plain final",
193
+ }),
194
+ );
195
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
196
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
197
+ expect.objectContaining({
198
+ mediaUrl: "https://example.com/a.png",
199
+ }),
200
+ );
201
+ });
202
+
203
+ it("keeps distinct non-streaming final payloads", async () => {
204
+ const options = setupNonStreamingAutoDispatcher();
205
+ await options.deliver({ text: "notice header" }, { kind: "final" });
206
+ await options.deliver({ text: "actual answer body" }, { kind: "final" });
207
+
208
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
209
+ expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
210
+ 1,
211
+ expect.objectContaining({ text: "notice header" }),
212
+ );
213
+ expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
214
+ 2,
215
+ expect.objectContaining({ text: "actual answer body" }),
216
+ );
217
+ });
218
+ });
@@ -0,0 +1,200 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
4
+ const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
5
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
6
+ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7
+ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
8
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
9
+ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
10
+ const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
11
+ const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
12
+ const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
13
+ const streamingInstances = vi.hoisted(() => [] as any[]);
14
+
15
+ vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
16
+ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
17
+ vi.mock("./send.js", () => ({
18
+ sendMessageFeishu: sendMessageFeishuMock,
19
+ sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
20
+ }));
21
+ vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
22
+ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
23
+ vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
24
+ vi.mock("./typing.js", () => ({
25
+ addTypingIndicator: addTypingIndicatorMock,
26
+ removeTypingIndicator: removeTypingIndicatorMock,
27
+ }));
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
+ });
49
+
50
+ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
51
+
52
+ describe("createFeishuReplyDispatcher receipt reaction", () => {
53
+ beforeEach(() => {
54
+ vi.useRealTimers();
55
+ });
56
+
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ streamingInstances.length = 0;
60
+
61
+ resolveFeishuAccountMock.mockReturnValue({
62
+ accountId: "main",
63
+ appId: "app_id",
64
+ appSecret: "app_secret",
65
+ domain: "feishu",
66
+ config: {
67
+ renderMode: "auto",
68
+ streaming: true,
69
+ },
70
+ });
71
+
72
+ resolveReceiveIdTypeMock.mockReturnValue("chat_id");
73
+ createFeishuClientMock.mockReturnValue({});
74
+
75
+ createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
76
+ dispatcher: {},
77
+ replyOptions: {},
78
+ markDispatchIdle: vi.fn(),
79
+ _opts: opts,
80
+ }));
81
+
82
+ getFeishuRuntimeMock.mockReturnValue({
83
+ channel: {
84
+ text: {
85
+ resolveTextChunkLimit: vi.fn(() => 4000),
86
+ resolveChunkMode: vi.fn(() => "line"),
87
+ resolveMarkdownTableMode: vi.fn(() => "preserve"),
88
+ convertMarkdownTables: vi.fn((text) => text),
89
+ chunkTextWithMode: vi.fn((text) => [text]),
90
+ },
91
+ reply: {
92
+ createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
93
+ resolveHumanDelayConfig: vi.fn(() => undefined),
94
+ },
95
+ },
96
+ });
97
+ });
98
+
99
+ it("skips receipt reaction when account typingIndicator is disabled", async () => {
100
+ resolveFeishuAccountMock.mockReturnValue({
101
+ accountId: "main",
102
+ appId: "app_id",
103
+ appSecret: "app_secret",
104
+ domain: "feishu",
105
+ config: {
106
+ renderMode: "auto",
107
+ streaming: true,
108
+ typingIndicator: false,
109
+ },
110
+ });
111
+
112
+ createFeishuReplyDispatcher({
113
+ cfg: {} as never,
114
+ agentId: "agent",
115
+ runtime: {} as never,
116
+ chatId: "oc_chat",
117
+ replyToMessageId: "om_parent",
118
+ });
119
+
120
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
121
+ await options.onReplyStart?.();
122
+
123
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("skips receipt reaction for stale replayed messages", async () => {
127
+ createFeishuReplyDispatcher({
128
+ cfg: {} as never,
129
+ agentId: "agent",
130
+ runtime: {} as never,
131
+ chatId: "oc_chat",
132
+ replyToMessageId: "om_parent",
133
+ messageCreateTimeMs: Date.now() - 3 * 60_000,
134
+ });
135
+
136
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
137
+ await options.onReplyStart?.();
138
+
139
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it("treats second-based timestamps as stale for receipt suppression", async () => {
143
+ createFeishuReplyDispatcher({
144
+ cfg: {} as never,
145
+ agentId: "agent",
146
+ runtime: {} as never,
147
+ chatId: "oc_chat",
148
+ replyToMessageId: "om_parent",
149
+ messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
150
+ });
151
+
152
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
153
+ await options.onReplyStart?.();
154
+
155
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("falls back to receipt reaction for fresh messages even when streaming is enabled", async () => {
159
+ createFeishuReplyDispatcher({
160
+ cfg: {} as never,
161
+ agentId: "agent",
162
+ runtime: {} as never,
163
+ chatId: "oc_chat",
164
+ replyToMessageId: "om_parent",
165
+ messageCreateTimeMs: Date.now() - 30_000,
166
+ });
167
+
168
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
169
+ await options.onReplyStart?.();
170
+
171
+ expect(streamingInstances).toHaveLength(0);
172
+ expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
173
+ expect(addTypingIndicatorMock).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ messageId: "om_parent",
176
+ }),
177
+ );
178
+ });
179
+
180
+ it("adds a receipt reaction immediately on reply start instead of starting streaming", async () => {
181
+ createFeishuReplyDispatcher({
182
+ cfg: {} as never,
183
+ agentId: "agent",
184
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
185
+ chatId: "oc_chat",
186
+ replyToMessageId: "om_parent",
187
+ });
188
+
189
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
190
+ await options.onReplyStart?.();
191
+
192
+ expect(streamingInstances).toHaveLength(0);
193
+ expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
194
+ expect(addTypingIndicatorMock).toHaveBeenCalledWith(
195
+ expect.objectContaining({
196
+ messageId: "om_parent",
197
+ }),
198
+ );
199
+ });
200
+ });
@@ -136,86 +136,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
136
136
  };
137
137
  }
138
138
 
139
- it("skips typing indicator when account typingIndicator is disabled", async () => {
140
- resolveFeishuAccountMock.mockReturnValue({
141
- accountId: "main",
142
- appId: "app_id",
143
- appSecret: "app_secret",
144
- domain: "feishu",
145
- config: {
146
- renderMode: "auto",
147
- streaming: true,
148
- typingIndicator: false,
149
- },
150
- });
151
-
152
- createFeishuReplyDispatcher({
153
- cfg: {} as never,
154
- agentId: "agent",
155
- runtime: {} as never,
156
- chatId: "oc_chat",
157
- replyToMessageId: "om_parent",
158
- });
159
-
160
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
161
- await options.onReplyStart?.();
162
-
163
- expect(addTypingIndicatorMock).not.toHaveBeenCalled();
164
- });
165
-
166
- it("skips typing indicator for stale replayed messages", async () => {
167
- createFeishuReplyDispatcher({
168
- cfg: {} as never,
169
- agentId: "agent",
170
- runtime: {} as never,
171
- chatId: "oc_chat",
172
- replyToMessageId: "om_parent",
173
- messageCreateTimeMs: Date.now() - 3 * 60_000,
174
- });
175
-
176
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
177
- await options.onReplyStart?.();
178
-
179
- expect(addTypingIndicatorMock).not.toHaveBeenCalled();
180
- });
181
-
182
- it("treats second-based timestamps as stale for typing suppression", async () => {
183
- createFeishuReplyDispatcher({
184
- cfg: {} as never,
185
- agentId: "agent",
186
- runtime: {} as never,
187
- chatId: "oc_chat",
188
- replyToMessageId: "om_parent",
189
- messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
190
- });
191
-
192
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
193
- await options.onReplyStart?.();
194
-
195
- expect(addTypingIndicatorMock).not.toHaveBeenCalled();
196
- });
197
-
198
- it("keeps typing indicator for fresh messages", async () => {
199
- createFeishuReplyDispatcher({
200
- cfg: {} as never,
201
- agentId: "agent",
202
- runtime: {} as never,
203
- chatId: "oc_chat",
204
- replyToMessageId: "om_parent",
205
- messageCreateTimeMs: Date.now() - 30_000,
206
- });
207
-
208
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
209
- await options.onReplyStart?.();
210
-
211
- expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
212
- expect(addTypingIndicatorMock).toHaveBeenCalledWith(
213
- expect.objectContaining({
214
- messageId: "om_parent",
215
- }),
216
- );
217
- });
218
-
219
139
  it("keeps auto mode plain text on non-streaming send path", async () => {
220
140
  const { options } = createDispatcherHarness();
221
141
  await options.deliver({ text: "plain text" }, { kind: "final" });
@@ -265,86 +185,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
265
185
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
266
186
  });
267
187
 
268
- it("closes streaming with block text when final reply is missing", async () => {
269
- const { options } = createDispatcherHarness({
270
- runtime: createRuntimeLogger(),
271
- });
272
- await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
273
- await options.onIdle?.();
274
-
275
- expect(streamingInstances).toHaveLength(1);
276
- expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
277
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
278
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
279
- });
280
-
281
- it("delivers distinct final payloads after streaming close", async () => {
282
- const { options } = createDispatcherHarness({
283
- runtime: createRuntimeLogger(),
284
- });
285
- await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
286
- await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
287
-
288
- expect(streamingInstances).toHaveLength(2);
289
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
290
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
291
- expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
292
- expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
293
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
294
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
295
- });
296
-
297
- it("skips exact duplicate final text after streaming close", async () => {
298
- const { options } = createDispatcherHarness({
299
- runtime: createRuntimeLogger(),
300
- });
301
- await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
302
- await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
303
-
304
- expect(streamingInstances).toHaveLength(1);
305
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
306
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
307
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
308
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
309
- });
310
- it("suppresses duplicate final text while still sending media", async () => {
311
- const options = setupNonStreamingAutoDispatcher();
312
- await options.deliver({ text: "plain final" }, { kind: "final" });
313
- await options.deliver(
314
- { text: "plain final", mediaUrl: "https://example.com/a.png" },
315
- { kind: "final" },
316
- );
317
-
318
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
319
- expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
320
- expect.objectContaining({
321
- text: "plain final",
322
- }),
323
- );
324
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
325
- expect(sendMediaFeishuMock).toHaveBeenCalledWith(
326
- expect.objectContaining({
327
- mediaUrl: "https://example.com/a.png",
328
- }),
329
- );
330
- });
331
-
332
- it("keeps distinct non-streaming final payloads", async () => {
333
- const options = setupNonStreamingAutoDispatcher();
334
- await options.deliver({ text: "notice header" }, { kind: "final" });
335
- await options.deliver({ text: "actual answer body" }, { kind: "final" });
336
-
337
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
338
- expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
339
- 1,
340
- expect.objectContaining({ text: "notice header" }),
341
- );
342
- expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
343
- 2,
344
- expect.objectContaining({ text: "actual answer body" }),
345
- );
346
- });
347
-
348
188
  it("treats block updates as delta chunks", async () => {
349
189
  resolveFeishuAccountMock.mockReturnValue({
350
190
  accountId: "main",
@@ -421,93 +261,4 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
421
261
  );
422
262
  });
423
263
 
424
- it("passes replyInThread to sendMessageFeishu for plain text", async () => {
425
- const { options } = createDispatcherHarness({
426
- replyToMessageId: "om_msg",
427
- replyInThread: true,
428
- });
429
- await options.deliver({ text: "plain text" }, { kind: "final" });
430
-
431
- expect(sendMessageFeishuMock).toHaveBeenCalledWith(
432
- expect.objectContaining({
433
- replyToMessageId: "om_msg",
434
- replyInThread: true,
435
- }),
436
- );
437
- });
438
-
439
- it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
440
- resolveFeishuAccountMock.mockReturnValue({
441
- accountId: "main",
442
- appId: "app_id",
443
- appSecret: "app_secret",
444
- domain: "feishu",
445
- config: {
446
- renderMode: "card",
447
- streaming: false,
448
- },
449
- });
450
-
451
- const { options } = createDispatcherHarness({
452
- replyToMessageId: "om_msg",
453
- replyInThread: true,
454
- });
455
- await options.deliver({ text: "card text" }, { kind: "final" });
456
-
457
- expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
458
- expect.objectContaining({
459
- replyToMessageId: "om_msg",
460
- replyInThread: true,
461
- }),
462
- );
463
- });
464
-
465
- it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
466
- const { options } = createDispatcherHarness({
467
- runtime: createRuntimeLogger(),
468
- replyToMessageId: "om_msg",
469
- replyInThread: true,
470
- });
471
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
472
-
473
- expect(streamingInstances).toHaveLength(1);
474
- expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
475
- replyToMessageId: "om_msg",
476
- replyInThread: true,
477
- });
478
- });
479
-
480
- it("disables streaming for thread replies and keeps reply metadata", async () => {
481
- const { options } = createDispatcherHarness({
482
- runtime: createRuntimeLogger(),
483
- replyToMessageId: "om_msg",
484
- replyInThread: false,
485
- threadReply: true,
486
- rootId: "om_root_topic",
487
- });
488
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
489
-
490
- expect(streamingInstances).toHaveLength(0);
491
- expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
492
- expect.objectContaining({
493
- replyToMessageId: "om_msg",
494
- replyInThread: true,
495
- }),
496
- );
497
- });
498
-
499
- it("passes replyInThread to media attachments", async () => {
500
- const { options } = createDispatcherHarness({
501
- replyToMessageId: "om_msg",
502
- replyInThread: true,
503
- });
504
- await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
505
-
506
- expect(sendMediaFeishuMock).toHaveBeenCalledWith(
507
- expect.objectContaining({
508
- replyToMessageId: "om_msg",
509
- replyInThread: true,
510
- }),
511
- );
512
- });
513
264
  });