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.
Files changed (79) hide show
  1. package/README.md +7 -4
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +46 -6
  4. package/src/core/agent/agent-session-lifecycle.js +80 -3
  5. package/src/core/agent/core-tools.js +1 -1
  6. package/src/core/agent/pi-auth-login.js +1 -1
  7. package/src/core/agent/pi-runtime.js +1 -1
  8. package/src/core/agent/runtime-context.js +1 -1
  9. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  10. package/src/core/artifacts/artifact-store.js +1 -1
  11. package/src/core/capabilities/capability-service.js +1 -1
  12. package/src/core/config/config-defaults.js +30 -1
  13. package/src/core/config/config-store.js +1 -1
  14. package/src/core/conversation/session-seed-store.js +1 -1
  15. package/src/core/tasks/task-store.js +1 -1
  16. package/src/core/tools/daemon-client.js +180 -0
  17. package/src/core/tools/daemon-health.js +5 -2
  18. package/src/core/tools/daemon-processes.js +19 -3
  19. package/src/core/tools/daemon-protocol.js +72 -0
  20. package/src/core/tools/daemon-runtime.js +13 -490
  21. package/src/core/tools/daemon-worker.js +310 -0
  22. package/src/core/tools/ipc-client.js +2 -2
  23. package/src/core/tools/memory-pressure.js +56 -0
  24. package/src/core/tools/official-tool-installer.js +1 -1
  25. package/src/core/tools/tool-config.js +1 -1
  26. package/src/core/tools/tool-process-output.js +100 -0
  27. package/src/core/tools/tool-process-runner.js +175 -0
  28. package/src/core/tools/tool-registry.js +130 -186
  29. package/src/core/tools/tool-resource-note-store.js +1 -1
  30. package/src/core/tools/tool-usage-store.js +1 -1
  31. package/src/core/tools/weighted-resource-governor.js +192 -38
  32. package/src/index.js +14 -2
  33. package/src/official-tools.lock.json +430 -60
  34. package/src/platform/paths.js +152 -0
  35. package/src/runtime/bootstrap-cli.js +121 -0
  36. package/src/runtime/bootstrap-config.js +97 -0
  37. package/src/runtime/bootstrap-telegram.js +325 -0
  38. package/src/runtime/bootstrap.js +6 -543
  39. package/src/runtime/doctor.js +6 -3
  40. package/src/runtime/flush.js +1 -1
  41. package/src/runtime/ipc/ipc-server.js +1 -1
  42. package/src/runtime/log-viewer.js +1 -1
  43. package/src/runtime/oom-protection.js +20 -0
  44. package/src/runtime/paths.js +3 -151
  45. package/src/runtime/restart-receipt.js +1 -1
  46. package/src/runtime/service-manager.js +1 -1
  47. package/src/runtime/service-supervisor.js +14 -0
  48. package/src/runtime/slave-cli.js +2 -1
  49. package/src/runtime/tool-process-supervisor.js +1 -1
  50. package/src/runtime/tui.js +200 -0
  51. package/src/runtime/update-manager.js +1 -1
  52. package/src/runtime/worker-recovery-report.js +142 -0
  53. package/src/transport/telegram/bot.js +42 -320
  54. package/src/transport/telegram/prompt-builders.js +8 -3
  55. package/src/transport/telegram/telegram-prompt-controller.js +346 -0
  56. package/src/transport/telegram/workspace-topic-store.js +1 -1
  57. package/test/agent-session-lifecycle.test.js +92 -0
  58. package/test/architecture-boundaries.test.js +29 -0
  59. package/test/bootstrap.test.js +65 -0
  60. package/test/daemon-process-invocation.test.js +27 -0
  61. package/test/daemon-runtime.test.js +56 -1
  62. package/test/doctor.test.js +22 -0
  63. package/test/memory-pressure.test.js +36 -0
  64. package/test/model-selection.test.js +11 -1
  65. package/test/official-tool-dependencies.test.js +1 -1
  66. package/test/official-tool-installer.test.js +18 -1
  67. package/test/oom-protection.test.js +32 -0
  68. package/test/paths.test.js +7 -0
  69. package/test/pi-compaction.test.js +9 -0
  70. package/test/service-manager.test.js +6 -1
  71. package/test/slave-cli.test.js +20 -1
  72. package/test/telegram-prompt-controller.test.js +81 -0
  73. package/test/telegram-text-artifact.test.js +30 -0
  74. package/test/tool-registry-run.test.js +147 -4
  75. package/test/tui.test.js +41 -0
  76. package/test/weighted-resource-governor.test.js +103 -5
  77. package/test/worker-heap-circuit-breaker.test.js +79 -0
  78. package/test/worker-recovery-report.test.js +69 -0
  79. package/test-fixtures/fake-daemon.js +5 -0
@@ -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.deepEqual(config.toolExecution.capacities, {});
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.2.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
+ });
@@ -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");
@@ -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
- { type: "acquire", execution: { resourceClass: "browser", weight: 1 }, label: "heavy-tool" },
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);
@@ -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
+ });