@easyoref/agent 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/__tests__/clarify.test.ts +827 -0
  2. package/__tests__/config.test.ts +304 -0
  3. package/__tests__/enrichment.integration.test.ts +871 -0
  4. package/__tests__/graph.test.ts +661 -0
  5. package/dist/auth.d.ts +11 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +54 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/dry-run.d.ts +12 -0
  10. package/dist/dry-run.d.ts.map +1 -0
  11. package/dist/dry-run.js +236 -0
  12. package/dist/dry-run.js.map +1 -0
  13. package/dist/extract.d.ts +180 -0
  14. package/dist/extract.d.ts.map +1 -0
  15. package/dist/extract.js +210 -0
  16. package/dist/extract.js.map +1 -0
  17. package/dist/graph.d.ts +4083 -0
  18. package/dist/graph.d.ts.map +1 -0
  19. package/dist/graph.js +162 -0
  20. package/dist/graph.js.map +1 -0
  21. package/dist/index.d.ts +23 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +23 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/models.d.ts +7 -0
  26. package/dist/models.d.ts.map +1 -0
  27. package/dist/models.js +18 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/nodes/clarify-node.d.ts +132 -0
  30. package/dist/nodes/clarify-node.d.ts.map +1 -0
  31. package/dist/nodes/clarify-node.js +118 -0
  32. package/dist/nodes/clarify-node.js.map +1 -0
  33. package/dist/nodes/clarify.d.ts +6 -0
  34. package/dist/nodes/clarify.d.ts.map +1 -0
  35. package/dist/nodes/clarify.js +124 -0
  36. package/dist/nodes/clarify.js.map +1 -0
  37. package/dist/nodes/edit-node.d.ts +71 -0
  38. package/dist/nodes/edit-node.d.ts.map +1 -0
  39. package/dist/nodes/edit-node.js +496 -0
  40. package/dist/nodes/edit-node.js.map +1 -0
  41. package/dist/nodes/edit.d.ts +6 -0
  42. package/dist/nodes/edit.d.ts.map +1 -0
  43. package/dist/nodes/edit.js +22 -0
  44. package/dist/nodes/edit.js.map +1 -0
  45. package/dist/nodes/extract-node.d.ts +174 -0
  46. package/dist/nodes/extract-node.d.ts.map +1 -0
  47. package/dist/nodes/extract-node.js +233 -0
  48. package/dist/nodes/extract-node.js.map +1 -0
  49. package/dist/nodes/extract.d.ts +6 -0
  50. package/dist/nodes/extract.d.ts.map +1 -0
  51. package/dist/nodes/extract.js +49 -0
  52. package/dist/nodes/extract.js.map +1 -0
  53. package/dist/nodes/filter-agent.d.ts +11 -0
  54. package/dist/nodes/filter-agent.d.ts.map +1 -0
  55. package/dist/nodes/filter-agent.js +60 -0
  56. package/dist/nodes/filter-agent.js.map +1 -0
  57. package/dist/nodes/filter-node.d.ts +9 -0
  58. package/dist/nodes/filter-node.d.ts.map +1 -0
  59. package/dist/nodes/filter-node.js +111 -0
  60. package/dist/nodes/filter-node.js.map +1 -0
  61. package/dist/nodes/filters.d.ts +13 -0
  62. package/dist/nodes/filters.d.ts.map +1 -0
  63. package/dist/nodes/filters.js +111 -0
  64. package/dist/nodes/filters.js.map +1 -0
  65. package/dist/nodes/message-node.d.ts +71 -0
  66. package/dist/nodes/message-node.d.ts.map +1 -0
  67. package/dist/nodes/message-node.js +491 -0
  68. package/dist/nodes/message-node.js.map +1 -0
  69. package/dist/nodes/message.d.ts +71 -0
  70. package/dist/nodes/message.d.ts.map +1 -0
  71. package/dist/nodes/message.js +496 -0
  72. package/dist/nodes/message.js.map +1 -0
  73. package/dist/nodes/vote-node.d.ts +13 -0
  74. package/dist/nodes/vote-node.d.ts.map +1 -0
  75. package/dist/nodes/vote-node.js +232 -0
  76. package/dist/nodes/vote-node.js.map +1 -0
  77. package/dist/nodes/vote.d.ts +13 -0
  78. package/dist/nodes/vote.d.ts.map +1 -0
  79. package/dist/nodes/vote.js +232 -0
  80. package/dist/nodes/vote.js.map +1 -0
  81. package/dist/queue.d.ts +15 -0
  82. package/dist/queue.d.ts.map +1 -0
  83. package/dist/queue.js +41 -0
  84. package/dist/queue.js.map +1 -0
  85. package/dist/redis.d.ts +8 -0
  86. package/dist/redis.d.ts.map +1 -0
  87. package/dist/redis.js +33 -0
  88. package/dist/redis.js.map +1 -0
  89. package/dist/runtime/auth.d.ts +11 -0
  90. package/dist/runtime/auth.d.ts.map +1 -0
  91. package/dist/runtime/auth.js +54 -0
  92. package/dist/runtime/auth.js.map +1 -0
  93. package/dist/runtime/dry-run.d.ts +12 -0
  94. package/dist/runtime/dry-run.d.ts.map +1 -0
  95. package/dist/runtime/dry-run.js +236 -0
  96. package/dist/runtime/dry-run.js.map +1 -0
  97. package/dist/runtime/queue.d.ts +15 -0
  98. package/dist/runtime/queue.d.ts.map +1 -0
  99. package/dist/runtime/queue.js +41 -0
  100. package/dist/runtime/queue.js.map +1 -0
  101. package/dist/runtime/redis.d.ts +8 -0
  102. package/dist/runtime/redis.d.ts.map +1 -0
  103. package/dist/runtime/redis.js +33 -0
  104. package/dist/runtime/redis.js.map +1 -0
  105. package/dist/runtime/worker.d.ts +14 -0
  106. package/dist/runtime/worker.d.ts.map +1 -0
  107. package/dist/runtime/worker.js +135 -0
  108. package/dist/runtime/worker.js.map +1 -0
  109. package/dist/tools/alert-history.d.ts +18 -0
  110. package/dist/tools/alert-history.d.ts.map +1 -0
  111. package/dist/tools/alert-history.js +98 -0
  112. package/dist/tools/alert-history.js.map +1 -0
  113. package/dist/tools/betterstack-log.d.ts +15 -0
  114. package/dist/tools/betterstack-log.d.ts.map +1 -0
  115. package/dist/tools/betterstack-log.js +80 -0
  116. package/dist/tools/betterstack-log.js.map +1 -0
  117. package/dist/tools/index.d.ts +44 -0
  118. package/dist/tools/index.d.ts.map +1 -0
  119. package/dist/tools/index.js +20 -0
  120. package/dist/tools/index.js.map +1 -0
  121. package/dist/tools/read-sources.d.ts +15 -0
  122. package/dist/tools/read-sources.d.ts.map +1 -0
  123. package/dist/tools/read-sources.js +67 -0
  124. package/dist/tools/read-sources.js.map +1 -0
  125. package/dist/tools/resolve-area.d.ts +19 -0
  126. package/dist/tools/resolve-area.d.ts.map +1 -0
  127. package/dist/tools/resolve-area.js +147 -0
  128. package/dist/tools/resolve-area.js.map +1 -0
  129. package/dist/tools.d.ts +115 -0
  130. package/dist/tools.d.ts.map +1 -0
  131. package/dist/tools.js +439 -0
  132. package/dist/tools.js.map +1 -0
  133. package/dist/worker.d.ts +14 -0
  134. package/dist/worker.d.ts.map +1 -0
  135. package/dist/worker.js +135 -0
  136. package/dist/worker.js.map +1 -0
  137. package/package.json +26 -0
  138. package/src/graph.ts +200 -0
  139. package/src/index.ts +27 -0
  140. package/src/models.ts +20 -0
  141. package/src/nodes/clarify-node.ts +172 -0
  142. package/src/nodes/edit-node.ts +695 -0
  143. package/src/nodes/extract-node.ts +299 -0
  144. package/src/nodes/filter-node.ts +139 -0
  145. package/src/nodes/message.ts +695 -0
  146. package/src/nodes/vote-node.ts +354 -0
  147. package/src/nodes/vote.ts +354 -0
  148. package/src/runtime/auth.ts +63 -0
  149. package/src/runtime/dry-run.ts +303 -0
  150. package/src/runtime/queue.ts +53 -0
  151. package/src/runtime/redis.ts +38 -0
  152. package/src/runtime/worker.ts +167 -0
  153. package/src/tools/alert-history.ts +120 -0
  154. package/src/tools/betterstack-log.ts +102 -0
  155. package/src/tools/index.ts +23 -0
  156. package/src/tools/read-sources.ts +86 -0
  157. package/src/tools/resolve-area.ts +202 -0
  158. package/tsconfig.json +14 -0
