arisa 5.1.2 → 5.1.8

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 (39) hide show
  1. package/AGENTS.md +12 -4
  2. package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
  3. package/README.md +25 -0
  4. package/package.json +1 -1
  5. package/src/core/agent/agent-manager.js +55 -12
  6. package/src/core/config/config-defaults.js +3 -1
  7. package/src/core/tools/daemon-processes.js +48 -6
  8. package/src/core/tools/daemon-runtime.js +370 -138
  9. package/src/core/tools/ipc-client.js +3 -0
  10. package/src/core/tools/official-tool-catalog.js +32 -0
  11. package/src/core/tools/official-tool-installer.js +183 -0
  12. package/src/core/tools/tool-registry.js +203 -18
  13. package/src/core/tools/tool-resource-note-store.js +78 -0
  14. package/src/index.js +25 -2
  15. package/src/official-tools.lock.json +40 -0
  16. package/src/runtime/arisa-capabilities.js +43 -3
  17. package/src/runtime/create-app.js +9 -0
  18. package/src/runtime/create-headless-app.js +77 -0
  19. package/src/runtime/doctor.js +27 -2
  20. package/src/runtime/headless-tool-executor.js +45 -0
  21. package/src/runtime/paths.js +16 -4
  22. package/src/runtime/secure-request-file.js +21 -0
  23. package/src/runtime/slave-bootstrap-url.js +51 -0
  24. package/src/runtime/slave-cli.js +267 -0
  25. package/src/runtime/slave-service.js +225 -0
  26. package/src/runtime/tool-usage-report.js +11 -3
  27. package/src/transport/telegram/bot.js +37 -7
  28. package/test/capabilities-security.test.js +29 -0
  29. package/test/daemon-catalog-conformance.test.js +3 -1
  30. package/test/daemon-runtime.test.js +58 -2
  31. package/test/official-tool-installer.test.js +107 -0
  32. package/test/paths.test.js +6 -12
  33. package/test/slave-cli.test.js +282 -0
  34. package/test/telegram-text-artifact.test.js +24 -1
  35. package/test/tool-capability-search.test.js +55 -0
  36. package/test/tool-registry-run.test.js +70 -1
  37. package/test/tool-resource-note.test.js +50 -0
  38. package/test/tool-usage.test.js +10 -5
  39. package/test-fixtures/fake-daemon.js +12 -1
