arisa 5.1.49 → 5.1.64
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/AGENTS.md +0 -2
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +49 -489
- package/src/core/agent/agent-session-lifecycle.js +181 -0
- package/src/core/agent/pi-capability-tools.js +183 -0
- package/src/core/artifacts/artifact-store.js +73 -17
- package/src/core/capabilities/capability-service.js +340 -0
- package/src/core/config/config-defaults.js +28 -1
- package/src/core/tasks/task-routing.js +7 -0
- package/src/core/tasks/task-runner.js +68 -0
- package/src/core/tasks/task-store.js +382 -92
- package/src/core/tools/tool-output-materializer.js +5 -5
- package/src/core/tools/tool-registry.js +20 -5
- package/src/core/tools/weighted-resource-governor.js +153 -0
- package/src/index.js +20 -0
- package/src/official-tools.lock.json +62 -45
- package/src/runtime/arisa-capabilities.js +51 -242
- package/src/runtime/create-app.js +11 -2
- package/src/runtime/create-headless-app.js +7 -4
- package/src/runtime/paths.js +4 -0
- package/src/runtime/service-manager.js +3 -1
- package/src/runtime/service-supervisor.js +98 -0
- package/src/transport/telegram/bot.js +186 -374
- package/src/transport/telegram/chat-queue.js +83 -6
- package/src/transport/telegram/prompt-builders.js +9 -0
- package/src/transport/telegram/reply-topic-routing.js +111 -0
- package/src/transport/telegram/task-dispatcher.js +96 -36
- package/src/transport/telegram/telegram-auth-controller.js +180 -0
- package/src/transport/telegram/telegram-session-bridge.js +177 -0
- package/src/transport/telegram/telegram-tools-command.js +28 -0
- package/src/transport/telegram/telegram-workspace-controller.js +66 -0
- package/src/transport/telegram/workspace-topic-store.js +228 -0
- package/test/agent-session-lifecycle.test.js +58 -0
- package/test/artifact-store.test.js +38 -2
- package/test/capabilities-security.test.js +58 -0
- package/test/chat-queue.test.js +32 -0
- package/test/context-and-task-bounds.test.js +76 -1
- package/test/device-code-message.test.js +9 -0
- package/test/media-caption.test.js +1 -1
- package/test/model-selection.test.js +9 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/paths.test.js +8 -0
- package/test/pi-capability-tools.test.js +65 -0
- package/test/service-manager.test.js +48 -0
- package/test/session-start-operational-notes.test.js +1 -1
- package/test/task-idempotency.test.js +40 -0
- package/test/task-routing.test.js +62 -0
- package/test/task-store.test.js +231 -7
- package/test/telegram-reply-topic-routing.test.js +94 -0
- package/test/telegram-task-dispatcher.test.js +150 -23
- package/test/telegram-text-artifact.test.js +13 -2
- package/test/telegram-tools-command.test.js +47 -0
- package/test/telegram-workspace-topic-store.test.js +124 -0
- package/test/tool-registry-run.test.js +41 -0
- package/test/weighted-resource-governor.test.js +95 -0
package/test/task-store.test.js
CHANGED
|
@@ -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 });
|
|
@@ -59,12 +59,37 @@ test("claims only due pending tasks and marks them running", async () => {
|
|
|
59
59
|
assert.equal((await store.get("done")).status, "done");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
test("claims overdue tasks chronologically and records actual execution start", async () => {
|
|
63
|
+
await resetHome();
|
|
64
|
+
const store = new TaskStore();
|
|
65
|
+
const older = new Date(Date.now() - 2_000).toISOString();
|
|
66
|
+
const newer = new Date(Date.now() - 1_000).toISOString();
|
|
67
|
+
await store.add({ id: "newer", kind: "agent_task", runAt: newer });
|
|
68
|
+
await store.add({ id: "older", kind: "agent_task", runAt: older });
|
|
69
|
+
|
|
70
|
+
const claimed = await store.claimDue(2);
|
|
71
|
+
assert.deepEqual(claimed.map((task) => task.id), ["older", "newer"]);
|
|
72
|
+
assert.ok(claimed.every((task) => task.claimedAt));
|
|
73
|
+
assert.equal(claimed[0].executionStartedAt, undefined);
|
|
74
|
+
|
|
75
|
+
const started = await store.markExecutionStarted("older");
|
|
76
|
+
assert.ok(started.executionStartedAt);
|
|
77
|
+
});
|
|
78
|
+
|
|
62
79
|
test("recovers interrupted running tasks for retry after restart", async () => {
|
|
63
80
|
await resetHome();
|
|
64
81
|
const store = new TaskStore();
|
|
65
82
|
const runAt = new Date(Date.now() - 1000).toISOString();
|
|
66
83
|
|
|
67
84
|
await store.add({ id: "interrupted-once", kind: "agent_task", runAt, status: "running" });
|
|
85
|
+
await store.add({
|
|
86
|
+
id: "claimed-not-started",
|
|
87
|
+
kind: "agent_task",
|
|
88
|
+
runAt,
|
|
89
|
+
status: "running",
|
|
90
|
+
attempts: 1,
|
|
91
|
+
claimedAt: new Date().toISOString()
|
|
92
|
+
});
|
|
68
93
|
await store.add({
|
|
69
94
|
id: "interrupted-recurring",
|
|
70
95
|
kind: "poll_tool",
|
|
@@ -77,16 +102,22 @@ test("recovers interrupted running tasks for retry after restart", async () => {
|
|
|
77
102
|
|
|
78
103
|
const recovered = await store.recoverInterrupted();
|
|
79
104
|
|
|
80
|
-
assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "interrupted-recurring"]);
|
|
81
|
-
assert.
|
|
82
|
-
assert.
|
|
105
|
+
assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "claimed-not-started", "interrupted-recurring"]);
|
|
106
|
+
assert.equal(recovered[0].status, "outcome_uncertain");
|
|
107
|
+
assert.equal(recovered[1].status, "pending");
|
|
108
|
+
assert.equal(recovered[1].attempts, 0);
|
|
109
|
+
assert.equal(recovered[1].lastError, "execution interrupted before start");
|
|
110
|
+
assert.equal(recovered[2].status, "pending");
|
|
111
|
+
assert.ok(Date.parse(recovered[2].runAt) > Date.now());
|
|
112
|
+
assert.equal(recovered[0].lastError, "execution interrupted before confirmation");
|
|
113
|
+
assert.equal(recovered[2].lastError, "execution interrupted before confirmation");
|
|
83
114
|
assert.equal((await store.get("still-pending")).status, "pending");
|
|
84
115
|
assert.equal((await store.get("already-done")).status, "done");
|
|
85
116
|
|
|
86
117
|
const restartedStore = new TaskStore();
|
|
87
118
|
assert.deepEqual(
|
|
88
119
|
(await restartedStore.claimDue()).map((task) => task.id),
|
|
89
|
-
["
|
|
120
|
+
["claimed-not-started", "still-pending"]
|
|
90
121
|
);
|
|
91
122
|
});
|
|
92
123
|
|
|
@@ -114,6 +145,198 @@ test("completes one-off tasks and re-schedules recurring interval tasks", async
|
|
|
114
145
|
assert.ok(repeat.lastRunAt);
|
|
115
146
|
});
|
|
116
147
|
|
|
148
|
+
test("compacts terminal payloads while preserving routing and audit identifiers", async () => {
|
|
149
|
+
await resetHome();
|
|
150
|
+
const store = new TaskStore();
|
|
151
|
+
const route = { transport: "telegram", destination: { chatId: -1001, threadId: 87 } };
|
|
152
|
+
const payload = {
|
|
153
|
+
chatId: "chat-1",
|
|
154
|
+
prompt: "private operational prompt ".repeat(200),
|
|
155
|
+
args: { cursor: "large private state".repeat(100) },
|
|
156
|
+
acknowledgement: "received",
|
|
157
|
+
toolName: "checker",
|
|
158
|
+
resourceId: "inbox:primary",
|
|
159
|
+
artifactId: "artifact-1"
|
|
160
|
+
};
|
|
161
|
+
await store.add({ id: "compact-done", kind: "agent_task", payload, route });
|
|
162
|
+
await store.add({ id: "compact-failed", kind: "agent_task", payload });
|
|
163
|
+
await store.add({ id: "compact-uncertain", kind: "agent_task", payload });
|
|
164
|
+
|
|
165
|
+
const done = await store.complete("compact-done");
|
|
166
|
+
const failed = await store.fail("compact-failed", "permanent failure");
|
|
167
|
+
const uncertain = await store.retryOrFail("compact-uncertain", "unknown outcome", {
|
|
168
|
+
retryable: false,
|
|
169
|
+
outcomeUncertain: true
|
|
170
|
+
});
|
|
171
|
+
const expectedPayload = {
|
|
172
|
+
chatId: "chat-1",
|
|
173
|
+
toolName: "checker",
|
|
174
|
+
resourceId: "inbox:primary",
|
|
175
|
+
artifactId: "artifact-1"
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const task of [done, failed, uncertain]) {
|
|
179
|
+
assert.deepEqual(task.payload, expectedPayload);
|
|
180
|
+
assert.equal(task.payloadCompacted, true);
|
|
181
|
+
assert.equal(task.retry, undefined);
|
|
182
|
+
assert.equal(task.recurrence, undefined);
|
|
183
|
+
}
|
|
184
|
+
assert.deepEqual(done.route, route);
|
|
185
|
+
assert.ok(done.completedAt);
|
|
186
|
+
assert.equal(failed.error, "permanent failure");
|
|
187
|
+
assert.ok(uncertain.uncertainAt);
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
(await store.list({ chatId: "chat-1" })).map((task) => task.id),
|
|
190
|
+
["compact-done", "compact-failed", "compact-uncertain"]
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("startup recovery compacts historical terminal tasks without changing active payloads", async () => {
|
|
195
|
+
await resetHome();
|
|
196
|
+
await mkdir(path.dirname(tasksFile), { recursive: true });
|
|
197
|
+
await writeFile(tasksFile, `${JSON.stringify([
|
|
198
|
+
{
|
|
199
|
+
id: "historical-done",
|
|
200
|
+
kind: "agent_task",
|
|
201
|
+
status: "done",
|
|
202
|
+
payload: { chatId: "chat-1", prompt: "obsolete prompt", args: { large: "value" } },
|
|
203
|
+
retry: { maxAttempts: 3 },
|
|
204
|
+
recurrence: null
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "active-pending",
|
|
208
|
+
kind: "agent_task",
|
|
209
|
+
status: "pending",
|
|
210
|
+
runAt: new Date(Date.now() + 60_000).toISOString(),
|
|
211
|
+
payload: { chatId: "chat-1", prompt: "still required" }
|
|
212
|
+
}
|
|
213
|
+
], null, 2)}\n`, "utf8");
|
|
214
|
+
|
|
215
|
+
const store = new TaskStore();
|
|
216
|
+
assert.deepEqual(await store.recoverInterrupted(), []);
|
|
217
|
+
const persisted = JSON.parse(await readFile(tasksFile, "utf8"));
|
|
218
|
+
const historical = persisted.find((task) => task.id === "historical-done");
|
|
219
|
+
const active = persisted.find((task) => task.id === "active-pending");
|
|
220
|
+
|
|
221
|
+
assert.deepEqual(historical.payload, { chatId: "chat-1" });
|
|
222
|
+
assert.equal(historical.payloadCompacted, true);
|
|
223
|
+
assert.equal(historical.retry, undefined);
|
|
224
|
+
assert.equal(historical.recurrence, undefined);
|
|
225
|
+
assert.equal(active.payload.prompt, "still required");
|
|
226
|
+
assert.ok(active.retry);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("backs off known failures and fails after the attempt limit", async () => {
|
|
230
|
+
await resetHome();
|
|
231
|
+
const store = new TaskStore();
|
|
232
|
+
const runAt = new Date(Date.now() - 1000).toISOString();
|
|
233
|
+
await store.add({
|
|
234
|
+
id: "retrying",
|
|
235
|
+
kind: "agent_task",
|
|
236
|
+
runAt,
|
|
237
|
+
retry: { maxAttempts: 2, baseDelaySeconds: 1, maxDelaySeconds: 10, multiplier: 2 }
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await store.claimDue();
|
|
241
|
+
const retrying = await store.retryOrFail("retrying", "temporary");
|
|
242
|
+
assert.equal(retrying.status, "pending");
|
|
243
|
+
assert.equal(retrying.attempts, 1);
|
|
244
|
+
assert.ok(Date.parse(retrying.runAt) > Date.now());
|
|
245
|
+
|
|
246
|
+
retrying.runAt = runAt;
|
|
247
|
+
store.tasks.find((task) => task.id === "retrying").runAt = runAt;
|
|
248
|
+
await store.save();
|
|
249
|
+
await store.claimDue();
|
|
250
|
+
const failed = await store.retryOrFail("retrying", "still broken");
|
|
251
|
+
assert.equal(failed.status, "failed");
|
|
252
|
+
assert.equal(failed.attempts, 2);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("keeps recurring tasks scheduled after a run exhausts its retries", async () => {
|
|
256
|
+
await resetHome();
|
|
257
|
+
const store = new TaskStore();
|
|
258
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
259
|
+
await store.add({
|
|
260
|
+
id: "recurring-failure",
|
|
261
|
+
kind: "agent_task",
|
|
262
|
+
runAt: past,
|
|
263
|
+
recurrence: { type: "interval", everySeconds: 60 },
|
|
264
|
+
retry: { maxAttempts: 1 }
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await store.claimDue();
|
|
268
|
+
const result = await store.retryOrFail("recurring-failure", "temporary outage");
|
|
269
|
+
|
|
270
|
+
assert.equal(result.status, "pending");
|
|
271
|
+
assert.equal(result.terminalFailure, true);
|
|
272
|
+
assert.equal(result.lastOutcome, "failed");
|
|
273
|
+
assert.equal(result.lastError, "temporary outage");
|
|
274
|
+
assert.equal(result.attempts, 0);
|
|
275
|
+
assert.equal(result.consecutiveFailures, 1);
|
|
276
|
+
assert.ok(Date.parse(result.runAt) > Date.now());
|
|
277
|
+
|
|
278
|
+
const persisted = await store.get("recurring-failure");
|
|
279
|
+
assert.equal(persisted.terminalFailure, undefined);
|
|
280
|
+
assert.equal(persisted.status, "pending");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("moves legacy Telegram routing out of task payloads", async () => {
|
|
284
|
+
await resetHome();
|
|
285
|
+
const store = new TaskStore();
|
|
286
|
+
const task = await store.add({
|
|
287
|
+
id: "routed",
|
|
288
|
+
kind: "agent_task",
|
|
289
|
+
payload: {
|
|
290
|
+
chatId: "owner",
|
|
291
|
+
telegramContext: { transportChatId: -1001, messageThreadId: 87 }
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
assert.deepEqual(task.route, {
|
|
296
|
+
transport: "telegram",
|
|
297
|
+
destination: { chatId: -1001, threadId: 87 }
|
|
298
|
+
});
|
|
299
|
+
assert.equal(task.payload.telegramContext, undefined);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("keeps recurring tasks scheduled after an uncertain outcome without replaying the occurrence", async () => {
|
|
303
|
+
await resetHome();
|
|
304
|
+
const store = new TaskStore();
|
|
305
|
+
await store.add({
|
|
306
|
+
id: "recurring-uncertain",
|
|
307
|
+
kind: "agent_task",
|
|
308
|
+
status: "running",
|
|
309
|
+
attempts: 1,
|
|
310
|
+
recurrence: { type: "interval", everySeconds: 60 }
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const task = await store.retryOrFail("recurring-uncertain", "deadline exceeded", {
|
|
314
|
+
retryable: false,
|
|
315
|
+
outcomeUncertain: true
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
assert.equal(task.status, "pending");
|
|
319
|
+
assert.equal(task.lastOutcome, "outcome_uncertain");
|
|
320
|
+
assert.equal(task.attempts, 0);
|
|
321
|
+
assert.equal(task.terminalFailure, true);
|
|
322
|
+
assert.ok(Date.parse(task.runAt) > Date.now());
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("records uncertain outcomes without retrying", async () => {
|
|
326
|
+
await resetHome();
|
|
327
|
+
const store = new TaskStore();
|
|
328
|
+
await store.add({ id: "uncertain", kind: "agent_task", status: "running", attempts: 1 });
|
|
329
|
+
|
|
330
|
+
const task = await store.retryOrFail("uncertain", "turn interrupted", {
|
|
331
|
+
retryable: false,
|
|
332
|
+
outcomeUncertain: true
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
assert.equal(task.status, "outcome_uncertain");
|
|
336
|
+
assert.equal(task.error, "turn interrupted");
|
|
337
|
+
assert.ok(task.uncertainAt);
|
|
338
|
+
});
|
|
339
|
+
|
|
117
340
|
test("fails and cancels tasks by id", async () => {
|
|
118
341
|
await resetHome();
|
|
119
342
|
const store = new TaskStore();
|
|
@@ -138,6 +361,7 @@ test("cancelAll preserves done and failed tasks and respects chat filters", asyn
|
|
|
138
361
|
await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
|
|
139
362
|
await store.add({ id: "chat-1-done", kind: "agent_task", status: "done" }, { payload: { chatId: "chat-1" } });
|
|
140
363
|
await store.add({ id: "chat-1-failed", kind: "agent_task", status: "failed" }, { payload: { chatId: "chat-1" } });
|
|
364
|
+
await store.add({ id: "chat-1-uncertain", kind: "agent_task", status: "outcome_uncertain" }, { payload: { chatId: "chat-1" } });
|
|
141
365
|
|
|
142
366
|
const removed = await store.cancelAll({ chatId: "chat-1" });
|
|
143
367
|
const remaining = await store.list();
|
|
@@ -145,6 +369,6 @@ test("cancelAll preserves done and failed tasks and respects chat filters", asyn
|
|
|
145
369
|
assert.deepEqual(removed.map((task) => task.id), ["chat-1-pending"]);
|
|
146
370
|
assert.deepEqual(
|
|
147
371
|
remaining.map((task) => task.id),
|
|
148
|
-
["chat-2-pending", "chat-1-done", "chat-1-failed"]
|
|
372
|
+
["chat-2-pending", "chat-1-done", "chat-1-failed", "chat-1-uncertain"]
|
|
149
373
|
);
|
|
150
374
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
appendGeneralReplyRoutingInstruction,
|
|
5
|
+
extractReplyTopicMetadata,
|
|
6
|
+
isGeneralWorkspaceRoute,
|
|
7
|
+
routeGeneralWorkspaceReply
|
|
8
|
+
} from "../src/transport/telegram/reply-topic-routing.js";
|
|
9
|
+
|
|
10
|
+
const topics = [
|
|
11
|
+
{ threadId: 23, name: "Stories", description: "Published stories and editorial work" },
|
|
12
|
+
{ threadId: 87, name: "CBPR", description: "Castle Bravo press campaign" },
|
|
13
|
+
{ threadId: 114, name: "CORE", description: "Arisa core engineering" }
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const generalRoute = {
|
|
17
|
+
workspace: true,
|
|
18
|
+
ownerChatId: 42,
|
|
19
|
+
sessionId: "42",
|
|
20
|
+
scopeChatId: 42,
|
|
21
|
+
transportChatId: -100123,
|
|
22
|
+
threadId: null,
|
|
23
|
+
topicThreadId: 1,
|
|
24
|
+
generalTopicId: 1
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test("General prompts describe conservative dynamic reply routing", () => {
|
|
28
|
+
const prompt = appendGeneralReplyRoutingInstruction("Incoming Telegram message.", topics, [
|
|
29
|
+
{ name: "Infrastructure", proposedAt: "2026-08-24T00:00:00.000Z" }
|
|
30
|
+
]);
|
|
31
|
+
assert.match(prompt, /only because the incoming message was written in General/);
|
|
32
|
+
assert.match(prompt, /never applies to a private chat/);
|
|
33
|
+
assert.match(prompt, /original message must stay in General/);
|
|
34
|
+
assert.match(prompt, /23: Stories/);
|
|
35
|
+
assert.match(prompt, /87: CBPR/);
|
|
36
|
+
assert.match(prompt, /114: CORE/);
|
|
37
|
+
assert.match(prompt, /Do not repeat these recent topic proposals: Infrastructure/);
|
|
38
|
+
assert.match(prompt, /Never create a topic without explicit confirmation/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an allowed trailing marker is stripped and selects its topic", () => {
|
|
42
|
+
assert.deepEqual(extractReplyTopicMetadata("Implemented.\n[[ARISA_REPLY_TOPIC:114]]", topics), {
|
|
43
|
+
text: "Implemented.",
|
|
44
|
+
threadId: 114,
|
|
45
|
+
proposal: ""
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("invalid markers are stripped without rerouting", () => {
|
|
50
|
+
assert.deepEqual(extractReplyTopicMetadata("Keep this here.\n[[ARISA_REPLY_TOPIC:999]]", topics), {
|
|
51
|
+
text: "Keep this here.",
|
|
52
|
+
threadId: null,
|
|
53
|
+
proposal: ""
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("topic proposals are stripped and returned as transport metadata", () => {
|
|
58
|
+
assert.deepEqual(extractReplyTopicMetadata("Should we create it?\n[[ARISA_PROPOSE_TOPIC:Research Lab]]", topics), {
|
|
59
|
+
text: "Should we create it?",
|
|
60
|
+
threadId: null,
|
|
61
|
+
proposal: "Research Lab"
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("only replies originating in a supergroup General topic can change destination", () => {
|
|
66
|
+
assert.equal(isGeneralWorkspaceRoute(generalRoute), true);
|
|
67
|
+
const routed = routeGeneralWorkspaceReply({
|
|
68
|
+
route: generalRoute,
|
|
69
|
+
topics,
|
|
70
|
+
text: "Core update.\n[[ARISA_REPLY_TOPIC:114]]"
|
|
71
|
+
});
|
|
72
|
+
assert.equal(routed.text, "Core update.");
|
|
73
|
+
assert.equal(routed.route.sessionId, "42");
|
|
74
|
+
assert.equal(routed.route.threadId, 114);
|
|
75
|
+
assert.equal(routed.topic.name, "CORE");
|
|
76
|
+
|
|
77
|
+
const privateRoute = {
|
|
78
|
+
workspace: false,
|
|
79
|
+
sessionId: "42",
|
|
80
|
+
scopeChatId: 42,
|
|
81
|
+
transportChatId: 42,
|
|
82
|
+
threadId: null
|
|
83
|
+
};
|
|
84
|
+
assert.equal(isGeneralWorkspaceRoute(privateRoute), false);
|
|
85
|
+
const privateReply = routeGeneralWorkspaceReply({
|
|
86
|
+
route: privateRoute,
|
|
87
|
+
topics,
|
|
88
|
+
text: "Private response.\n[[ARISA_REPLY_TOPIC:114]]"
|
|
89
|
+
});
|
|
90
|
+
assert.equal(privateReply.route, privateRoute);
|
|
91
|
+
assert.equal(privateReply.topic, null);
|
|
92
|
+
assert.equal(privateReply.proposal, "");
|
|
93
|
+
assert.equal(privateReply.text, "Private response.");
|
|
94
|
+
});
|
|
@@ -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,65 @@ 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("
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
37
|
-
assert.
|
|
38
|
-
assert.
|
|
39
|
-
|
|
52
|
+
assert.deepEqual(calls[0][1].route, { transport: "telegram", destination: { chatId: -1001, threadId: 9 } });
|
|
53
|
+
assert.equal(calls[0][1].timeoutMs, 15 * 60_000);
|
|
54
|
+
assert.equal(calls.some(([name]) => name === "complete"), false);
|
|
55
|
+
confirmExecution();
|
|
56
|
+
await running;
|
|
57
|
+
assert.deepEqual(calls.at(-1), ["complete", "task-1"]);
|
|
40
58
|
});
|
|
41
59
|
|
|
42
|
-
test("
|
|
60
|
+
test("passes bounded execution deadlines to scheduled prompts", async () => {
|
|
61
|
+
const { calls, dispatcher } = createHarness({
|
|
62
|
+
dependencies: { taskTimeouts: { agentTimeoutMs: 900, eventTimeoutMs: 300 } }
|
|
63
|
+
});
|
|
64
|
+
await dispatcher.runClaimedTask({
|
|
65
|
+
id: "deadline-task",
|
|
66
|
+
kind: "agent_task",
|
|
67
|
+
payload: { chatId: 123, prompt: "work" }
|
|
68
|
+
});
|
|
69
|
+
await dispatcher.runClaimedTask({
|
|
70
|
+
id: "deadline-event",
|
|
71
|
+
kind: "agent_event",
|
|
72
|
+
payload: { chatId: 123, prompt: "event" }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const enqueues = calls.filter(([name]) => name === "enqueue");
|
|
76
|
+
assert.equal(enqueues[0][1].timeoutMs, 900);
|
|
77
|
+
assert.equal(enqueues[1][1].timeoutMs, 300);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("acknowledges an agent event before executing it", async () => {
|
|
43
81
|
const { calls, dispatcher } = createHarness();
|
|
44
|
-
await dispatcher.
|
|
82
|
+
await dispatcher.runClaimedTask({
|
|
45
83
|
id: "event-1",
|
|
46
84
|
kind: "agent_event",
|
|
47
85
|
payload: { chatId: 123, prompt: "something happened", acknowledgement: "received" }
|
|
@@ -54,9 +92,9 @@ test("acknowledges an agent event before queueing it", async () => {
|
|
|
54
92
|
assert.deepEqual(calls[2], ["complete", "event-1"]);
|
|
55
93
|
});
|
|
56
94
|
|
|
57
|
-
test("runs poll tools headlessly and
|
|
95
|
+
test("runs poll tools headlessly and confirms their result", async () => {
|
|
58
96
|
const { calls, dispatcher } = createHarness();
|
|
59
|
-
await dispatcher.
|
|
97
|
+
await dispatcher.runClaimedTask({
|
|
60
98
|
id: "poll-1",
|
|
61
99
|
kind: "poll_tool",
|
|
62
100
|
payload: { chatId: 123, toolName: "checker", args: { cursor: "4" } }
|
|
@@ -68,18 +106,107 @@ test("runs poll tools headlessly and completes the checker task", async () => {
|
|
|
68
106
|
]);
|
|
69
107
|
});
|
|
70
108
|
|
|
71
|
-
test("
|
|
109
|
+
test("retries a known poll failure with backoff", async () => {
|
|
110
|
+
const { calls, dispatcher } = createHarness({
|
|
111
|
+
dependencies: {
|
|
112
|
+
agentManager: {
|
|
113
|
+
async runTool(input) {
|
|
114
|
+
calls.push(["runTool", input]);
|
|
115
|
+
return { ok: false, status: "failed", error: "temporary checker failure" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await dispatcher.runClaimedTask({
|
|
121
|
+
id: "poll-failed",
|
|
122
|
+
kind: "poll_tool",
|
|
123
|
+
payload: { chatId: 123, toolName: "checker" }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(calls.at(-1), [
|
|
127
|
+
"retryOrFail",
|
|
128
|
+
"poll-failed",
|
|
129
|
+
"temporary checker failure",
|
|
130
|
+
{ retryable: true }
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("fails malformed tasks without retrying", async () => {
|
|
72
135
|
const { calls, dispatcher } = createHarness();
|
|
73
|
-
await dispatcher.
|
|
74
|
-
await dispatcher.
|
|
136
|
+
await dispatcher.runClaimedTask({ id: "bad-1", kind: "agent_task", payload: {} });
|
|
137
|
+
await dispatcher.runClaimedTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
|
|
75
138
|
|
|
76
139
|
assert.deepEqual(calls, [
|
|
77
|
-
["
|
|
78
|
-
["
|
|
140
|
+
["retryOrFail", "bad-1", "Task missing chatId: agent_task", { retryable: false }],
|
|
141
|
+
["retryOrFail", "bad-2", "Unsupported task: other", { retryable: false }],
|
|
142
|
+
["send", 123, "⚠️ Arisa task failed\nTask: other (bad-2)\nError: Unsupported task: other\nNo further retries are scheduled.", undefined]
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("notifies the routed Telegram topic once retries are exhausted", async () => {
|
|
147
|
+
const { calls, dispatcher } = createHarness({
|
|
148
|
+
taskStore: {
|
|
149
|
+
async retryOrFail(taskId, error, options) {
|
|
150
|
+
calls.push(["retryOrFail", taskId, error.message, options]);
|
|
151
|
+
return {
|
|
152
|
+
status: "pending",
|
|
153
|
+
terminalFailure: true,
|
|
154
|
+
runAt: "2026-08-21T00:00:00.000Z"
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
dependencies: {
|
|
159
|
+
enqueueAsyncPrompt: async () => { throw new Error("token=very-secret-value queue unavailable"); }
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await dispatcher.runClaimedTask({
|
|
164
|
+
id: "recurring-bad",
|
|
165
|
+
kind: "agent_task",
|
|
166
|
+
payload: { chatId: 123, prompt: "private payload must not appear" },
|
|
167
|
+
route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } }
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(calls.at(-1), [
|
|
171
|
+
"send",
|
|
172
|
+
-1001,
|
|
173
|
+
"⚠️ Arisa task failed\nTask: agent_task (recurring-bad)\nError: token=[redacted] queue unavailable\nNext run: 2026-08-21T00:00:00.000Z",
|
|
174
|
+
{ message_thread_id: 87 }
|
|
79
175
|
]);
|
|
176
|
+
assert.equal(calls.at(-1)[2].includes("private payload"), false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("serializes tasks within one destination while independent destinations continue", async () => {
|
|
180
|
+
const tasks = [
|
|
181
|
+
{ id: "first", kind: "agent_task", payload: { chatId: 123, prompt: "first" }, route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } } },
|
|
182
|
+
{ id: "second", kind: "agent_task", payload: { chatId: 123, prompt: "second" }, route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } } },
|
|
183
|
+
{ id: "other", kind: "agent_task", payload: { chatId: 123, prompt: "other" }, route: { transport: "telegram", destination: { chatId: -1001, threadId: 23 } } }
|
|
184
|
+
];
|
|
185
|
+
let releaseFirst;
|
|
186
|
+
const firstExecution = new Promise((resolve) => { releaseFirst = resolve; });
|
|
187
|
+
const { calls, dispatcher } = createHarness({
|
|
188
|
+
taskStore: { async claimDue() { return tasks; } },
|
|
189
|
+
dependencies: {
|
|
190
|
+
enqueueAsyncPrompt: async (input) => {
|
|
191
|
+
calls.push(["enqueue", input.label]);
|
|
192
|
+
if (input.label.includes("first")) await firstExecution;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const dispatching = dispatcher.dispatchDueTasks();
|
|
198
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
199
|
+
assert.ok(calls.some((call) => call[0] === "enqueue" && call[1].includes("first")));
|
|
200
|
+
assert.ok(calls.some((call) => call[0] === "enqueue" && call[1].includes("other")));
|
|
201
|
+
assert.equal(calls.some((call) => call[0] === "enqueue" && call[1].includes("second")), false);
|
|
202
|
+
|
|
203
|
+
releaseFirst();
|
|
204
|
+
await dispatching;
|
|
205
|
+
const enqueueOrder = calls.filter((call) => call[0] === "enqueue").map((call) => call[1]);
|
|
206
|
+
assert.ok(enqueueOrder.indexOf("scheduled task first") < enqueueOrder.indexOf("scheduled task second"));
|
|
80
207
|
});
|
|
81
208
|
|
|
82
|
-
test("due-task dispatch
|
|
209
|
+
test("due-task dispatch retries one failure without blocking another task", async () => {
|
|
83
210
|
const tasks = [
|
|
84
211
|
{ id: "bad", kind: "agent_task", payload: { chatId: 123, prompt: "fail" } },
|
|
85
212
|
{ id: "good", kind: "poll_tool", payload: { chatId: 123, toolName: "checker" } }
|
|
@@ -95,8 +222,8 @@ test("due-task dispatch isolates failures between claimed tasks", async () => {
|
|
|
95
222
|
|
|
96
223
|
assert.deepEqual(calls, [
|
|
97
224
|
["claimDue", 10],
|
|
98
|
-
["fail", "bad", "queue unavailable"],
|
|
99
225
|
["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
|
|
100
|
-
["complete", "good"]
|
|
226
|
+
["complete", "good"],
|
|
227
|
+
["retryOrFail", "bad", "queue unavailable", { retryable: true }]
|
|
101
228
|
]);
|
|
102
229
|
});
|
|
@@ -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
|
-
|
|
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);
|