@@ -0,0 +1,827 @@
1
+ /**
2
+ * Tests for tool calling: tools.ts, clarify.ts, and shouldClarify routing.
3
+ *
4
+ * These tests mock external dependencies (GramJS, fetch, LLM) and verify:
5
+ * - Tool execution and error handling
6
+ * - ReAct loop flow (with/without tool calls)
7
+ * - Conditional edge routing logic
8
+ * - Contradiction detection
9
+ * - Area proximity resolution
10
+ */
11
+
12
+ import {
13
+ VotedResultSchema,
14
+ type ValidatedExtraction,
15
+ type VotedResult,
16
+ } from "@easyoref/shared";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // ── Mocks (must be defined before imports) ─────────────
20
+
21
+ vi.mock("@easyoref/shared", async () => {
22
+ const actual = await vi.importActual("@easyoref/shared");
23
+ return {
24
+ ...actual,
25
+ config: {
26
+ agent: {
27
+ model: "test-model",
28
+ apiKey: "test-key",
29
+ mcpTools: true,
30
+ clarifyFetchCount: 3,
31
+ confidenceThreshold: 0.6,
32
+ filterModel: "google/gemini-2.5-flash-lite",
33
+ extractModel: "google/gemini-3.1-flash-lite-preview",
34
+ channels: ["@idf_telegram", "@N12LIVE", "@kann_news"],
35
+ areaLabels: { הרצליה: "Герцлия" },
36
+ },
37
+ botToken: "",
38
+ orefApiUrl: "https://mock.oref.api/alerts",
39
+ orefHistoryUrl: "",
40
+ logtailToken: "test-logtail-token",
41
+ areas: ["הרצליה", "תל אביב - דרום העיר ויפו"],
42
+ language: "ru",
43
+ },
44
+ getRedis: vi.fn().mockReturnValue({
45
+ lpush: vi.fn(),
46
+ expire: vi.fn(),
47
+ }),
48
+ pushSessionPost: vi.fn(),
49
+ pushChannelPost: vi.fn(),
50
+ };
51
+ });
52
+
53
+ vi.mock("@easyoref/monitoring", () => ({
54
+ info: vi.fn(),
55
+ warn: vi.fn(),
56
+ error: vi.fn(),
57
+ debug: vi.fn(),
58
+ }));
59
+
60
+ vi.mock("@easyoref/gramjs", () => ({
61
+ fetchRecentChannelPosts: vi.fn(),
62
+ }));
63
+
64
+ // ── Helpers ────────────────────────────────────────────
65
+
66
+ function makeVotedResult(overrides: Partial<VotedResult> = {}): VotedResult {
67
+ return VotedResultSchema.parse({
68
+ etaRefinedMinutes: undefined,
69
+ eta_citations: [],
70
+ countryOrigins: [{ name: "Lebanon", citations: [1] }],
71
+ rocketCountMin: 10,
72
+ rocketCountMax: 15,
73
+ rocket_citations: [1, 2],
74
+ rocket_confidence: 0.7,
75
+ isCassette: undefined,
76
+ isCassette_confidence: 0,
77
+ intercepted: 8,
78
+ interceptedQual: undefined,
79
+ interceptedConfidence: 0.6,
80
+ seaImpact: undefined,
81
+ seaImpactQual: undefined,
82
+ sea_confidence: 0,
83
+ openAreaImpact: undefined,
84
+ openAreaImpactQual: undefined,
85
+ open_area_confidence: 0,
86
+ hitsConfirmed: 1,
87
+ hits_citations: [2],
88
+ hits_confidence: 0.4,
89
+ casualties: undefined,
90
+ casualties_citations: [],
91
+ casualties_confidence: 0,
92
+ injuries: undefined,
93
+ injuries_citations: [],
94
+ injuries_confidence: 0,
95
+ confidence: 0.45,
96
+ sourcesCount: 2,
97
+ citedSources: [
98
+ { index: 1, channel: "@idf_telegram", messageUrl: undefined },
99
+ { index: 2, channel: "@N12LIVE", messageUrl: undefined },
100
+ ],
101
+ ...overrides,
102
+ });
103
+ }
104
+
105
+ function makeExtraction(
106
+ overrides: Partial<ValidatedExtraction> = {},
107
+ ): ValidatedExtraction {
108
+ return {
109
+ channel: "@idf_telegram",
110
+ regionRelevance: 0.9,
111
+ sourceTrust: 0.8,
112
+ tone: "calm",
113
+ timeRelevance: 0.9,
114
+ countryOrigin: "Lebanon",
115
+ rocketCount: 12,
116
+ isCassette: undefined,
117
+ intercepted: 8,
118
+ interceptedQual: undefined,
119
+ seaImpact: undefined,
120
+ seaImpactQual: undefined,
121
+ openAreaImpact: undefined,
122
+ openAreaImpactQual: undefined,
123
+ hitsConfirmed: 1,
124
+ casualties: undefined,
125
+ injuries: undefined,
126
+ etaRefinedMinutes: undefined,
127
+ confidence: 0.7,
128
+ valid: true,
129
+ ...overrides,
130
+ };
131
+ }
132
+
133
+ // ═════════════════════════════════════════════════════════
134
+ // 1. readSourcesTool
135
+ // ═════════════════════════════════════════════════════════
136
+
137
+ describe("readSourcesTool", () => {
138
+ let readSourcesTool: typeof import("../src/tools/index.js").readSourcesTool;
139
+ let fetchRecentChannelPosts: ReturnType<typeof vi.fn>;
140
+
141
+ beforeEach(async () => {
142
+ vi.resetModules();
143
+ const toolsMod = await import("../src/tools/index.js");
144
+ readSourcesTool = toolsMod.readSourcesTool;
145
+ const gramjsMod = await import("@easyoref/gramjs");
146
+ fetchRecentChannelPosts = gramjsMod.fetchRecentChannelPosts as ReturnType<
147
+ typeof vi.fn
148
+ >;
149
+ });
150
+
151
+ afterEach(() => vi.restoreAllMocks());
152
+
153
+ it("returns posts from channel", async () => {
154
+ fetchRecentChannelPosts.mockResolvedValueOnce([
155
+ {
156
+ text: "IDF reports 12 rockets launched",
157
+ ts: 1700000000,
158
+ messageUrl: "https://t.me/idf/100",
159
+ },
160
+ {
161
+ text: "Iron Dome intercepted majority",
162
+ ts: 1700000001,
163
+ messageUrl: "https://t.me/idf/101",
164
+ },
165
+ ]);
166
+
167
+ const result = await readSourcesTool.invoke({
168
+ channel: "@idf_telegram",
169
+ limit: 3,
170
+ });
171
+
172
+ const parsed = JSON.parse(result);
173
+ expect(parsed.channel).toBe("@idf_telegram");
174
+ expect(parsed.posts).toHaveLength(2);
175
+ expect(parsed.posts[0].text).toContain("12 rockets");
176
+ expect(parsed.count).toBe(2);
177
+ expect(fetchRecentChannelPosts).toHaveBeenCalledWith("@idf_telegram", 3);
178
+ });
179
+
180
+ it("limits posts to clarifyFetchCount", async () => {
181
+ fetchRecentChannelPosts.mockResolvedValueOnce([
182
+ { text: "Post1", ts: 1, messageUrl: undefined },
183
+ ]);
184
+
185
+ // limit=4 (max allowed by schema) should be capped to clarifyFetchCount=3
186
+ await readSourcesTool.invoke({ channel: "@test", limit: 4 });
187
+ expect(fetchRecentChannelPosts).toHaveBeenCalledWith("@test", 3);
188
+ });
189
+
190
+ it("truncates long texts to 800 chars", async () => {
191
+ const longText = "A".repeat(1500);
192
+ fetchRecentChannelPosts.mockResolvedValueOnce([
193
+ { text: longText, ts: 1, messageUrl: undefined },
194
+ ]);
195
+
196
+ const result = await readSourcesTool.invoke({
197
+ channel: "@test",
198
+ limit: 1,
199
+ });
200
+
201
+ const parsed = JSON.parse(result);
202
+ expect(parsed.posts[0].text.length).toBe(800);
203
+ });
204
+
205
+ it("returns empty array when no posts", async () => {
206
+ fetchRecentChannelPosts.mockResolvedValueOnce([]);
207
+
208
+ const result = await readSourcesTool.invoke({
209
+ channel: "@empty",
210
+ limit: 2,
211
+ });
212
+
213
+ const parsed = JSON.parse(result);
214
+ expect(parsed.posts).toHaveLength(0);
215
+ expect(parsed.note).toContain("No recent posts");
216
+ });
217
+
218
+ it("handles FLOOD error gracefully (retry: false)", async () => {
219
+ fetchRecentChannelPosts.mockRejectedValueOnce(new Error("FLOOD_WAIT_420"));
220
+
221
+ const result = await readSourcesTool.invoke({
222
+ channel: "@flooded",
223
+ limit: 1,
224
+ });
225
+
226
+ const parsed = JSON.parse(result);
227
+ expect(parsed.error).toContain("Failed to fetch");
228
+ expect(parsed.retry).toBe(false);
229
+ });
230
+
231
+ it("handles generic error gracefully (retry: true)", async () => {
232
+ fetchRecentChannelPosts.mockRejectedValueOnce(new Error("Network timeout"));
233
+
234
+ const result = await readSourcesTool.invoke({
235
+ channel: "@broken",
236
+ limit: 1,
237
+ });
238
+
239
+ const parsed = JSON.parse(result);
240
+ expect(parsed.error).toContain("Network timeout");
241
+ expect(parsed.retry).toBe(true);
242
+ });
243
+ });
244
+
245
+ // ═════════════════════════════════════════════════════════
246
+ // 2. alertHistoryTool
247
+ // ═════════════════════════════════════════════════════════
248
+
249
+ describe("alertHistoryTool", () => {
250
+ let alertHistoryTool: typeof import("../src/tools/index.js").alertHistoryTool;
251
+ const originalFetch = globalThis.fetch;
252
+
253
+ beforeEach(async () => {
254
+ vi.resetModules();
255
+ const toolsMod = await import("../src/tools/index.js");
256
+ alertHistoryTool = toolsMod.alertHistoryTool;
257
+ });
258
+
259
+ afterEach(() => {
260
+ globalThis.fetch = originalFetch;
261
+ vi.restoreAllMocks();
262
+ });
263
+
264
+ it("returns empty when no history", async () => {
265
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
266
+ ok: true,
267
+ text: async () => "",
268
+ });
269
+
270
+ const result = await alertHistoryTool.invoke({
271
+ area: "תל אביב",
272
+ last_minutes: 30,
273
+ });
274
+ const parsed = JSON.parse(result);
275
+ expect(parsed.alerts).toEqual([]);
276
+ expect(parsed.note).toContain("No alert history");
277
+ });
278
+
279
+ it("filters history by area", async () => {
280
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
281
+ ok: true,
282
+ text: async () =>
283
+ JSON.stringify([
284
+ {
285
+ alertDate: "2024-01-15 10:30",
286
+ title: "ירי רקטות",
287
+ data: "תל אביב - דרום העיר ויפו",
288
+ category_desc: "Missiles",
289
+ },
290
+ {
291
+ alertDate: "2024-01-15 10:31",
292
+ title: "ירי רקטות",
293
+ data: "חיפה - מערב",
294
+ category_desc: "Missiles",
295
+ },
296
+ ]),
297
+ });
298
+
299
+ const result = await alertHistoryTool.invoke({
300
+ area: "תל אביב",
301
+ last_minutes: 30,
302
+ });
303
+ const parsed = JSON.parse(result);
304
+ expect(parsed.alerts).toHaveLength(1);
305
+ expect(parsed.total_in_period).toBe(2);
306
+ expect(parsed.relevant_count).toBe(1);
307
+ expect(parsed.alerts[0].date).toBe("2024-01-15 10:30");
308
+ });
309
+
310
+ it("handles HTTP error", async () => {
311
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
312
+ ok: false,
313
+ status: 503,
314
+ });
315
+
316
+ const result = await alertHistoryTool.invoke({
317
+ area: "test",
318
+ last_minutes: 30,
319
+ });
320
+ const parsed = JSON.parse(result);
321
+ expect(parsed.error).toContain("503");
322
+ expect(parsed.retry).toBe(true);
323
+ });
324
+
325
+ it("handles network failure", async () => {
326
+ globalThis.fetch = vi.fn().mockRejectedValueOnce(new Error("fetch failed"));
327
+
328
+ const result = await alertHistoryTool.invoke({
329
+ area: "test",
330
+ last_minutes: 30,
331
+ });
332
+ const parsed = JSON.parse(result);
333
+ expect(parsed.error).toContain("fetch failed");
334
+ expect(parsed.retry).toBe(true);
335
+ });
336
+ });
337
+
338
+ // ═════════════════════════════════════════════════════════
339
+ // 3. shouldClarify routing logic
340
+ // ═════════════════════════════════════════════════════════
341
+
342
+ describe("shouldClarify routing", () => {
343
+ // Test the pure routing logic by extracting the conditions
344
+ // (shouldClarify is not exported, so we test its logic directly)
345
+
346
+ function shouldClarify(state: {
347
+ clarifyAttempted: boolean;
348
+ mcpToolsEnabled: boolean;
349
+ votedResult: VotedResult | undefined;
350
+ confidenceThreshold: number;
351
+ }): "clarify" | "editMessage" {
352
+ if (state.clarifyAttempted) return "editMessage";
353
+ if (!state.mcpToolsEnabled) return "editMessage";
354
+ if (!state.votedResult) return "editMessage";
355
+ if (state.votedResult.confidence < state.confidenceThreshold)
356
+ return "clarify";
357
+ return "editMessage";
358
+ }
359
+
360
+ it("routes to clarify when confidence < threshold", () => {
361
+ const result = shouldClarify({
362
+ clarifyAttempted: false,
363
+ mcpToolsEnabled: true,
364
+ votedResult: makeVotedResult({ confidence: 0.4 }),
365
+ confidenceThreshold: 0.6,
366
+ });
367
+ expect(result).toBe("clarify");
368
+ });
369
+
370
+ it("routes to editMessage when confidence >= threshold", () => {
371
+ const result = shouldClarify({
372
+ clarifyAttempted: false,
373
+ mcpToolsEnabled: true,
374
+ votedResult: makeVotedResult({ confidence: 0.8 }),
375
+ confidenceThreshold: 0.6,
376
+ });
377
+ expect(result).toBe("editMessage");
378
+ });
379
+
380
+ it("routes to editMessage when already clarified", () => {
381
+ const result = shouldClarify({
382
+ clarifyAttempted: true,
383
+ mcpToolsEnabled: true,
384
+ votedResult: makeVotedResult({ confidence: 0.3 }),
385
+ confidenceThreshold: 0.6,
386
+ });
387
+ expect(result).toBe("editMessage");
388
+ });
389
+
390
+ it("routes to editMessage when MCP tools disabled", () => {
391
+ const result = shouldClarify({
392
+ clarifyAttempted: false,
393
+ mcpToolsEnabled: false,
394
+ votedResult: makeVotedResult({ confidence: 0.3 }),
395
+ confidenceThreshold: 0.6,
396
+ });
397
+ expect(result).toBe("editMessage");
398
+ });
399
+
400
+ it("routes to editMessage when votedResult is undefined", () => {
401
+ const result = shouldClarify({
402
+ clarifyAttempted: false,
403
+ mcpToolsEnabled: true,
404
+ votedResult: undefined,
405
+ confidenceThreshold: 0.6,
406
+ });
407
+ expect(result).toBe("editMessage");
408
+ });
409
+
410
+ it("routes to clarify at exact boundary (0.59 < 0.6)", () => {
411
+ const result = shouldClarify({
412
+ clarifyAttempted: false,
413
+ mcpToolsEnabled: true,
414
+ votedResult: makeVotedResult({ confidence: 0.59 }),
415
+ confidenceThreshold: 0.6,
416
+ });
417
+ expect(result).toBe("clarify");
418
+ });
419
+
420
+ it("routes to editMessage at exact threshold (0.6 >= 0.6)", () => {
421
+ const result = shouldClarify({
422
+ clarifyAttempted: false,
423
+ mcpToolsEnabled: true,
424
+ votedResult: makeVotedResult({ confidence: 0.6 }),
425
+ confidenceThreshold: 0.6,
426
+ });
427
+ expect(result).toBe("editMessage");
428
+ });
429
+ });
430
+
431
+ // ═════════════════════════════════════════════════════════
432
+ // 4. Contradiction detection
433
+ // ═════════════════════════════════════════════════════════
434
+
435
+ describe("contradiction detection", () => {
436
+ // Extracted from clarify.ts describeContradictions logic
437
+ function describeContradictions(
438
+ _extractions: ValidatedExtraction[],
439
+ voted: VotedResult,
440
+ ): string {
441
+ const issues: string[] = [];
442
+ if (voted.countryOrigins && voted.countryOrigins.length > 1) {
443
+ const names = voted.countryOrigins.map((c) => c.name).join(", ");
444
+ issues.push(`Multiple origin countries reported: ${names}`);
445
+ }
446
+ if (
447
+ voted.rocketCountMin !== undefined &&
448
+ voted.rocketCountMax !== undefined &&
449
+ voted.rocketCountMax - voted.rocketCountMin > 3
450
+ ) {
451
+ issues.push(
452
+ `Wide rocket count range: ${voted.rocketCountMin}–${voted.rocketCountMax}`,
453
+ );
454
+ }
455
+ if (voted.interceptedConfidence < 0.5 && voted.intercepted !== undefined) {
456
+ issues.push(
457
+ `Intercepted count (${
458
+ voted.intercepted
459
+ }) has low confidence: ${voted.interceptedConfidence.toFixed(2)}`,
460
+ );
461
+ }
462
+ if (voted.hitsConfidence < 0.5 && voted.hitsConfirmed !== undefined) {
463
+ issues.push(
464
+ `Hits confirmed (${
465
+ voted.hitsConfirmed
466
+ }) has low confidence: ${voted.hitsConfidence.toFixed(2)}`,
467
+ );
468
+ }
469
+ issues.push(`Overall confidence: ${voted.confidence}`);
470
+ issues.push(`Sources count: ${voted.sourcesCount}`);
471
+ return issues.join("\n");
472
+ }
473
+
474
+ it("detects multiple country origins", () => {
475
+ const voted = makeVotedResult({
476
+ countryOrigins: [
477
+ { name: "Lebanon", citations: [1] },
478
+ { name: "Iran", citations: [2] },
479
+ ],
480
+ });
481
+ const result = describeContradictions([], voted);
482
+ expect(result).toContain("Multiple origin countries");
483
+ expect(result).toContain("Lebanon");
484
+ expect(result).toContain("Iran");
485
+ });
486
+
487
+ it("detects wide rocket count range", () => {
488
+ const voted = makeVotedResult({
489
+ rocketCountMin: 5,
490
+ rocketCountMax: 20,
491
+ });
492
+ const result = describeContradictions([], voted);
493
+ expect(result).toContain("Wide rocket count range: 5–20");
494
+ });
495
+
496
+ it("detects low intercepted confidence", () => {
497
+ const voted = makeVotedResult({
498
+ intercepted: 5,
499
+ interceptedConfidence: 0.3,
500
+ });
501
+ const result = describeContradictions([], voted);
502
+ expect(result).toContain("Intercepted count (5) has low confidence: 0.30");
503
+ });
504
+
505
+ it("detects low hits confidence", () => {
506
+ const voted = makeVotedResult({
507
+ hitsConfirmed: 2,
508
+ hitsConfidence: 0.25,
509
+ });
510
+ const result = describeContradictions([], voted);
511
+ expect(result).toContain("Hits confirmed (2) has low confidence: 0.25");
512
+ });
513
+
514
+ it("does not flag narrow rocket count range", () => {
515
+ const voted = makeVotedResult({
516
+ rocketCountMin: 10,
517
+ rocketCountMax: 12,
518
+ });
519
+ const result = describeContradictions([], voted);
520
+ expect(result).not.toContain("Wide rocket count range");
521
+ });
522
+
523
+ it("always includes overall confidence and sources count", () => {
524
+ const voted = makeVotedResult({ confidence: 0.45, sourcesCount: 3 });
525
+ const result = describeContradictions([], voted);
526
+ expect(result).toContain("Overall confidence: 0.45");
527
+ expect(result).toContain("Sources count: 3");
528
+ });
529
+ });
530
+
531
+ // ═════════════════════════════════════════════════════════
532
+ // 5. ClarifyOutput structure
533
+ // ═════════════════════════════════════════════════════════
534
+
535
+ describe("clarify output contract", () => {
536
+ it("ClarifyOutput has expected shape", () => {
537
+ // Type-level test: ensure the interface we expect
538
+ const output = {
539
+ newPosts: [{ channel: "@test", text: "test", ts: 1 }],
540
+ newExtractions: [makeExtraction()],
541
+ toolCallCount: 2,
542
+ clarified: true,
543
+ };
544
+ expect(output.newPosts).toHaveLength(1);
545
+ expect(output.newExtractions).toHaveLength(1);
546
+ expect(output.toolCallCount).toBe(2);
547
+ expect(output.clarified).toBe(true);
548
+ });
549
+
550
+ it("ClarifyInput has expected fields", () => {
551
+ const input = {
552
+ alertId: "test-1",
553
+ alertAreas: ["תל אביב"],
554
+ alertType: "red_alert" as const,
555
+ messageId: 123,
556
+ currentText: "text",
557
+ extractions: [makeExtraction()],
558
+ votedResult: makeVotedResult(),
559
+ };
560
+ expect(input.alertAreas).toContain("תל אביב");
561
+ expect(input.extractions).toHaveLength(1);
562
+ });
563
+ });
564
+
565
+ // ═════════════════════════════════════════════════════════
566
+ // 6. clarifyTools array
567
+ // ═════════════════════════════════════════════════════════
568
+
569
+ describe("clarifyTools export", () => {
570
+ it("exports exactly 4 tools", async () => {
571
+ const { clarifyTools } = await import("../src/tools/index.js");
572
+ expect(clarifyTools).toHaveLength(4);
573
+ });
574
+
575
+ it("has correct tool names", async () => {
576
+ const { clarifyTools } = await import("../src/tools/index.js");
577
+ const names = clarifyTools.map((t) => t.name);
578
+ expect(names).toContain("read_telegram_sources");
579
+ expect(names).toContain("alert_history");
580
+ expect(names).toContain("resolve_area");
581
+ expect(names).toContain("betterstack_log");
582
+ });
583
+
584
+ it("does not include old MCP-prefixed tool names", async () => {
585
+ const { clarifyTools } = await import("../src/tools/index.js");
586
+ const names = clarifyTools.map((t) => t.name);
587
+ expect(names).not.toContain("telegram_mtproto_mcp_read_sources");
588
+ expect(names).not.toContain("pikud_haoref_mcp");
589
+ expect(names).not.toContain("telegram_bot_mcp_read_target");
590
+ });
591
+ });
592
+
593
+ // ═════════════════════════════════════════════════════════
594
+ // 7. resolveAreaTool
595
+ // ═════════════════════════════════════════════════════════
596
+
597
+ describe("resolveAreaTool", () => {
598
+ let resolveAreaTool: typeof import("../src/tools/index.js").resolveAreaTool;
599
+
600
+ beforeEach(async () => {
601
+ vi.resetModules();
602
+ const toolsMod = await import("../src/tools/index.js");
603
+ resolveAreaTool = toolsMod.resolveAreaTool;
604
+ });
605
+
606
+ afterEach(() => vi.restoreAllMocks());
607
+
608
+ it("resolves direct match (תל אביב)", async () => {
609
+ const result = await resolveAreaTool.invoke({ location: "תל אביב" });
610
+ const parsed = JSON.parse(result);
611
+ expect(parsed.relevant).toBe(true);
612
+ expect(parsed.reasoning).toContain("directly matches");
613
+ });
614
+
615
+ it("resolves same zone (פתח תקווה → הרצליה via שרון/גוש דן)", async () => {
616
+ const result = await resolveAreaTool.invoke({ location: "פתח תקווה" });
617
+ const parsed = JSON.parse(result);
618
+ expect(parsed.relevant).toBe(true);
619
+ expect(parsed.sameZone).toBeTruthy();
620
+ });
621
+
622
+ it("resolves region keyword (מרכז → תל אביב)", async () => {
623
+ const result = await resolveAreaTool.invoke({ location: "מרכז" });
624
+ const parsed = JSON.parse(result);
625
+ expect(parsed.relevant).toBe(true);
626
+ });
627
+
628
+ it("rejects unrelated area (חיפה)", async () => {
629
+ const result = await resolveAreaTool.invoke({ location: "קריית שמונה" });
630
+ const parsed = JSON.parse(result);
631
+ expect(parsed.relevant).toBe(false);
632
+ });
633
+ });
634
+
635
+ // ═════════════════════════════════════════════════════════
636
+ // 8. resolveAreaProximity (unit)
637
+ // ═════════════════════════════════════════════════════════
638
+
639
+ describe("resolveAreaProximity", () => {
640
+ let resolveAreaProximity: typeof import("../src/tools/index.js")._resolveAreaProximity;
641
+
642
+ beforeEach(async () => {
643
+ vi.resetModules();
644
+ const toolsMod = await import("../src/tools/index.js");
645
+ resolveAreaProximity = toolsMod._resolveAreaProximity;
646
+ });
647
+
648
+ const monitored = ["הרצליה", "תל אביב - דרום העיר ויפו"];
649
+
650
+ it("direct match — exact monitored area", () => {
651
+ const r = resolveAreaProximity("הרצליה", monitored);
652
+ expect(r.relevant).toBe(true);
653
+ expect(r.sameZone).toBeUndefined();
654
+ expect(r.monitoredMatch).toContain("הרצליה");
655
+ });
656
+
657
+ it("direct match — partial prefix (תל אביב)", () => {
658
+ const r = resolveAreaProximity("תל אביב", monitored);
659
+ expect(r.relevant).toBe(true);
660
+ });
661
+
662
+ it("zone match — פתח תקווה in גוש דן with תל אביב", () => {
663
+ const r = resolveAreaProximity("פתח תקווה", monitored);
664
+ expect(r.relevant).toBe(true);
665
+ expect(r.sameZone).toBe("גוש דן");
666
+ });
667
+
668
+ it("zone match — רעננה in שרון with הרצליה", () => {
669
+ const r = resolveAreaProximity("רעננה", monitored);
670
+ expect(r.relevant).toBe(true);
671
+ expect(r.sameZone).toBe("שרון");
672
+ });
673
+
674
+ it("region keyword — מרכז includes תל אביב", () => {
675
+ const r = resolveAreaProximity("מרכז", monitored);
676
+ expect(r.relevant).toBe(true);
677
+ expect(r.sameZone).toBe("מרכז");
678
+ });
679
+
680
+ it("no match — קריית שמונה (north)", () => {
681
+ const r = resolveAreaProximity("קריית שמונה", monitored);
682
+ expect(r.relevant).toBe(false);
683
+ expect(r.sameZone).toBe("גליל עליון");
684
+ });
685
+
686
+ it("no match — completely unknown area", () => {
687
+ const r = resolveAreaProximity("אום אל פחם", monitored);
688
+ expect(r.relevant).toBe(false);
689
+ expect(r.sameZone).toBeUndefined();
690
+ });
691
+
692
+ it("zone match — בני ברק in גוש דן", () => {
693
+ const r = resolveAreaProximity("בני ברק", monitored);
694
+ expect(r.relevant).toBe(true);
695
+ expect(r.sameZone).toBe("גוש דן");
696
+ });
697
+ });
698
+
699
+ // ═════════════════════════════════════════════════════════
700
+ // 9. formatOrefDate
701
+ // ═════════════════════════════════════════════════════════
702
+
703
+ describe("formatOrefDate", () => {
704
+ it("formats date as DD.MM.YYYY", async () => {
705
+ const { _formatOrefDate } = await import("../src/tools/index.js");
706
+ const d = new Date("2024-03-09T12:00:00Z");
707
+ expect(_formatOrefDate(d)).toBe("09.03.2024");
708
+ });
709
+
710
+ it("pads single digit day and month", async () => {
711
+ const { _formatOrefDate } = await import("../src/tools/index.js");
712
+ const d = new Date("2024-01-05T00:00:00Z");
713
+ expect(_formatOrefDate(d)).toBe("05.01.2024");
714
+ });
715
+ });
716
+
717
+ // ═════════════════════════════════════════════════════════
718
+ // 10. betterstackLogTool
719
+ // ═════════════════════════════════════════════════════════
720
+
721
+ describe("betterstackLogTool", () => {
722
+ let betterstackLogTool: typeof import("../src/tools/index.js").betterstackLogTool;
723
+ const originalFetch = globalThis.fetch;
724
+
725
+ beforeEach(async () => {
726
+ vi.resetModules();
727
+ const toolsMod = await import("../src/tools/index.js");
728
+ betterstackLogTool = toolsMod.betterstackLogTool;
729
+ });
730
+
731
+ afterEach(() => {
732
+ globalThis.fetch = originalFetch;
733
+ vi.restoreAllMocks();
734
+ });
735
+
736
+ it("returns log events matching query", async () => {
737
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
738
+ ok: true,
739
+ json: async () => ({
740
+ results: [
741
+ {
742
+ timestamp: "2024-01-15T10:30:00Z",
743
+ message: "Alert processed",
744
+ },
745
+ {
746
+ timestamp: "2024-01-15T10:29:00Z",
747
+ message: "Enrichment started",
748
+ },
749
+ ],
750
+ }),
751
+ });
752
+
753
+ const result = await betterstackLogTool.invoke({
754
+ query: "enrichment",
755
+ lastMinutes: 15,
756
+ });
757
+ const parsed = JSON.parse(result);
758
+ expect(parsed.logs).toHaveLength(2);
759
+ expect(parsed.logs[0].message).toBe("Alert processed");
760
+ expect(parsed.count).toBe(2);
761
+ expect(parsed.query).toBe("enrichment");
762
+ });
763
+
764
+ it("returns empty when no matching logs", async () => {
765
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
766
+ ok: true,
767
+ json: async () => ({ results: [] }),
768
+ });
769
+
770
+ const result = await betterstackLogTool.invoke({
771
+ query: "nonexistent",
772
+ lastMinutes: 5,
773
+ });
774
+ const parsed = JSON.parse(result);
775
+ expect(parsed.logs).toHaveLength(0);
776
+ expect(parsed.count).toBe(0);
777
+ });
778
+
779
+ it("handles HTTP error", async () => {
780
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
781
+ ok: false,
782
+ status: 401,
783
+ text: async () => "Unauthorized",
784
+ });
785
+
786
+ const result = await betterstackLogTool.invoke({
787
+ query: "test",
788
+ lastMinutes: 10,
789
+ });
790
+ const parsed = JSON.parse(result);
791
+ expect(parsed.error).toContain("Invalid Better Stack credentials");
792
+ expect(parsed.hint).toBeDefined();
793
+ });
794
+
795
+ it("handles network failure", async () => {
796
+ globalThis.fetch = vi.fn().mockRejectedValueOnce(new Error("ECONNREFUSED"));
797
+
798
+ const result = await betterstackLogTool.invoke({
799
+ query: "test",
800
+ lastMinutes: 10,
801
+ });
802
+ const parsed = JSON.parse(result);
803
+ expect(parsed.error).toContain("ECONNREFUSED");
804
+ expect(parsed.retry).toBe(true);
805
+ });
806
+
807
+ it("handles missing token gracefully", async () => {
808
+ vi.resetModules();
809
+ vi.doMock("@easyoref/shared", async () => {
810
+ const actual = await vi.importActual("@easyoref/shared");
811
+ return {
812
+ ...actual,
813
+ config: {
814
+ ...(actual.config ?? {}),
815
+ logtailToken: "",
816
+ },
817
+ };
818
+ });
819
+ const toolsMod = await import("../src/tools/index.js");
820
+ const result = await toolsMod.betterstackLogTool.invoke({
821
+ query: "test",
822
+ lastMinutes: 10,
823
+ });
824
+ const parsed = JSON.parse(result);
825
+ expect(parsed.error).toContain("Better Stack token not configured");
826
+ });
827
+ });