@@ -0,0 +1,282 @@
1
+ import assert from "node:assert/strict";
2
+ import { access, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { createHeadlessApp } from "../src/runtime/create-headless-app.js";
7
+ import { parseSlaveBootstrapUrl } from "../src/runtime/slave-bootstrap-url.js";
8
+ import { withSecureRequestFile } from "../src/runtime/secure-request-file.js";
9
+ import { ensureMasterSlaveTool, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
10
+ import {
11
+ buildSlaveSystemdUnit,
12
+ getSlavePaths,
13
+ installSlaveSystemdService,
14
+ registerSlaveServiceProcess,
15
+ selectSlaveServiceAccount
16
+ } from "../src/runtime/slave-service.js";
17
+
18
+ const secret = `arisa_secret_v1_${"a".repeat(43)}`;
19
+
20
+ test("strictly parses IPv4 and bracketed IPv6 Slave bootstrap URLs", () => {
21
+ assert.deepEqual(parseSlaveBootstrapUrl(`tcp://198.51.100.12:4719/${secret}`), {
22
+ endpoint: "tcp://198.51.100.12:4719",
23
+ host: "198.51.100.12",
24
+ ipVersion: 4,
25
+ port: 4719,
26
+ secret,
27
+ url: `tcp://198.51.100.12:4719/${secret}`
28
+ });
29
+ assert.equal(parseSlaveBootstrapUrl(`tcp://[2001:db8::1]:4719/${secret}`).ipVersion, 6);
30
+ });
31
+
32
+ test("rejects non-literal hosts and every extra URL component", () => {
33
+ for (const invalid of [
34
+ `https://198.51.100.12:4719/${secret}`,
35
+ `tcp://master.example:4719/${secret}`,
36
+ `tcp://198.51.100.12/${secret}`,
37
+ `tcp://user@198.51.100.12:4719/${secret}`,
38
+ `tcp://198.51.100.12:4719/${secret}/extra`,
39
+ `tcp://198.51.100.12:4719/${secret}?`,
40
+ `tcp://198.51.100.12:4719/${secret}#fragment`,
41
+ "tcp://198.51.100.12:4719/short"
42
+ ]) {
43
+ assert.throws(() => parseSlaveBootstrapUrl(invalid));
44
+ }
45
+ });
46
+
47
+ test("hands one-shot requests through a 0600 file and removes it", async (t) => {
48
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-secure-request-"));
49
+ t.after(() => rm(root, { recursive: true, force: true }));
50
+ let handedFile;
51
+ const result = await withSecureRequestFile({ directory: root, value: { secret } }, async (file) => {
52
+ handedFile = file;
53
+ assert.equal((await stat(file)).mode & 0o777, 0o600);
54
+ assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { secret });
55
+ return "consumed";
56
+ });
57
+ assert.equal(result, "consumed");
58
+ await assert.rejects(() => access(handedFile), { code: "ENOENT" });
59
+ });
60
+
61
+ test("never selects root without an explicit second confirmation", async () => {
62
+ await assert.rejects(() => selectSlaveServiceAccount({ euid: 0 }), /explicit account selection/);
63
+ const rejectedAnswers = ["3", "yes"];
64
+ await assert.rejects(
65
+ () => selectSlaveServiceAccount({ euid: 0, ask: async () => rejectedAnswers.shift() }),
66
+ /Root execution was not confirmed/
67
+ );
68
+ const acceptedAnswers = ["3", "RUN AS ROOT"];
69
+ assert.deepEqual(
70
+ await selectSlaveServiceAccount({ euid: 0, ask: async () => acceptedAnswers.shift() }),
71
+ { scope: "system", user: "root", root: true, dedicated: false }
72
+ );
73
+ });
74
+
75
+ test("builds a dedicated headless systemd service with isolated state", () => {
76
+ const unit = buildSlaveSystemdUnit({
77
+ account: { scope: "system", user: "arisa-slave" },
78
+ slaveHome: "/var/lib/arisa-slave",
79
+ entryFile: "/opt/arisa/src/index.js",
80
+ platform: "linux",
81
+ nodePath: "/usr/bin/node"
82
+ });
83
+ assert.match(unit, /User=arisa-slave/);
84
+ assert.match(unit, /Environment="ARISA_HOME=\/var\/lib\/arisa-slave"/);
85
+ assert.match(unit, /ExecStart="\/usr\/bin\/node" "\/opt\/arisa\/src\/index\.js" slave --service-runner/);
86
+ assert.match(unit, /StandardOutput=append:\/var\/lib\/arisa-slave\/state\/arisa-slave\.log/);
87
+ assert.doesNotMatch(unit, /Telegram|Pi Agent/);
88
+ });
89
+
90
+ test("refuses to replace the PID of an active Slave host", async (t) => {
91
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-pid-"));
92
+ t.after(() => rm(home, { recursive: true, force: true }));
93
+ const paths = getSlavePaths(home);
94
+ await registerSlaveServiceProcess(paths);
95
+ assert.equal(await readFile(paths.pidFile, "utf8"), `${process.pid}\n`);
96
+ await writeFile(paths.pidFile, `${process.ppid}\n`, { mode: 0o600 });
97
+ await assert.rejects(() => registerSlaveServiceProcess(paths), /already running/);
98
+ });
99
+
100
+ test("installs the dedicated Linux systemd target without silently selecting root", async (t) => {
101
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-systemd-"));
102
+ t.after(() => rm(root, { recursive: true, force: true }));
103
+ const calls = [];
104
+ let accountExists = false;
105
+ const execute = async (command, args) => {
106
+ calls.push([command, args]);
107
+ if (command === "id" && args[0] === "-u" && !accountExists) throw new Error("missing user");
108
+ if (command === "useradd") accountExists = true;
109
+ if (command === "id" && args[0] === "-gn") return { stdout: "arisa-slave\n", stderr: "" };
110
+ return { stdout: "", stderr: "" };
111
+ };
112
+ const result = await installSlaveSystemdService({
113
+ account: { scope: "system", user: "arisa-slave", root: false, dedicated: true },
114
+ slaveHome: path.join(root, "home"),
115
+ entryFile: "/opt/arisa/src/index.js",
116
+ execute,
117
+ platform: "linux",
118
+ systemUnitDir: path.join(root, "units")
119
+ });
120
+ assert.equal(result.account.root, false);
121
+ assert.equal(await access(result.unitFile).then(() => true, () => false), true);
122
+ assert.ok(calls.some(([command]) => command === "useradd"));
123
+ assert.ok(calls.some(([command, args]) => command === "systemctl" && args.includes("enable") && args.includes("--now")));
124
+ });
125
+
126
+ test("validates before effects and keeps the bootstrap secret out of service metadata", async (t) => {
127
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-home-"));
128
+ t.after(() => rm(home, { recursive: true, force: true }));
129
+ const paths = getSlavePaths(home);
130
+ const calls = [];
131
+ const result = await runSlaveBootstrap(`tcp://198.51.100.12:4719/${secret}`, {
132
+ paths,
133
+ entryFile: "/opt/arisa/src/index.js",
134
+ platform: "linux",
135
+ selectAccount: async () => ({ scope: "user", user: "tester", root: false, dedicated: false }),
136
+ ensureTool: async () => { calls.push("tool"); },
137
+ installService: async () => { calls.push("service"); },
138
+ invokeTool: async (_paths, args) => {
139
+ calls.push("invoke");
140
+ assert.equal(args.action, "slave.bootstrap");
141
+ assert.equal((await stat(args.bootstrapFile)).mode & 0o777, 0o600);
142
+ assert.deepEqual(JSON.parse(await readFile(args.bootstrapFile, "utf8")), {
143
+ url: `tcp://198.51.100.12:4719/${secret}`
144
+ });
145
+ return { ok: true };
146
+ },
147
+ output: { log: () => {} }
148
+ });
149
+ assert.deepEqual(result, { ok: true });
150
+ assert.deepEqual(calls, ["tool", "invoke", "service"]);
151
+ const descriptor = await readFile(paths.descriptorFile, "utf8");
152
+ const config = await readFile(paths.configFile, "utf8");
153
+ assert.doesNotMatch(descriptor, /arisa_secret/);
154
+ assert.doesNotMatch(config, /arisa_secret/);
155
+
156
+ calls.length = 0;
157
+ await assert.rejects(
158
+ () => runSlaveBootstrap("tcp://hostname:4719/invalid", {
159
+ paths,
160
+ platform: "linux",
161
+ selectAccount: async () => { calls.push("account"); }
162
+ })
163
+ );
164
+ assert.deepEqual(calls, []);
165
+ });
166
+
167
+ test("ships an immutable verified master-slave bootstrap lock", async (t) => {
168
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-lock-"));
169
+ t.after(() => rm(home, { recursive: true, force: true }));
170
+ const calls = [];
171
+ const result = await ensureMasterSlaveTool(getSlavePaths(home), {
172
+ install: async (request) => {
173
+ calls.push(request);
174
+ return { commit: request.lock.commit };
175
+ }
176
+ });
177
+ assert.equal(result.installed, true);
178
+ assert.match(result.commit, /^[a-f0-9]{40}$/);
179
+ assert.equal(calls[0].toolName, "master-slave");
180
+ assert.ok(Object.keys(calls[0].lock.tools["master-slave"].files).length > 0);
181
+ });
182
+
183
+ test("starts the headless composition without Telegram or Pi components", async () => {
184
+ const calls = [];
185
+ const toolRegistry = {
186
+ load: async () => { calls.push("tools.load"); },
187
+ list: () => [],
188
+ run: async () => ({ ok: true })
189
+ };
190
+ const taskStore = {
191
+ recoverInterrupted: async () => { calls.push("tasks.recover"); },
192
+ addMany: async () => []
193
+ };
194
+ const supervisor = {
195
+ start: async () => { calls.push("supervisor.start"); },
196
+ stop: async () => { calls.push("supervisor.stop"); }
197
+ };
198
+ const ipcServer = {
199
+ socketPath: "/isolated/slave.sock",
200
+ start: async () => { calls.push("ipc.start"); },
201
+ stop: async () => { calls.push("ipc.stop"); }
202
+ };
203
+ const app = await createHeadlessApp({
204
+ configLoader: async () => ({ daemons: { supervisorIntervalMs: 1 } }),
205
+ artifactStoreFactory: () => ({ forChat: () => ({}) }),
206
+ taskStoreFactory: () => taskStore,
207
+ toolRegistryFactory: () => toolRegistry,
208
+ supervisorFactory: () => supervisor,
209
+ capabilitiesFactory: ({ agentManager }) => {
210
+ assert.equal(typeof agentManager.runTool, "function");
211
+ calls.push("capabilities.create");
212
+ return { dispatch: async () => ({}) };
213
+ },
214
+ ipcServerFactory: () => ipcServer
215
+ });
216
+ await app.start();
217
+ await app.stop();
218
+ assert.deepEqual(calls, [
219
+ "tools.load",
220
+ "capabilities.create",
221
+ "ipc.start",
222
+ "tasks.recover",
223
+ "supervisor.start",
224
+ "supervisor.stop",
225
+ "ipc.stop"
226
+ ]);
227
+ });
228
+
229
+ test("combines systemd and local tool diagnostics for Slave status", async (t) => {
230
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-status-"));
231
+ t.after(() => rm(home, { recursive: true, force: true }));
232
+ const output = [];
233
+ const result = await runSlaveCli({
234
+ positionals: ["status"],
235
+ paths: getSlavePaths(home),
236
+ controlService: async (_paths, operation) => {
237
+ assert.equal(operation, "status");
238
+ return { running: true, status: "active" };
239
+ },
240
+ toolInstalled: async () => true,
241
+ invokeTool: async (_paths, args) => {
242
+ assert.deepEqual(args, { action: "slave.status" });
243
+ return {
244
+ ok: true,
245
+ output: {
246
+ json: {
247
+ daemon: { state: "ready" },
248
+ role: "slave",
249
+ endpoint: "tcp://198.51.100.12:4719",
250
+ identityFingerprint: "abcd1234",
251
+ paired: true,
252
+ toolCount: 4,
253
+ jobs: { active: 1, queued: 2, failed: 0 },
254
+ pendingSecrets: 0
255
+ }
256
+ }
257
+ };
258
+ },
259
+ output: { log: (line) => output.push(line) }
260
+ });
261
+ assert.equal(result.systemd.running, true);
262
+ assert.match(output[0], /Systemd: active/);
263
+ assert.match(output[0], /Daemon: ready/);
264
+ assert.match(output[0], /Role: slave/);
265
+ assert.match(output[0], /Endpoint: tcp:\/\/198\.51\.100\.12:4719/);
266
+ assert.match(output[0], /Identity: abcd1234/);
267
+ assert.match(output[0], /Paired: yes/);
268
+ assert.match(output[0], /Tools: 4/);
269
+ assert.match(output[0], /Jobs: active=1, queued=2, failed=0/);
270
+ assert.match(output[0], /Pending secrets: 0/);
271
+ });
272
+
273
+ test("prints Slave help without touching service or Master bootstrap state", async () => {
274
+ const output = [];
275
+ const result = await runSlaveCli({
276
+ flags: { help: true },
277
+ output: { log: (line) => output.push(line) },
278
+ controlService: async () => assert.fail("service must not be inspected for help")
279
+ });
280
+ assert.deepEqual(result, { help: true });
281
+ assert.match(output[0], /^Usage: arisa slave/);
282
+ });
@@ -1,8 +1,31 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildPrompt, buildReactionPrompt, shouldIncludeArtifactReference } from "../src/transport/telegram/bot.js";
3
+ import { buildPrompt, buildReactionPrompt, isScheduledTaskPrompt, shouldIncludeArtifactReference, withPromptSpeed } from "../src/transport/telegram/bot.js";
4
4
  import { captureIncomingArtifact } from "../src/transport/telegram/media.js";
