talon-agent 1.0.0 → 1.2.0
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/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- package/src/util/workspace.ts +51 -10
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("../util/log.js", () => ({
|
|
4
|
-
log: vi.fn(),
|
|
4
|
+
log: vi.fn(),
|
|
5
|
+
logError: vi.fn(),
|
|
6
|
+
logWarn: vi.fn(),
|
|
7
|
+
logDebug: vi.fn(),
|
|
5
8
|
}));
|
|
6
9
|
|
|
7
10
|
vi.mock("../core/plugin.js", () => ({
|
|
@@ -10,19 +13,21 @@ vi.mock("../core/plugin.js", () => ({
|
|
|
10
13
|
|
|
11
14
|
// ── Test formatting module ──────────────────────────────────────────────────
|
|
12
15
|
|
|
13
|
-
const { buildAdaptiveCard, splitTeamsMessage, stripHtml } =
|
|
14
|
-
"../frontend/teams/formatting.js"
|
|
15
|
-
);
|
|
16
|
+
const { buildAdaptiveCard, splitTeamsMessage, stripHtml } =
|
|
17
|
+
await import("../frontend/teams/formatting.js");
|
|
16
18
|
|
|
17
19
|
describe("teams formatting", () => {
|
|
18
|
-
|
|
19
20
|
describe("buildAdaptiveCard", () => {
|
|
20
21
|
it("builds a basic card with text", () => {
|
|
21
22
|
const card = buildAdaptiveCard("Hello Teams!");
|
|
22
23
|
expect(card.type).toBe("message");
|
|
23
24
|
expect(card.attachments).toHaveLength(1);
|
|
24
|
-
const attachment = (
|
|
25
|
-
|
|
25
|
+
const attachment = (
|
|
26
|
+
card.attachments as Array<Record<string, unknown>>
|
|
27
|
+
)[0];
|
|
28
|
+
expect(attachment.contentType).toBe(
|
|
29
|
+
"application/vnd.microsoft.card.adaptive",
|
|
30
|
+
);
|
|
26
31
|
const content = attachment.content as Record<string, unknown>;
|
|
27
32
|
expect(content.type).toBe("AdaptiveCard");
|
|
28
33
|
expect(content.version).toBe("1.5");
|
|
@@ -36,7 +41,9 @@ describe("teams formatting", () => {
|
|
|
36
41
|
{ text: "Docs", url: "https://docs.example.com" },
|
|
37
42
|
{ text: "Repo", url: "https://github.com/example" },
|
|
38
43
|
]);
|
|
39
|
-
const content = (
|
|
44
|
+
const content = (
|
|
45
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
46
|
+
).content as Record<string, unknown>;
|
|
40
47
|
const actions = content.actions as Array<Record<string, unknown>>;
|
|
41
48
|
expect(actions).toHaveLength(2);
|
|
42
49
|
expect(actions[0].type).toBe("Action.OpenUrl");
|
|
@@ -49,7 +56,9 @@ describe("teams formatting", () => {
|
|
|
49
56
|
{ text: "Option A" },
|
|
50
57
|
{ text: "Option B" },
|
|
51
58
|
]);
|
|
52
|
-
const content = (
|
|
59
|
+
const content = (
|
|
60
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
61
|
+
).content as Record<string, unknown>;
|
|
53
62
|
const actions = content.actions as Array<Record<string, unknown>>;
|
|
54
63
|
expect(actions[0].type).toBe("Action.Submit");
|
|
55
64
|
expect(actions[0].data).toEqual({ choice: "Option A" });
|
|
@@ -57,15 +66,155 @@ describe("teams formatting", () => {
|
|
|
57
66
|
|
|
58
67
|
it("omits actions when no buttons provided", () => {
|
|
59
68
|
const card = buildAdaptiveCard("No buttons");
|
|
60
|
-
const content = (
|
|
69
|
+
const content = (
|
|
70
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
71
|
+
).content as Record<string, unknown>;
|
|
61
72
|
expect(content.actions).toBeUndefined();
|
|
62
73
|
});
|
|
63
74
|
|
|
64
75
|
it("omits actions for empty button array", () => {
|
|
65
76
|
const card = buildAdaptiveCard("Empty buttons", []);
|
|
66
|
-
const content = (
|
|
77
|
+
const content = (
|
|
78
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
79
|
+
).content as Record<string, unknown>;
|
|
67
80
|
expect(content.actions).toBeUndefined();
|
|
68
81
|
});
|
|
82
|
+
|
|
83
|
+
it("renders fenced code block as monospace Container", () => {
|
|
84
|
+
const card = buildAdaptiveCard("```\nconst x = 1;\nconst y = 2;\n```");
|
|
85
|
+
const content = (
|
|
86
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
87
|
+
).content as Record<string, unknown>;
|
|
88
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
89
|
+
const codeBlock = body.find((b) => b.type === "Container");
|
|
90
|
+
expect(codeBlock).toBeDefined();
|
|
91
|
+
const items = codeBlock!.items as Array<Record<string, unknown>>;
|
|
92
|
+
expect(items.some((i) => i.fontType === "Monospace")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders unordered list as TextBlock with dashes", () => {
|
|
96
|
+
const card = buildAdaptiveCard("- item one\n- item two\n- item three");
|
|
97
|
+
const content = (
|
|
98
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
99
|
+
).content as Record<string, unknown>;
|
|
100
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
101
|
+
const listBlock = body.find(
|
|
102
|
+
(b) =>
|
|
103
|
+
typeof b.text === "string" &&
|
|
104
|
+
(b.text as string).includes("- item one"),
|
|
105
|
+
);
|
|
106
|
+
expect(listBlock).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders ordered list with numeric prefixes", () => {
|
|
110
|
+
const card = buildAdaptiveCard("1. first\n2. second\n3. third");
|
|
111
|
+
const content = (
|
|
112
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
113
|
+
).content as Record<string, unknown>;
|
|
114
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
115
|
+
const listBlock = body.find(
|
|
116
|
+
(b) =>
|
|
117
|
+
typeof b.text === "string" && (b.text as string).includes("1. first"),
|
|
118
|
+
);
|
|
119
|
+
expect(listBlock).toBeDefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders markdown table as Table element", () => {
|
|
123
|
+
const tableMarkdown =
|
|
124
|
+
"| Header A | Header B |\n| --- | --- |\n| Row 1A | Row 1B |\n| Row 2A | Row 2B |";
|
|
125
|
+
const card = buildAdaptiveCard(tableMarkdown);
|
|
126
|
+
const content = (
|
|
127
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
128
|
+
).content as Record<string, unknown>;
|
|
129
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
130
|
+
const tableBlock = body.find((b) => b.type === "Table");
|
|
131
|
+
expect(tableBlock).toBeDefined();
|
|
132
|
+
expect(tableBlock!.firstRowAsHeader).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("renders blockquote as emphasis Container", () => {
|
|
136
|
+
const card = buildAdaptiveCard("> This is a quote");
|
|
137
|
+
const content = (
|
|
138
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
139
|
+
).content as Record<string, unknown>;
|
|
140
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
141
|
+
const bqBlock = body.find(
|
|
142
|
+
(b) => b.type === "Container" && b.style === "emphasis",
|
|
143
|
+
);
|
|
144
|
+
expect(bqBlock).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("renders horizontal rule as separator TextBlock", () => {
|
|
148
|
+
const card = buildAdaptiveCard("Above\n\n---\n\nBelow");
|
|
149
|
+
const content = (
|
|
150
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
151
|
+
).content as Record<string, unknown>;
|
|
152
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
153
|
+
const hrBlock = body.find(
|
|
154
|
+
(b) => typeof b.text === "string" && (b.text as string).includes("───"),
|
|
155
|
+
);
|
|
156
|
+
expect(hrBlock).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("falls back to TextBlock when body would be empty", () => {
|
|
160
|
+
// A text with only whitespace results in space tokens → empty body → fallback
|
|
161
|
+
const card = buildAdaptiveCard(" \n\n ");
|
|
162
|
+
const content = (
|
|
163
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
164
|
+
).content as Record<string, unknown>;
|
|
165
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
166
|
+
expect(body.length).toBeGreaterThanOrEqual(1);
|
|
167
|
+
expect(body[0]!.type).toBe("TextBlock");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("skips default-case token with no text property (line 123 FALSE branch)", () => {
|
|
171
|
+
// A link reference definition produces a 'def' token with no `.text` property
|
|
172
|
+
// → falls into default: case → `"text" in token` is false → condition FALSE → skipped
|
|
173
|
+
// body ends up empty → fallback TextBlock is added
|
|
174
|
+
const card = buildAdaptiveCard("[ref]: http://example.com");
|
|
175
|
+
const content = (
|
|
176
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
177
|
+
).content as Record<string, unknown>;
|
|
178
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
179
|
+
expect(body.length).toBeGreaterThanOrEqual(1);
|
|
180
|
+
expect(body[0]!.type).toBe("TextBlock");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("renders heading with bold styling", () => {
|
|
184
|
+
const card = buildAdaptiveCard("## Section Title");
|
|
185
|
+
const content = (
|
|
186
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
187
|
+
).content as Record<string, unknown>;
|
|
188
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
189
|
+
const heading = body.find((b) => b.weight === "Bolder");
|
|
190
|
+
expect(heading).toBeDefined();
|
|
191
|
+
expect(heading!.size).toBe("Medium");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("replaces empty code block lines with non-breaking space", () => {
|
|
195
|
+
// Empty line in code block triggers (line || " ") branch
|
|
196
|
+
const card = buildAdaptiveCard("```\nfirst line\n\nthird line\n```");
|
|
197
|
+
const content = (
|
|
198
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
199
|
+
).content as Record<string, unknown>;
|
|
200
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
201
|
+
const codeBlock = body.find((b) => b.type === "Container") as
|
|
202
|
+
| Record<string, unknown>
|
|
203
|
+
| undefined;
|
|
204
|
+
expect(codeBlock).toBeDefined();
|
|
205
|
+
const items = codeBlock!.items as Array<Record<string, unknown>>;
|
|
206
|
+
// The empty line should become " " (non-breaking space placeholder)
|
|
207
|
+
const emptyLineBlock = items.find((i) => i.text === "\u00a0");
|
|
208
|
+
expect(emptyLineBlock).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("splitTeamsMessage returns remaining text as final chunk", () => {
|
|
212
|
+
// text that splits evenly won't have leftover, test a case with leftover
|
|
213
|
+
const text = "A".repeat(4000) + "\n\n" + "B".repeat(100);
|
|
214
|
+
const chunks = splitTeamsMessage(text, 4500);
|
|
215
|
+
// Should have 1 chunk since total is < 4500
|
|
216
|
+
expect(chunks.join("")).toContain("BBBB");
|
|
217
|
+
});
|
|
69
218
|
});
|
|
70
219
|
|
|
71
220
|
describe("splitTeamsMessage", () => {
|
|
@@ -74,14 +223,17 @@ describe("teams formatting", () => {
|
|
|
74
223
|
});
|
|
75
224
|
|
|
76
225
|
it("splits on paragraph boundaries", () => {
|
|
77
|
-
const text =
|
|
226
|
+
const text =
|
|
227
|
+
"A".repeat(5000) + "\n\n" + "B".repeat(5000) + "\n\n" + "C".repeat(100);
|
|
78
228
|
const chunks = splitTeamsMessage(text, 6000);
|
|
79
229
|
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
|
80
230
|
expect(chunks[0]).toContain("A");
|
|
81
231
|
});
|
|
82
232
|
|
|
83
233
|
it("falls back to line boundaries when no paragraph break", () => {
|
|
84
|
-
const text = Array.from({ length: 200 }, (_, i) => `Line ${i}`).join(
|
|
234
|
+
const text = Array.from({ length: 200 }, (_, i) => `Line ${i}`).join(
|
|
235
|
+
"\n",
|
|
236
|
+
);
|
|
85
237
|
const chunks = splitTeamsMessage(text, 500);
|
|
86
238
|
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
|
87
239
|
for (const chunk of chunks) {
|
|
@@ -108,7 +260,9 @@ describe("teams formatting", () => {
|
|
|
108
260
|
});
|
|
109
261
|
|
|
110
262
|
it("handles nested tags", () => {
|
|
111
|
-
expect(stripHtml("<div><p>Text <em>here</em></p></div>")).toBe(
|
|
263
|
+
expect(stripHtml("<div><p>Text <em>here</em></p></div>")).toBe(
|
|
264
|
+
"Text here",
|
|
265
|
+
);
|
|
112
266
|
});
|
|
113
267
|
|
|
114
268
|
it("returns plain text unchanged", () => {
|
|
@@ -145,13 +299,20 @@ describe("teams actions", () => {
|
|
|
145
299
|
}));
|
|
146
300
|
|
|
147
301
|
const { Gateway } = await import("../core/gateway.js");
|
|
148
|
-
const { createTeamsActionHandler } =
|
|
302
|
+
const { createTeamsActionHandler } =
|
|
303
|
+
await import("../frontend/teams/actions.js");
|
|
149
304
|
|
|
150
305
|
const gateway = new Gateway();
|
|
151
306
|
gateway.setContext(123);
|
|
152
|
-
const handler = createTeamsActionHandler(
|
|
153
|
-
|
|
154
|
-
|
|
307
|
+
const handler = createTeamsActionHandler(
|
|
308
|
+
"https://webhook.example.com",
|
|
309
|
+
gateway,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const result = await handler(
|
|
313
|
+
{ action: "send_message", text: "Hello Teams!" },
|
|
314
|
+
123,
|
|
315
|
+
);
|
|
155
316
|
expect(result?.ok).toBe(true);
|
|
156
317
|
expect(result?.message_id).toBeDefined();
|
|
157
318
|
expect(gateway.getMessageCount(123)).toBe(1);
|
|
@@ -160,9 +321,13 @@ describe("teams actions", () => {
|
|
|
160
321
|
|
|
161
322
|
it("send_message returns ok for empty text (no-op)", async () => {
|
|
162
323
|
const { Gateway } = await import("../core/gateway.js");
|
|
163
|
-
const { createTeamsActionHandler } =
|
|
324
|
+
const { createTeamsActionHandler } =
|
|
325
|
+
await import("../frontend/teams/actions.js");
|
|
164
326
|
const gateway = new Gateway();
|
|
165
|
-
const handler = createTeamsActionHandler(
|
|
327
|
+
const handler = createTeamsActionHandler(
|
|
328
|
+
"https://webhook.example.com",
|
|
329
|
+
gateway,
|
|
330
|
+
);
|
|
166
331
|
|
|
167
332
|
const result = await handler({ action: "send_message", text: "" }, 123);
|
|
168
333
|
expect(result?.ok).toBe(true);
|
|
@@ -170,13 +335,21 @@ describe("teams actions", () => {
|
|
|
170
335
|
|
|
171
336
|
it("send_message handles webhook failure", async () => {
|
|
172
337
|
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
173
|
-
proxyFetch: vi.fn(async () => ({
|
|
338
|
+
proxyFetch: vi.fn(async () => ({
|
|
339
|
+
ok: false,
|
|
340
|
+
status: 500,
|
|
341
|
+
text: async () => "Internal Error",
|
|
342
|
+
})),
|
|
174
343
|
}));
|
|
175
344
|
|
|
176
345
|
const { Gateway } = await import("../core/gateway.js");
|
|
177
|
-
const { createTeamsActionHandler } =
|
|
346
|
+
const { createTeamsActionHandler } =
|
|
347
|
+
await import("../frontend/teams/actions.js");
|
|
178
348
|
const gateway = new Gateway();
|
|
179
|
-
const handler = createTeamsActionHandler(
|
|
349
|
+
const handler = createTeamsActionHandler(
|
|
350
|
+
"https://webhook.example.com",
|
|
351
|
+
gateway,
|
|
352
|
+
);
|
|
180
353
|
|
|
181
354
|
const result = await handler({ action: "send_message", text: "fail" }, 123);
|
|
182
355
|
expect(result?.ok).toBe(false);
|
|
@@ -185,9 +358,13 @@ describe("teams actions", () => {
|
|
|
185
358
|
|
|
186
359
|
it("get_chat_info returns channel info", async () => {
|
|
187
360
|
const { Gateway } = await import("../core/gateway.js");
|
|
188
|
-
const { createTeamsActionHandler } =
|
|
361
|
+
const { createTeamsActionHandler } =
|
|
362
|
+
await import("../frontend/teams/actions.js");
|
|
189
363
|
const gateway = new Gateway();
|
|
190
|
-
const handler = createTeamsActionHandler(
|
|
364
|
+
const handler = createTeamsActionHandler(
|
|
365
|
+
"https://webhook.example.com",
|
|
366
|
+
gateway,
|
|
367
|
+
);
|
|
191
368
|
|
|
192
369
|
const result = await handler({ action: "get_chat_info" }, 456);
|
|
193
370
|
expect(result?.ok).toBe(true);
|
|
@@ -197,11 +374,22 @@ describe("teams actions", () => {
|
|
|
197
374
|
|
|
198
375
|
it("unsupported actions return ok (graceful no-ops)", async () => {
|
|
199
376
|
const { Gateway } = await import("../core/gateway.js");
|
|
200
|
-
const { createTeamsActionHandler } =
|
|
377
|
+
const { createTeamsActionHandler } =
|
|
378
|
+
await import("../frontend/teams/actions.js");
|
|
201
379
|
const gateway = new Gateway();
|
|
202
|
-
const handler = createTeamsActionHandler(
|
|
203
|
-
|
|
204
|
-
|
|
380
|
+
const handler = createTeamsActionHandler(
|
|
381
|
+
"https://webhook.example.com",
|
|
382
|
+
gateway,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
for (const action of [
|
|
386
|
+
"react",
|
|
387
|
+
"edit_message",
|
|
388
|
+
"delete_message",
|
|
389
|
+
"pin_message",
|
|
390
|
+
"unpin_message",
|
|
391
|
+
"forward_message",
|
|
392
|
+
]) {
|
|
205
393
|
const result = await handler({ action }, 123);
|
|
206
394
|
expect(result?.ok).toBe(true);
|
|
207
395
|
}
|
|
@@ -209,13 +397,185 @@ describe("teams actions", () => {
|
|
|
209
397
|
|
|
210
398
|
it("unknown actions return null", async () => {
|
|
211
399
|
const { Gateway } = await import("../core/gateway.js");
|
|
212
|
-
const { createTeamsActionHandler } =
|
|
400
|
+
const { createTeamsActionHandler } =
|
|
401
|
+
await import("../frontend/teams/actions.js");
|
|
213
402
|
const gateway = new Gateway();
|
|
214
|
-
const handler = createTeamsActionHandler(
|
|
403
|
+
const handler = createTeamsActionHandler(
|
|
404
|
+
"https://webhook.example.com",
|
|
405
|
+
gateway,
|
|
406
|
+
);
|
|
215
407
|
|
|
216
408
|
const result = await handler({ action: "totally_unknown" }, 123);
|
|
217
409
|
expect(result).toBeNull();
|
|
218
410
|
});
|
|
411
|
+
|
|
412
|
+
it("send_message_with_buttons posts card with buttons to webhook", async () => {
|
|
413
|
+
vi.resetModules();
|
|
414
|
+
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
415
|
+
proxyFetch: vi.fn(async () => ({ ok: true, status: 200 })),
|
|
416
|
+
}));
|
|
417
|
+
vi.doMock("../util/log.js", () => ({
|
|
418
|
+
log: vi.fn(),
|
|
419
|
+
logError: vi.fn(),
|
|
420
|
+
logWarn: vi.fn(),
|
|
421
|
+
logDebug: vi.fn(),
|
|
422
|
+
}));
|
|
423
|
+
vi.doMock("../core/plugin.js", () => ({
|
|
424
|
+
handlePluginAction: vi.fn(async () => null),
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
428
|
+
const { createTeamsActionHandler } =
|
|
429
|
+
await import("../frontend/teams/actions.js");
|
|
430
|
+
const gateway = new Gateway();
|
|
431
|
+
const handler = createTeamsActionHandler(
|
|
432
|
+
"https://webhook.example.com",
|
|
433
|
+
gateway,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const result = await handler(
|
|
437
|
+
{
|
|
438
|
+
action: "send_message_with_buttons",
|
|
439
|
+
text: "Choose an option",
|
|
440
|
+
rows: [
|
|
441
|
+
[
|
|
442
|
+
{ text: "Option A", url: "https://example.com" },
|
|
443
|
+
{ text: "Option B" },
|
|
444
|
+
],
|
|
445
|
+
],
|
|
446
|
+
},
|
|
447
|
+
123,
|
|
448
|
+
);
|
|
449
|
+
expect(result?.ok).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("send_message_with_buttons handles webhook failure", async () => {
|
|
453
|
+
vi.resetModules();
|
|
454
|
+
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
455
|
+
proxyFetch: vi.fn(async () => ({ ok: false, status: 503 })),
|
|
456
|
+
}));
|
|
457
|
+
vi.doMock("../util/log.js", () => ({
|
|
458
|
+
log: vi.fn(),
|
|
459
|
+
logError: vi.fn(),
|
|
460
|
+
logWarn: vi.fn(),
|
|
461
|
+
logDebug: vi.fn(),
|
|
462
|
+
}));
|
|
463
|
+
vi.doMock("../core/plugin.js", () => ({
|
|
464
|
+
handlePluginAction: vi.fn(async () => null),
|
|
465
|
+
}));
|
|
466
|
+
|
|
467
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
468
|
+
const { createTeamsActionHandler } =
|
|
469
|
+
await import("../frontend/teams/actions.js");
|
|
470
|
+
const gateway = new Gateway();
|
|
471
|
+
const handler = createTeamsActionHandler(
|
|
472
|
+
"https://webhook.example.com",
|
|
473
|
+
gateway,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const result = await handler(
|
|
477
|
+
{
|
|
478
|
+
action: "send_message_with_buttons",
|
|
479
|
+
text: "Click one",
|
|
480
|
+
rows: [[{ text: "Fail" }]],
|
|
481
|
+
},
|
|
482
|
+
123,
|
|
483
|
+
);
|
|
484
|
+
expect(result?.ok).toBe(false);
|
|
485
|
+
expect(result?.error).toBeDefined();
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ── Formatting default token type (branch coverage) ───────────────────────
|
|
490
|
+
|
|
491
|
+
describe("teams formatting — default token type", () => {
|
|
492
|
+
it("renders raw HTML block via default case", () => {
|
|
493
|
+
// Raw HTML block generates an 'html' token with a text property,
|
|
494
|
+
// which falls into the default: case of the switch in markdownToCardBody
|
|
495
|
+
const card = buildAdaptiveCard("<div>some raw HTML content</div>");
|
|
496
|
+
const content = (
|
|
497
|
+
(card.attachments as unknown[])[0] as Record<string, unknown>
|
|
498
|
+
).content as Record<string, unknown>;
|
|
499
|
+
const body = content.body as Array<Record<string, unknown>>;
|
|
500
|
+
expect(body.length).toBeGreaterThan(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("stripHtml falls back to regex when cheerio throws", async () => {
|
|
504
|
+
vi.resetModules();
|
|
505
|
+
vi.doMock("cheerio", () => ({
|
|
506
|
+
default: {},
|
|
507
|
+
load: vi.fn(() => {
|
|
508
|
+
throw new Error("cheerio unavailable");
|
|
509
|
+
}),
|
|
510
|
+
}));
|
|
511
|
+
vi.doMock("../util/log.js", () => ({
|
|
512
|
+
log: vi.fn(),
|
|
513
|
+
logError: vi.fn(),
|
|
514
|
+
logWarn: vi.fn(),
|
|
515
|
+
logDebug: vi.fn(),
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
const { stripHtml: stripHtmlFresh } =
|
|
519
|
+
await import("../frontend/teams/formatting.js");
|
|
520
|
+
const result = stripHtmlFresh("<p>Hello <b>world</b></p>");
|
|
521
|
+
expect(result).toBe("Hello world");
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// ── teams actions branch coverage ─────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
describe("teams actions — branch coverage", () => {
|
|
528
|
+
beforeEach(() => {
|
|
529
|
+
vi.resetModules();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("send_message with undefined text uses empty string fallback", async () => {
|
|
533
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
534
|
+
const { createTeamsActionHandler } =
|
|
535
|
+
await import("../frontend/teams/actions.js");
|
|
536
|
+
const gateway = new Gateway();
|
|
537
|
+
const handler = createTeamsActionHandler(
|
|
538
|
+
"https://webhook.example.com",
|
|
539
|
+
gateway,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// text is undefined → triggers body.text ?? ""
|
|
543
|
+
const result = await handler({ action: "send_message" }, 123);
|
|
544
|
+
expect(result?.ok).toBe(true); // empty text → no-op
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("send_message_with_buttons with undefined text uses empty string fallback", async () => {
|
|
548
|
+
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
549
|
+
proxyFetch: vi.fn(async () => ({ ok: true, status: 200 })),
|
|
550
|
+
}));
|
|
551
|
+
vi.doMock("../util/log.js", () => ({
|
|
552
|
+
log: vi.fn(),
|
|
553
|
+
logError: vi.fn(),
|
|
554
|
+
logWarn: vi.fn(),
|
|
555
|
+
logDebug: vi.fn(),
|
|
556
|
+
}));
|
|
557
|
+
vi.doMock("../core/plugin.js", () => ({
|
|
558
|
+
handlePluginAction: vi.fn(async () => null),
|
|
559
|
+
}));
|
|
560
|
+
|
|
561
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
562
|
+
const { createTeamsActionHandler } =
|
|
563
|
+
await import("../frontend/teams/actions.js");
|
|
564
|
+
const gateway = new Gateway();
|
|
565
|
+
gateway.setContext(123);
|
|
566
|
+
const handler = createTeamsActionHandler(
|
|
567
|
+
"https://webhook.example.com",
|
|
568
|
+
gateway,
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// text is undefined → triggers body.text ?? ""
|
|
572
|
+
const result = await handler(
|
|
573
|
+
{ action: "send_message_with_buttons", rows: [[{ text: "OK" }]] },
|
|
574
|
+
123,
|
|
575
|
+
);
|
|
576
|
+
expect(result?.ok).toBe(true);
|
|
577
|
+
gateway.clearContext(123);
|
|
578
|
+
});
|
|
219
579
|
});
|
|
220
580
|
|
|
221
581
|
// ── Test proxy-fetch ────────────────────────────────────────────────────────
|
|
@@ -227,6 +587,37 @@ describe("proxy-fetch", () => {
|
|
|
227
587
|
});
|
|
228
588
|
});
|
|
229
589
|
|
|
590
|
+
describe("splitTeamsMessage — remaining is empty after split", () => {
|
|
591
|
+
it("does not push trailing empty chunk when split eats trailing whitespace", () => {
|
|
592
|
+
// Covers the false branch of `if (remaining)` at line 205.
|
|
593
|
+
// After splitting: remaining = "\n\n".trimStart() = "" → not pushed.
|
|
594
|
+
const maxLen = 10_000;
|
|
595
|
+
const text = "A".repeat(maxLen) + "\n\n"; // exactly maxLen + trailing whitespace
|
|
596
|
+
const chunks = splitTeamsMessage(text, maxLen);
|
|
597
|
+
// Only one chunk (the "A"s), trailing whitespace is discarded
|
|
598
|
+
expect(chunks).toHaveLength(1);
|
|
599
|
+
expect(chunks[0]).toBe("A".repeat(maxLen));
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("buildAdaptiveCard — blockquote without text field", () => {
|
|
604
|
+
it("blockquote token with undefined text defaults to empty string", () => {
|
|
605
|
+
// The `bqToken.text ?? ""` branch — need a blockquote that has no text field.
|
|
606
|
+
// This is hard to trigger via normal markdown, but a > with no content would
|
|
607
|
+
// produce a blockquote with empty text which exercises the String() conversion.
|
|
608
|
+
// Just calling with minimal blockquote markdown covers the path:
|
|
609
|
+
const card = buildAdaptiveCard("> ");
|
|
610
|
+
const body = (
|
|
611
|
+
(card.attachments as Array<Record<string, unknown>>)[0].content as Record<
|
|
612
|
+
string,
|
|
613
|
+
unknown
|
|
614
|
+
>
|
|
615
|
+
).body as Array<Record<string, unknown>>;
|
|
616
|
+
// Should produce some body (either Container or fallback)
|
|
617
|
+
expect(body).toBeDefined();
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
230
621
|
// ── Test graph client types ─────────────────────────────────────────────────
|
|
231
622
|
|
|
232
623
|
describe("graph module exports", () => {
|
|
@@ -237,3 +628,107 @@ describe("graph module exports", () => {
|
|
|
237
628
|
expect(typeof graph.deviceCodeAuth).toBe("function");
|
|
238
629
|
});
|
|
239
630
|
});
|
|
631
|
+
|
|
632
|
+
describe("teams actions — non-Error throw coverage", () => {
|
|
633
|
+
beforeEach(() => {
|
|
634
|
+
vi.resetModules();
|
|
635
|
+
vi.doMock("../util/log.js", () => ({
|
|
636
|
+
log: vi.fn(),
|
|
637
|
+
logError: vi.fn(),
|
|
638
|
+
logWarn: vi.fn(),
|
|
639
|
+
logDebug: vi.fn(),
|
|
640
|
+
}));
|
|
641
|
+
vi.doMock("../core/plugin.js", () => ({
|
|
642
|
+
handlePluginAction: vi.fn(async () => null),
|
|
643
|
+
}));
|
|
644
|
+
vi.doMock("../storage/cron-store.js", () => ({
|
|
645
|
+
addCronJob: vi.fn(),
|
|
646
|
+
getCronJob: vi.fn(),
|
|
647
|
+
getCronJobsForChat: vi.fn(() => []),
|
|
648
|
+
updateCronJob: vi.fn(),
|
|
649
|
+
deleteCronJob: vi.fn(),
|
|
650
|
+
recordCronRun: vi.fn(),
|
|
651
|
+
validateCronExpression: vi.fn(() => ({ valid: true, next: "" })),
|
|
652
|
+
generateCronId: vi.fn(() => "id"),
|
|
653
|
+
loadCronJobs: vi.fn(),
|
|
654
|
+
}));
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("send_message covers String(err) branch when postToTeams throws a non-Error", async () => {
|
|
658
|
+
// Mock postToTeams (via proxyFetch) to throw a plain string, not an Error instance
|
|
659
|
+
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
660
|
+
proxyFetch: vi.fn(async () => {
|
|
661
|
+
throw "non-error webhook failure";
|
|
662
|
+
}), // eslint-disable-line @typescript-eslint/no-throw-literal
|
|
663
|
+
}));
|
|
664
|
+
|
|
665
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
666
|
+
const { createTeamsActionHandler } =
|
|
667
|
+
await import("../frontend/teams/actions.js");
|
|
668
|
+
const gateway = new Gateway();
|
|
669
|
+
const handler = createTeamsActionHandler(
|
|
670
|
+
"https://webhook.example.com",
|
|
671
|
+
gateway,
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const result = await handler({ action: "send_message", text: "test" }, 123);
|
|
675
|
+
expect(result?.ok).toBe(false);
|
|
676
|
+
// Error was non-Error (string) → String(err) used → error message is the string itself
|
|
677
|
+
expect(result?.error).toContain("non-error webhook failure");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("send_message_with_buttons covers String(err) branch when proxyFetch throws a non-Error", async () => {
|
|
681
|
+
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
682
|
+
proxyFetch: vi.fn(async () => {
|
|
683
|
+
throw "non-error button failure";
|
|
684
|
+
}), // eslint-disable-line @typescript-eslint/no-throw-literal
|
|
685
|
+
}));
|
|
686
|
+
|
|
687
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
688
|
+
const { createTeamsActionHandler } =
|
|
689
|
+
await import("../frontend/teams/actions.js");
|
|
690
|
+
const gateway = new Gateway();
|
|
691
|
+
const handler = createTeamsActionHandler(
|
|
692
|
+
"https://webhook.example.com",
|
|
693
|
+
gateway,
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
const result = await handler(
|
|
697
|
+
{
|
|
698
|
+
action: "send_message_with_buttons",
|
|
699
|
+
text: "click me",
|
|
700
|
+
rows: [[{ text: "OK" }]],
|
|
701
|
+
},
|
|
702
|
+
123,
|
|
703
|
+
);
|
|
704
|
+
expect(result?.ok).toBe(false);
|
|
705
|
+
expect(result?.error).toContain("non-error button failure");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("covers () => '' catch callback when resp.text() throws on webhook failure", async () => {
|
|
709
|
+
// When resp.ok is false AND resp.text() throws, the catch(() => "") callback fires
|
|
710
|
+
vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
|
|
711
|
+
proxyFetch: vi.fn(async () => ({
|
|
712
|
+
ok: false,
|
|
713
|
+
status: 500,
|
|
714
|
+
text: async () => {
|
|
715
|
+
throw new Error("body read failed");
|
|
716
|
+
},
|
|
717
|
+
})),
|
|
718
|
+
}));
|
|
719
|
+
|
|
720
|
+
const { Gateway } = await import("../core/gateway.js");
|
|
721
|
+
const { createTeamsActionHandler } =
|
|
722
|
+
await import("../frontend/teams/actions.js");
|
|
723
|
+
const gateway = new Gateway();
|
|
724
|
+
const handler = createTeamsActionHandler(
|
|
725
|
+
"https://webhook.example.com",
|
|
726
|
+
gateway,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const result = await handler({ action: "send_message", text: "test" }, 123);
|
|
730
|
+
expect(result?.ok).toBe(false);
|
|
731
|
+
// body fell back to "" so error message ends with "500 "
|
|
732
|
+
expect(result?.error).toContain("500");
|
|
733
|
+
});
|
|
734
|
+
});
|