arisa 5.1.66 → 5.2.7
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 +7 -4
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +46 -6
- package/src/core/agent/agent-session-lifecycle.js +80 -3
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +1 -1
- package/src/core/config/config-defaults.js +30 -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-store.js +1 -1
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-health.js +5 -2
- package/src/core/tools/daemon-processes.js +19 -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 +130 -186
- 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 +192 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +430 -60
- 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 +6 -3
- 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/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- 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 +2 -1
- package/src/runtime/tool-process-supervisor.js +1 -1
- 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 +42 -320
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/telegram-prompt-controller.js +346 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +56 -1
- package/test/doctor.test.js +22 -0
- package/test/memory-pressure.test.js +36 -0
- package/test/model-selection.test.js +11 -1
- package/test/official-tool-dependencies.test.js +1 -1
- 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 +9 -0
- package/test/service-manager.test.js +6 -1
- package/test/slave-cli.test.js +20 -1
- package/test/telegram-prompt-controller.test.js +81 -0
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +147 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +103 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test-fixtures/fake-daemon.js +5 -0
package/test/doctor.test.js
CHANGED
|
@@ -151,6 +151,28 @@ test("stops only a registered duplicate Arisa service with verified identity", a
|
|
|
151
151
|
assert.match(report.repairs.join("\n"), /Stopped duplicate Arisa service process 321/);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("does not stop the supervisor that owns the current worker", async () => {
|
|
155
|
+
const supervisorPid = 321;
|
|
156
|
+
const stopped = [];
|
|
157
|
+
const report = await runDoctor({
|
|
158
|
+
agentManager: { getRuntimeDiagnostic: async () => runtime() },
|
|
159
|
+
toolProcessSupervisor: { repair: async () => [] },
|
|
160
|
+
daemonPolicy,
|
|
161
|
+
doctorPolicy,
|
|
162
|
+
listProcesses: async () => [{
|
|
163
|
+
pid: supervisorPid,
|
|
164
|
+
command: `${process.execPath} ${serviceEntryFile} --service-runner`
|
|
165
|
+
}],
|
|
166
|
+
serviceStatus: async () => ({ running: true, pid: supervisorPid }),
|
|
167
|
+
stopProcess: async (pid) => { stopped.push(pid); },
|
|
168
|
+
inspectResources: async () => system,
|
|
169
|
+
supervisorPid
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.deepEqual(stopped, []);
|
|
173
|
+
assert.deepEqual(report.repairs, []);
|
|
174
|
+
});
|
|
175
|
+
|
|
154
176
|
test("requires complete positive doctor context policy", async () => {
|
|
155
177
|
await assert.rejects(
|
|
156
178
|
runDoctor({
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { memoryPressureReason, readMemoryPressure } from "../src/core/tools/memory-pressure.js";
|
|
4
|
+
|
|
5
|
+
test("reads Linux available memory, swap pressure, and worker RSS", async () => {
|
|
6
|
+
const snapshot = await readMemoryPressure({
|
|
7
|
+
platform: "linux",
|
|
8
|
+
readMemInfo: async () => [
|
|
9
|
+
"MemTotal: 1000000 kB",
|
|
10
|
+
"MemAvailable: 200000 kB",
|
|
11
|
+
"SwapTotal: 500000 kB",
|
|
12
|
+
"SwapFree: 100000 kB"
|
|
13
|
+
].join("\n"),
|
|
14
|
+
freeMemory: () => 1,
|
|
15
|
+
totalMemory: () => 2,
|
|
16
|
+
processMemory: () => ({ rss: 123 * 1024 * 1024 })
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(snapshot.availableBytes, 200_000 * 1024);
|
|
20
|
+
assert.equal(snapshot.swapUsedPercent, 80);
|
|
21
|
+
assert.equal(snapshot.workerRssBytes, 123 * 1024 * 1024);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("classifies each configured memory pressure boundary", () => {
|
|
25
|
+
const policy = { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 };
|
|
26
|
+
assert.match(memoryPressureReason({
|
|
27
|
+
workerRssBytes: 400 * 1024 * 1024,
|
|
28
|
+
swapTotalBytes: 0,
|
|
29
|
+
swapUsedPercent: 0
|
|
30
|
+
}, policy), /worker RSS/);
|
|
31
|
+
assert.match(memoryPressureReason({
|
|
32
|
+
workerRssBytes: 100 * 1024 * 1024,
|
|
33
|
+
swapTotalBytes: 100,
|
|
34
|
+
swapUsedPercent: 96
|
|
35
|
+
}, policy), /swap use/);
|
|
36
|
+
});
|
|
@@ -302,7 +302,17 @@ test("centralizes Telegram and Pi defaults in config", () => {
|
|
|
302
302
|
assert.equal(config.telegram.busyMessageMode, "steer");
|
|
303
303
|
assert.equal(config.toolExecution.defaultCapacity, toolExecutionConfigDefaults.defaultCapacity);
|
|
304
304
|
assert.equal(config.toolExecution.maxQueuedPerClass, 100);
|
|
305
|
-
assert.
|
|
305
|
+
assert.equal(config.toolExecution.maxWorkerRssMb, 384);
|
|
306
|
+
assert.equal(config.toolExecution.maxSwapUsedPercent, 95);
|
|
307
|
+
assert.equal(config.toolExecution.initialToolMemoryMb, 384);
|
|
308
|
+
assert.equal(config.toolExecution.minimumToolMemoryMb, 128);
|
|
309
|
+
assert.equal(config.toolExecution.maximumToolMemoryMb, 4096);
|
|
310
|
+
assert.equal(config.toolExecution.systemReserveMb, 128);
|
|
311
|
+
assert.equal(config.toolExecution.coreReserveMb, 384);
|
|
312
|
+
assert.equal(config.toolExecution.toolHeapPercent, 65);
|
|
313
|
+
assert.equal(config.toolExecution.toolMemoryHighPercent, 85);
|
|
314
|
+
assert.equal(config.toolExecution.toolSwapMaxMb, 128);
|
|
315
|
+
assert.deepEqual(config.toolExecution.capacities, { browser: 1, orchestrator: 1 });
|
|
306
316
|
assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
|
|
307
317
|
assert.equal(config.pi.speed, piConfigDefaults.speed);
|
|
308
318
|
});
|
|
@@ -12,7 +12,7 @@ test("official orchestrators declare their hard tool dependencies", async () =>
|
|
|
12
12
|
"pr-campaign": "^0.1.0",
|
|
13
13
|
"gmail-workspace": "^0.1.0"
|
|
14
14
|
});
|
|
15
|
-
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.
|
|
15
|
+
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.4.0" });
|
|
16
16
|
assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
17
17
|
assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
18
18
|
assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -60,6 +60,23 @@ test("verifies the exact file set and digests", async (t) => {
|
|
|
60
60
|
await assert.rejects(() => verifyOfficialToolTree(source, files), /unexpected=extra.js/);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("every catalog tool is represented in the bundled lock", async () => {
|
|
64
|
+
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
65
|
+
const toolsDir = fileURLToPath(new URL("../../tools/", import.meta.url));
|
|
66
|
+
const entries = await readdir(toolsDir, { withFileTypes: true });
|
|
67
|
+
const catalogNames = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!entry.isDirectory()) continue;
|
|
70
|
+
try {
|
|
71
|
+
await readFile(path.join(toolsDir, entry.name, "tool.manifest.json"), "utf8");
|
|
72
|
+
catalogNames.push(entry.name);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code !== "ENOENT") throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
assert.deepEqual(Object.keys(lock.tools).sort(), catalogNames.sort());
|
|
78
|
+
});
|
|
79
|
+
|
|
63
80
|
test("every bundled official tool lock matches the catalog source", async () => {
|
|
64
81
|
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
65
82
|
for (const [name, entry] of Object.entries(lock.tools)) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { protectCoreFromOom } from "../src/runtime/oom-protection.js";
|
|
4
|
+
|
|
5
|
+
test("lowers Linux core OOM priority when permitted", async () => {
|
|
6
|
+
const writes = [];
|
|
7
|
+
assert.equal(await protectCoreFromOom({
|
|
8
|
+
platform: "linux",
|
|
9
|
+
score: -900,
|
|
10
|
+
writeScore: async (value) => writes.push(value)
|
|
11
|
+
}), true);
|
|
12
|
+
assert.deepEqual(writes, [-900]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("keeps running when core OOM priority cannot be changed", async () => {
|
|
16
|
+
const logs = [];
|
|
17
|
+
assert.equal(await protectCoreFromOom({
|
|
18
|
+
platform: "linux",
|
|
19
|
+
writeScore: async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); },
|
|
20
|
+
logger: { log: (...parts) => logs.push(parts.join(" ")) }
|
|
21
|
+
}), false);
|
|
22
|
+
assert.match(logs.join("\n"), /could not be lowered: EACCES/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("does not touch OOM controls outside Linux", async () => {
|
|
26
|
+
let called = false;
|
|
27
|
+
assert.equal(await protectCoreFromOom({
|
|
28
|
+
platform: "darwin",
|
|
29
|
+
writeScore: async () => { called = true; }
|
|
30
|
+
}), false);
|
|
31
|
+
assert.equal(called, false);
|
|
32
|
+
});
|
package/test/paths.test.js
CHANGED
|
@@ -17,9 +17,16 @@ import {
|
|
|
17
17
|
getToolStateDir,
|
|
18
18
|
stateDir
|
|
19
19
|
} from "../src/runtime/paths.js";
|
|
20
|
+
import * as publicPaths from "../src/runtime/paths.js";
|
|
21
|
+
import * as platformPaths from "../src/platform/paths.js";
|
|
20
22
|
|
|
21
23
|
const execFileAsync = promisify(execFile);
|
|
22
24
|
|
|
25
|
+
test("runtime paths remains an exact compatibility facade for platform paths", () => {
|
|
26
|
+
assert.deepEqual(Object.keys(publicPaths).sort(), Object.keys(platformPaths).sort());
|
|
27
|
+
for (const name of Object.keys(platformPaths)) assert.equal(publicPaths[name], platformPaths[name]);
|
|
28
|
+
});
|
|
29
|
+
|
|
23
30
|
test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
24
31
|
const artifactsDir = getChatArtifactsDir("chat-1");
|
|
25
32
|
|
|
@@ -14,6 +14,15 @@ test("provides Pi compaction defaults through Arisa config", () => {
|
|
|
14
14
|
assert.deepEqual(config.pi.compaction, piConfigDefaults.compaction);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
test("merges partial resident session cache overrides with defaults", () => {
|
|
18
|
+
const config = applyConfigDefaults({ pi: { sessionCache: { maxSessions: 2 } } });
|
|
19
|
+
|
|
20
|
+
assert.deepEqual(config.pi.sessionCache, {
|
|
21
|
+
maxSessions: 2,
|
|
22
|
+
maxPersistedBytes: 48 * 1024 * 1024
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
17
26
|
test("merges partial Pi compaction overrides with defaults", () => {
|
|
18
27
|
const config = applyConfigDefaults({
|
|
19
28
|
pi: { compaction: { reserveTokens: 8_192 } }
|
|
@@ -243,6 +243,7 @@ test("accepts restart handoff from a worker owned by the active supervisor", asy
|
|
|
243
243
|
test("supervisor restarts an unexpectedly exited worker and forwards shutdown", async () => {
|
|
244
244
|
const children = [];
|
|
245
245
|
const delays = [];
|
|
246
|
+
const reports = [];
|
|
246
247
|
const spawnProcess = () => {
|
|
247
248
|
const child = new EventEmitter();
|
|
248
249
|
child.pid = 100 + children.length;
|
|
@@ -261,13 +262,17 @@ test("supervisor restarts an unexpectedly exited worker and forwards shutdown",
|
|
|
261
262
|
restartBackoffMaxMs: 20,
|
|
262
263
|
stableRuntimeMs: 60_000,
|
|
263
264
|
spawnProcess,
|
|
264
|
-
wait: async (ms) => { delays.push(ms); }
|
|
265
|
+
wait: async (ms) => { delays.push(ms); },
|
|
266
|
+
onUnexpectedExit: async (report) => { reports.push(report); }
|
|
265
267
|
});
|
|
266
268
|
const running = supervisor.start();
|
|
267
269
|
children[0].emit("exit", 1, null);
|
|
268
270
|
await new Promise((resolve) => setImmediate(resolve));
|
|
269
271
|
assert.equal(children.length, 2);
|
|
270
272
|
assert.deepEqual(delays, [5]);
|
|
273
|
+
assert.equal(reports.length, 1);
|
|
274
|
+
assert.equal(reports[0].code, 1);
|
|
275
|
+
assert.equal(reports[0].restartDelayMs, 5);
|
|
271
276
|
await supervisor.stop();
|
|
272
277
|
await running;
|
|
273
278
|
assert.equal(children[1].killedWith, "SIGTERM");
|
package/test/slave-cli.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import test from "node:test";
|
|
|
6
6
|
import { createHeadlessApp } from "../src/runtime/create-headless-app.js";
|
|
7
7
|
import { parseSlaveBootstrapUrl } from "../src/runtime/slave-bootstrap-url.js";
|
|
8
8
|
import { withSecureRequestFile } from "../src/runtime/secure-request-file.js";
|
|
9
|
-
import { ensureMasterSlaveTool, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
|
|
9
|
+
import { ensureMasterSlaveTool, formatSlaveStatus, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
|
|
10
10
|
import {
|
|
11
11
|
buildSlaveSystemdUnit,
|
|
12
12
|
getSlavePaths,
|
|
@@ -104,6 +104,25 @@ test("escapes systemd WorkingDirectory paths without quoting the entire value",
|
|
|
104
104
|
assert.match(unit, /^StandardOutput=append:\/srv\/arisa\\x20slave\/state\/arisa-slave\.log$/m);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("reports Master connectivity separately from pairing and daemon readiness", () => {
|
|
108
|
+
const text = formatSlaveStatus({
|
|
109
|
+
systemd: { running: true, status: "active" },
|
|
110
|
+
diagnostic: {
|
|
111
|
+
daemon: { state: "ready" },
|
|
112
|
+
role: "slave",
|
|
113
|
+
endpoint: "tcp://198.51.100.12:4719",
|
|
114
|
+
paired: true,
|
|
115
|
+
network: { connected: false },
|
|
116
|
+
toolCount: 1,
|
|
117
|
+
jobs: { active: 0, queued: 0, failed: 0 },
|
|
118
|
+
pendingSecrets: 0
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
assert.match(text, /Daemon: ready/);
|
|
122
|
+
assert.match(text, /Paired: yes/);
|
|
123
|
+
assert.match(text, /Connected: no/);
|
|
124
|
+
});
|
|
125
|
+
|
|
107
126
|
test("refuses to replace the PID of an active Slave host", async (t) => {
|
|
108
127
|
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-pid-"));
|
|
109
128
|
t.after(() => rm(home, { recursive: true, force: true }));
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
},
|
|
17
|
+
sessionSeeds: {
|
|
18
|
+
clear: async (chatId) => calls.cleared.push(chatId)
|
|
19
|
+
},
|
|
20
|
+
workspaceTopics: {},
|
|
21
|
+
contextRoute: (ctx) => ctx.route,
|
|
22
|
+
getChatState: (chatId) => stateStore.get(chatId),
|
|
23
|
+
createTelegramSessionBridge: () => ({}),
|
|
24
|
+
createWorkspaceAccessGuard: () => async () => {},
|
|
25
|
+
sendTextReply: async () => {},
|
|
26
|
+
authController: { notifyIssueIfNeeded: async () => false },
|
|
27
|
+
ensureWorkspaceTopicModelSelection: async () => {},
|
|
28
|
+
ensureQueuedTyping: async () => {},
|
|
29
|
+
withTyping: async (_ctx, work) => work(),
|
|
30
|
+
resolveBusyMessageMode: () => "steer",
|
|
31
|
+
...overrides
|
|
32
|
+
});
|
|
33
|
+
return { controller, stateStore, calls };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("busy /new resets only the active session and replaces its queued prompt", async () => {
|
|
37
|
+
const { controller, stateStore, calls } = createController();
|
|
38
|
+
const route = {
|
|
39
|
+
workspace: true,
|
|
40
|
+
sessionId: "topic-87",
|
|
41
|
+
scopeChatId: "owner",
|
|
42
|
+
transportChatId: "group",
|
|
43
|
+
threadId: 87
|
|
44
|
+
};
|
|
45
|
+
const state = stateStore.get(route.sessionId);
|
|
46
|
+
state.processing = true;
|
|
47
|
+
state.pendingPrompts.push("stale prompt");
|
|
48
|
+
|
|
49
|
+
await controller.handleNewCommand({ route, from: { language_code: "es" } });
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(calls.cleared, [route.sessionId]);
|
|
52
|
+
assert.deepEqual(calls.reset, [[route.sessionId]]);
|
|
53
|
+
assert.equal(state.pendingPrompts.length, 1);
|
|
54
|
+
assert.match(state.pendingPrompts[0], /System event: \/new requested/);
|
|
55
|
+
assert.equal(state.continueAfterClose, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a busy prompt for another topic queues instead of steering the active session", async () => {
|
|
59
|
+
const { controller, stateStore, calls } = createController();
|
|
60
|
+
const state = stateStore.get("topic-session");
|
|
61
|
+
state.processing = true;
|
|
62
|
+
state.activeRoute = { transportChatId: "group", threadId: 87 };
|
|
63
|
+
state.activeSession = {
|
|
64
|
+
isStreaming: true,
|
|
65
|
+
steer: async (prompt) => calls.steered.push(prompt)
|
|
66
|
+
};
|
|
67
|
+
const ctx = {
|
|
68
|
+
route: { transportChatId: "group", threadId: 114 }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await controller.enqueuePrompt({
|
|
72
|
+
chatId: "topic-session",
|
|
73
|
+
prompt: "different destination",
|
|
74
|
+
label: "cross-topic prompt",
|
|
75
|
+
ctx,
|
|
76
|
+
busyMessageMode: "steer"
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(calls.steered, []);
|
|
80
|
+
assert.deepEqual(state.pendingPrompts, ["different destination"]);
|
|
81
|
+
});
|
|
@@ -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: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { access, 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";
|
|
@@ -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 } = 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", {
|
|
@@ -171,7 +200,11 @@ test("loads weighted execution metadata from the tool manifest", async () => {
|
|
|
171
200
|
|
|
172
201
|
assert.deepEqual(registry.get("heavy-tool").execution, {
|
|
173
202
|
resourceClass: "browser",
|
|
174
|
-
weight: 2
|
|
203
|
+
weight: 2,
|
|
204
|
+
deduplicateConcurrent: false,
|
|
205
|
+
maxHeapMb: 4096,
|
|
206
|
+
maxMemoryMb: 16_384,
|
|
207
|
+
maxOutputBytes: 1_048_576
|
|
175
208
|
});
|
|
176
209
|
});
|
|
177
210
|
|
|
@@ -229,11 +262,62 @@ test("wraps declared tool runs in the shared execution governor", async () => {
|
|
|
229
262
|
|
|
230
263
|
assert.equal(result.ok, true);
|
|
231
264
|
assert.deepEqual(calls, [
|
|
232
|
-
{
|
|
265
|
+
{
|
|
266
|
+
type: "acquire",
|
|
267
|
+
execution: {
|
|
268
|
+
resourceClass: "browser",
|
|
269
|
+
weight: 1,
|
|
270
|
+
deduplicateConcurrent: false,
|
|
271
|
+
maxHeapMb: 4096,
|
|
272
|
+
maxMemoryMb: 16_384,
|
|
273
|
+
maxOutputBytes: 1_048_576
|
|
274
|
+
},
|
|
275
|
+
label: "heavy-tool"
|
|
276
|
+
},
|
|
233
277
|
{ type: "release", label: "heavy-tool" }
|
|
234
278
|
]);
|
|
235
279
|
});
|
|
236
280
|
|
|
281
|
+
test("joins exact concurrent duplicates only for tools that opt in", async () => {
|
|
282
|
+
await resetHome();
|
|
283
|
+
const dir = await createFakeTool("single-flight-tool", {
|
|
284
|
+
execution: { resourceClass: "orchestrator", weight: 1, deduplicateConcurrent: true }
|
|
285
|
+
});
|
|
286
|
+
await writeFile(path.join(dir, "index.js"), `import { appendFile, readFile } from "node:fs/promises";
|
|
287
|
+
const requestFile = process.argv[process.argv.indexOf("--request-file") + 1];
|
|
288
|
+
const request = JSON.parse(await readFile(requestFile, "utf8"));
|
|
289
|
+
await appendFile(request.args.counterFile, "x");
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
291
|
+
process.stdout.write(JSON.stringify({ ok: true, output: { text: request.args.value } }));
|
|
292
|
+
`, "utf8");
|
|
293
|
+
const counterFile = path.join(homeDir, "single-flight-count.txt");
|
|
294
|
+
const registry = new ToolRegistry({
|
|
295
|
+
executionPolicy: {
|
|
296
|
+
capacities: { orchestrator: 1 },
|
|
297
|
+
maxWorkerRssMb: 4096,
|
|
298
|
+
maxSwapUsedPercent: 100
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
await registry.load();
|
|
302
|
+
const invocation = {
|
|
303
|
+
name: "single-flight-tool",
|
|
304
|
+
chatId: "123",
|
|
305
|
+
request: { args: { counterFile, value: "same" } }
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const [first, second] = await Promise.all([registry.run(invocation), registry.run(invocation)]);
|
|
309
|
+
|
|
310
|
+
assert.deepEqual(second, first);
|
|
311
|
+
assert.equal(await readFile(counterFile, "utf8"), "x");
|
|
312
|
+
await Promise.all([
|
|
313
|
+
registry.run({ ...invocation, request: { args: { counterFile, value: "cross-chat" } } }),
|
|
314
|
+
registry.run({ ...invocation, chatId: "456", request: { args: { counterFile, value: "cross-chat" } } })
|
|
315
|
+
]);
|
|
316
|
+
assert.equal(await readFile(counterFile, "utf8"), "xxx");
|
|
317
|
+
await registry.run({ ...invocation, request: { args: { counterFile, value: "different" } } });
|
|
318
|
+
assert.equal(await readFile(counterFile, "utf8"), "xxxx");
|
|
319
|
+
});
|
|
320
|
+
|
|
237
321
|
test("runs a registered tool process with an enriched request and cleans up request files", async () => {
|
|
238
322
|
await resetHome();
|
|
239
323
|
await createFakeTool("fake-tool");
|
|
@@ -359,6 +443,57 @@ test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only",
|
|
|
359
443
|
assert.doesNotMatch(JSON.stringify(events), /stream diagnostic/);
|
|
360
444
|
});
|
|
361
445
|
|
|
446
|
+
test("contains an isolated tool heap failure and keeps the registry alive", async () => {
|
|
447
|
+
await resetHome();
|
|
448
|
+
const dir = await createFakeTool("heap-bomb-tool", {
|
|
449
|
+
execution: {
|
|
450
|
+
resourceClass: "browser",
|
|
451
|
+
weight: 1,
|
|
452
|
+
maxHeapMb: 64,
|
|
453
|
+
maxMemoryMb: 128,
|
|
454
|
+
maxOutputBytes: 65_536
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
await writeFile(path.join(dir, "index.js"), `
|
|
458
|
+
const retained = [];
|
|
459
|
+
while (true) retained.push(new Array(1_000_000).fill("heap-pressure"));
|
|
460
|
+
`, "utf8");
|
|
461
|
+
await createFakeTool("healthy-after-oom");
|
|
462
|
+
const registry = new ToolRegistry({
|
|
463
|
+
runTimeoutMs: 30_000,
|
|
464
|
+
executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
|
|
465
|
+
});
|
|
466
|
+
await registry.load();
|
|
467
|
+
|
|
468
|
+
const failed = await registry.run({ name: "heap-bomb-tool", chatId: "chat-1", request: { args: {} } });
|
|
469
|
+
assert.equal(failed.ok, false);
|
|
470
|
+
assert.equal(failed.status, "outcome_uncertain");
|
|
471
|
+
|
|
472
|
+
const healthy = await registry.run({ name: "healthy-after-oom", chatId: "chat-1", request: { args: {} } });
|
|
473
|
+
assert.equal(healthy.ok, true);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("terminates oversized isolated tool output without taking down the registry", async () => {
|
|
477
|
+
await resetHome();
|
|
478
|
+
const dir = await createFakeTool("oversized-tool", {
|
|
479
|
+
execution: { resourceClass: "browser", weight: 1, maxOutputBytes: 65_536 }
|
|
480
|
+
});
|
|
481
|
+
await writeFile(path.join(dir, "index.js"), `process.stdout.write("x".repeat(70_000));`, "utf8");
|
|
482
|
+
await createFakeTool("healthy-tool");
|
|
483
|
+
const registry = new ToolRegistry({
|
|
484
|
+
executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
|
|
485
|
+
});
|
|
486
|
+
await registry.load();
|
|
487
|
+
|
|
488
|
+
const oversized = await registry.run({ name: "oversized-tool", chatId: "chat-1", request: { args: {} } });
|
|
489
|
+
assert.equal(oversized.ok, false);
|
|
490
|
+
assert.equal(oversized.status, "outcome_uncertain");
|
|
491
|
+
assert.match(oversized.error, /exceeds 65536 bytes/);
|
|
492
|
+
|
|
493
|
+
const healthy = await registry.run({ name: "healthy-tool", chatId: "chat-1", request: { args: {} } });
|
|
494
|
+
assert.equal(healthy.ok, true);
|
|
495
|
+
});
|
|
496
|
+
|
|
362
497
|
test("rejects invalid NDJSON sequences and a second terminal event", async () => {
|
|
363
498
|
const invalidSequence = createToolOutputParser("sequence-tool");
|
|
364
499
|
await invalidSequence.push(`${JSON.stringify({ version: 1, jobId: "job", type: "accepted", sequence: 1, payload: {} })}\n`);
|
|
@@ -375,6 +510,14 @@ test("rejects invalid NDJSON sequences and a second terminal event", async () =>
|
|
|
375
510
|
);
|
|
376
511
|
});
|
|
377
512
|
|
|
513
|
+
test("bounds accumulated legacy output before parsing", async () => {
|
|
514
|
+
const parser = createToolOutputParser("legacy-tool", { maxOutputBytes: 10 });
|
|
515
|
+
await assert.rejects(
|
|
516
|
+
() => parser.push("12345678901"),
|
|
517
|
+
(error) => error.code === "TOOL_OUTPUT_LIMIT"
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
378
521
|
test("preserves pretty-printed single JSON tool responses", async () => {
|
|
379
522
|
const parser = createToolOutputParser("legacy-tool");
|
|
380
523
|
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
|
+
});
|