arisa 5.1.68 → 5.2.17
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/README.md +28 -6
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +123 -8
- package/src/core/agent/agent-session-lifecycle.js +108 -8
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-capability-tools.js +7 -4
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/session-history-reader.js +168 -0
- package/src/core/agent/session-preload-migration.js +162 -0
- package/src/core/agent/session-rotation.js +29 -0
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +4 -3
- package/src/core/config/config-defaults.js +59 -1
- package/src/core/config/config-store.js +1 -1
- package/src/core/conversation/session-seed-store.js +1 -1
- package/src/core/tasks/task-runner.js +8 -4
- package/src/core/tasks/task-store.js +48 -5
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +26 -3
- package/src/core/tools/daemon-protocol.js +72 -0
- package/src/core/tools/daemon-runtime.js +13 -490
- package/src/core/tools/daemon-worker.js +310 -0
- package/src/core/tools/ipc-client.js +2 -2
- package/src/core/tools/memory-pressure.js +56 -0
- package/src/core/tools/official-tool-installer.js +1 -1
- package/src/core/tools/tool-config.js +1 -1
- package/src/core/tools/tool-process-output.js +100 -0
- package/src/core/tools/tool-process-runner.js +175 -0
- package/src/core/tools/tool-registry.js +131 -191
- package/src/core/tools/tool-resource-note-store.js +1 -1
- package/src/core/tools/tool-usage-store.js +1 -1
- package/src/core/tools/weighted-resource-governor.js +189 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +526 -78
- package/src/platform/paths.js +152 -0
- package/src/runtime/bootstrap-cli.js +121 -0
- package/src/runtime/bootstrap-config.js +97 -0
- package/src/runtime/bootstrap-telegram.js +325 -0
- package/src/runtime/bootstrap.js +6 -543
- package/src/runtime/doctor.js +26 -76
- package/src/runtime/flush.js +1 -1
- package/src/runtime/ipc/ipc-server.js +1 -1
- package/src/runtime/log-viewer.js +1 -1
- package/src/runtime/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/process-inspection.js +78 -0
- package/src/runtime/restart-receipt.js +1 -1
- package/src/runtime/service-manager.js +1 -1
- package/src/runtime/service-supervisor.js +14 -0
- package/src/runtime/slave-cli.js +1 -1
- package/src/runtime/tool-process-supervisor.js +36 -9
- package/src/runtime/tui.js +200 -0
- package/src/runtime/update-manager.js +1 -1
- package/src/runtime/worker-recovery-report.js +142 -0
- package/src/transport/telegram/bot.js +45 -321
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/task-dispatcher.js +36 -11
- package/src/transport/telegram/telegram-prompt-controller.js +359 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/agent-turn-coordinator.test.js +45 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +38 -5
- package/test/doctor.test.js +41 -0
- package/test/memory-pressure.test.js +41 -0
- package/test/model-selection.test.js +11 -1
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- package/test/official-tool-installer.test.js +18 -1
- package/test/oom-protection.test.js +32 -0
- package/test/paths.test.js +7 -0
- package/test/pi-compaction.test.js +30 -0
- package/test/service-manager.test.js +6 -1
- package/test/session-history-reader.test.js +84 -0
- package/test/session-preload-migration.test.js +120 -0
- package/test/session-rotation.test.js +110 -0
- package/test/task-store.test.js +32 -0
- package/test/telegram-prompt-controller.test.js +82 -0
- package/test/telegram-task-dispatcher.test.js +66 -5
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +117 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +125 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test/worker-tool-fanout.test.js +79 -0
- package/test-fixtures/fake-daemon.js +5 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
4
|
+
import { AgentManager } from "../src/core/agent/agent-manager.js";
|
|
5
|
+
import { compactionRotationRequest, normalizeSessionRotationPolicy } from "../src/core/agent/session-rotation.js";
|
|
6
|
+
|
|
7
|
+
const mebibyte = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function compactionEvent(summary = "checkpoint") {
|
|
10
|
+
return {
|
|
11
|
+
type: "compaction_end",
|
|
12
|
+
aborted: false,
|
|
13
|
+
errorMessage: "",
|
|
14
|
+
result: { summary }
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("normalizes the automatic session rotation policy", () => {
|
|
19
|
+
assert.deepEqual(normalizeSessionRotationPolicy(), {
|
|
20
|
+
enabled: true,
|
|
21
|
+
compactAtPersistedBytes: 24 * mebibyte,
|
|
22
|
+
maxPersistedBytes: 32 * mebibyte
|
|
23
|
+
});
|
|
24
|
+
assert.deepEqual(normalizeSessionRotationPolicy({
|
|
25
|
+
enabled: false,
|
|
26
|
+
compactAtPersistedBytes: 8,
|
|
27
|
+
maxPersistedBytes: 12
|
|
28
|
+
}), {
|
|
29
|
+
enabled: false,
|
|
30
|
+
compactAtPersistedBytes: 8,
|
|
31
|
+
maxPersistedBytes: 12
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("requests rotation only after a successful oversized compaction", () => {
|
|
36
|
+
assert.equal(compactionRotationRequest(compactionEvent(), 24 * mebibyte), null);
|
|
37
|
+
assert.equal(compactionRotationRequest({ ...compactionEvent(), aborted: true }, 25 * mebibyte), null);
|
|
38
|
+
assert.equal(compactionRotationRequest(compactionEvent(), 25 * mebibyte, { enabled: false }), null);
|
|
39
|
+
const request = compactionRotationRequest(compactionEvent("latest summary"), 25 * mebibyte);
|
|
40
|
+
assert.equal(request.persistedBytes, 25 * mebibyte);
|
|
41
|
+
assert.match(request.handoff, /latest summary/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("compacts at the preventive persisted-size threshold and then rotates", async () => {
|
|
45
|
+
const config = applyConfigDefaults({ telegram: {}, pi: { provider: "test", model: "test" } });
|
|
46
|
+
const manager = new AgentManager({
|
|
47
|
+
config,
|
|
48
|
+
artifactStore: {},
|
|
49
|
+
toolRegistry: {},
|
|
50
|
+
taskStore: {},
|
|
51
|
+
logger: null
|
|
52
|
+
});
|
|
53
|
+
let compactions = 0;
|
|
54
|
+
const context = {
|
|
55
|
+
activeUsers: 1,
|
|
56
|
+
session: {
|
|
57
|
+
sessionFile: "/sessions/preventive.jsonl",
|
|
58
|
+
async compact() {
|
|
59
|
+
compactions += 1;
|
|
60
|
+
manager.scheduleCompactionRotationCheck("chat", context, compactionEvent("preventive summary"));
|
|
61
|
+
},
|
|
62
|
+
async close() {}
|
|
63
|
+
},
|
|
64
|
+
rotationCheckPromise: Promise.resolve(),
|
|
65
|
+
rotationRequest: null
|
|
66
|
+
};
|
|
67
|
+
manager.sessions.set("chat", context);
|
|
68
|
+
manager.estimatePersistedSessionBytes = async () => 25 * mebibyte;
|
|
69
|
+
|
|
70
|
+
await manager.releaseSessionContext("chat", context);
|
|
71
|
+
|
|
72
|
+
assert.equal(compactions, 1);
|
|
73
|
+
assert.equal(manager.sessions.has("chat"), false);
|
|
74
|
+
assert.equal(manager.pendingNewSessions.has("chat"), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rotates after active work releases and preserves the parent session path", async () => {
|
|
78
|
+
const config = applyConfigDefaults({ telegram: {}, pi: { provider: "test", model: "test" } });
|
|
79
|
+
let closes = 0;
|
|
80
|
+
const manager = new AgentManager({
|
|
81
|
+
config,
|
|
82
|
+
artifactStore: {},
|
|
83
|
+
toolRegistry: {},
|
|
84
|
+
taskStore: {},
|
|
85
|
+
logger: null
|
|
86
|
+
});
|
|
87
|
+
const context = {
|
|
88
|
+
activeUsers: 1,
|
|
89
|
+
session: {
|
|
90
|
+
sessionFile: "/sessions/oversized.jsonl",
|
|
91
|
+
async close() { closes += 1; }
|
|
92
|
+
},
|
|
93
|
+
rotationCheckPromise: Promise.resolve(),
|
|
94
|
+
rotationRequest: null
|
|
95
|
+
};
|
|
96
|
+
manager.sessions.set("chat", context);
|
|
97
|
+
manager.estimatePersistedSessionBytes = async () => 65 * mebibyte;
|
|
98
|
+
|
|
99
|
+
manager.scheduleCompactionRotationCheck("chat", context, compactionEvent("handoff summary"));
|
|
100
|
+
await manager.releaseSessionContext("chat", context);
|
|
101
|
+
|
|
102
|
+
assert.equal(closes, 1);
|
|
103
|
+
assert.equal(manager.sessions.has("chat"), false);
|
|
104
|
+
assert.equal(manager.pendingNewSessions.has("chat"), true);
|
|
105
|
+
assert.deepEqual(manager.pendingSessionHandoffs.get("chat"), {
|
|
106
|
+
text: "Automatic session rotation after compaction. Continue from this checkpoint:\n\nhandoff summary",
|
|
107
|
+
parentSession: "/sessions/oversized.jsonl",
|
|
108
|
+
source: "compaction-rotation"
|
|
109
|
+
});
|
|
110
|
+
});
|
package/test/task-store.test.js
CHANGED
|
@@ -121,6 +121,38 @@ test("recovers interrupted running tasks for retry after restart", async () => {
|
|
|
121
121
|
);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test("persists auth blocks, claims only due probes, and clears the block after success", async () => {
|
|
125
|
+
await resetHome();
|
|
126
|
+
const store = new TaskStore();
|
|
127
|
+
await store.add({
|
|
128
|
+
id: "auth-poll",
|
|
129
|
+
kind: "poll_tool",
|
|
130
|
+
runAt: new Date(Date.now() - 1000).toISOString(),
|
|
131
|
+
payload: { toolName: "checker", args: { action: "poll" } },
|
|
132
|
+
recurrence: { type: "interval", everySeconds: 60 }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await store.claimDue();
|
|
136
|
+
const blocked = await store.blockAuth("auth-poll", "authentication expired", {
|
|
137
|
+
retryAfterSeconds: 3600,
|
|
138
|
+
probeArgs: { action: "auth-status" }
|
|
139
|
+
});
|
|
140
|
+
assert.equal(blocked.status, "blocked_auth");
|
|
141
|
+
assert.equal(blocked.authBlockedNew, true);
|
|
142
|
+
assert.deepEqual(blocked.authBlock.probeArgs, { action: "auth-status" });
|
|
143
|
+
assert.deepEqual(await store.claimDue(), []);
|
|
144
|
+
|
|
145
|
+
store.tasks.find((task) => task.id === "auth-poll").runAt = new Date(Date.now() - 1000).toISOString();
|
|
146
|
+
await store.save();
|
|
147
|
+
const [probe] = await store.claimDue();
|
|
148
|
+
assert.equal(probe.status, "running");
|
|
149
|
+
assert.ok(probe.authBlock);
|
|
150
|
+
|
|
151
|
+
const completed = await store.complete("auth-poll");
|
|
152
|
+
assert.equal(completed.status, "pending");
|
|
153
|
+
assert.equal(completed.authBlock, undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
124
156
|
test("completes one-off tasks and re-schedules recurring interval tasks", async () => {
|
|
125
157
|
await resetHome();
|
|
126
158
|
const store = new TaskStore();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createChatStateStore } from "../src/transport/telegram/chat-queue.js";
|
|
4
|
+
import { createTelegramPromptController } from "../src/transport/telegram/telegram-prompt-controller.js";
|
|
5
|
+
|
|
6
|
+
function createController(overrides = {}) {
|
|
7
|
+
const stateStore = createChatStateStore();
|
|
8
|
+
const calls = { cleared: [], reset: [], steered: [] };
|
|
9
|
+
const controller = createTelegramPromptController({
|
|
10
|
+
config: { pi: { chatModels: {} }, telegram: {} },
|
|
11
|
+
api: {},
|
|
12
|
+
artifactStore: {},
|
|
13
|
+
toolRegistry: {},
|
|
14
|
+
agentManager: {
|
|
15
|
+
resetSession: (...args) => calls.reset.push(args),
|
|
16
|
+
runTurn: async (_options, work) => work()
|
|
17
|
+
},
|
|
18
|
+
sessionSeeds: {
|
|
19
|
+
clear: async (chatId) => calls.cleared.push(chatId)
|
|
20
|
+
},
|
|
21
|
+
workspaceTopics: {},
|
|
22
|
+
contextRoute: (ctx) => ctx.route,
|
|
23
|
+
getChatState: (chatId) => stateStore.get(chatId),
|
|
24
|
+
createTelegramSessionBridge: () => ({}),
|
|
25
|
+
createWorkspaceAccessGuard: () => async () => {},
|
|
26
|
+
sendTextReply: async () => {},
|
|
27
|
+
authController: { notifyIssueIfNeeded: async () => false },
|
|
28
|
+
ensureWorkspaceTopicModelSelection: async () => {},
|
|
29
|
+
ensureQueuedTyping: async () => {},
|
|
30
|
+
withTyping: async (_ctx, work) => work(),
|
|
31
|
+
resolveBusyMessageMode: () => "steer",
|
|
32
|
+
...overrides
|
|
33
|
+
});
|
|
34
|
+
return { controller, stateStore, calls };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("busy /new resets only the active session and replaces its queued prompt", async () => {
|
|
38
|
+
const { controller, stateStore, calls } = createController();
|
|
39
|
+
const route = {
|
|
40
|
+
workspace: true,
|
|
41
|
+
sessionId: "topic-87",
|
|
42
|
+
scopeChatId: "owner",
|
|
43
|
+
transportChatId: "group",
|
|
44
|
+
threadId: 87
|
|
45
|
+
};
|
|
46
|
+
const state = stateStore.get(route.sessionId);
|
|
47
|
+
state.processing = true;
|
|
48
|
+
state.pendingPrompts.push("stale prompt");
|
|
49
|
+
|
|
50
|
+
await controller.handleNewCommand({ route, from: { language_code: "es" } });
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(calls.cleared, [route.sessionId]);
|
|
53
|
+
assert.deepEqual(calls.reset, [[route.sessionId]]);
|
|
54
|
+
assert.equal(state.pendingPrompts.length, 1);
|
|
55
|
+
assert.match(state.pendingPrompts[0], /System event: \/new requested/);
|
|
56
|
+
assert.equal(state.continueAfterClose, true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a busy prompt for another topic queues instead of steering the active session", async () => {
|
|
60
|
+
const { controller, stateStore, calls } = createController();
|
|
61
|
+
const state = stateStore.get("topic-session");
|
|
62
|
+
state.processing = true;
|
|
63
|
+
state.activeRoute = { transportChatId: "group", threadId: 87 };
|
|
64
|
+
state.activeSession = {
|
|
65
|
+
isStreaming: true,
|
|
66
|
+
steer: async (prompt) => calls.steered.push(prompt)
|
|
67
|
+
};
|
|
68
|
+
const ctx = {
|
|
69
|
+
route: { transportChatId: "group", threadId: 114 }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
await controller.enqueuePrompt({
|
|
73
|
+
chatId: "topic-session",
|
|
74
|
+
prompt: "different destination",
|
|
75
|
+
label: "cross-topic prompt",
|
|
76
|
+
ctx,
|
|
77
|
+
busyMessageMode: "steer"
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(calls.steered, []);
|
|
81
|
+
assert.deepEqual(state.pendingPrompts, ["different destination"]);
|
|
82
|
+
});
|
|
@@ -7,6 +7,10 @@ function createHarness(overrides = {}) {
|
|
|
7
7
|
const taskStore = {
|
|
8
8
|
async fail(...args) { calls.push(["fail", ...args]); return { status: "failed" }; },
|
|
9
9
|
async complete(...args) { calls.push(["complete", ...args]); return { status: "done" }; },
|
|
10
|
+
async blockAuth(taskId, error, resolution) {
|
|
11
|
+
calls.push(["blockAuth", taskId, error.message, resolution]);
|
|
12
|
+
return { status: "blocked_auth", authBlockedNew: true, runAt: "2026-09-02T05:00:00.000Z" };
|
|
13
|
+
},
|
|
10
14
|
async retryOrFail(taskId, error, options) {
|
|
11
15
|
calls.push(["retryOrFail", taskId, error.message, options]);
|
|
12
16
|
return { status: options.retryable ? "pending" : "failed" };
|
|
@@ -21,7 +25,10 @@ function createHarness(overrides = {}) {
|
|
|
21
25
|
artifactStore: { forChat() { throw new Error("unexpected artifact access"); } },
|
|
22
26
|
toolRegistry: {},
|
|
23
27
|
resourceNotes: { async get() { return ""; } },
|
|
24
|
-
agentManager: {
|
|
28
|
+
agentManager: {
|
|
29
|
+
async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
|
|
30
|
+
async runTool(input) { calls.push(["runTool", input]); return { ok: true }; }
|
|
31
|
+
},
|
|
25
32
|
logger: null,
|
|
26
33
|
...overrides.dependencies
|
|
27
34
|
});
|
|
@@ -74,7 +81,9 @@ test("passes bounded execution deadlines to scheduled prompts", async () => {
|
|
|
74
81
|
|
|
75
82
|
const enqueues = calls.filter(([name]) => name === "enqueue");
|
|
76
83
|
assert.equal(enqueues[0][1].timeoutMs, 900);
|
|
84
|
+
assert.equal(enqueues[0][1].priority, "background");
|
|
77
85
|
assert.equal(enqueues[1][1].timeoutMs, 300);
|
|
86
|
+
assert.equal(enqueues[1][1].priority, "interactive");
|
|
78
87
|
});
|
|
79
88
|
|
|
80
89
|
test("acknowledges an agent event before executing it", async () => {
|
|
@@ -101,15 +110,66 @@ test("runs poll tools headlessly and confirms their result", async () => {
|
|
|
101
110
|
});
|
|
102
111
|
|
|
103
112
|
assert.deepEqual(calls, [
|
|
113
|
+
["runTurn", { priority: "background", label: "poll tool checker" }],
|
|
104
114
|
["runTool", { name: "checker", request: { args: { cursor: "4" } }, chatId: 123 }],
|
|
105
115
|
["complete", "poll-1"]
|
|
106
116
|
]);
|
|
107
117
|
});
|
|
108
118
|
|
|
119
|
+
test("pauses a poll once when its tool reports terminal authentication failure", async () => {
|
|
120
|
+
const resolution = {
|
|
121
|
+
type: "reauthentication_required",
|
|
122
|
+
retryAfterSeconds: 3600,
|
|
123
|
+
probeArgs: { action: "auth-status" }
|
|
124
|
+
};
|
|
125
|
+
const { calls, dispatcher } = createHarness({
|
|
126
|
+
dependencies: {
|
|
127
|
+
agentManager: {
|
|
128
|
+
async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
|
|
129
|
+
async runTool(input) {
|
|
130
|
+
calls.push(["runTool", input]);
|
|
131
|
+
return { ok: false, status: "blocked_auth", error: "authentication expired", resolution };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await dispatcher.runClaimedTask({
|
|
138
|
+
id: "poll-auth",
|
|
139
|
+
kind: "poll_tool",
|
|
140
|
+
payload: { chatId: 123, toolName: "checker", args: { action: "poll" } }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(calls.at(-2), ["blockAuth", "poll-auth", "authentication expired", resolution]);
|
|
144
|
+
assert.deepEqual(calls.at(-1), [
|
|
145
|
+
"send",
|
|
146
|
+
123,
|
|
147
|
+
"⚠️ Arisa automation paused for authentication\nTool: checker\nReason: authentication expired\nNext authentication check: 2026-09-02T05:00:00.000Z",
|
|
148
|
+
undefined
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("uses a lightweight auth probe before resuming blocked poll work", async () => {
|
|
153
|
+
const { calls, dispatcher } = createHarness();
|
|
154
|
+
await dispatcher.runClaimedTask({
|
|
155
|
+
id: "poll-resume",
|
|
156
|
+
kind: "poll_tool",
|
|
157
|
+
authBlock: { probeArgs: { action: "auth-status" }, retryAfterSeconds: 3600 },
|
|
158
|
+
payload: { chatId: 123, toolName: "checker", args: { action: "poll" } }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(calls.filter(([name]) => name === "runTool").map((call) => call[1].request.args), [
|
|
162
|
+
{ action: "auth-status" },
|
|
163
|
+
{ action: "poll" }
|
|
164
|
+
]);
|
|
165
|
+
assert.deepEqual(calls.at(-1), ["complete", "poll-resume"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
109
168
|
test("retries a known poll failure with backoff", async () => {
|
|
110
169
|
const { calls, dispatcher } = createHarness({
|
|
111
170
|
dependencies: {
|
|
112
171
|
agentManager: {
|
|
172
|
+
async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
|
|
113
173
|
async runTool(input) {
|
|
114
174
|
calls.push(["runTool", input]);
|
|
115
175
|
return { ok: false, status: "failed", error: "temporary checker failure" };
|
|
@@ -220,10 +280,11 @@ test("due-task dispatch retries one failure without blocking another task", asyn
|
|
|
220
280
|
|
|
221
281
|
await dispatcher.dispatchDueTasks();
|
|
222
282
|
|
|
223
|
-
assert.deepEqual(calls, [
|
|
283
|
+
assert.deepEqual(calls.slice(0, 3), [
|
|
224
284
|
["claimDue", 10],
|
|
225
|
-
["
|
|
226
|
-
["
|
|
227
|
-
["retryOrFail", "bad", "queue unavailable", { retryable: true }]
|
|
285
|
+
["runTurn", { priority: "background", label: "poll tool checker" }],
|
|
286
|
+
["runTool", { name: "checker", request: { args: {} }, chatId: 123 }]
|
|
228
287
|
]);
|
|
288
|
+
assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["complete", "good"])));
|
|
289
|
+
assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["retryOrFail", "bad", "queue unavailable", { retryable: true }])));
|
|
229
290
|
});
|
|
@@ -110,6 +110,36 @@ test("surfaces Telegram forwarding provenance in the prompt", () => {
|
|
|
110
110
|
assert.match(prompt, /forwardedAt: 2026-/);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("surfaces Telegram selected quote text when the replied message has no body", () => {
|
|
114
|
+
const ctx = createTextContext("update that too");
|
|
115
|
+
ctx.message.reply_to_message = {
|
|
116
|
+
message_id: 824,
|
|
117
|
+
from: { username: "ArisaWaybot" }
|
|
118
|
+
};
|
|
119
|
+
ctx.message.quote = { text: "master-slave 0.1.9" };
|
|
120
|
+
|
|
121
|
+
const prompt = buildPrompt({ ctx });
|
|
122
|
+
|
|
123
|
+
assert.match(prompt, /quotedMessageId: 824/);
|
|
124
|
+
assert.match(prompt, /quotedSelection: master-slave 0\.1\.9/);
|
|
125
|
+
assert.doesNotMatch(prompt, /no textual body available/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("surfaces quoted Telegram forum topic metadata", () => {
|
|
129
|
+
const ctx = createTextContext("continue here");
|
|
130
|
+
ctx.message.reply_to_message = {
|
|
131
|
+
message_id: 824,
|
|
132
|
+
from: { username: "ArisaWaybot" },
|
|
133
|
+
forum_topic_created: { name: "storybot" }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const prompt = buildPrompt({ ctx });
|
|
137
|
+
|
|
138
|
+
assert.match(prompt, /quotedKind: forum_topic_created/);
|
|
139
|
+
assert.match(prompt, /quotedTopicName: storybot/);
|
|
140
|
+
assert.doesNotMatch(prompt, /no textual body available/);
|
|
141
|
+
});
|
|
142
|
+
|
|
113
143
|
test("formats Telegram reaction changes as lightweight feedback", () => {
|
|
114
144
|
const prompt = buildReactionPrompt({
|
|
115
145
|
reaction: {
|
|
@@ -8,7 +8,9 @@ const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-registry-home-"
|
|
|
8
8
|
process.env.HOME = homeDir;
|
|
9
9
|
process.env.USERPROFILE = homeDir;
|
|
10
10
|
|
|
11
|
-
const { ToolRegistry, createToolOutputParser } = await import("../src/core/tools/tool-registry.js");
|
|
11
|
+
const { ToolRegistry, createToolOutputParser, isolatedToolProcessInvocation, readyDaemonAdmission } = await import("../src/core/tools/tool-registry.js");
|
|
12
|
+
const { createToolOutputParser: directToolOutputParser } = await import("../src/core/tools/tool-process-output.js");
|
|
13
|
+
const { isolatedToolProcessInvocation: directToolProcessInvocation } = await import("../src/core/tools/tool-process-runner.js");
|
|
12
14
|
const {
|
|
13
15
|
arisaHomeDir,
|
|
14
16
|
arisaPackageDir,
|
|
@@ -98,6 +100,33 @@ setInterval(() => {}, 1_000);
|
|
|
98
100
|
return dir;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
test("preserves process helpers through the ToolRegistry compatibility facade", () => {
|
|
104
|
+
assert.equal(createToolOutputParser, directToolOutputParser);
|
|
105
|
+
assert.equal(isolatedToolProcessInvocation, directToolProcessInvocation);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("wraps declared Linux tool processes in a memory-limited cgroup", () => {
|
|
109
|
+
assert.deepEqual(isolatedToolProcessInvocation(
|
|
110
|
+
["--max-old-space-size=192", "/tool/index.js", "run"],
|
|
111
|
+
{ maxMemoryMb: 384 },
|
|
112
|
+
{ platform: "linux", systemdAvailable: true, oomAdjustAvailable: true }
|
|
113
|
+
), {
|
|
114
|
+
command: "systemd-run",
|
|
115
|
+
args: [
|
|
116
|
+
"--scope", "--quiet", "--collect", "--slice=arisa-tools.slice",
|
|
117
|
+
"-p", "MemoryHigh=326M",
|
|
118
|
+
"-p", "MemoryMax=384M",
|
|
119
|
+
"-p", "MemorySwapMax=128M",
|
|
120
|
+
"--", "choom", "-n", "500", "--", "node", "--max-old-space-size=192", "/tool/index.js", "run"
|
|
121
|
+
],
|
|
122
|
+
isolated: true
|
|
123
|
+
});
|
|
124
|
+
assert.equal(isolatedToolProcessInvocation(["tool.js"], { maxMemoryMb: 384 }, {
|
|
125
|
+
platform: "darwin",
|
|
126
|
+
systemdAvailable: false
|
|
127
|
+
}).isolated, false);
|
|
128
|
+
});
|
|
129
|
+
|
|
101
130
|
test("loads and lists installed tools from the user tools directory", async () => {
|
|
102
131
|
await resetHome();
|
|
103
132
|
await createFakeTool("fake-tool", {
|
|
@@ -172,7 +201,10 @@ test("loads weighted execution metadata from the tool manifest", async () => {
|
|
|
172
201
|
assert.deepEqual(registry.get("heavy-tool").execution, {
|
|
173
202
|
resourceClass: "browser",
|
|
174
203
|
weight: 2,
|
|
175
|
-
deduplicateConcurrent: false
|
|
204
|
+
deduplicateConcurrent: false,
|
|
205
|
+
maxHeapMb: 4096,
|
|
206
|
+
maxMemoryMb: 16_384,
|
|
207
|
+
maxOutputBytes: 1_048_576
|
|
176
208
|
});
|
|
177
209
|
});
|
|
178
210
|
|
|
@@ -210,6 +242,15 @@ test("reports dependency status in help and blocks a tool with a missing depende
|
|
|
210
242
|
);
|
|
211
243
|
});
|
|
212
244
|
|
|
245
|
+
test("only a live ready declared daemon bypasses worker RSS spawn admission", () => {
|
|
246
|
+
const tool = { daemon: { scope: "chat", health: "internal" } };
|
|
247
|
+
assert.deepEqual(readyDaemonAdmission(tool, { alive: true, state: "ready", restart: { requested: false } }), { ignoreWorkerRss: true });
|
|
248
|
+
assert.deepEqual(readyDaemonAdmission(tool, { alive: false, state: "ready", restart: { requested: false } }), {});
|
|
249
|
+
assert.deepEqual(readyDaemonAdmission(tool, { alive: true, state: "degraded", restart: { requested: false } }), {});
|
|
250
|
+
assert.deepEqual(readyDaemonAdmission(tool, { alive: true, state: "ready", restart: { requested: true } }), {});
|
|
251
|
+
assert.deepEqual(readyDaemonAdmission({}, { alive: true, state: "ready" }), {});
|
|
252
|
+
});
|
|
253
|
+
|
|
213
254
|
test("wraps declared tool runs in the shared execution governor", async () => {
|
|
214
255
|
await resetHome();
|
|
215
256
|
await createFakeTool("heavy-tool", {
|
|
@@ -232,7 +273,14 @@ test("wraps declared tool runs in the shared execution governor", async () => {
|
|
|
232
273
|
assert.deepEqual(calls, [
|
|
233
274
|
{
|
|
234
275
|
type: "acquire",
|
|
235
|
-
execution: {
|
|
276
|
+
execution: {
|
|
277
|
+
resourceClass: "browser",
|
|
278
|
+
weight: 1,
|
|
279
|
+
deduplicateConcurrent: false,
|
|
280
|
+
maxHeapMb: 4096,
|
|
281
|
+
maxMemoryMb: 16_384,
|
|
282
|
+
maxOutputBytes: 1_048_576
|
|
283
|
+
},
|
|
236
284
|
label: "heavy-tool"
|
|
237
285
|
},
|
|
238
286
|
{ type: "release", label: "heavy-tool" }
|
|
@@ -252,7 +300,13 @@ await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
252
300
|
process.stdout.write(JSON.stringify({ ok: true, output: { text: request.args.value } }));
|
|
253
301
|
`, "utf8");
|
|
254
302
|
const counterFile = path.join(homeDir, "single-flight-count.txt");
|
|
255
|
-
const registry = new ToolRegistry({
|
|
303
|
+
const registry = new ToolRegistry({
|
|
304
|
+
executionPolicy: {
|
|
305
|
+
capacities: { orchestrator: 1 },
|
|
306
|
+
maxWorkerRssMb: 4096,
|
|
307
|
+
maxSwapUsedPercent: 100
|
|
308
|
+
}
|
|
309
|
+
});
|
|
256
310
|
await registry.load();
|
|
257
311
|
const invocation = {
|
|
258
312
|
name: "single-flight-tool",
|
|
@@ -398,6 +452,57 @@ test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only",
|
|
|
398
452
|
assert.doesNotMatch(JSON.stringify(events), /stream diagnostic/);
|
|
399
453
|
});
|
|
400
454
|
|
|
455
|
+
test("contains an isolated tool heap failure and keeps the registry alive", async () => {
|
|
456
|
+
await resetHome();
|
|
457
|
+
const dir = await createFakeTool("heap-bomb-tool", {
|
|
458
|
+
execution: {
|
|
459
|
+
resourceClass: "browser",
|
|
460
|
+
weight: 1,
|
|
461
|
+
maxHeapMb: 64,
|
|
462
|
+
maxMemoryMb: 128,
|
|
463
|
+
maxOutputBytes: 65_536
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
await writeFile(path.join(dir, "index.js"), `
|
|
467
|
+
const retained = [];
|
|
468
|
+
while (true) retained.push(new Array(1_000_000).fill("heap-pressure"));
|
|
469
|
+
`, "utf8");
|
|
470
|
+
await createFakeTool("healthy-after-oom");
|
|
471
|
+
const registry = new ToolRegistry({
|
|
472
|
+
runTimeoutMs: 30_000,
|
|
473
|
+
executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
|
|
474
|
+
});
|
|
475
|
+
await registry.load();
|
|
476
|
+
|
|
477
|
+
const failed = await registry.run({ name: "heap-bomb-tool", chatId: "chat-1", request: { args: {} } });
|
|
478
|
+
assert.equal(failed.ok, false);
|
|
479
|
+
assert.equal(failed.status, "outcome_uncertain");
|
|
480
|
+
|
|
481
|
+
const healthy = await registry.run({ name: "healthy-after-oom", chatId: "chat-1", request: { args: {} } });
|
|
482
|
+
assert.equal(healthy.ok, true);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("terminates oversized isolated tool output without taking down the registry", async () => {
|
|
486
|
+
await resetHome();
|
|
487
|
+
const dir = await createFakeTool("oversized-tool", {
|
|
488
|
+
execution: { resourceClass: "browser", weight: 1, maxOutputBytes: 65_536 }
|
|
489
|
+
});
|
|
490
|
+
await writeFile(path.join(dir, "index.js"), `process.stdout.write("x".repeat(70_000));`, "utf8");
|
|
491
|
+
await createFakeTool("healthy-tool");
|
|
492
|
+
const registry = new ToolRegistry({
|
|
493
|
+
executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
|
|
494
|
+
});
|
|
495
|
+
await registry.load();
|
|
496
|
+
|
|
497
|
+
const oversized = await registry.run({ name: "oversized-tool", chatId: "chat-1", request: { args: {} } });
|
|
498
|
+
assert.equal(oversized.ok, false);
|
|
499
|
+
assert.equal(oversized.status, "outcome_uncertain");
|
|
500
|
+
assert.match(oversized.error, /exceeds 65536 bytes/);
|
|
501
|
+
|
|
502
|
+
const healthy = await registry.run({ name: "healthy-tool", chatId: "chat-1", request: { args: {} } });
|
|
503
|
+
assert.equal(healthy.ok, true);
|
|
504
|
+
});
|
|
505
|
+
|
|
401
506
|
test("rejects invalid NDJSON sequences and a second terminal event", async () => {
|
|
402
507
|
const invalidSequence = createToolOutputParser("sequence-tool");
|
|
403
508
|
await invalidSequence.push(`${JSON.stringify({ version: 1, jobId: "job", type: "accepted", sequence: 1, payload: {} })}\n`);
|
|
@@ -414,6 +519,14 @@ test("rejects invalid NDJSON sequences and a second terminal event", async () =>
|
|
|
414
519
|
);
|
|
415
520
|
});
|
|
416
521
|
|
|
522
|
+
test("bounds accumulated legacy output before parsing", async () => {
|
|
523
|
+
const parser = createToolOutputParser("legacy-tool", { maxOutputBytes: 10 });
|
|
524
|
+
await assert.rejects(
|
|
525
|
+
() => parser.push("12345678901"),
|
|
526
|
+
(error) => error.code === "TOOL_OUTPUT_LIMIT"
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
417
530
|
test("preserves pretty-printed single JSON tool responses", async () => {
|
|
418
531
|
const parser = createToolOutputParser("legacy-tool");
|
|
419
532
|
const output = JSON.stringify({ ok: true, output: { text: "legacy" } }, null, 2);
|
package/test/tui.test.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createTuiCapabilityTools, resolveTuiChatId } from "../src/runtime/tui.js";
|
|
4
|
+
|
|
5
|
+
test("uses the first authorized owner scope for TUI capabilities", () => {
|
|
6
|
+
assert.equal(resolveTuiChatId({ telegram: { authorizedChatIds: [879964957, 2] } }), 879964957);
|
|
7
|
+
assert.throws(() => resolveTuiChatId({ telegram: { authorizedChatIds: [] } }), /authorized chat/);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("adapts Pi TUI tools to the running Arisa IPC service", async () => {
|
|
11
|
+
const calls = [];
|
|
12
|
+
const client = {
|
|
13
|
+
tools: {
|
|
14
|
+
list: async (params) => { calls.push(["list", params]); return { tools: [] }; },
|
|
15
|
+
help: async () => "help",
|
|
16
|
+
skills: async () => [],
|
|
17
|
+
setConfig: async () => ({ ok: true }),
|
|
18
|
+
run: async () => ({ ok: true })
|
|
19
|
+
},
|
|
20
|
+
tasks: {
|
|
21
|
+
list: async () => [],
|
|
22
|
+
cancel: async () => ({ ok: true }),
|
|
23
|
+
cancelAll: async () => ({ ok: true })
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const tools = createTuiCapabilityTools(client);
|
|
27
|
+
assert.deepEqual(tools.map((tool) => tool.name), [
|
|
28
|
+
"list_tools",
|
|
29
|
+
"tool_help",
|
|
30
|
+
"tool_skills",
|
|
31
|
+
"set_tool_config",
|
|
32
|
+
"run_tool",
|
|
33
|
+
"list_scheduled_tasks",
|
|
34
|
+
"cancel_scheduled_task",
|
|
35
|
+
"cancel_all_scheduled_tasks"
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const result = await tools[0].execute("call", { query: "email" });
|
|
39
|
+
assert.deepEqual(calls, [["list", { query: "email" }]]);
|
|
40
|
+
assert.match(result.content[0].text, /"tools"/);
|
|
41
|
+
});
|