@openclaw/feishu 2026.3.12 → 2026.5.1-beta.1
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/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +115 -22
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +798 -786
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +77 -25
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +76 -35
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +413 -87
- package/src/media.ts +488 -154
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +220 -313
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +194 -92
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +24 -36
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +297 -39
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +272 -0
- package/src/monitor.webhook-security.test.ts +125 -91
- package/src/monitor.webhook.test-helpers.ts +116 -0
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +627 -53
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +122 -118
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +23 -60
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +721 -168
- package/src/reply-dispatcher.ts +422 -172
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +127 -42
- package/src/send.test.ts +386 -4
- package/src/send.ts +486 -164
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
|
@@ -1,22 +1,62 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
type StreamingSessionStub = {
|
|
4
|
+
active: boolean;
|
|
5
|
+
start: ReturnType<typeof vi.fn>;
|
|
6
|
+
update: ReturnType<typeof vi.fn>;
|
|
7
|
+
close: ReturnType<typeof vi.fn>;
|
|
8
|
+
isActive: ReturnType<typeof vi.fn>;
|
|
9
|
+
};
|
|
10
|
+
|
|
3
11
|
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
4
12
|
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
13
|
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
14
|
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
15
|
+
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
16
|
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
|
8
17
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
9
18
|
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
10
19
|
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
|
11
20
|
const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
|
|
12
21
|
const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
13
|
-
const streamingInstances = vi.hoisted(()
|
|
22
|
+
const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []);
|
|
23
|
+
|
|
24
|
+
function mergeStreamingText(
|
|
25
|
+
previousText: string | undefined,
|
|
26
|
+
nextText: string | undefined,
|
|
27
|
+
): string {
|
|
28
|
+
const previous = typeof previousText === "string" ? previousText : "";
|
|
29
|
+
const next = typeof nextText === "string" ? nextText : "";
|
|
30
|
+
if (!next) {
|
|
31
|
+
return previous;
|
|
32
|
+
}
|
|
33
|
+
if (!previous || next === previous) {
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
if (next.startsWith(previous) || next.includes(previous)) {
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
if (previous.startsWith(next) || previous.includes(next)) {
|
|
40
|
+
return previous;
|
|
41
|
+
}
|
|
42
|
+
const maxOverlap = Math.min(previous.length, next.length);
|
|
43
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
44
|
+
if (previous.slice(-overlap) === next.slice(0, overlap)) {
|
|
45
|
+
return `${previous}${next.slice(overlap)}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return `${previous}${next}`;
|
|
49
|
+
}
|
|
14
50
|
|
|
15
|
-
vi.mock("./accounts.js", () => ({
|
|
51
|
+
vi.mock("./accounts.js", () => ({
|
|
52
|
+
resolveFeishuAccount: resolveFeishuAccountMock,
|
|
53
|
+
resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
|
|
54
|
+
}));
|
|
16
55
|
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
|
|
17
56
|
vi.mock("./send.js", () => ({
|
|
18
57
|
sendMessageFeishu: sendMessageFeishuMock,
|
|
19
58
|
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
|
59
|
+
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
|
|
20
60
|
}));
|
|
21
61
|
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
|
|
22
62
|
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
|
@@ -25,48 +65,41 @@ vi.mock("./typing.js", () => ({
|
|
|
25
65
|
addTypingIndicator: addTypingIndicatorMock,
|
|
26
66
|
removeTypingIndicator: removeTypingIndicatorMock,
|
|
27
67
|
}));
|
|
28
|
-
vi.mock("./streaming-card.js", () =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}));
|
|
68
|
+
vi.mock("./streaming-card.js", () => {
|
|
69
|
+
return {
|
|
70
|
+
mergeStreamingText,
|
|
71
|
+
FeishuStreamingSession: class {
|
|
72
|
+
active = false;
|
|
73
|
+
start = vi.fn(async () => {
|
|
74
|
+
this.active = true;
|
|
75
|
+
});
|
|
76
|
+
update = vi.fn(async () => {});
|
|
77
|
+
close = vi.fn(async () => {
|
|
78
|
+
this.active = false;
|
|
79
|
+
});
|
|
80
|
+
isActive = vi.fn(() => this.active);
|
|
81
|
+
|
|
82
|
+
constructor() {
|
|
83
|
+
streamingInstances.push(this);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
});
|
|
62
88
|
|
|
63
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
clearFeishuStreamingStartBackoffForTests,
|
|
91
|
+
createFeishuReplyDispatcher,
|
|
92
|
+
} from "./reply-dispatcher.js";
|
|
64
93
|
|
|
65
94
|
describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
95
|
+
type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
|
|
96
|
+
|
|
66
97
|
beforeEach(() => {
|
|
67
98
|
vi.clearAllMocks();
|
|
99
|
+
clearFeishuStreamingStartBackoffForTests();
|
|
68
100
|
streamingInstances.length = 0;
|
|
69
101
|
sendMediaFeishuMock.mockResolvedValue(undefined);
|
|
102
|
+
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
|
|
70
103
|
|
|
71
104
|
resolveFeishuAccountMock.mockReturnValue({
|
|
72
105
|
accountId: "main",
|
|
@@ -128,6 +161,25 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
128
161
|
return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
129
162
|
}
|
|
130
163
|
|
|
164
|
+
function createRuntimeLogger() {
|
|
165
|
+
return { log: vi.fn(), error: vi.fn() } as never;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
|
|
169
|
+
const result = createFeishuReplyDispatcher({
|
|
170
|
+
cfg: {} as never,
|
|
171
|
+
agentId: "agent",
|
|
172
|
+
runtime: {} as never,
|
|
173
|
+
chatId: "oc_chat",
|
|
174
|
+
...overrides,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
result,
|
|
179
|
+
options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
131
183
|
it("skips typing indicator when account typingIndicator is disabled", async () => {
|
|
132
184
|
resolveFeishuAccountMock.mockReturnValue({
|
|
133
185
|
accountId: "main",
|
|
@@ -209,14 +261,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
209
261
|
});
|
|
210
262
|
|
|
211
263
|
it("keeps auto mode plain text on non-streaming send path", async () => {
|
|
212
|
-
|
|
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];
|
|
264
|
+
const { options } = createDispatcherHarness();
|
|
220
265
|
await options.deliver({ text: "plain text" }, { kind: "final" });
|
|
221
266
|
|
|
222
267
|
expect(streamingInstances).toHaveLength(0);
|
|
@@ -225,14 +270,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
225
270
|
});
|
|
226
271
|
|
|
227
272
|
it("suppresses internal block payload delivery", async () => {
|
|
228
|
-
|
|
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];
|
|
273
|
+
const { options } = createDispatcherHarness();
|
|
236
274
|
await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
|
|
237
275
|
|
|
238
276
|
expect(streamingInstances).toHaveLength(0);
|
|
@@ -253,86 +291,154 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
253
291
|
});
|
|
254
292
|
|
|
255
293
|
it("uses streaming session for auto mode markdown payloads", async () => {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
agentId: "agent",
|
|
259
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
260
|
-
chatId: "oc_chat",
|
|
294
|
+
const { options } = createDispatcherHarness({
|
|
295
|
+
runtime: createRuntimeLogger(),
|
|
261
296
|
rootId: "om_root_topic",
|
|
262
297
|
});
|
|
263
|
-
|
|
264
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
265
298
|
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
299
|
+
await options.onIdle?.();
|
|
266
300
|
|
|
267
301
|
expect(streamingInstances).toHaveLength(1);
|
|
268
302
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
269
|
-
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
303
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
304
|
+
"oc_chat",
|
|
305
|
+
"chat_id",
|
|
306
|
+
expect.objectContaining({
|
|
307
|
+
replyToMessageId: undefined,
|
|
308
|
+
replyInThread: undefined,
|
|
309
|
+
rootId: "om_root_topic",
|
|
310
|
+
header: { title: "agent", template: "blue" },
|
|
311
|
+
note: "Agent: agent",
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
274
314
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
275
315
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
276
316
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
277
317
|
});
|
|
278
318
|
|
|
279
319
|
it("closes streaming with block text when final reply is missing", async () => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
agentId: "agent",
|
|
283
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
284
|
-
chatId: "oc_chat",
|
|
320
|
+
const { options } = createDispatcherHarness({
|
|
321
|
+
runtime: createRuntimeLogger(),
|
|
285
322
|
});
|
|
286
|
-
|
|
287
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
288
323
|
await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
|
|
289
324
|
await options.onIdle?.();
|
|
290
325
|
|
|
291
326
|
expect(streamingInstances).toHaveLength(1);
|
|
292
327
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
293
328
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
294
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```"
|
|
329
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
|
|
330
|
+
note: "Agent: agent",
|
|
331
|
+
});
|
|
295
332
|
});
|
|
296
333
|
|
|
297
|
-
it("
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
agentId: "agent",
|
|
301
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
302
|
-
chatId: "oc_chat",
|
|
334
|
+
it("coalesces distinct final payloads into one streaming card until idle", async () => {
|
|
335
|
+
const { options } = createDispatcherHarness({
|
|
336
|
+
runtime: createRuntimeLogger(),
|
|
303
337
|
});
|
|
304
|
-
|
|
305
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
306
338
|
await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
|
|
307
339
|
await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
|
|
340
|
+
await options.onIdle?.();
|
|
308
341
|
|
|
309
|
-
expect(streamingInstances).toHaveLength(
|
|
342
|
+
expect(streamingInstances).toHaveLength(1);
|
|
310
343
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
311
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith(
|
|
312
|
-
|
|
313
|
-
|
|
344
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith(
|
|
345
|
+
"```md\n完整回复第一段 + 第二段\n```",
|
|
346
|
+
{
|
|
347
|
+
note: "Agent: agent",
|
|
348
|
+
},
|
|
349
|
+
);
|
|
314
350
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
315
351
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
316
352
|
});
|
|
317
353
|
|
|
318
354
|
it("skips exact duplicate final text after streaming close", async () => {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
agentId: "agent",
|
|
322
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
323
|
-
chatId: "oc_chat",
|
|
355
|
+
const { options } = createDispatcherHarness({
|
|
356
|
+
runtime: createRuntimeLogger(),
|
|
324
357
|
});
|
|
325
|
-
|
|
326
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
327
358
|
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
359
|
+
await options.onIdle?.();
|
|
328
360
|
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
329
361
|
|
|
330
362
|
expect(streamingInstances).toHaveLength(1);
|
|
331
363
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
332
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```"
|
|
364
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
|
|
365
|
+
note: "Agent: agent",
|
|
366
|
+
});
|
|
367
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
368
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("skips final text already closed by idle streaming", async () => {
|
|
372
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
373
|
+
accountId: "main",
|
|
374
|
+
appId: "app_id",
|
|
375
|
+
appSecret: "app_secret",
|
|
376
|
+
domain: "feishu",
|
|
377
|
+
config: {
|
|
378
|
+
renderMode: "card",
|
|
379
|
+
streaming: true,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const { result, options } = createDispatcherHarness({
|
|
384
|
+
runtime: createRuntimeLogger(),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await options.onReplyStart?.();
|
|
388
|
+
result.replyOptions.onPartialReply?.({ text: "```md\nidle streamed reply\n```" });
|
|
389
|
+
await options.onIdle?.();
|
|
390
|
+
await options.deliver({ text: "```md\nidle streamed reply\n```" }, { kind: "final" });
|
|
391
|
+
|
|
392
|
+
expect(streamingInstances).toHaveLength(1);
|
|
393
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
394
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nidle streamed reply\n```", {
|
|
395
|
+
note: "Agent: agent",
|
|
396
|
+
});
|
|
333
397
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
334
398
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
399
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
335
400
|
});
|
|
401
|
+
|
|
402
|
+
it("skips distinct late final text after streaming card close", async () => {
|
|
403
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
404
|
+
accountId: "main",
|
|
405
|
+
appId: "app_id",
|
|
406
|
+
appSecret: "app_secret",
|
|
407
|
+
domain: "feishu",
|
|
408
|
+
config: {
|
|
409
|
+
renderMode: "card",
|
|
410
|
+
streaming: true,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const { options } = createDispatcherHarness({
|
|
415
|
+
runtime: createRuntimeLogger(),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await options.deliver({ text: "First complete answer" }, { kind: "final" });
|
|
419
|
+
await options.onIdle?.();
|
|
420
|
+
await options.deliver(
|
|
421
|
+
{ text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
|
|
422
|
+
{ kind: "final" },
|
|
423
|
+
);
|
|
424
|
+
await options.onIdle?.();
|
|
425
|
+
|
|
426
|
+
expect(streamingInstances).toHaveLength(1);
|
|
427
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
428
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
|
|
429
|
+
note: "Agent: agent",
|
|
430
|
+
});
|
|
431
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
432
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
433
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
434
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
435
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
436
|
+
expect.objectContaining({
|
|
437
|
+
mediaUrl: "https://example.com/a.png",
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
336
442
|
it("suppresses duplicate final text while still sending media", async () => {
|
|
337
443
|
const options = setupNonStreamingAutoDispatcher();
|
|
338
444
|
await options.deliver({ text: "plain final" }, { kind: "final" });
|
|
@@ -383,33 +489,108 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
383
489
|
},
|
|
384
490
|
});
|
|
385
491
|
|
|
386
|
-
const result =
|
|
387
|
-
|
|
388
|
-
agentId: "agent",
|
|
389
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
390
|
-
chatId: "oc_chat",
|
|
492
|
+
const { result, options } = createDispatcherHarness({
|
|
493
|
+
runtime: createRuntimeLogger(),
|
|
391
494
|
});
|
|
392
|
-
|
|
393
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
394
495
|
await options.onReplyStart?.();
|
|
395
|
-
|
|
496
|
+
result.replyOptions.onPartialReply?.({ text: "hello" });
|
|
396
497
|
await options.deliver({ text: "lo world" }, { kind: "block" });
|
|
397
498
|
await options.onIdle?.();
|
|
398
499
|
|
|
399
500
|
expect(streamingInstances).toHaveLength(1);
|
|
400
501
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
401
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world"
|
|
502
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
|
|
503
|
+
note: "Agent: agent",
|
|
504
|
+
});
|
|
402
505
|
});
|
|
403
506
|
|
|
404
|
-
it("
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
507
|
+
it("skips block payloads that exactly repeat the latest partial snapshot", async () => {
|
|
508
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
509
|
+
accountId: "main",
|
|
510
|
+
appId: "app_id",
|
|
511
|
+
appSecret: "app_secret",
|
|
512
|
+
domain: "feishu",
|
|
513
|
+
config: {
|
|
514
|
+
renderMode: "card",
|
|
515
|
+
streaming: true,
|
|
516
|
+
},
|
|
410
517
|
});
|
|
411
518
|
|
|
412
|
-
const options =
|
|
519
|
+
const { result, options } = createDispatcherHarness({
|
|
520
|
+
runtime: createRuntimeLogger(),
|
|
521
|
+
});
|
|
522
|
+
await options.onReplyStart?.();
|
|
523
|
+
result.replyOptions.onPartialReply?.({ text: "```md\npartial\n```" });
|
|
524
|
+
await options.deliver({ text: "```md\npartial\n```" }, { kind: "block" });
|
|
525
|
+
await options.onIdle?.();
|
|
526
|
+
|
|
527
|
+
expect(streamingInstances).toHaveLength(1);
|
|
528
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
529
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial\n```", {
|
|
530
|
+
note: "Agent: agent",
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("preserves previous generation blocks when partial snapshots reset after tools", async () => {
|
|
535
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
536
|
+
accountId: "main",
|
|
537
|
+
appId: "app_id",
|
|
538
|
+
appSecret: "app_secret",
|
|
539
|
+
domain: "feishu",
|
|
540
|
+
config: {
|
|
541
|
+
renderMode: "card",
|
|
542
|
+
streaming: true,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const { result, options } = createDispatcherHarness({
|
|
547
|
+
runtime: createRuntimeLogger(),
|
|
548
|
+
});
|
|
549
|
+
await options.onReplyStart?.();
|
|
550
|
+
result.replyOptions.onPartialReply?.({
|
|
551
|
+
text: "Preparing the lookup plan with enough text to count as one block.",
|
|
552
|
+
});
|
|
553
|
+
result.replyOptions.onPartialReply?.({ text: "Found" });
|
|
554
|
+
result.replyOptions.onPartialReply?.({ text: "Found the answer." });
|
|
555
|
+
await options.onIdle?.();
|
|
556
|
+
|
|
557
|
+
expect(streamingInstances).toHaveLength(1);
|
|
558
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith(
|
|
559
|
+
"Preparing the lookup plan with enough text to count as one block.Found the answer.",
|
|
560
|
+
{
|
|
561
|
+
note: "Agent: agent",
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("strips reasoning tags from streamed partial snapshots", async () => {
|
|
567
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
568
|
+
accountId: "main",
|
|
569
|
+
appId: "app_id",
|
|
570
|
+
appSecret: "app_secret",
|
|
571
|
+
domain: "feishu",
|
|
572
|
+
config: {
|
|
573
|
+
renderMode: "card",
|
|
574
|
+
streaming: true,
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
const { result, options } = createDispatcherHarness({
|
|
579
|
+
runtime: createRuntimeLogger(),
|
|
580
|
+
});
|
|
581
|
+
await options.onReplyStart?.();
|
|
582
|
+
result.replyOptions.onPartialReply?.({
|
|
583
|
+
text: "<thinking>private chain of thought</thinking>\nvisible answer",
|
|
584
|
+
});
|
|
585
|
+
await options.onIdle?.();
|
|
586
|
+
|
|
587
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("visible answer", {
|
|
588
|
+
note: "Agent: agent",
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("sends media-only payloads as attachments", async () => {
|
|
593
|
+
const { options } = createDispatcherHarness();
|
|
413
594
|
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
414
595
|
|
|
415
596
|
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
@@ -423,15 +604,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
423
604
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
424
605
|
});
|
|
425
606
|
|
|
426
|
-
it("
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
});
|
|
607
|
+
it("passes audioAsVoice to media attachments", async () => {
|
|
608
|
+
const { options } = createDispatcherHarness();
|
|
609
|
+
await options.deliver(
|
|
610
|
+
{ mediaUrl: "https://example.com/reply.mp3", audioAsVoice: true },
|
|
611
|
+
{ kind: "final" },
|
|
612
|
+
);
|
|
433
613
|
|
|
434
|
-
|
|
614
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
615
|
+
expect.objectContaining({
|
|
616
|
+
mediaUrl: "https://example.com/reply.mp3",
|
|
617
|
+
audioAsVoice: true,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
|
|
623
|
+
const { options } = createDispatcherHarness();
|
|
435
624
|
await options.deliver(
|
|
436
625
|
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
|
|
437
626
|
{ kind: "final" },
|
|
@@ -447,18 +636,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
447
636
|
});
|
|
448
637
|
|
|
449
638
|
it("sends attachments after streaming final markdown replies", async () => {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
agentId: "agent",
|
|
453
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
454
|
-
chatId: "oc_chat",
|
|
639
|
+
const { options } = createDispatcherHarness({
|
|
640
|
+
runtime: createRuntimeLogger(),
|
|
455
641
|
});
|
|
456
|
-
|
|
457
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
458
642
|
await options.deliver(
|
|
459
643
|
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
|
|
460
644
|
{ kind: "final" },
|
|
461
645
|
);
|
|
646
|
+
await options.onIdle?.();
|
|
462
647
|
|
|
463
648
|
expect(streamingInstances).toHaveLength(1);
|
|
464
649
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
@@ -472,16 +657,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
472
657
|
});
|
|
473
658
|
|
|
474
659
|
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
|
475
|
-
|
|
476
|
-
cfg: {} as never,
|
|
477
|
-
agentId: "agent",
|
|
478
|
-
runtime: {} as never,
|
|
479
|
-
chatId: "oc_chat",
|
|
660
|
+
const { options } = createDispatcherHarness({
|
|
480
661
|
replyToMessageId: "om_msg",
|
|
481
662
|
replyInThread: true,
|
|
482
663
|
});
|
|
483
|
-
|
|
484
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
485
664
|
await options.deliver({ text: "plain text" }, { kind: "final" });
|
|
486
665
|
|
|
487
666
|
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
@@ -492,7 +671,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
492
671
|
);
|
|
493
672
|
});
|
|
494
673
|
|
|
495
|
-
it("passes replyInThread to
|
|
674
|
+
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
|
|
496
675
|
resolveFeishuAccountMock.mockReturnValue({
|
|
497
676
|
accountId: "main",
|
|
498
677
|
appId: "app_id",
|
|
@@ -504,19 +683,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
504
683
|
},
|
|
505
684
|
});
|
|
506
685
|
|
|
507
|
-
|
|
508
|
-
cfg: {} as never,
|
|
509
|
-
agentId: "agent",
|
|
510
|
-
runtime: {} as never,
|
|
511
|
-
chatId: "oc_chat",
|
|
686
|
+
const { options } = createDispatcherHarness({
|
|
512
687
|
replyToMessageId: "om_msg",
|
|
513
688
|
replyInThread: true,
|
|
514
689
|
});
|
|
515
|
-
|
|
516
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
517
690
|
await options.deliver({ text: "card text" }, { kind: "final" });
|
|
518
691
|
|
|
519
|
-
expect(
|
|
692
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
|
520
693
|
expect.objectContaining({
|
|
521
694
|
replyToMessageId: "om_msg",
|
|
522
695
|
replyInThread: true,
|
|
@@ -524,61 +697,382 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
524
697
|
);
|
|
525
698
|
});
|
|
526
699
|
|
|
527
|
-
it("
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
532
|
-
chatId: "oc_chat",
|
|
533
|
-
replyToMessageId: "om_msg",
|
|
534
|
-
replyInThread: true,
|
|
700
|
+
it("streams reasoning content as blockquote before answer", async () => {
|
|
701
|
+
const { result, options } = createDispatcherHarness({
|
|
702
|
+
runtime: createRuntimeLogger(),
|
|
703
|
+
allowReasoningPreview: true,
|
|
535
704
|
});
|
|
536
705
|
|
|
537
|
-
|
|
538
|
-
|
|
706
|
+
await options.onReplyStart?.();
|
|
707
|
+
// Core agent sends pre-formatted text from formatReasoningMessage
|
|
708
|
+
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" });
|
|
709
|
+
result.replyOptions.onReasoningStream?.({
|
|
710
|
+
text: "Reasoning:\n_thinking step 1_\n_step 2_",
|
|
711
|
+
});
|
|
712
|
+
result.replyOptions.onPartialReply?.({ text: "answer part" });
|
|
713
|
+
result.replyOptions.onReasoningEnd?.();
|
|
714
|
+
await options.deliver({ text: "answer part final" }, { kind: "final" });
|
|
715
|
+
await options.onIdle?.();
|
|
716
|
+
|
|
717
|
+
expect(streamingInstances).toHaveLength(1);
|
|
718
|
+
const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) =>
|
|
719
|
+
typeof c[0] === "string" ? c[0] : "",
|
|
720
|
+
);
|
|
721
|
+
const reasoningUpdate = updateCalls.find((c) => c.includes("Thinking"));
|
|
722
|
+
expect(reasoningUpdate).toContain("> 💭 **Thinking**");
|
|
723
|
+
// formatReasoningPrefix strips "Reasoning:" prefix and italic markers
|
|
724
|
+
expect(reasoningUpdate).toContain("> thinking step");
|
|
725
|
+
expect(reasoningUpdate).not.toContain("Reasoning:");
|
|
726
|
+
expect(reasoningUpdate).not.toMatch(/> _.*_/);
|
|
727
|
+
|
|
728
|
+
const combinedUpdate = updateCalls.find((c) => c.includes("Thinking") && c.includes("---"));
|
|
729
|
+
expect(combinedUpdate).toBeDefined();
|
|
730
|
+
|
|
731
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
732
|
+
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
|
733
|
+
expect(closeArg).toContain("> 💭 **Thinking**");
|
|
734
|
+
expect(closeArg).toContain("---");
|
|
735
|
+
expect(closeArg).toContain("answer part final");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("provides onReasoningStream and onReasoningEnd when reasoning previews are allowed", () => {
|
|
739
|
+
const { result } = createDispatcherHarness({
|
|
740
|
+
runtime: createRuntimeLogger(),
|
|
741
|
+
allowReasoningPreview: true,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
|
|
745
|
+
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("omits reasoning callbacks unless reasoning previews are allowed", () => {
|
|
749
|
+
const { result } = createDispatcherHarness({
|
|
750
|
+
runtime: createRuntimeLogger(),
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
expect(result.replyOptions.onReasoningStream).toBeUndefined();
|
|
754
|
+
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("omits reasoning callbacks when streaming is disabled", () => {
|
|
758
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
759
|
+
accountId: "main",
|
|
760
|
+
appId: "app_id",
|
|
761
|
+
appSecret: "app_secret",
|
|
762
|
+
domain: "feishu",
|
|
763
|
+
config: {
|
|
764
|
+
renderMode: "auto",
|
|
765
|
+
streaming: false,
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const { result } = createDispatcherHarness({
|
|
770
|
+
runtime: createRuntimeLogger(),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
expect(result.replyOptions.onReasoningStream).toBeUndefined();
|
|
774
|
+
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("renders reasoning-only card when no answer text arrives", async () => {
|
|
778
|
+
const { result, options } = createDispatcherHarness({
|
|
779
|
+
runtime: createRuntimeLogger(),
|
|
780
|
+
allowReasoningPreview: true,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
await options.onReplyStart?.();
|
|
784
|
+
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" });
|
|
785
|
+
result.replyOptions.onReasoningEnd?.();
|
|
786
|
+
await options.onIdle?.();
|
|
787
|
+
|
|
788
|
+
expect(streamingInstances).toHaveLength(1);
|
|
789
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
790
|
+
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
|
791
|
+
expect(closeArg).toContain("> 💭 **Thinking**");
|
|
792
|
+
expect(closeArg).toContain("> deep thought");
|
|
793
|
+
expect(closeArg).not.toContain("Reasoning:");
|
|
794
|
+
expect(closeArg).not.toContain("---");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("ignores empty reasoning payloads", async () => {
|
|
798
|
+
const { result, options } = createDispatcherHarness({
|
|
799
|
+
runtime: createRuntimeLogger(),
|
|
800
|
+
allowReasoningPreview: true,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await options.onReplyStart?.();
|
|
804
|
+
result.replyOptions.onReasoningStream?.({ text: "" });
|
|
805
|
+
result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
|
|
806
|
+
await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
|
|
807
|
+
await options.onIdle?.();
|
|
539
808
|
|
|
540
809
|
expect(streamingInstances).toHaveLength(1);
|
|
541
|
-
|
|
810
|
+
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
|
811
|
+
expect(closeArg).not.toContain("Thinking");
|
|
812
|
+
expect(closeArg).toBe("```ts\ncode\n```");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("deduplicates final text by raw answer payload, not combined card text", async () => {
|
|
816
|
+
const { result, options } = createDispatcherHarness({
|
|
817
|
+
runtime: createRuntimeLogger(),
|
|
818
|
+
allowReasoningPreview: true,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
await options.onReplyStart?.();
|
|
822
|
+
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" });
|
|
823
|
+
result.replyOptions.onReasoningEnd?.();
|
|
824
|
+
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
|
|
825
|
+
await options.onIdle?.();
|
|
826
|
+
|
|
827
|
+
expect(streamingInstances).toHaveLength(1);
|
|
828
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
829
|
+
|
|
830
|
+
// Deliver the same raw answer text again — should be deduped
|
|
831
|
+
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
|
|
832
|
+
|
|
833
|
+
// No second streaming session since the raw answer text matches
|
|
834
|
+
expect(streamingInstances).toHaveLength(1);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
|
838
|
+
const { options } = createDispatcherHarness({
|
|
839
|
+
runtime: createRuntimeLogger(),
|
|
542
840
|
replyToMessageId: "om_msg",
|
|
543
841
|
replyInThread: true,
|
|
544
842
|
});
|
|
843
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
844
|
+
|
|
845
|
+
expect(streamingInstances).toHaveLength(1);
|
|
846
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
847
|
+
"oc_chat",
|
|
848
|
+
"chat_id",
|
|
849
|
+
expect.objectContaining({
|
|
850
|
+
replyToMessageId: "om_msg",
|
|
851
|
+
replyInThread: true,
|
|
852
|
+
header: { title: "agent", template: "blue" },
|
|
853
|
+
note: "Agent: agent",
|
|
854
|
+
}),
|
|
855
|
+
);
|
|
545
856
|
});
|
|
546
857
|
|
|
547
|
-
it("
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
agentId: "agent",
|
|
551
|
-
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
552
|
-
chatId: "oc_chat",
|
|
858
|
+
it("uses streaming cards for thread replies and keeps topic metadata", async () => {
|
|
859
|
+
const { options } = createDispatcherHarness({
|
|
860
|
+
runtime: createRuntimeLogger(),
|
|
553
861
|
replyToMessageId: "om_msg",
|
|
554
862
|
replyInThread: false,
|
|
555
863
|
threadReply: true,
|
|
556
864
|
rootId: "om_root_topic",
|
|
557
865
|
});
|
|
558
|
-
|
|
559
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
560
866
|
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
561
867
|
|
|
562
|
-
expect(streamingInstances).toHaveLength(
|
|
563
|
-
expect(
|
|
868
|
+
expect(streamingInstances).toHaveLength(1);
|
|
869
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
870
|
+
"oc_chat",
|
|
871
|
+
"chat_id",
|
|
564
872
|
expect.objectContaining({
|
|
565
873
|
replyToMessageId: "om_msg",
|
|
566
874
|
replyInThread: true,
|
|
875
|
+
rootId: "om_root_topic",
|
|
567
876
|
}),
|
|
568
877
|
);
|
|
878
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("omits the generic main header from streaming and static cards", async () => {
|
|
882
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
883
|
+
accountId: "main",
|
|
884
|
+
appId: "app_id",
|
|
885
|
+
appSecret: "app_secret",
|
|
886
|
+
domain: "feishu",
|
|
887
|
+
config: {
|
|
888
|
+
renderMode: "card",
|
|
889
|
+
streaming: true,
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const { options } = createDispatcherHarness({
|
|
894
|
+
agentId: "main",
|
|
895
|
+
runtime: createRuntimeLogger(),
|
|
896
|
+
});
|
|
897
|
+
await options.deliver({ text: "streamed card" }, { kind: "final" });
|
|
898
|
+
await options.onIdle?.();
|
|
899
|
+
|
|
900
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
901
|
+
"oc_chat",
|
|
902
|
+
"chat_id",
|
|
903
|
+
expect.objectContaining({
|
|
904
|
+
header: undefined,
|
|
905
|
+
}),
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
909
|
+
accountId: "main",
|
|
910
|
+
appId: "app_id",
|
|
911
|
+
appSecret: "app_secret",
|
|
912
|
+
domain: "feishu",
|
|
913
|
+
config: {
|
|
914
|
+
renderMode: "card",
|
|
915
|
+
streaming: false,
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const { options: staticOptions } = createDispatcherHarness({
|
|
920
|
+
agentId: "main",
|
|
921
|
+
runtime: createRuntimeLogger(),
|
|
922
|
+
});
|
|
923
|
+
await staticOptions.deliver({ text: "static card" }, { kind: "final" });
|
|
924
|
+
|
|
925
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
|
926
|
+
expect.objectContaining({
|
|
927
|
+
header: undefined,
|
|
928
|
+
}),
|
|
929
|
+
);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("shows transient tool status on streaming cards but omits it from the final close", async () => {
|
|
933
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
934
|
+
accountId: "main",
|
|
935
|
+
appId: "app_id",
|
|
936
|
+
appSecret: "app_secret",
|
|
937
|
+
domain: "feishu",
|
|
938
|
+
config: {
|
|
939
|
+
renderMode: "card",
|
|
940
|
+
streaming: true,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const { result, options } = createDispatcherHarness({
|
|
945
|
+
runtime: createRuntimeLogger(),
|
|
946
|
+
});
|
|
947
|
+
await options.onReplyStart?.();
|
|
948
|
+
result.replyOptions.onToolStart?.({ name: "web_search" });
|
|
949
|
+
result.replyOptions.onPartialReply?.({ text: "final answer" });
|
|
950
|
+
await options.onIdle?.();
|
|
951
|
+
|
|
952
|
+
const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) =>
|
|
953
|
+
typeof call[0] === "string" ? call[0] : "",
|
|
954
|
+
);
|
|
955
|
+
expect(updateTexts.some((text) => text.includes("Using: web_search"))).toBe(true);
|
|
956
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", {
|
|
957
|
+
note: "Agent: agent",
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("does not suppress a later final after error closeout", async () => {
|
|
962
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
963
|
+
accountId: "main",
|
|
964
|
+
appId: "app_id",
|
|
965
|
+
appSecret: "app_secret",
|
|
966
|
+
domain: "feishu",
|
|
967
|
+
config: {
|
|
968
|
+
renderMode: "card",
|
|
969
|
+
streaming: true,
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
|
|
973
|
+
|
|
974
|
+
const { options } = createDispatcherHarness({
|
|
975
|
+
runtime: createRuntimeLogger(),
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
await expect(
|
|
979
|
+
options.deliver(
|
|
980
|
+
{ text: "First answer", mediaUrl: "https://example.com/a.png" },
|
|
981
|
+
{ kind: "final" },
|
|
982
|
+
),
|
|
983
|
+
).rejects.toThrow("media failed");
|
|
984
|
+
await Promise.all([
|
|
985
|
+
options.onError?.(new Error("media failed"), { kind: "final" }),
|
|
986
|
+
options.onIdle?.(),
|
|
987
|
+
]);
|
|
988
|
+
await options.deliver({ text: "Second answer" }, { kind: "final" });
|
|
989
|
+
await options.onIdle?.();
|
|
990
|
+
|
|
991
|
+
expect(streamingInstances).toHaveLength(2);
|
|
992
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
|
|
993
|
+
note: "Agent: agent",
|
|
994
|
+
});
|
|
995
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("Second answer", {
|
|
996
|
+
note: "Agent: agent",
|
|
997
|
+
});
|
|
998
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
999
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("does not suppress a recovery final after late media failure", async () => {
|
|
1003
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
1004
|
+
accountId: "main",
|
|
1005
|
+
appId: "app_id",
|
|
1006
|
+
appSecret: "app_secret",
|
|
1007
|
+
domain: "feishu",
|
|
1008
|
+
config: {
|
|
1009
|
+
renderMode: "card",
|
|
1010
|
+
streaming: true,
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const { options } = createDispatcherHarness({
|
|
1015
|
+
runtime: createRuntimeLogger(),
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
await options.deliver({ text: "First answer" }, { kind: "final" });
|
|
1019
|
+
await options.onIdle?.();
|
|
1020
|
+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
|
|
1021
|
+
await expect(
|
|
1022
|
+
options.deliver(
|
|
1023
|
+
{ text: "Late attachment", mediaUrl: "https://example.com/a.png" },
|
|
1024
|
+
{ kind: "final" },
|
|
1025
|
+
),
|
|
1026
|
+
).rejects.toThrow("media failed");
|
|
1027
|
+
await options.onError?.(new Error("media failed"), { kind: "final" });
|
|
1028
|
+
await options.deliver({ text: "Recovered answer" }, { kind: "final" });
|
|
1029
|
+
await options.onIdle?.();
|
|
1030
|
+
|
|
1031
|
+
expect(streamingInstances).toHaveLength(2);
|
|
1032
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
|
|
1033
|
+
note: "Agent: agent",
|
|
1034
|
+
});
|
|
1035
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("Recovered answer", {
|
|
1036
|
+
note: "Agent: agent",
|
|
1037
|
+
});
|
|
1038
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("cleans streaming state even when close throws", async () => {
|
|
1042
|
+
const origPush = streamingInstances.push.bind(streamingInstances);
|
|
1043
|
+
streamingInstances.push = (...args: StreamingSessionStub[]) => {
|
|
1044
|
+
if (args.length > 0 && streamingInstances.length === 0) {
|
|
1045
|
+
args[0].close = vi.fn(async () => {
|
|
1046
|
+
args[0].active = false;
|
|
1047
|
+
throw new Error("close failed");
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return origPush(...args);
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
const { options } = createDispatcherHarness({
|
|
1055
|
+
runtime: createRuntimeLogger(),
|
|
1056
|
+
});
|
|
1057
|
+
await options.deliver({ text: "```md\nfirst\n```" }, { kind: "final" });
|
|
1058
|
+
await expect(options.onIdle?.()).rejects.toThrow("close failed");
|
|
1059
|
+
await options.deliver({ text: "```md\nsecond\n```" }, { kind: "final" });
|
|
1060
|
+
await options.onIdle?.();
|
|
1061
|
+
|
|
1062
|
+
expect(streamingInstances).toHaveLength(2);
|
|
1063
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\nsecond\n```", {
|
|
1064
|
+
note: "Agent: agent",
|
|
1065
|
+
});
|
|
1066
|
+
} finally {
|
|
1067
|
+
streamingInstances.push = origPush;
|
|
1068
|
+
}
|
|
569
1069
|
});
|
|
570
1070
|
|
|
571
1071
|
it("passes replyInThread to media attachments", async () => {
|
|
572
|
-
|
|
573
|
-
cfg: {} as never,
|
|
574
|
-
agentId: "agent",
|
|
575
|
-
runtime: {} as never,
|
|
576
|
-
chatId: "oc_chat",
|
|
1072
|
+
const { options } = createDispatcherHarness({
|
|
577
1073
|
replyToMessageId: "om_msg",
|
|
578
1074
|
replyInThread: true,
|
|
579
1075
|
});
|
|
580
|
-
|
|
581
|
-
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
582
1076
|
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
583
1077
|
|
|
584
1078
|
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
@@ -588,4 +1082,63 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
588
1082
|
}),
|
|
589
1083
|
);
|
|
590
1084
|
});
|
|
1085
|
+
|
|
1086
|
+
it("backs off streaming retries after start() throws (HTTP 400)", async () => {
|
|
1087
|
+
const errorMock = vi.fn();
|
|
1088
|
+
let shouldFailStart = true;
|
|
1089
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
1090
|
+
|
|
1091
|
+
// Intercept streaming instance creation to make first start() reject
|
|
1092
|
+
const origPush = streamingInstances.push.bind(streamingInstances);
|
|
1093
|
+
streamingInstances.push = (...args: StreamingSessionStub[]) => {
|
|
1094
|
+
if (shouldFailStart) {
|
|
1095
|
+
args[0].start = vi
|
|
1096
|
+
.fn()
|
|
1097
|
+
.mockRejectedValue(new Error("Create card request failed with HTTP 400"));
|
|
1098
|
+
shouldFailStart = false;
|
|
1099
|
+
}
|
|
1100
|
+
return origPush(...args);
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
try {
|
|
1104
|
+
createFeishuReplyDispatcher({
|
|
1105
|
+
cfg: {} as never,
|
|
1106
|
+
agentId: "agent",
|
|
1107
|
+
runtime: { log: vi.fn(), error: errorMock } as never,
|
|
1108
|
+
chatId: "oc_chat",
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
1112
|
+
|
|
1113
|
+
// First deliver with markdown triggers startStreaming - which will fail
|
|
1114
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
1115
|
+
|
|
1116
|
+
// Wait for the async error to propagate
|
|
1117
|
+
await vi.waitFor(() => {
|
|
1118
|
+
expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed"));
|
|
1119
|
+
});
|
|
1120
|
+
expect(streamingInstances).toHaveLength(1);
|
|
1121
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);
|
|
1122
|
+
|
|
1123
|
+
// Immediate next markdown reply should skip a new streaming start and
|
|
1124
|
+
// fall back directly to a normal card instead of paying the 400 latency.
|
|
1125
|
+
await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
|
|
1126
|
+
|
|
1127
|
+
expect(streamingInstances).toHaveLength(1);
|
|
1128
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2);
|
|
1129
|
+
|
|
1130
|
+
// After the short backoff expires, retry streaming so fixed permissions
|
|
1131
|
+
// or transient Feishu failures recover without a process restart.
|
|
1132
|
+
nowSpy.mockReturnValue(62_000);
|
|
1133
|
+
await options.deliver({ text: "```ts\nconst z = 3\n```" }, { kind: "final" });
|
|
1134
|
+
await options.onIdle?.();
|
|
1135
|
+
|
|
1136
|
+
expect(streamingInstances).toHaveLength(2);
|
|
1137
|
+
expect(streamingInstances[1].start).toHaveBeenCalled();
|
|
1138
|
+
expect(streamingInstances[1].close).toHaveBeenCalled();
|
|
1139
|
+
} finally {
|
|
1140
|
+
streamingInstances.push = origPush;
|
|
1141
|
+
nowSpy.mockRestore();
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
591
1144
|
});
|