5
5
 
6
+ test("scheduled agent prompts use normal speed for one turn and restore chat speed", async () => {
7
+ let speed = 1.5;
8
+ const speedController = {
9
+ setSpeed(value) { speed = value; }
10
+ };
11
+ assert.equal(isScheduledTaskPrompt("Scheduled task fired.\ntaskId: one"), true);
12
+ assert.equal(isScheduledTaskPrompt("Incoming Telegram message."), false);
13
+
14
+ await withPromptSpeed({ speedController, speed: 1, restoreSpeed: () => 1.5 }, async () => {
15
+ assert.equal(speed, 1);
16
+ });
17
+ assert.equal(speed, 1.5);
18
+
19
+ await assert.rejects(
20
+ withPromptSpeed({ speedController, speed: 1, restoreSpeed: () => 1.5 }, async () => {
21
+ assert.equal(speed, 1);
22
+ throw new Error("failed turn");
23
+ }),
24
+ /failed turn/
25
+ );
26
+ assert.equal(speed, 1.5);
27
+ });
28
+
6
29
  function createTextContext(text = "hello") {
7
30
  return {
8
31
  chat: { id: 123 },
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { rankToolMatches } from "../src/core/tools/tool-registry.js";
4
+ import { searchOfficialToolCatalog } from "../src/core/tools/official-tool-catalog.js";
5
+
6
+ const tools = [
7
+ {
8
+ name: "x-reader",
9
+ description: "Read public X posts",
10
+ category: "social",
11
+ keywords: ["posts", "reader", "x"],
12
+ input: ["text/plain"],
13
+ output: ["application/json"]
14
+ },
15
+ {
16
+ name: "x-session-reader",
17
+ description: "Read posts and bookmarks from an X session",
18
+ category: "social",
19
+ keywords: ["bookmarks", "session", "x"],
20
+ input: ["application/json"],
21
+ output: ["application/json", "text/csv"]
22
+ }
23
+ ];
24
+
25
+ test("capability search ranks exact keyword matches first", () => {
26
+ const matches = rankToolMatches(tools, "bookmarks");
27
+ assert.equal(matches.length, 1);
28
+ assert.equal(matches[0].tool.name, "x-session-reader");
29
+ assert.equal(matches[0].score, 12);
30
+ });
31
+
32
+ test("capability search covers descriptions, inputs, and outputs", () => {
33
+ assert.equal(rankToolMatches(tools, "public")[0].tool.name, "x-reader");
34
+ assert.equal(rankToolMatches(tools, "csv")[0].tool.name, "x-session-reader");
35
+ });
36
+
37
+ test("official catalog fallback ranks remote manifests", async () => {
38
+ const responses = new Map([
39
+ ["https://api.github.com/repos/clasen/Arisa/contents/tools", [
40
+ { name: "x-reader", type: "dir" },
41
+ { name: "x-session-reader", type: "dir" }
42
+ ]],
43
+ ["https://raw.githubusercontent.com/clasen/Arisa/main/tools/x-reader/tool.manifest.json", tools[0]],
44
+ ["https://raw.githubusercontent.com/clasen/Arisa/main/tools/x-session-reader/tool.manifest.json", tools[1]]
45
+ ]);
46
+ const fetchImpl = async (url) => ({
47
+ ok: responses.has(url),
48
+ status: responses.has(url) ? 200 : 404,
49
+ json: async () => responses.get(url)
50
+ });
51
+ const matches = await searchOfficialToolCatalog("bookmarks", { fetchImpl });
52
+ assert.equal(matches.length, 1);
53
+ assert.equal(matches[0].name, "x-session-reader");
54
+ assert.equal(matches[0].source, "official-catalog");
55
+ });
@@ -8,7 +8,7 @@ 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 } = await import("../src/core/tools/tool-registry.js");
11
+ const { ToolRegistry, createToolOutputParser } = await import("../src/core/tools/tool-registry.js");
12
12
  const {
13
13
  arisaHomeDir,
14
14
  arisaPackageDir,
@@ -69,6 +69,26 @@ process.stdout.write(JSON.stringify({
69
69
  return dir;
70
70
  }
71
71
 
72
+ async function createStreamingTool(name = "stream-tool") {
73
+ const dir = await createFakeTool(name, { version: "2.1.0", packageDigest: "sha256:test", requirements: { ffmpeg: {} } });
74
+ await writeFile(path.join(dir, "index.js"), `const frames = [
75
+ { version: 1, jobId: "stream-job", type: "accepted", sequence: 1, payload: {} },
76
+ { version: 1, jobId: "stream-job", type: "progress", sequence: 2, payload: { percent: 50 } },
77
+ { version: 1, jobId: "stream-job", type: "chunk", sequence: 3, payload: { text: "part" } },
78
+ { version: 1, jobId: "stream-job", type: "completed", sequence: 4, payload: { result: { ok: true, output: { text: "stream completed" } } } }
79
+ ];
80
+ process.stderr.write("stream diagnostic\\n");
81
+ for (const frame of frames) {
82
+ const line = JSON.stringify(frame) + "\\n";
83
+ const midpoint = Math.floor(line.length / 2);
84
+ process.stdout.write(line.slice(0, midpoint));
85
+ await new Promise((resolve) => setTimeout(resolve, 2));
86
+ process.stdout.write(line.slice(midpoint));
87
+ }
88
+ `, "utf8");
89
+ return dir;
90
+ }
91
+
72
92
  test("loads and lists installed tools from the user tools directory", async () => {
73
93
  await resetHome();
74
94
  await createFakeTool("fake-tool", {
@@ -81,6 +101,9 @@ test("loads and lists installed tools from the user tools directory", async () =
81
101
 
82
102
  assert.deepEqual(registry.list(), [{
83
103
  name: "fake-tool",
104
+ version: null,
105
+ packageDigest: null,
106
+ requirements: [],
84
107
  description: "Fake test tool",
85
108
  input: ["text/plain"],
86
109
  output: ["text/plain"],
@@ -192,3 +215,49 @@ test("rejects unknown tools", async () => {
192
215
  /Tool not found: missing-tool/
193
216
  );
194
217
  });
218
+
219
+ test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only", async () => {
220
+ await resetHome();
221
+ await createStreamingTool();
222
+ const logs = [];
223
+ const registry = new ToolRegistry({ logger: { log: (...args) => logs.push(args.join(" ")) } });
224
+ await registry.load();
225
+ const events = [];
226
+
227
+ const result = await registry.run({
228
+ name: "stream-tool",
229
+ chatId: "chat-1",
230
+ request: { text: "hello" },
231
+ onEvent: (event) => events.push(event)
232
+ });
233
+
234
+ assert.equal(result.ok, true);
235
+ assert.equal(result.output.text, "stream completed");
236
+ assert.deepEqual(events.map((event) => event.type), ["accepted", "progress", "chunk", "completed"]);
237
+ assert.match(logs.join("\n"), /stream diagnostic/);
238
+ assert.doesNotMatch(JSON.stringify(events), /stream diagnostic/);
239
+ });
240
+
241
+ test("rejects invalid NDJSON sequences and a second terminal event", async () => {
242
+ const invalidSequence = createToolOutputParser("sequence-tool");
243
+ await invalidSequence.push(`${JSON.stringify({ version: 1, jobId: "job", type: "accepted", sequence: 1, payload: {} })}\n`);
244
+ await assert.rejects(
245
+ () => invalidSequence.push(`${JSON.stringify({ version: 1, jobId: "job", type: "chunk", sequence: 3, payload: {} })}\n`),
246
+ /Invalid tool event sequence/
247
+ );
248
+
249
+ const duplicateTerminal = createToolOutputParser("terminal-tool");
250
+ await duplicateTerminal.push(`${JSON.stringify({ version: 1, jobId: "job", type: "completed", sequence: 1, payload: { result: { ok: true } } })}\n`);
251
+ await assert.rejects(
252
+ () => duplicateTerminal.push(`${JSON.stringify({ version: 1, jobId: "job", type: "failed", sequence: 2, payload: { error: "late" } })}\n`),
253
+ /more than one terminal event/
254
+ );
255
+ });
256
+
257
+ test("preserves pretty-printed single JSON tool responses", async () => {
258
+ const parser = createToolOutputParser("legacy-tool");
259
+ const output = JSON.stringify({ ok: true, output: { text: "legacy" } }, null, 2);
260
+ await parser.push(output.slice(0, 11));
261
+ await parser.push(output.slice(11));
262
+ assert.deepEqual(await parser.finish(), { mode: "legacy", output });
263
+ });
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { ToolResourceNoteStore } from "../src/core/tools/tool-resource-note-store.js";
7
+ import { buildAsyncTaskPrompt } from "../src/transport/telegram/bot.js";
8
+
9
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-resource-notes-"));
10
+ const resolveFile = (chatId) => path.join(root, String(chatId), "notes.json");
11
+
12
+ test.after(async () => rm(root, { recursive: true, force: true }));
13
+
14
+ test("stores short notes by chat, tool, and exact resource", async () => {
15
+ const store = new ToolResourceNoteStore({ resolveFile });
16
+ await store.set("chat-a", "whatsapp-web", "group@g.us", "They call me Peter.");
17
+ assert.equal(await store.get("chat-a", "whatsapp-web", "group@g.us"), "They call me Peter.");
18
+ assert.equal(await store.get("chat-b", "whatsapp-web", "group@g.us"), "");
19
+ assert.equal(await store.get("chat-a", "other-tool", "group@g.us"), "");
20
+ const persisted = JSON.parse(await readFile(resolveFile("chat-a"), "utf8"));
21
+ assert.equal(persisted.tools["whatsapp-web"]["group@g.us"].note, "They call me Peter.");
22
+ });
23
+
24
+ test("enforces the 200-character limit and clears empty notes", async () => {
25
+ const store = new ToolResourceNoteStore({ resolveFile });
26
+ await assert.rejects(
27
+ () => store.set("chat-a", "whatsapp-web", "other@g.us", "x".repeat(201)),
28
+ /at most 200 characters/
29
+ );
30
+ await store.set("chat-a", "whatsapp-web", "other@g.us", "temporary");
31
+ await store.set("chat-a", "whatsapp-web", "other@g.us", "");
32
+ assert.equal(await store.get("chat-a", "whatsapp-web", "other@g.us"), "");
33
+ });
34
+
35
+ test("injects a matching resource note before scheduled event text", async () => {
36
+ const store = new ToolResourceNoteStore({ resolveFile });
37
+ await store.set("chat-a", "whatsapp-web", "group@g.us", "They call me Peter.");
38
+ const prompt = await buildAsyncTaskPrompt({
39
+ task: {
40
+ id: "task-1",
41
+ payload: { chatId: "chat-a", prompt: "Incoming WhatsApp message." },
42
+ source: { toolName: "whatsapp-web", resourceId: "group@g.us" }
43
+ },
44
+ artifactStore: { forChat: () => ({ get: async () => null }) },
45
+ toolRegistry: {},
46
+ resourceNotes: store
47
+ });
48
+ assert.match(prompt, /resourceNote: They call me Peter\./);
49
+ assert.ok(prompt.indexOf("resourceNote:") < prompt.indexOf("text: Incoming"));
50
+ });
@@ -26,12 +26,17 @@ test("counts concurrent tool uses per chat", async () => {
26
26
  }
27
27
  });
28
28
 
29
- test("formats narrow tool usage counts", () => {
29
+ test("formats narrow tool usage counts with bullets and right-aligned numbers", () => {
30
30
  const report = formatToolUsageReport([
31
- { name: "campaign-draft-runner", count: 12 },
32
- { name: "gmail-workspace", count: 3 }
31
+ { name: "gmail-workspace", count: 3 },
32
+ { name: "campaign-draft-runner", count: 12 }
33
33
  ]);
34
- assert.match(report, /campaign-draft-runner\s+12/);
35
- assert.match(report, /gmail-workspace\s+3/);
34
+ assert.match(report, /- campaign-draft-runner 12/);
35
+ assert.match(report, /- gmail-workspace\s+3/);
36
+ const rows = report.split("\n").filter((line) => line.startsWith("- "));
37
+ assert.match(rows[0], /campaign-draft-runner/);
38
+ assert.match(rows[1], /gmail-workspace/);
39
+ assert.deepEqual(rows.map((line) => line.match(/\d+$/).index + line.match(/\d+$/)[0].length), [27, 27]);
40
+ assert.deepEqual(rows.map((line) => line.length), [27, 27]);
36
41
  assert.ok(report.split("\n").every((line) => [...line].length <= 35));
37
42
  });
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import {
3
3
  readDaemonLaunchContext,
4
+ readJson,
4
5
  writeJson
5
6
  } from "../src/core/tools/daemon-processes.js";
6
7
  import { createDaemonRuntime } from "../src/core/tools/daemon-runtime.js";
@@ -40,8 +41,18 @@ if (process.argv[2] !== "daemon") {
40
41
  await runtime.workLoop({
41
42
  healthCheck,
42
43
  recover,
43
- processJob: async (payload) => {
44
+ processJob: async (payload, execution) => {
44
45
  if (payload.action === "fail") throw new Error("synthetic job failure");
46
+ if (payload.action === "stream") {
47
+ await execution.emit("progress", { percent: 50 });
48
+ await execution.emit("chunk", { text: "partial" });
49
+ }
50
+ if (payload.action === "count") {
51
+ const countFile = `${runtime.paths.root}/effects.json`;
52
+ const current = await readJson(countFile, { count: 0 });
53
+ await writeJson(countFile, { count: current.count + 1 });
54
+ return { count: current.count + 1 };
55
+ }
45
56
  return { echo: payload.value ?? null };
46
57
  }
47
58
  });