arisa 5.1.49 → 5.1.60

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 (37) hide show
  1. package/AGENTS.md +0 -2
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +39 -489
  4. package/src/core/agent/agent-session-lifecycle.js +181 -0
  5. package/src/core/agent/pi-capability-tools.js +183 -0
  6. package/src/core/artifacts/artifact-store.js +73 -17
  7. package/src/core/capabilities/capability-service.js +340 -0
  8. package/src/core/tasks/task-routing.js +7 -0
  9. package/src/core/tasks/task-runner.js +53 -0
  10. package/src/core/tasks/task-store.js +316 -92
  11. package/src/core/tools/tool-output-materializer.js +5 -5
  12. package/src/official-tools.lock.json +7 -4
  13. package/src/runtime/arisa-capabilities.js +51 -242
  14. package/src/runtime/create-app.js +10 -1
  15. package/src/runtime/create-headless-app.js +5 -2
  16. package/src/transport/telegram/bot.js +112 -368
  17. package/src/transport/telegram/chat-queue.js +72 -6
  18. package/src/transport/telegram/prompt-builders.js +9 -0
  19. package/src/transport/telegram/task-dispatcher.js +73 -36
  20. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  21. package/src/transport/telegram/telegram-session-bridge.js +170 -0
  22. package/src/transport/telegram/telegram-tools-command.js +28 -0
  23. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  24. package/test/agent-session-lifecycle.test.js +58 -0
  25. package/test/artifact-store.test.js +38 -2
  26. package/test/capabilities-security.test.js +58 -0
  27. package/test/context-and-task-bounds.test.js +76 -1
  28. package/test/device-code-message.test.js +9 -0
  29. package/test/media-caption.test.js +1 -1
  30. package/test/pi-capability-tools.test.js +65 -0
  31. package/test/session-start-operational-notes.test.js +1 -1
  32. package/test/task-idempotency.test.js +40 -0
  33. package/test/task-routing.test.js +62 -0
  34. package/test/task-store.test.js +178 -6
  35. package/test/telegram-task-dispatcher.test.js +99 -23
  36. package/test/telegram-text-artifact.test.js +13 -2
  37. package/test/telegram-tools-command.test.js +47 -0
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -9,7 +9,7 @@ process.env.HOME = homeDir;
9
9
  process.env.USERPROFILE = homeDir;
10
10
 
11
11
  const { TaskStore } = await import("../src/core/tasks/task-store.js");
12
- const { arisaHomeDir } = await import("../src/runtime/paths.js");
12
+ const { arisaHomeDir, tasksFile } = await import("../src/runtime/paths.js");
13
13
 
14
14
  async function resetHome() {
15
15
  await rm(arisaHomeDir, { recursive: true, force: true });
@@ -78,15 +78,17 @@ test("recovers interrupted running tasks for retry after restart", async () => {
78
78
  const recovered = await store.recoverInterrupted();
79
79
 
80
80
  assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "interrupted-recurring"]);
81
- assert.ok(recovered.every((task) => task.status === "pending"));
82
- assert.ok(recovered.every((task) => task.runAt === runAt));
81
+ assert.equal(recovered[0].status, "outcome_uncertain");
82
+ assert.equal(recovered[1].status, "pending");
83
+ assert.ok(Date.parse(recovered[1].runAt) > Date.now());
84
+ assert.ok(recovered.every((task) => task.lastError === "execution interrupted before confirmation"));
83
85
  assert.equal((await store.get("still-pending")).status, "pending");
84
86
  assert.equal((await store.get("already-done")).status, "done");
85
87
 
86
88
  const restartedStore = new TaskStore();
87
89
  assert.deepEqual(
88
90
  (await restartedStore.claimDue()).map((task) => task.id),
89
- ["interrupted-once", "interrupted-recurring", "still-pending"]
91
+ ["still-pending"]
90
92
  );
91
93
  });
92
94
 
@@ -114,6 +116,175 @@ test("completes one-off tasks and re-schedules recurring interval tasks", async
114
116
  assert.ok(repeat.lastRunAt);
115
117
  });
116
118
 
