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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. 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(), logError: vi.fn(), logWarn: vi.fn(), logDebug: 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 } = await import(
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 = (card.attachments as Array<Record<string, unknown>>)[0];
25
- expect(attachment.contentType).toBe("application/vnd.microsoft.card.adaptive");
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 = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
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 = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
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 = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
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 = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
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 = "A".repeat(5000) + "\n\n" + "B".repeat(5000) + "\n\n" + "C".repeat(100);
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("\n");
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("Text here");
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 } = await import("../frontend/teams/actions.js");
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("https://webhook.example.com", gateway);
153
-
154
- const result = await handler({ action: "send_message", text: "Hello Teams!" }, 123);
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 } = await import("../frontend/teams/actions.js");
324
+ const { createTeamsActionHandler } =
325
+ await import("../frontend/teams/actions.js");
164
326
  const gateway = new Gateway();
165
- const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
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 () => ({ ok: false, status: 500, text: async () => "Internal Error" })),
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 } = await import("../frontend/teams/actions.js");
346
+ const { createTeamsActionHandler } =
347
+ await import("../frontend/teams/actions.js");
178
348
  const gateway = new Gateway();
179
- const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
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 } = await import("../frontend/teams/actions.js");
361
+ const { createTeamsActionHandler } =
362
+ await import("../frontend/teams/actions.js");
189
363
  const gateway = new Gateway();
190
- const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
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 } = await import("../frontend/teams/actions.js");
377
+ const { createTeamsActionHandler } =
378
+ await import("../frontend/teams/actions.js");
201
379
  const gateway = new Gateway();
202
- const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
203
-
204
- for (const action of ["react", "edit_message", "delete_message", "pin_message", "unpin_message", "forward_message"]) {
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 } = await import("../frontend/teams/actions.js");
400
+ const { createTeamsActionHandler } =
401
+ await import("../frontend/teams/actions.js");
213
402
  const gateway = new Gateway();
214
- const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
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
+ });