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.
Files changed (97) hide show
  1. package/README.md +28 -6
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +123 -8
  4. package/src/core/agent/agent-session-lifecycle.js +108 -8
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/core-tools.js +1 -1
  7. package/src/core/agent/pi-auth-login.js +1 -1
  8. package/src/core/agent/pi-capability-tools.js +7 -4
  9. package/src/core/agent/pi-runtime.js +1 -1
  10. package/src/core/agent/runtime-context.js +1 -1
  11. package/src/core/agent/session-history-reader.js +168 -0
  12. package/src/core/agent/session-preload-migration.js +162 -0
  13. package/src/core/agent/session-rotation.js +29 -0
  14. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/artifacts/artifact-store.js +1 -1
  17. package/src/core/capabilities/capability-service.js +4 -3
  18. package/src/core/config/config-defaults.js +59 -1
  19. package/src/core/config/config-store.js +1 -1
  20. package/src/core/conversation/session-seed-store.js +1 -1
  21. package/src/core/tasks/task-runner.js +8 -4
  22. package/src/core/tasks/task-store.js +48 -5
  23. package/src/core/tools/daemon-client.js +180 -0
  24. package/src/core/tools/daemon-processes.js +26 -3
  25. package/src/core/tools/daemon-protocol.js +72 -0
  26. package/src/core/tools/daemon-runtime.js +13 -490
  27. package/src/core/tools/daemon-worker.js +310 -0
  28. package/src/core/tools/ipc-client.js +2 -2
  29. package/src/core/tools/memory-pressure.js +56 -0
  30. package/src/core/tools/official-tool-installer.js +1 -1
  31. package/src/core/tools/tool-config.js +1 -1
  32. package/src/core/tools/tool-process-output.js +100 -0
  33. package/src/core/tools/tool-process-runner.js +175 -0
  34. package/src/core/tools/tool-registry.js +131 -191
  35. package/src/core/tools/tool-resource-note-store.js +1 -1
  36. package/src/core/tools/tool-usage-store.js +1 -1
  37. package/src/core/tools/weighted-resource-governor.js +189 -38
  38. package/src/index.js +14 -2
  39. package/src/official-tools.lock.json +526 -78
  40. package/src/platform/paths.js +152 -0
  41. package/src/runtime/bootstrap-cli.js +121 -0
  42. package/src/runtime/bootstrap-config.js +97 -0
  43. package/src/runtime/bootstrap-telegram.js +325 -0
  44. package/src/runtime/bootstrap.js +6 -543
  45. package/src/runtime/doctor.js +26 -76
  46. package/src/runtime/flush.js +1 -1
  47. package/src/runtime/ipc/ipc-server.js +1 -1
  48. package/src/runtime/log-viewer.js +1 -1
  49. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  50. package/src/runtime/oom-protection.js +20 -0
  51. package/src/runtime/paths.js +3 -151
  52. package/src/runtime/process-inspection.js +78 -0
  53. package/src/runtime/restart-receipt.js +1 -1
  54. package/src/runtime/service-manager.js +1 -1
  55. package/src/runtime/service-supervisor.js +14 -0
  56. package/src/runtime/slave-cli.js +1 -1
  57. package/src/runtime/tool-process-supervisor.js +36 -9
  58. package/src/runtime/tui.js +200 -0
  59. package/src/runtime/update-manager.js +1 -1
  60. package/src/runtime/worker-recovery-report.js +142 -0
  61. package/src/transport/telegram/bot.js +45 -321
  62. package/src/transport/telegram/chat-queue.js +6 -2
  63. package/src/transport/telegram/prompt-builders.js +8 -3
  64. package/src/transport/telegram/task-dispatcher.js +36 -11
  65. package/src/transport/telegram/telegram-prompt-controller.js +359 -0
  66. package/src/transport/telegram/workspace-topic-store.js +1 -1
  67. package/test/agent-session-lifecycle.test.js +92 -0
  68. package/test/agent-turn-coordinator.test.js +45 -0
  69. package/test/architecture-boundaries.test.js +29 -0
  70. package/test/bootstrap.test.js +65 -0
  71. package/test/context-and-task-bounds.test.js +2 -1
  72. package/test/daemon-process-invocation.test.js +27 -0
  73. package/test/daemon-runtime.test.js +38 -5
  74. package/test/doctor.test.js +41 -0
  75. package/test/memory-pressure.test.js +41 -0
  76. package/test/model-selection.test.js +11 -1
  77. package/test/obsolete-daemon-reaper.test.js +61 -0
  78. package/test/official-tool-dependencies.test.js +7 -2
  79. package/test/official-tool-installer.test.js +18 -1
  80. package/test/oom-protection.test.js +32 -0
  81. package/test/paths.test.js +7 -0
  82. package/test/pi-compaction.test.js +30 -0
  83. package/test/service-manager.test.js +6 -1
  84. package/test/session-history-reader.test.js +84 -0
  85. package/test/session-preload-migration.test.js +120 -0
  86. package/test/session-rotation.test.js +110 -0
  87. package/test/task-store.test.js +32 -0
  88. package/test/telegram-prompt-controller.test.js +82 -0
  89. package/test/telegram-task-dispatcher.test.js +66 -5
  90. package/test/telegram-text-artifact.test.js +30 -0
  91. package/test/tool-registry-run.test.js +117 -4
  92. package/test/tui.test.js +41 -0
  93. package/test/weighted-resource-governor.test.js +125 -5
  94. package/test/worker-heap-circuit-breaker.test.js +79 -0
  95. package/test/worker-recovery-report.test.js +69 -0
  96. package/test/worker-tool-fanout.test.js +79 -0
  97. 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
+ });
@@ -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: { async runTool(input) { calls.push(["runTool", input]); return { ok: true }; } },
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
- ["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
226
- ["complete", "good"],
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: { resourceClass: "browser", weight: 1, deduplicateConcurrent: false },
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({ executionPolicy: { capacities: { orchestrator: 1 } } });
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);
@@ -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
+ });