119
+ test("compacts terminal payloads while preserving routing and audit identifiers", async () => {
120
+ await resetHome();
121
+ const store = new TaskStore();
122
+ const route = { transport: "telegram", destination: { chatId: -1001, threadId: 87 } };
123
+ const payload = {
124
+ chatId: "chat-1",
125
+ prompt: "private operational prompt ".repeat(200),
126
+ args: { cursor: "large private state".repeat(100) },
127
+ acknowledgement: "received",
128
+ toolName: "checker",
129
+ resourceId: "inbox:primary",
130
+ artifactId: "artifact-1"
131
+ };
132
+ await store.add({ id: "compact-done", kind: "agent_task", payload, route });
133
+ await store.add({ id: "compact-failed", kind: "agent_task", payload });
134
+ await store.add({ id: "compact-uncertain", kind: "agent_task", payload });
135
+
136
+ const done = await store.complete("compact-done");
137
+ const failed = await store.fail("compact-failed", "permanent failure");
138
+ const uncertain = await store.retryOrFail("compact-uncertain", "unknown outcome", {
139
+ retryable: false,
140
+ outcomeUncertain: true
141
+ });
142
+ const expectedPayload = {
143
+ chatId: "chat-1",
144
+ toolName: "checker",
145
+ resourceId: "inbox:primary",
146
+ artifactId: "artifact-1"
147
+ };
148
+
149
+ for (const task of [done, failed, uncertain]) {
150
+ assert.deepEqual(task.payload, expectedPayload);
151
+ assert.equal(task.payloadCompacted, true);
152
+ assert.equal(task.retry, undefined);
153
+ assert.equal(task.recurrence, undefined);
154
+ }
155
+ assert.deepEqual(done.route, route);
156
+ assert.ok(done.completedAt);
157
+ assert.equal(failed.error, "permanent failure");
158
+ assert.ok(uncertain.uncertainAt);
159
+ assert.deepEqual(
160
+ (await store.list({ chatId: "chat-1" })).map((task) => task.id),
161
+ ["compact-done", "compact-failed", "compact-uncertain"]
162
+ );
163
+ });
164
+
165
+ test("startup recovery compacts historical terminal tasks without changing active payloads", async () => {
166
+ await resetHome();
167
+ await mkdir(path.dirname(tasksFile), { recursive: true });
168
+ await writeFile(tasksFile, `${JSON.stringify([
169
+ {
170
+ id: "historical-done",
171
+ kind: "agent_task",
172
+ status: "done",
173
+ payload: { chatId: "chat-1", prompt: "obsolete prompt", args: { large: "value" } },
174
+ retry: { maxAttempts: 3 },
175
+ recurrence: null
176
+ },
177
+ {
178
+ id: "active-pending",
179
+ kind: "agent_task",
180
+ status: "pending",
181
+ runAt: new Date(Date.now() + 60_000).toISOString(),
182
+ payload: { chatId: "chat-1", prompt: "still required" }
183
+ }
184
+ ], null, 2)}\n`, "utf8");
185
+
186
+ const store = new TaskStore();
187
+ assert.deepEqual(await store.recoverInterrupted(), []);
188
+ const persisted = JSON.parse(await readFile(tasksFile, "utf8"));
189
+ const historical = persisted.find((task) => task.id === "historical-done");
190
+ const active = persisted.find((task) => task.id === "active-pending");
191
+
192
+ assert.deepEqual(historical.payload, { chatId: "chat-1" });
193
+ assert.equal(historical.payloadCompacted, true);
194
+ assert.equal(historical.retry, undefined);
195
+ assert.equal(historical.recurrence, undefined);
196
+ assert.equal(active.payload.prompt, "still required");
197
+ assert.ok(active.retry);
198
+ });
199
+
200
+ test("backs off known failures and fails after the attempt limit", async () => {
201
+ await resetHome();
202
+ const store = new TaskStore();
203
+ const runAt = new Date(Date.now() - 1000).toISOString();
204
+ await store.add({
205
+ id: "retrying",
206
+ kind: "agent_task",
207
+ runAt,
208
+ retry: { maxAttempts: 2, baseDelaySeconds: 1, maxDelaySeconds: 10, multiplier: 2 }
209
+ });
210
+
211
+ await store.claimDue();
212
+ const retrying = await store.retryOrFail("retrying", "temporary");
213
+ assert.equal(retrying.status, "pending");
214
+ assert.equal(retrying.attempts, 1);
215
+ assert.ok(Date.parse(retrying.runAt) > Date.now());
216
+
217
+ retrying.runAt = runAt;
218
+ store.tasks.find((task) => task.id === "retrying").runAt = runAt;
219
+ await store.save();
220
+ await store.claimDue();
221
+ const failed = await store.retryOrFail("retrying", "still broken");
222
+ assert.equal(failed.status, "failed");
223
+ assert.equal(failed.attempts, 2);
224
+ });
225
+
226
+ test("keeps recurring tasks scheduled after a run exhausts its retries", async () => {
227
+ await resetHome();
228
+ const store = new TaskStore();
229
+ const past = new Date(Date.now() - 1000).toISOString();
230
+ await store.add({
231
+ id: "recurring-failure",
232
+ kind: "agent_task",
233
+ runAt: past,
234
+ recurrence: { type: "interval", everySeconds: 60 },
235
+ retry: { maxAttempts: 1 }
236
+ });
237
+
238
+ await store.claimDue();
239
+ const result = await store.retryOrFail("recurring-failure", "temporary outage");
240
+
241
+ assert.equal(result.status, "pending");
242
+ assert.equal(result.terminalFailure, true);
243
+ assert.equal(result.lastOutcome, "failed");
244
+ assert.equal(result.lastError, "temporary outage");
245
+ assert.equal(result.attempts, 0);
246
+ assert.equal(result.consecutiveFailures, 1);
247
+ assert.ok(Date.parse(result.runAt) > Date.now());
248
+
249
+ const persisted = await store.get("recurring-failure");
250
+ assert.equal(persisted.terminalFailure, undefined);
251
+ assert.equal(persisted.status, "pending");
252
+ });
253
+
254
+ test("moves legacy Telegram routing out of task payloads", async () => {
255
+ await resetHome();
256
+ const store = new TaskStore();
257
+ const task = await store.add({
258
+ id: "routed",
259
+ kind: "agent_task",
260
+ payload: {
261
+ chatId: "owner",
262
+ telegramContext: { transportChatId: -1001, messageThreadId: 87 }
263
+ }
264
+ });
265
+
266
+ assert.deepEqual(task.route, {
267
+ transport: "telegram",
268
+ destination: { chatId: -1001, threadId: 87 }
269
+ });
270
+ assert.equal(task.payload.telegramContext, undefined);
271
+ });
272
+
273
+ test("records uncertain outcomes without retrying", async () => {
274
+ await resetHome();
275
+ const store = new TaskStore();
276
+ await store.add({ id: "uncertain", kind: "agent_task", status: "running", attempts: 1 });
277
+
278
+ const task = await store.retryOrFail("uncertain", "turn interrupted", {
279
+ retryable: false,
280
+ outcomeUncertain: true
281
+ });
282
+
283
+ assert.equal(task.status, "outcome_uncertain");
284
+ assert.equal(task.error, "turn interrupted");
285
+ assert.ok(task.uncertainAt);
286
+ });
287
+
117
288
  test("fails and cancels tasks by id", async () => {
118
289
  await resetHome();
119
290
  const store = new TaskStore();
@@ -138,6 +309,7 @@ test("cancelAll preserves done and failed tasks and respects chat filters", asyn
138
309
  await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
139
310
  await store.add({ id: "chat-1-done", kind: "agent_task", status: "done" }, { payload: { chatId: "chat-1" } });
140
311
  await store.add({ id: "chat-1-failed", kind: "agent_task", status: "failed" }, { payload: { chatId: "chat-1" } });
312
+ await store.add({ id: "chat-1-uncertain", kind: "agent_task", status: "outcome_uncertain" }, { payload: { chatId: "chat-1" } });
141
313
 
142
314
  const removed = await store.cancelAll({ chatId: "chat-1" });
143
315
  const remaining = await store.list();
@@ -145,6 +317,6 @@ test("cancelAll preserves done and failed tasks and respects chat filters", asyn
145
317
  assert.deepEqual(removed.map((task) => task.id), ["chat-1-pending"]);
146
318
  assert.deepEqual(
147
319
  remaining.map((task) => task.id),
148
- ["chat-2-pending", "chat-1-done", "chat-1-failed"]
320
+ ["chat-2-pending", "chat-1-done", "chat-1-failed", "chat-1-uncertain"]
149
321
  );
150
322
  });
@@ -5,8 +5,12 @@ import { createTelegramTaskDispatcher } from "../src/transport/telegram/task-dis
5
5
  function createHarness(overrides = {}) {
6
6
  const calls = [];
7
7
  const taskStore = {
8
- async fail(...args) { calls.push(["fail", ...args]); },
9
- async complete(...args) { calls.push(["complete", ...args]); },
8
+ async fail(...args) { calls.push(["fail", ...args]); return { status: "failed" }; },
9
+ async complete(...args) { calls.push(["complete", ...args]); return { status: "done" }; },
10
+ async retryOrFail(taskId, error, options) {
11
+ calls.push(["retryOrFail", taskId, error.message, options]);
12
+ return { status: options.retryable ? "pending" : "failed" };
13
+ },
10
14
  async claimDue() { return []; },
11
15
  ...overrides.taskStore
12
16
  };
@@ -17,31 +21,44 @@ function createHarness(overrides = {}) {
17
21
  artifactStore: { forChat() { throw new Error("unexpected artifact access"); } },
18
22
  toolRegistry: {},
19
23
  resourceNotes: { async get() { return ""; } },
20
- agentManager: { async runTool(input) { calls.push(["runTool", input]); } },
24
+ agentManager: { async runTool(input) { calls.push(["runTool", input]); return { ok: true }; } },
21
25
  logger: null,
22
26
  ...overrides.dependencies
23
27
  });
24
28
  return { calls, taskStore, dispatcher };
25
29
  }
26
30
 
27
- test("dispatches an agent task into the Telegram prompt queue", async () => {
28
- const { calls, dispatcher } = createHarness();
29
- await dispatcher.dispatchTask({
31
+ test("confirms an agent task only after prompt execution resolves", async () => {
32
+ let confirmExecution;
33
+ const execution = new Promise((resolve) => { confirmExecution = resolve; });
34
+ const { calls, dispatcher } = createHarness({
35
+ dependencies: {
36
+ enqueueAsyncPrompt: async (input) => {
37
+ calls.push(["enqueue", input]);
38
+ await execution;
39
+ }
40
+ }
41
+ });
42
+ const running = dispatcher.runClaimedTask({
30
43
  id: "task-1",
31
44
  kind: "agent_task",
32
- payload: { chatId: 123, prompt: "do the thing", telegramContext: { messageThreadId: 9 } }
45
+ status: "running",
46
+ payload: { chatId: 123, prompt: "do the thing" },
47
+ route: { transport: "telegram", destination: { chatId: -1001, threadId: 9 } }
33
48
  });
34
49
 
50
+ await new Promise((resolve) => setImmediate(resolve));
35
51
  assert.equal(calls[0][0], "enqueue");
36
- assert.equal(calls[0][1].chatId, 123);
37
- assert.match(calls[0][1].prompt, /taskId: task-1/);
38
- assert.match(calls[0][1].prompt, /text: do the thing/);
39
- assert.deepEqual(calls[1], ["complete", "task-1"]);
52
+ assert.deepEqual(calls[0][1].route, { transport: "telegram", destination: { chatId: -1001, threadId: 9 } });
53
+ assert.equal(calls.some(([name]) => name === "complete"), false);
54
+ confirmExecution();
55
+ await running;
56
+ assert.deepEqual(calls.at(-1), ["complete", "task-1"]);
40
57
  });
41
58
 
42
- test("acknowledges an agent event before queueing it", async () => {
59
+ test("acknowledges an agent event before executing it", async () => {
43
60
  const { calls, dispatcher } = createHarness();
44
- await dispatcher.dispatchTask({
61
+ await dispatcher.runClaimedTask({
45
62
  id: "event-1",
46
63
  kind: "agent_event",
47
64
  payload: { chatId: 123, prompt: "something happened", acknowledgement: "received" }
@@ -54,9 +71,9 @@ test("acknowledges an agent event before queueing it", async () => {
54
71
  assert.deepEqual(calls[2], ["complete", "event-1"]);
55
72
  });
56
73
 
57
- test("runs poll tools headlessly and completes the checker task", async () => {
74
+ test("runs poll tools headlessly and confirms their result", async () => {
58
75
  const { calls, dispatcher } = createHarness();
59
- await dispatcher.dispatchTask({
76
+ await dispatcher.runClaimedTask({
60
77
  id: "poll-1",
61
78
  kind: "poll_tool",
62
79
  payload: { chatId: 123, toolName: "checker", args: { cursor: "4" } }
@@ -68,18 +85,77 @@ test("runs poll tools headlessly and completes the checker task", async () => {
68
85
  ]);
69
86
  });
70
87
 
71
- test("fails malformed and unsupported tasks without enqueueing them", async () => {
88
+ test("retries a known poll failure with backoff", async () => {
89
+ const { calls, dispatcher } = createHarness({
90
+ dependencies: {
91
+ agentManager: {
92
+ async runTool(input) {
93
+ calls.push(["runTool", input]);
94
+ return { ok: false, status: "failed", error: "temporary checker failure" };
95
+ }
96
+ }
97
+ }
98
+ });
99
+ await dispatcher.runClaimedTask({
100
+ id: "poll-failed",
101
+ kind: "poll_tool",
102
+ payload: { chatId: 123, toolName: "checker" }
103
+ });
104
+
105
+ assert.deepEqual(calls.at(-1), [
106
+ "retryOrFail",
107
+ "poll-failed",
108
+ "temporary checker failure",
109
+ { retryable: true }
110
+ ]);
111
+ });
112
+
113
+ test("fails malformed tasks without retrying", async () => {
72
114
  const { calls, dispatcher } = createHarness();
73
- await dispatcher.dispatchTask({ id: "bad-1", kind: "agent_task", payload: {} });
74
- await dispatcher.dispatchTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
115
+ await dispatcher.runClaimedTask({ id: "bad-1", kind: "agent_task", payload: {} });
116
+ await dispatcher.runClaimedTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
75
117
 
76
118
  assert.deepEqual(calls, [
77
- ["fail", "bad-1", "Task missing chatId: agent_task"],
78
- ["fail", "bad-2", "Unsupported task: other"]
119
+ ["retryOrFail", "bad-1", "Task missing chatId: agent_task", { retryable: false }],
120
+ ["retryOrFail", "bad-2", "Unsupported task: other", { retryable: false }],
121
+ ["send", 123, "⚠️ Arisa task failed\nTask: other (bad-2)\nError: Unsupported task: other\nNo further retries are scheduled.", undefined]
122
+ ]);
123
+ });
124
+
125
+ test("notifies the routed Telegram topic once retries are exhausted", async () => {
126
+ const { calls, dispatcher } = createHarness({
127
+ taskStore: {
128
+ async retryOrFail(taskId, error, options) {
129
+ calls.push(["retryOrFail", taskId, error.message, options]);
130
+ return {
131
+ status: "pending",
132
+ terminalFailure: true,
133
+ runAt: "2026-08-21T00:00:00.000Z"
134
+ };
135
+ }
136
+ },
137
+ dependencies: {
138
+ enqueueAsyncPrompt: async () => { throw new Error("token=very-secret-value queue unavailable"); }
139
+ }
140
+ });
141
+
142
+ await dispatcher.runClaimedTask({
143
+ id: "recurring-bad",
144
+ kind: "agent_task",
145
+ payload: { chatId: 123, prompt: "private payload must not appear" },
146
+ route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } }
147
+ });
148
+
149
+ assert.deepEqual(calls.at(-1), [
150
+ "send",
151
+ -1001,
152
+ "⚠️ Arisa task failed\nTask: agent_task (recurring-bad)\nError: token=[redacted] queue unavailable\nNext run: 2026-08-21T00:00:00.000Z",
153
+ { message_thread_id: 87 }
79
154
  ]);
155
+ assert.equal(calls.at(-1)[2].includes("private payload"), false);
80
156
  });
81
157
 
82
- test("due-task dispatch isolates failures between claimed tasks", async () => {
158
+ test("due-task dispatch retries one failure without blocking another task", async () => {
83
159
  const tasks = [
84
160
  { id: "bad", kind: "agent_task", payload: { chatId: 123, prompt: "fail" } },
85
161
  { id: "good", kind: "poll_tool", payload: { chatId: 123, toolName: "checker" } }
@@ -95,8 +171,8 @@ test("due-task dispatch isolates failures between claimed tasks", async () => {
95
171
 
96
172
  assert.deepEqual(calls, [
97
173
  ["claimDue", 10],
98
- ["fail", "bad", "queue unavailable"],
99
174
  ["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
100
- ["complete", "good"]
175
+ ["complete", "good"],
176
+ ["retryOrFail", "bad", "queue unavailable", { retryable: true }]
101
177
  ]);
102
178
  });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildPrompt, buildReactionPrompt, isScheduledTaskPrompt, shouldIncludeArtifactReference, withPromptSpeed } from "../src/transport/telegram/bot.js";
3
+ import { buildPrompt, buildReactionPrompt, isScheduledTaskPrompt, scheduledPromptSpeedOptions, shouldIncludeArtifactReference, withPromptSpeed } from "../src/transport/telegram/bot.js";
4
4
  import { captureIncomingArtifact } from "../src/transport/telegram/media.js";
5
5
 
6
6
  test("scheduled agent prompts use normal speed for one turn and restore chat speed", async () => {
@@ -11,7 +11,18 @@ test("scheduled agent prompts use normal speed for one turn and restore chat spe
11
11
  assert.equal(isScheduledTaskPrompt("Scheduled task fired.\ntaskId: one"), true);
12
12
  assert.equal(isScheduledTaskPrompt("Incoming Telegram message."), false);
13
13
 
14
- await withPromptSpeed({ speedController, speed: 1, restoreSpeed: () => 1.5 }, async () => {
14
+ const speedOptions = scheduledPromptSpeedOptions({
15
+ prompt: "Scheduled task fired.\ntaskId: one",
16
+ session: {
17
+ model: { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.5" }
18
+ },
19
+ speedController,
20
+ configuredSpeed: 1.5
21
+ });
22
+ assert.equal(speedOptions.speed, 1);
23
+ assert.equal(speedOptions.restoreSpeed(), 1.5);
24
+
25
+ await withPromptSpeed(speedOptions, async () => {
15
26
  assert.equal(speed, 1);
16
27
  });
17
28
  assert.equal(speed, 1.5);
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createTelegramToolsCommandHandler } from "../src/transport/telegram/telegram-tools-command.js";
4
+
5
+ test("/tools shows typing and sends the scoped usage report", async () => {
6
+ const events = [];
7
+ const handler = createTelegramToolsCommandHandler({
8
+ authorize: async () => ({ ok: true }),
9
+ contextRoute: () => ({ scopeChatId: 879964957 }),
10
+ toolRegistry: {
11
+ usage: async (chatId) => {
12
+ assert.equal(chatId, 879964957);
13
+ return [{ name: "example", count: 2, official: true }];
14
+ }
15
+ },
16
+ withTyping: async (_ctx, work) => {
17
+ events.push("typing");
18
+ return work();
19
+ },
20
+ logger: null
21
+ });
22
+ const ctx = {
23
+ chat: { id: 879964957 },
24
+ reply: async (text, options) => events.push({ text, options })
25
+ };
26
+
27
+ await handler(ctx);
28
+
29
+ assert.equal(events[0], "typing");
30
+ assert.match(events[1].text, /example/);
31
+ assert.deepEqual(events[1].options, { parse_mode: "HTML" });
32
+ });
33
+
34
+ test("/tools reports failures instead of disappearing", async () => {
35
+ const replies = [];
36
+ const handler = createTelegramToolsCommandHandler({
37
+ authorize: async () => ({ ok: true }),
38
+ contextRoute: () => ({ scopeChatId: 1 }),
39
+ toolRegistry: { usage: async () => { throw new Error("store unavailable"); } },
40
+ withTyping: async (_ctx, work) => work(),
41
+ logger: null
42
+ });
43
+
44
+ await handler({ chat: { id: 1 }, reply: async (text) => replies.push(text) });
45
+
46
+ assert.deepEqual(replies, ["Tool usage report failed: store unavailable"]);
47
+ });