@actagent/feishu 2026.6.2
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/README.md +11 -0
- package/actagent.plugin.json +224 -0
- package/api.ts +33 -0
- package/channel-entry.ts +21 -0
- package/channel-plugin-api.ts +2 -0
- package/contract-api.ts +17 -0
- package/index.ts +83 -0
- package/legacy-state-migrations-api.ts +2 -0
- package/npm-shrinkwrap.json +539 -0
- package/package.json +64 -0
- package/runtime-api.ts +58 -0
- package/runtime-setter-api.ts +3 -0
- package/secret-contract-api.ts +6 -0
- package/security-contract-api.ts +2 -0
- package/session-key-api.ts +2 -0
- package/setup-api.ts +4 -0
- package/setup-entry.test.ts +33 -0
- package/setup-entry.ts +25 -0
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +113 -0
- package/src/accounts.test.ts +481 -0
- package/src/accounts.ts +380 -0
- package/src/agent-config.ts +22 -0
- package/src/app-registration.test.ts +62 -0
- package/src/app-registration.ts +355 -0
- package/src/approval-auth.test.ts +25 -0
- package/src/approval-auth.ts +26 -0
- package/src/async.test.ts +68 -0
- package/src/async.ts +109 -0
- package/src/audio-preflight.runtime.ts +10 -0
- package/src/bitable.test.ts +174 -0
- package/src/bitable.ts +781 -0
- package/src/bot-content.ts +488 -0
- package/src/bot-group-name.test.ts +148 -0
- package/src/bot-runtime-api.ts +13 -0
- package/src/bot-sender-name.test.ts +68 -0
- package/src/bot-sender-name.ts +137 -0
- package/src/bot.broadcast.test.ts +643 -0
- package/src/bot.card-action.test.ts +647 -0
- package/src/bot.checkBotMentioned.test.ts +266 -0
- package/src/bot.helpers.test.ts +136 -0
- package/src/bot.stripBotMention.test.ts +127 -0
- package/src/bot.test.ts +3817 -0
- package/src/bot.ts +1788 -0
- package/src/card-action.ts +515 -0
- package/src/card-interaction.test.ts +132 -0
- package/src/card-interaction.ts +160 -0
- package/src/card-test-helpers.ts +55 -0
- package/src/card-ux-approval.ts +66 -0
- package/src/card-ux-launcher.test.ts +126 -0
- package/src/card-ux-launcher.ts +136 -0
- package/src/card-ux-shared.ts +34 -0
- package/src/channel-runtime-api.ts +17 -0
- package/src/channel.runtime.ts +48 -0
- package/src/channel.test.ts +1337 -0
- package/src/channel.ts +1401 -0
- package/src/chat-schema.ts +30 -0
- package/src/chat.test.ts +295 -0
- package/src/chat.ts +198 -0
- package/src/client-timeout.ts +44 -0
- package/src/client.test.ts +463 -0
- package/src/client.ts +263 -0
- package/src/comment-dispatcher-runtime-api.ts +7 -0
- package/src/comment-dispatcher.test.ts +186 -0
- package/src/comment-dispatcher.ts +108 -0
- package/src/comment-handler-runtime-api.ts +4 -0
- package/src/comment-handler.test.ts +588 -0
- package/src/comment-handler.ts +304 -0
- package/src/comment-reaction.test.ts +139 -0
- package/src/comment-reaction.ts +260 -0
- package/src/comment-shared.test.ts +184 -0
- package/src/comment-shared.ts +405 -0
- package/src/comment-target.ts +45 -0
- package/src/config-schema.test.ts +327 -0
- package/src/config-schema.ts +338 -0
- package/src/conversation-id.test.ts +19 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-migrations.test.ts +90 -0
- package/src/dedup-migrations.ts +103 -0
- package/src/dedup.test.ts +95 -0
- package/src/dedup.ts +304 -0
- package/src/dedupe-key.ts +68 -0
- package/src/directory.static.ts +62 -0
- package/src/directory.test.ts +142 -0
- package/src/directory.ts +125 -0
- package/src/doc-schema.ts +183 -0
- package/src/doctor.test.ts +382 -0
- package/src/doctor.ts +876 -0
- package/src/docx-batch-insert.test.ts +117 -0
- package/src/docx-batch-insert.ts +223 -0
- package/src/docx-color-text.ts +154 -0
- package/src/docx-table-ops.test.ts +54 -0
- package/src/docx-table-ops.ts +316 -0
- package/src/docx-types.ts +39 -0
- package/src/docx.account-selection.test.ts +96 -0
- package/src/docx.test.ts +706 -0
- package/src/docx.ts +1598 -0
- package/src/drive-schema.ts +93 -0
- package/src/drive.test.ts +1240 -0
- package/src/drive.ts +830 -0
- package/src/dynamic-agent.test.ts +156 -0
- package/src/dynamic-agent.ts +144 -0
- package/src/event-types.ts +46 -0
- package/src/external-keys.test.ts +21 -0
- package/src/external-keys.ts +20 -0
- package/src/lifecycle.test-support.ts +223 -0
- package/src/media.test.ts +956 -0
- package/src/media.ts +1106 -0
- package/src/mention-target.types.ts +6 -0
- package/src/mention.ts +115 -0
- package/src/message-action-contract.ts +14 -0
- package/src/monitor-state-runtime-api.ts +8 -0
- package/src/monitor-transport-runtime-api.ts +11 -0
- package/src/monitor.account.ts +501 -0
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +215 -0
- package/src/monitor.bot-identity.ts +87 -0
- package/src/monitor.bot-menu-handler.ts +164 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +221 -0
- package/src/monitor.bot-menu.test.ts +200 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +265 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +418 -0
- package/src/monitor.cleanup.test.ts +384 -0
- package/src/monitor.comment-notice-handler.ts +106 -0
- package/src/monitor.comment.test.ts +968 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +5 -0
- package/src/monitor.message-handler.ts +346 -0
- package/src/monitor.reaction.test.ts +770 -0
- package/src/monitor.startup.test.ts +232 -0
- package/src/monitor.startup.ts +76 -0
- package/src/monitor.state.defaults.test.ts +47 -0
- package/src/monitor.state.ts +171 -0
- package/src/monitor.synthetic-error.ts +19 -0
- package/src/monitor.test-mocks.ts +47 -0
- package/src/monitor.transport.ts +451 -0
- package/src/monitor.ts +104 -0
- package/src/monitor.webhook-e2e.test.ts +284 -0
- package/src/monitor.webhook-security.test.ts +394 -0
- package/src/monitor.webhook.test-helpers.ts +138 -0
- package/src/outbound-runtime-api.ts +2 -0
- package/src/outbound.test.ts +1255 -0
- package/src/outbound.ts +742 -0
- package/src/perm-schema.ts +53 -0
- package/src/perm.ts +171 -0
- package/src/pins.ts +109 -0
- package/src/policy.test.ts +224 -0
- package/src/policy.ts +322 -0
- package/src/post.test.ts +106 -0
- package/src/post.ts +276 -0
- package/src/presentation-card.ts +204 -0
- package/src/probe.test.ts +310 -0
- package/src/probe.ts +181 -0
- package/src/processing-claims.ts +60 -0
- package/src/qr-terminal.ts +2 -0
- package/src/reactions.ts +124 -0
- package/src/reasoning-preview.test.ts +114 -0
- package/src/reasoning-preview.ts +29 -0
- package/src/reply-dispatcher-runtime-api.ts +8 -0
- package/src/reply-dispatcher.test.ts +2009 -0
- package/src/reply-dispatcher.ts +865 -0
- package/src/runtime.ts +10 -0
- package/src/secret-contract.ts +146 -0
- package/src/secret-input.ts +2 -0
- package/src/security-audit-shared.ts +70 -0
- package/src/security-audit.test.ts +60 -0
- package/src/security-audit.ts +2 -0
- package/src/send-result.ts +81 -0
- package/src/send-target.test.ts +87 -0
- package/src/send-target.ts +36 -0
- package/src/send.reply-fallback.test.ts +418 -0
- package/src/send.test.ts +661 -0
- package/src/send.ts +860 -0
- package/src/sequential-key.test.ts +73 -0
- package/src/sequential-key.ts +29 -0
- package/src/sequential-queue.test.ts +184 -0
- package/src/sequential-queue.ts +90 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +49 -0
- package/src/setup-core.ts +52 -0
- package/src/setup-surface.test.ts +485 -0
- package/src/setup-surface.ts +620 -0
- package/src/streaming-card.test.ts +549 -0
- package/src/streaming-card.ts +611 -0
- package/src/subagent-hooks.test.ts +632 -0
- package/src/subagent-hooks.ts +414 -0
- package/src/targets.ts +98 -0
- package/src/test-support/lifecycle-test-support.ts +459 -0
- package/src/thread-bindings.test.ts +181 -0
- package/src/thread-bindings.ts +332 -0
- package/src/tool-account-routing.test.ts +419 -0
- package/src/tool-account.test.ts +45 -0
- package/src/tool-account.ts +98 -0
- package/src/tool-factory-test-harness.ts +83 -0
- package/src/tool-result.test.ts +33 -0
- package/src/tool-result.ts +17 -0
- package/src/tools-config.test.ts +52 -0
- package/src/tools-config.ts +29 -0
- package/src/types.ts +111 -0
- package/src/typing.test.ts +145 -0
- package/src/typing.ts +215 -0
- package/src/wiki-schema.ts +70 -0
- package/src/wiki.ts +271 -0
- package/subagent-hooks-api.ts +22 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// Feishu tests cover streaming card plugin behavior.
|
|
2
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
|
|
6
|
+
vi.mock("actagent/plugin-sdk/ssrf-runtime", () => ({
|
|
7
|
+
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
FeishuStreamingSession,
|
|
12
|
+
mergeStreamingText,
|
|
13
|
+
resolveStreamingCardSendMode,
|
|
14
|
+
} from "./streaming-card.js";
|
|
15
|
+
|
|
16
|
+
type StreamingSessionState = {
|
|
17
|
+
cardId: string;
|
|
18
|
+
messageId: string;
|
|
19
|
+
sequence: number;
|
|
20
|
+
currentText: string;
|
|
21
|
+
sentText: string;
|
|
22
|
+
hasNote: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function setStreamingSessionInternals(
|
|
26
|
+
session: FeishuStreamingSession,
|
|
27
|
+
values: {
|
|
28
|
+
state: StreamingSessionState;
|
|
29
|
+
lastUpdateTime?: number;
|
|
30
|
+
},
|
|
31
|
+
): void {
|
|
32
|
+
const internals = session as unknown as {
|
|
33
|
+
state: StreamingSessionState;
|
|
34
|
+
lastUpdateTime: number;
|
|
35
|
+
};
|
|
36
|
+
internals.state = values.state;
|
|
37
|
+
if (values.lastUpdateTime !== undefined) {
|
|
38
|
+
internals.lastUpdateTime = values.lastUpdateTime;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("FeishuStreamingSession", () => {
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
vi.doUnmock("actagent/plugin-sdk/ssrf-runtime");
|
|
45
|
+
vi.resetModules();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.useRealTimers();
|
|
50
|
+
fetchWithSsrFGuardMock.mockReset();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function mockFetches(
|
|
59
|
+
updateBodies: string[],
|
|
60
|
+
failedContentUpdateIndexes: ReadonlySet<number> = new Set<number>(),
|
|
61
|
+
replaceBodies: string[] = [],
|
|
62
|
+
failedContentUpdateStatuses: ReadonlyMap<number, number> = new Map<number, number>(),
|
|
63
|
+
failedReplaceStatuses: ReadonlyMap<number, number> = new Map<number, number>(),
|
|
64
|
+
): void {
|
|
65
|
+
fetchWithSsrFGuardMock.mockImplementation(
|
|
66
|
+
async ({ url, init }: { url: string; init?: { body?: string } }) => {
|
|
67
|
+
const release = vi.fn(async () => {});
|
|
68
|
+
let ok = true;
|
|
69
|
+
let status = 200;
|
|
70
|
+
if (url.includes("/auth/")) {
|
|
71
|
+
return {
|
|
72
|
+
response: {
|
|
73
|
+
ok: true,
|
|
74
|
+
json: async () => ({
|
|
75
|
+
code: 0,
|
|
76
|
+
msg: "ok",
|
|
77
|
+
tenant_access_token: "token",
|
|
78
|
+
expire: 7200,
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
release,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (url.includes("/elements/content/content")) {
|
|
85
|
+
const updateIndex = updateBodies.length;
|
|
86
|
+
updateBodies.push(init?.body ?? "");
|
|
87
|
+
if (failedContentUpdateIndexes.has(updateIndex)) {
|
|
88
|
+
throw new Error(`content update ${updateIndex} failed`);
|
|
89
|
+
}
|
|
90
|
+
const failedStatus = failedContentUpdateStatuses.get(updateIndex);
|
|
91
|
+
if (failedStatus !== undefined) {
|
|
92
|
+
ok = false;
|
|
93
|
+
status = failedStatus;
|
|
94
|
+
}
|
|
95
|
+
} else if (url.includes("/elements/content")) {
|
|
96
|
+
const replaceIndex = replaceBodies.length;
|
|
97
|
+
replaceBodies.push(init?.body ?? "");
|
|
98
|
+
const failedStatus = failedReplaceStatuses.get(replaceIndex);
|
|
99
|
+
if (failedStatus !== undefined) {
|
|
100
|
+
ok = false;
|
|
101
|
+
status = failedStatus;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
response: {
|
|
106
|
+
ok,
|
|
107
|
+
status,
|
|
108
|
+
json: async () => ({ code: 0, msg: "ok" }),
|
|
109
|
+
},
|
|
110
|
+
release,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mockStreamingTokenStart(resolveAuthJson: (token: string) => Record<string, unknown>): {
|
|
117
|
+
authTokens: string[];
|
|
118
|
+
client: ConstructorParameters<typeof FeishuStreamingSession>[0];
|
|
119
|
+
} {
|
|
120
|
+
const release = vi.fn(async () => {});
|
|
121
|
+
const authTokens: string[] = [];
|
|
122
|
+
fetchWithSsrFGuardMock.mockImplementation(
|
|
123
|
+
async ({ url }: { url: string; init?: { body?: string } }) => {
|
|
124
|
+
if (url.includes("/auth/")) {
|
|
125
|
+
const token = `token-${authTokens.length + 1}`;
|
|
126
|
+
authTokens.push(token);
|
|
127
|
+
return {
|
|
128
|
+
response: { ok: true, json: async () => resolveAuthJson(token) },
|
|
129
|
+
release,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
response: {
|
|
134
|
+
ok: true,
|
|
135
|
+
json: async () => ({
|
|
136
|
+
code: 0,
|
|
137
|
+
msg: "ok",
|
|
138
|
+
data: { card_id: `card-${authTokens.length}` },
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
release,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
const client = {
|
|
146
|
+
im: {
|
|
147
|
+
message: {
|
|
148
|
+
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
} as unknown as ConstructorParameters<typeof FeishuStreamingSession>[0];
|
|
152
|
+
return { authTokens, client };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it("flushes throttled pending text after the throttle window", async () => {
|
|
156
|
+
vi.useFakeTimers();
|
|
157
|
+
vi.setSystemTime(1_000);
|
|
158
|
+
const updateBodies: string[] = [];
|
|
159
|
+
mockFetches(updateBodies);
|
|
160
|
+
|
|
161
|
+
const session = new FeishuStreamingSession({} as never, {
|
|
162
|
+
appId: "app_pending_flush",
|
|
163
|
+
appSecret: "secret",
|
|
164
|
+
});
|
|
165
|
+
setStreamingSessionInternals(session, {
|
|
166
|
+
state: {
|
|
167
|
+
cardId: "card_1",
|
|
168
|
+
messageId: "om_1",
|
|
169
|
+
sequence: 1,
|
|
170
|
+
currentText: "hello",
|
|
171
|
+
sentText: "hello",
|
|
172
|
+
hasNote: false,
|
|
173
|
+
},
|
|
174
|
+
lastUpdateTime: 1_000,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await session.update("hello small");
|
|
178
|
+
expect(updateBodies).toHaveLength(0);
|
|
179
|
+
|
|
180
|
+
await vi.advanceTimersByTimeAsync(160);
|
|
181
|
+
|
|
182
|
+
expect(updateBodies).toHaveLength(1);
|
|
183
|
+
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
|
|
184
|
+
content: "hello small",
|
|
185
|
+
sequence: 2,
|
|
186
|
+
uuid: "s_card_1_2",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("pushes natural-boundary updates immediately inside the throttle window", async () => {
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
vi.setSystemTime(2_000);
|
|
193
|
+
const updateBodies: string[] = [];
|
|
194
|
+
mockFetches(updateBodies);
|
|
195
|
+
|
|
196
|
+
const session = new FeishuStreamingSession({} as never, {
|
|
197
|
+
appId: "app_boundary_flush",
|
|
198
|
+
appSecret: "secret",
|
|
199
|
+
});
|
|
200
|
+
setStreamingSessionInternals(session, {
|
|
201
|
+
state: {
|
|
202
|
+
cardId: "card_2",
|
|
203
|
+
messageId: "om_2",
|
|
204
|
+
sequence: 1,
|
|
205
|
+
currentText: "hello",
|
|
206
|
+
sentText: "hello",
|
|
207
|
+
hasNote: false,
|
|
208
|
+
},
|
|
209
|
+
lastUpdateTime: 2_000,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await session.update("hello!");
|
|
213
|
+
|
|
214
|
+
expect(updateBodies).toHaveLength(1);
|
|
215
|
+
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
|
|
216
|
+
content: "hello!",
|
|
217
|
+
sequence: 2,
|
|
218
|
+
uuid: "s_card_2_2",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("retries cumulative content after a failed streaming update", async () => {
|
|
223
|
+
vi.useFakeTimers();
|
|
224
|
+
vi.setSystemTime(3_000);
|
|
225
|
+
const updateBodies: string[] = [];
|
|
226
|
+
mockFetches(updateBodies, new Set([0]));
|
|
227
|
+
|
|
228
|
+
const session = new FeishuStreamingSession({} as never, {
|
|
229
|
+
appId: "app_failed_delta_retry",
|
|
230
|
+
appSecret: "secret",
|
|
231
|
+
});
|
|
232
|
+
setStreamingSessionInternals(session, {
|
|
233
|
+
state: {
|
|
234
|
+
cardId: "card_3",
|
|
235
|
+
messageId: "om_3",
|
|
236
|
+
sequence: 1,
|
|
237
|
+
currentText: "hello",
|
|
238
|
+
sentText: "hello",
|
|
239
|
+
hasNote: false,
|
|
240
|
+
},
|
|
241
|
+
lastUpdateTime: 2_000,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await session.update("hello world");
|
|
245
|
+
await session.update("hello world!");
|
|
246
|
+
|
|
247
|
+
expect(updateBodies).toHaveLength(2);
|
|
248
|
+
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
|
|
249
|
+
content: "hello world",
|
|
250
|
+
sequence: 2,
|
|
251
|
+
uuid: "s_card_3_2",
|
|
252
|
+
});
|
|
253
|
+
expect(JSON.parse(updateBodies[1] ?? "{}")).toEqual({
|
|
254
|
+
content: "hello world!",
|
|
255
|
+
sequence: 3,
|
|
256
|
+
uuid: "s_card_3_3",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("retries cumulative content after a non-OK streaming update", async () => {
|
|
261
|
+
vi.useFakeTimers();
|
|
262
|
+
vi.setSystemTime(3_500);
|
|
263
|
+
const updateBodies: string[] = [];
|
|
264
|
+
mockFetches(updateBodies, new Set<number>(), [], new Map([[0, 429]]));
|
|
265
|
+
|
|
266
|
+
const session = new FeishuStreamingSession({} as never, {
|
|
267
|
+
appId: "app_non_ok_delta_retry",
|
|
268
|
+
appSecret: "secret",
|
|
269
|
+
});
|
|
270
|
+
setStreamingSessionInternals(session, {
|
|
271
|
+
state: {
|
|
272
|
+
cardId: "card_5",
|
|
273
|
+
messageId: "om_5",
|
|
274
|
+
sequence: 1,
|
|
275
|
+
currentText: "hello",
|
|
276
|
+
sentText: "hello",
|
|
277
|
+
hasNote: false,
|
|
278
|
+
},
|
|
279
|
+
lastUpdateTime: 2_000,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await session.update("hello world");
|
|
283
|
+
await session.update("hello world!");
|
|
284
|
+
|
|
285
|
+
expect(updateBodies).toHaveLength(2);
|
|
286
|
+
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
|
|
287
|
+
content: "hello world",
|
|
288
|
+
sequence: 2,
|
|
289
|
+
uuid: "s_card_5_2",
|
|
290
|
+
});
|
|
291
|
+
expect(JSON.parse(updateBodies[1] ?? "{}")).toEqual({
|
|
292
|
+
content: "hello world!",
|
|
293
|
+
sequence: 3,
|
|
294
|
+
uuid: "s_card_5_3",
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("replaces content when final text removes transient streamed status", async () => {
|
|
299
|
+
vi.useFakeTimers();
|
|
300
|
+
vi.setSystemTime(4_000);
|
|
301
|
+
const updateBodies: string[] = [];
|
|
302
|
+
const replaceBodies: string[] = [];
|
|
303
|
+
mockFetches(updateBodies, new Set<number>(), replaceBodies);
|
|
304
|
+
|
|
305
|
+
const session = new FeishuStreamingSession({} as never, {
|
|
306
|
+
appId: "app_final_rewrite",
|
|
307
|
+
appSecret: "secret",
|
|
308
|
+
});
|
|
309
|
+
setStreamingSessionInternals(session, {
|
|
310
|
+
state: {
|
|
311
|
+
cardId: "card_4",
|
|
312
|
+
messageId: "om_4",
|
|
313
|
+
sequence: 1,
|
|
314
|
+
currentText: "🔎 Web Search\n\nfinal answer",
|
|
315
|
+
sentText: "🔎 Web Search\n\nfinal answer",
|
|
316
|
+
hasNote: false,
|
|
317
|
+
},
|
|
318
|
+
lastUpdateTime: 3_000,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await session.close("final answer");
|
|
322
|
+
|
|
323
|
+
expect(updateBodies).toHaveLength(0);
|
|
324
|
+
expect(replaceBodies).toHaveLength(1);
|
|
325
|
+
const replacePayload = JSON.parse(replaceBodies[0] ?? "{}") as {
|
|
326
|
+
element?: string;
|
|
327
|
+
sequence?: number;
|
|
328
|
+
uuid?: string;
|
|
329
|
+
};
|
|
330
|
+
expect({
|
|
331
|
+
...replacePayload,
|
|
332
|
+
element: JSON.parse(replacePayload.element ?? "{}"),
|
|
333
|
+
}).toEqual({
|
|
334
|
+
element: {
|
|
335
|
+
tag: "markdown",
|
|
336
|
+
content: "final answer",
|
|
337
|
+
element_id: "content",
|
|
338
|
+
},
|
|
339
|
+
sequence: 2,
|
|
340
|
+
uuid: "r_card_4_2",
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("logs a final replacement failure when CardKit returns non-OK", async () => {
|
|
345
|
+
vi.useFakeTimers();
|
|
346
|
+
vi.setSystemTime(4_500);
|
|
347
|
+
const updateBodies: string[] = [];
|
|
348
|
+
const replaceBodies: string[] = [];
|
|
349
|
+
mockFetches(
|
|
350
|
+
updateBodies,
|
|
351
|
+
new Set<number>(),
|
|
352
|
+
replaceBodies,
|
|
353
|
+
new Map<number, number>(),
|
|
354
|
+
new Map([[0, 500]]),
|
|
355
|
+
);
|
|
356
|
+
const log = vi.fn();
|
|
357
|
+
|
|
358
|
+
const session = new FeishuStreamingSession(
|
|
359
|
+
{} as never,
|
|
360
|
+
{
|
|
361
|
+
appId: "app_final_rewrite_non_ok",
|
|
362
|
+
appSecret: "secret",
|
|
363
|
+
},
|
|
364
|
+
log,
|
|
365
|
+
);
|
|
366
|
+
setStreamingSessionInternals(session, {
|
|
367
|
+
state: {
|
|
368
|
+
cardId: "card_6",
|
|
369
|
+
messageId: "om_6",
|
|
370
|
+
sequence: 1,
|
|
371
|
+
currentText: "working\n\nfinal answer",
|
|
372
|
+
sentText: "working\n\nfinal answer",
|
|
373
|
+
hasNote: false,
|
|
374
|
+
},
|
|
375
|
+
lastUpdateTime: 3_000,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await session.close("final answer");
|
|
379
|
+
|
|
380
|
+
expect(updateBodies).toHaveLength(0);
|
|
381
|
+
expect(replaceBodies).toHaveLength(1);
|
|
382
|
+
expect(log).toHaveBeenCalledWith(
|
|
383
|
+
"Final replace failed: Error: Replace card content failed with HTTP 500",
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("reports no visible content when final close update fails before any accepted text", async () => {
|
|
388
|
+
vi.useFakeTimers();
|
|
389
|
+
vi.setSystemTime(4_800);
|
|
390
|
+
const updateBodies: string[] = [];
|
|
391
|
+
const replaceBodies: string[] = [];
|
|
392
|
+
mockFetches(updateBodies, new Set<number>(), replaceBodies, new Map([[0, 500]]));
|
|
393
|
+
const log = vi.fn();
|
|
394
|
+
|
|
395
|
+
const session = new FeishuStreamingSession(
|
|
396
|
+
{} as never,
|
|
397
|
+
{
|
|
398
|
+
appId: "app_final_update_non_ok",
|
|
399
|
+
appSecret: "secret",
|
|
400
|
+
},
|
|
401
|
+
log,
|
|
402
|
+
);
|
|
403
|
+
setStreamingSessionInternals(session, {
|
|
404
|
+
state: {
|
|
405
|
+
cardId: "card_7",
|
|
406
|
+
messageId: "om_7",
|
|
407
|
+
sequence: 1,
|
|
408
|
+
currentText: "",
|
|
409
|
+
sentText: "",
|
|
410
|
+
hasNote: false,
|
|
411
|
+
},
|
|
412
|
+
lastUpdateTime: 3_000,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await expect(session.close("final answer")).resolves.toBe(false);
|
|
416
|
+
|
|
417
|
+
expect(updateBodies).toHaveLength(1);
|
|
418
|
+
expect(replaceBodies).toHaveLength(0);
|
|
419
|
+
expect(log).toHaveBeenCalledWith(
|
|
420
|
+
"Final update failed: Error: Update card content failed with HTTP 500",
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("bounds streaming token cache lifetime when token expiry overflows", async () => {
|
|
425
|
+
vi.useFakeTimers();
|
|
426
|
+
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
|
427
|
+
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
|
428
|
+
code: 0,
|
|
429
|
+
msg: "ok",
|
|
430
|
+
tenant_access_token: token,
|
|
431
|
+
expire: Number.MAX_SAFE_INTEGER,
|
|
432
|
+
}));
|
|
433
|
+
|
|
434
|
+
await new FeishuStreamingSession(client, {
|
|
435
|
+
appId: "app_unsafe_token_expiry",
|
|
436
|
+
appSecret: "secret",
|
|
437
|
+
}).start("chat_id", "open_id");
|
|
438
|
+
expect(authTokens).toEqual(["token-1"]);
|
|
439
|
+
|
|
440
|
+
vi.setSystemTime(Date.now() + 7200 * 1000 - 60_000 + 1);
|
|
441
|
+
await new FeishuStreamingSession(client, {
|
|
442
|
+
appId: "app_unsafe_token_expiry",
|
|
443
|
+
appSecret: "secret",
|
|
444
|
+
}).start("chat_id", "open_id");
|
|
445
|
+
|
|
446
|
+
expect(authTokens).toEqual(["token-1", "token-2"]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("bounds streaming token fallback lifetime when the process clock is invalid", async () => {
|
|
450
|
+
const dateNow = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
|
451
|
+
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
|
452
|
+
code: 0,
|
|
453
|
+
msg: "ok",
|
|
454
|
+
tenant_access_token: token,
|
|
455
|
+
}));
|
|
456
|
+
|
|
457
|
+
await new FeishuStreamingSession(client, {
|
|
458
|
+
appId: "app_invalid_clock_token_expiry",
|
|
459
|
+
appSecret: "secret",
|
|
460
|
+
}).start("chat_id", "open_id");
|
|
461
|
+
expect(authTokens).toEqual(["token-1"]);
|
|
462
|
+
|
|
463
|
+
dateNow.mockReturnValue(7200 * 1000 - 60_000 + 1);
|
|
464
|
+
await new FeishuStreamingSession(client, {
|
|
465
|
+
appId: "app_invalid_clock_token_expiry",
|
|
466
|
+
appSecret: "secret",
|
|
467
|
+
}).start("chat_id", "open_id");
|
|
468
|
+
|
|
469
|
+
expect(authTokens).toEqual(["token-1", "token-2"]);
|
|
470
|
+
dateNow.mockRestore();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("treats an invalid process clock as a streaming token cache miss", async () => {
|
|
474
|
+
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-29T12:00:00.000Z"));
|
|
475
|
+
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
|
476
|
+
code: 0,
|
|
477
|
+
msg: "ok",
|
|
478
|
+
tenant_access_token: token,
|
|
479
|
+
expire: 7200,
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
await new FeishuStreamingSession(client, {
|
|
483
|
+
appId: "app_invalid_clock_cache_miss",
|
|
484
|
+
appSecret: "secret",
|
|
485
|
+
}).start("chat_id", "open_id");
|
|
486
|
+
expect(authTokens).toEqual(["token-1"]);
|
|
487
|
+
|
|
488
|
+
dateNow.mockReturnValue(8_640_000_000_000_001);
|
|
489
|
+
await new FeishuStreamingSession(client, {
|
|
490
|
+
appId: "app_invalid_clock_cache_miss",
|
|
491
|
+
appSecret: "secret",
|
|
492
|
+
}).start("chat_id", "open_id");
|
|
493
|
+
|
|
494
|
+
expect(authTokens).toEqual(["token-1", "token-2"]);
|
|
495
|
+
dateNow.mockRestore();
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe("mergeStreamingText", () => {
|
|
500
|
+
it("prefers the latest full text when it already includes prior text", () => {
|
|
501
|
+
expect(mergeStreamingText("hello", "hello world")).toBe("hello world");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("keeps previous text when the next partial is empty or redundant", () => {
|
|
505
|
+
expect(mergeStreamingText("hello", "")).toBe("hello");
|
|
506
|
+
expect(mergeStreamingText("hello world", "hello")).toBe("hello world");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("appends fragmented chunks without injecting newlines", () => {
|
|
510
|
+
expect(mergeStreamingText("hello wor", "ld")).toBe("hello world");
|
|
511
|
+
expect(mergeStreamingText("line1", "line2")).toBe("line1line2");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("merges overlap between adjacent partial snapshots", () => {
|
|
515
|
+
expect(mergeStreamingText("好的,让我", "让我再读取一遍")).toBe("好的,让我再读取一遍");
|
|
516
|
+
expect(mergeStreamingText("revision_id: 552", "2,一点变化都没有")).toBe(
|
|
517
|
+
"revision_id: 552,一点变化都没有",
|
|
518
|
+
);
|
|
519
|
+
expect(mergeStreamingText("abc", "cabc")).toBe("cabc");
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("resolveStreamingCardSendMode", () => {
|
|
524
|
+
it("prefers message.reply when reply target and root id both exist", () => {
|
|
525
|
+
expect(
|
|
526
|
+
resolveStreamingCardSendMode({
|
|
527
|
+
replyToMessageId: "om_parent",
|
|
528
|
+
rootId: "om_topic_root",
|
|
529
|
+
}),
|
|
530
|
+
).toBe("reply");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("falls back to root create when reply target is absent", () => {
|
|
534
|
+
expect(
|
|
535
|
+
resolveStreamingCardSendMode({
|
|
536
|
+
rootId: "om_topic_root",
|
|
537
|
+
}),
|
|
538
|
+
).toBe("root_create");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("uses create mode when no reply routing fields are provided", () => {
|
|
542
|
+
expect(resolveStreamingCardSendMode()).toBe("create");
|
|
543
|
+
expect(
|
|
544
|
+
resolveStreamingCardSendMode({
|
|
545
|
+
replyInThread: true,
|
|
546
|
+
}),
|
|
547
|
+
).toBe("create");
|
|
548
|
+
});
|
|
549
|
+
});
|