arisa 5.1.13 → 5.1.49
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/AGENTS.md +4 -0
- package/README.md +2 -0
- package/package.json +8 -10
- package/src/core/agent/agent-manager.js +132 -70
- package/src/core/agent/pi-runtime.js +0 -8
- package/src/core/agent/system-shell-tool.js +13 -2
- package/src/core/artifacts/artifact-store.js +17 -18
- package/src/core/config/config-defaults.js +2 -2
- package/src/core/conversation/session-seed-store.js +85 -0
- package/src/core/tools/ipc-client.js +0 -2
- package/src/core/tools/official-tool-installer.js +78 -6
- package/src/core/tools/tool-dependencies.js +99 -0
- package/src/core/tools/tool-output-materializer.js +41 -0
- package/src/core/tools/tool-registry.js +145 -28
- package/src/official-tools.lock.json +209 -5
- package/src/runtime/arisa-capabilities.js +12 -1
- package/src/runtime/create-app.js +1 -0
- package/src/runtime/doctor.js +72 -23
- package/src/runtime/headless-tool-executor.js +2 -32
- package/src/runtime/paths.js +7 -1
- package/src/runtime/restart-receipt.js +90 -0
- package/src/runtime/tool-usage-report.js +25 -10
- package/src/transport/telegram/bot.js +403 -1015
- package/src/transport/telegram/chat-queue.js +132 -0
- package/src/transport/telegram/media.js +2 -2
- package/src/transport/telegram/model-callback.js +211 -0
- package/src/transport/telegram/model-controls.js +164 -0
- package/src/transport/telegram/prompt-builders.js +372 -0
- package/src/transport/telegram/task-dispatcher.js +94 -0
- package/src/transport/telegram/update-command.js +1 -1
- package/src/transport/telegram/workspace-group.js +83 -0
- package/test/agent-tool-policy.test.js +7 -1
- package/test/capabilities-security.test.js +21 -0
- package/test/context-and-task-bounds.test.js +33 -5
- package/test/doctor.test.js +57 -4
- package/test/model-selection.test.js +47 -1
- package/test/official-tool-dependencies.test.js +25 -0
- package/test/official-tool-installer.test.js +37 -9
- package/test/paths.test.js +4 -4
- package/test/restart-receipt.test.js +39 -0
- package/test/session-start-operational-notes.test.js +47 -0
- package/test/telegram-prompt-builders.test.js +33 -0
- package/test/telegram-task-dispatcher.test.js +102 -0
- package/test/telegram-workspace-group.test.js +76 -0
- package/test/tool-dependencies.test.js +53 -0
- package/test/tool-registry-run.test.js +81 -1
- package/test/tool-usage.test.js +26 -4
- package/test/topic-initialization.test.js +66 -0
- package/src/core/conversation/conversation-history-store.js +0 -142
package/test/doctor.test.js
CHANGED
|
@@ -4,7 +4,6 @@ import { formatDoctorReport, runDoctor } from "../src/runtime/doctor.js";
|
|
|
4
4
|
import { serviceEntryFile } from "../src/runtime/service-manager.js";
|
|
5
5
|
|
|
6
6
|
const doctorPolicy = {
|
|
7
|
-
contextInspectionTimeoutMs: 1_000,
|
|
8
7
|
contextWarningPercent: 70,
|
|
9
8
|
contextCriticalPercent: 90,
|
|
10
9
|
contextInefficientMinTokens: 32_000,
|
|
@@ -22,7 +21,6 @@ function runtime(overrides = {}) {
|
|
|
22
21
|
harness: "pi",
|
|
23
22
|
sessions: 1,
|
|
24
23
|
closingSessions: 0,
|
|
25
|
-
managedProcessIds: [],
|
|
26
24
|
contexts: [],
|
|
27
25
|
...overrides
|
|
28
26
|
};
|
|
@@ -87,6 +85,61 @@ test("reports Pi context size and retained-content inefficiency", async () => {
|
|
|
87
85
|
assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
|
|
88
86
|
});
|
|
89
87
|
|
|
88
|
+
test("lists each checked daemon with its scope and state", async () => {
|
|
89
|
+
const { report } = await run({
|
|
90
|
+
repairs: [
|
|
91
|
+
{
|
|
92
|
+
record: { toolName: "master-slave", instanceId: "global", scope: { type: "global" } },
|
|
93
|
+
diagnostic: { state: "ready" },
|
|
94
|
+
outcome: "healthy"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
record: { toolName: "context-vault", instanceId: "chat-1", scope: { type: "chat" } },
|
|
98
|
+
diagnostic: { state: "stopped" },
|
|
99
|
+
outcome: "stopped"
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
report.infrastructure = {
|
|
105
|
+
role: "master",
|
|
106
|
+
daemon: { state: "ready" },
|
|
107
|
+
endpoint: "tcp://198.74.61.48:4719",
|
|
108
|
+
paired: null,
|
|
109
|
+
identityFingerprint: "unnecessarily-long-fingerprint",
|
|
110
|
+
toolCount: 0,
|
|
111
|
+
jobs: { active: 0, queued: 0, failed: 0 },
|
|
112
|
+
pendingSecrets: 0
|
|
113
|
+
};
|
|
114
|
+
const formatted = formatDoctorReport(report);
|
|
115
|
+
assert.match(formatted, /Daemons \(2\)\n Ready \(1\)\n - master-slave \[global\]/);
|
|
116
|
+
assert.match(formatted, / Stopped \(1\)\n - context-vault \[chat\]/);
|
|
117
|
+
assert.match(formatted, /Master\/Slave\n Mode master · ready/);
|
|
118
|
+
assert.match(formatted, /Endpoint 198\.74\.61\.48:4719/);
|
|
119
|
+
assert.match(formatted, /Activity idle/);
|
|
120
|
+
assert.doesNotMatch(formatted, /Identity|fingerprint|Paired/);
|
|
121
|
+
assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("reports missing tool dependencies as attention items", async () => {
|
|
125
|
+
const report = await runDoctor({
|
|
126
|
+
agentManager: { getRuntimeDiagnostic: async () => runtime() },
|
|
127
|
+
toolProcessSupervisor: { repair: async () => [] },
|
|
128
|
+
daemonPolicy,
|
|
129
|
+
doctorPolicy,
|
|
130
|
+
listProcesses: async () => [],
|
|
131
|
+
serviceStatus: async () => ({ running: false }),
|
|
132
|
+
inspectResources: async () => system,
|
|
133
|
+
inspectToolDependencies: async () => [{
|
|
134
|
+
tool: "magnific-mcp",
|
|
135
|
+
type: "missing",
|
|
136
|
+
dependency: "mcp-client",
|
|
137
|
+
range: "^0.1.0"
|
|
138
|
+
}]
|
|
139
|
+
});
|
|
140
|
+
assert.match(report.attention.join("\n"), /magnific-mcp requires mcp-client@\^0\.1\.0/);
|
|
141
|
+
});
|
|
142
|
+
|
|
90
143
|
test("stops only a registered duplicate Arisa service with verified identity", async () => {
|
|
91
144
|
const duplicatePid = 321;
|
|
92
145
|
const { report, stopped } = await run({
|
|
@@ -104,8 +157,8 @@ test("requires complete positive doctor context policy", async () => {
|
|
|
104
157
|
agentManager: { getRuntimeDiagnostic: async () => runtime() },
|
|
105
158
|
toolProcessSupervisor: { repair: async () => [] },
|
|
106
159
|
daemonPolicy,
|
|
107
|
-
doctorPolicy: { ...doctorPolicy,
|
|
160
|
+
doctorPolicy: { ...doctorPolicy, contextWarningPercent: 0 }
|
|
108
161
|
}),
|
|
109
|
-
/positive
|
|
162
|
+
/positive contextWarningPercent/
|
|
110
163
|
);
|
|
111
164
|
});
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
parseSpeedPickerAction,
|
|
28
28
|
reverseModelOrder
|
|
29
29
|
} from "../src/transport/telegram/model-picker.js";
|
|
30
|
-
import { closeModelPicker } from "../src/transport/telegram/
|
|
30
|
+
import { closeModelPicker, createTelegramModelCallbackHandler } from "../src/transport/telegram/model-callback.js";
|
|
31
31
|
|
|
32
32
|
function createConfig() {
|
|
33
33
|
return applyConfigDefaults({
|
|
@@ -221,6 +221,52 @@ test("closes the picker after selecting the already active model and effort", as
|
|
|
221
221
|
]);
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
test("model callback handler delegates unrelated callbacks", async () => {
|
|
225
|
+
let delegated = false;
|
|
226
|
+
const handler = createTelegramModelCallbackHandler({
|
|
227
|
+
config: createConfig(),
|
|
228
|
+
authorizeContext: async () => { throw new Error("must not authorize"); },
|
|
229
|
+
contextRoute: () => ({ sessionId: "123" }),
|
|
230
|
+
getChatState: () => ({ processing: false }),
|
|
231
|
+
logger: null
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await handler({ callbackQuery: { data: "other:action" } }, async () => { delegated = true; });
|
|
235
|
+
assert.equal(delegated, true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("model callback handler persists a non-reasoning model selection", async () => {
|
|
239
|
+
const config = createConfig();
|
|
240
|
+
const calls = [];
|
|
241
|
+
const handler = createTelegramModelCallbackHandler({
|
|
242
|
+
config,
|
|
243
|
+
authorizeContext: async () => ({ ok: true }),
|
|
244
|
+
contextRoute: () => ({ sessionId: "123" }),
|
|
245
|
+
getChatState: () => ({ processing: false }),
|
|
246
|
+
getProviderModels: async () => [{ provider: "openai-codex", id: "gpt-next", reasoning: false }],
|
|
247
|
+
showModelPicker: async () => {},
|
|
248
|
+
showEffortPicker: async () => {},
|
|
249
|
+
persistChatModel: async (...args) => calls.push(["persist", ...args]),
|
|
250
|
+
persistChatEffort: async () => {},
|
|
251
|
+
persistChatSpeed: async () => {},
|
|
252
|
+
logger: null
|
|
253
|
+
});
|
|
254
|
+
const ctx = {
|
|
255
|
+
chat: { id: 123 },
|
|
256
|
+
callbackQuery: { data: "model:0", message: { message_id: 456 } },
|
|
257
|
+
api: { async editMessageText(...args) { calls.push(["edit", ...args]); } },
|
|
258
|
+
async answerCallbackQuery(...args) { calls.push(["answer", ...args]); }
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
await handler(ctx, async () => {});
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(calls, [
|
|
264
|
+
["persist", "123", { provider: "openai-codex", id: "gpt-next", reasoning: false }, "off"],
|
|
265
|
+
["edit", 123, 456, "Model changed to openai-codex/gpt-next.\nA new chat context will start with your next message."],
|
|
266
|
+
["answer", { text: "Using gpt-next." }]
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
224
270
|
test("parses only model picker callback data", () => {
|
|
225
271
|
assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
|
|
226
272
|
assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
async function manifest(name) {
|
|
6
|
+
return JSON.parse(await readFile(new URL(`../../tools/${name}/tool.manifest.json`, import.meta.url), "utf8"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("official orchestrators declare their hard tool dependencies", async () => {
|
|
10
|
+
assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.1.0" });
|
|
11
|
+
assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
|
|
12
|
+
"pr-campaign": "^0.1.0",
|
|
13
|
+
"gmail-workspace": "^0.1.0"
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.2.0" });
|
|
16
|
+
assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
17
|
+
assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
18
|
+
assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("optional tool integrations do not become hard dependencies", async () => {
|
|
22
|
+
assert.deepEqual((await manifest("whatsapp-web")).toolDependencies, undefined);
|
|
23
|
+
assert.deepEqual((await manifest("pr-campaign")).toolDependencies, undefined);
|
|
24
|
+
assert.deepEqual((await manifest("master-slave")).toolDependencies, undefined);
|
|
25
|
+
});
|
|
@@ -60,14 +60,16 @@ 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("bundled
|
|
63
|
+
test("every bundled official tool lock matches the catalog source", async () => {
|
|
64
64
|
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
for (const [name, entry] of Object.entries(lock.tools)) {
|
|
66
|
+
const source = fileURLToPath(new URL(`../../tools/${name}/`, import.meta.url));
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
await verifyOfficialToolTree(source, entry.files),
|
|
69
|
+
{ files: Object.keys(entry.files).length },
|
|
70
|
+
name
|
|
71
|
+
);
|
|
72
|
+
}
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
test("rejects symbolic links before deployment", async (t) => {
|
|
@@ -83,15 +85,18 @@ test("installs a verified staged tree without overwriting an existing tool", asy
|
|
|
83
85
|
await mkdir(path.join(checkoutDir, "tools"), { recursive: true });
|
|
84
86
|
await cp(source, path.join(checkoutDir, "tools", "master-slave"), { recursive: true });
|
|
85
87
|
};
|
|
88
|
+
const lifecycle = [];
|
|
86
89
|
const result = await installLockedOfficialTool({
|
|
87
90
|
toolName: "master-slave",
|
|
88
91
|
lock: lock(files),
|
|
89
92
|
destination,
|
|
90
93
|
scratchRoot: root,
|
|
91
94
|
checkout,
|
|
92
|
-
|
|
95
|
+
installDependencies: async () => { lifecycle.push("dependencies"); },
|
|
96
|
+
validate: async () => { lifecycle.push("validate"); }
|
|
93
97
|
});
|
|
94
98
|
assert.equal(result.commit, "a".repeat(40));
|
|
99
|
+
assert.deepEqual(lifecycle, ["dependencies", "validate"]);
|
|
95
100
|
assert.equal(await readFile(path.join(destination, "index.js"), "utf8"), "process.stdout.write('ok');\n");
|
|
96
101
|
await assert.rejects(
|
|
97
102
|
() => installLockedOfficialTool({ toolName: "master-slave", lock: lock(files), destination, scratchRoot: root, checkout }),
|
|
@@ -111,8 +116,31 @@ test("loads the bundled lock before selecting the canonical tool destination", a
|
|
|
111
116
|
return { installed: true };
|
|
112
117
|
}
|
|
113
118
|
});
|
|
114
|
-
assert.deepEqual(result, { installed: true });
|
|
119
|
+
assert.deepEqual(result, { installed: true, dependencies: [] });
|
|
115
120
|
assert.equal(calls[0].toolName, "master-slave");
|
|
116
121
|
assert.deepEqual(calls[0].lock, lock(files));
|
|
117
122
|
assert.match(calls[0].destination, /tools\/master-slave$/);
|
|
118
123
|
});
|
|
124
|
+
|
|
125
|
+
test("installs locked tool dependencies before the requested tool", async (t) => {
|
|
126
|
+
const { root, files } = await fixture(t);
|
|
127
|
+
const dependencyLock = lock(files);
|
|
128
|
+
dependencyLock.tools = {
|
|
129
|
+
"mcp-client": { version: "0.1.0", toolDependencies: {}, files },
|
|
130
|
+
"magnific-mcp": { version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" }, files }
|
|
131
|
+
};
|
|
132
|
+
const lockFile = path.join(root, "dependency-lock.json");
|
|
133
|
+
await writeFile(lockFile, `${JSON.stringify(dependencyLock)}\n`);
|
|
134
|
+
const calls = [];
|
|
135
|
+
const result = await installBundledOfficialTool("magnific-mcp", {
|
|
136
|
+
lockFile,
|
|
137
|
+
resolveInstalledVersion: async () => undefined,
|
|
138
|
+
install: async ({ toolName }) => {
|
|
139
|
+
calls.push(toolName);
|
|
140
|
+
return { toolName, installed: true };
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
assert.deepEqual(calls, ["mcp-client", "magnific-mcp"]);
|
|
144
|
+
assert.equal(result.toolName, "magnific-mcp");
|
|
145
|
+
assert.deepEqual(result.dependencies, [{ name: "mcp-client", version: "0.1.0", status: "installed" }]);
|
|
146
|
+
});
|
package/test/paths.test.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
chatsDir,
|
|
10
10
|
createIpcSocketPath,
|
|
11
11
|
getChatArtifactsDir,
|
|
12
|
-
|
|
12
|
+
getChatSessionSeedFile,
|
|
13
13
|
getChatToolConfigPath,
|
|
14
14
|
getChatToolUsageFile,
|
|
15
15
|
getChatToolStateDir,
|
|
@@ -25,10 +25,10 @@ test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
|
25
25
|
assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
test("keeps
|
|
28
|
+
test("keeps pending session seeds scoped below the chat state directory", () => {
|
|
29
29
|
assert.equal(
|
|
30
|
-
|
|
31
|
-
path.join(chatsDir, "chat-1", "state", "
|
|
30
|
+
getChatSessionSeedFile("chat-1"),
|
|
31
|
+
path.join(chatsDir, "chat-1", "state", "session-seed.jsonl")
|
|
32
32
|
);
|
|
33
33
|
});
|
|
34
34
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { access, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { cancelRestartReceipt, deliverRestartReceipt, prepareRestartReceipt } from "../src/runtime/restart-receipt.js";
|
|
7
|
+
|
|
8
|
+
const identity = async () => ({ version: "5.1.30", commit: "abc123" });
|
|
9
|
+
|
|
10
|
+
test("restart receipt returns to the originating Telegram topic exactly once", async () => {
|
|
11
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-restart-receipt-"));
|
|
12
|
+
const receiptFile = path.join(directory, "receipt.json");
|
|
13
|
+
try {
|
|
14
|
+
await prepareRestartReceipt({ transportChatId: -1001, threadId: 23 }, { reason: "test" }, { receiptFile, getIdentity: identity });
|
|
15
|
+
const sent = [];
|
|
16
|
+
const result = await deliverRestartReceipt((...args) => sent.push(args), { receiptFile, getIdentity: identity });
|
|
17
|
+
assert.equal(result.verified, true);
|
|
18
|
+
assert.deepEqual(sent[0].slice(0, 1), [-1001]);
|
|
19
|
+
assert.equal(sent[0][2].message_thread_id, 23);
|
|
20
|
+
assert.match(sent[0][1], /Restart completed\.\ntest\nArisa 5\.1\.30 is running/);
|
|
21
|
+
assert.match(sent[0][1], /at commit abc123/);
|
|
22
|
+
assert.equal(await deliverRestartReceipt(() => {}, { receiptFile, getIdentity: identity }), null);
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a failed handoff can cancel only its own restart receipt", async () => {
|
|
29
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-restart-cancel-"));
|
|
30
|
+
const receiptFile = path.join(directory, "receipt.json");
|
|
31
|
+
try {
|
|
32
|
+
const receipt = await prepareRestartReceipt({ transportChatId: 42 }, {}, { receiptFile, getIdentity: identity });
|
|
33
|
+
assert.equal(await cancelRestartReceipt("another-id", { receiptFile }), false);
|
|
34
|
+
assert.equal(await cancelRestartReceipt(receipt.id, { receiptFile }), true);
|
|
35
|
+
await assert.rejects(access(receiptFile));
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(directory, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
async function loadNotesWithHome(homeDir, notesPayload) {
|
|
12
|
+
await writeFile(
|
|
13
|
+
path.join(homeDir, "state", "session-start-operational-notes.json"),
|
|
14
|
+
JSON.stringify(notesPayload),
|
|
15
|
+
"utf8"
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const script = `
|
|
19
|
+
const mod = await import(${JSON.stringify(new URL("../src/core/agent/agent-manager.js", import.meta.url).href)});
|
|
20
|
+
process.stdout.write(JSON.stringify(mod.loadSessionStartOperationalNotes()));
|
|
21
|
+
`;
|
|
22
|
+
const { stdout } = await execFileAsync(process.execPath, ["--input-type=module", "--eval", script], {
|
|
23
|
+
env: { ...process.env, ARISA_HOME: homeDir }
|
|
24
|
+
});
|
|
25
|
+
return JSON.parse(stdout);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("loads bounded durable operational notes at session start", async (t) => {
|
|
29
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-operational-notes-"));
|
|
30
|
+
t.after(() => rm(homeDir, { recursive: true, force: true }));
|
|
31
|
+
await rm(path.join(homeDir, "state"), { recursive: true, force: true }).catch(() => {});
|
|
32
|
+
await mkdir(path.join(homeDir, "state"), { recursive: true });
|
|
33
|
+
|
|
34
|
+
const notes = await loadNotesWithHome(homeDir, {
|
|
35
|
+
notes: [
|
|
36
|
+
" Responde al owner en español por defecto. ",
|
|
37
|
+
{ text: "Incluye reportes CBPR y tareas programadas." },
|
|
38
|
+
"",
|
|
39
|
+
null
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(notes, [
|
|
44
|
+
"Responde al owner en español por defecto.",
|
|
45
|
+
"Incluye reportes CBPR y tareas programadas."
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildStartupMessage,
|
|
5
|
+
normalizeIncomingArtifact
|
|
6
|
+
} from "../src/transport/telegram/prompt-builders.js";
|
|
7
|
+
|
|
8
|
+
test("builds localized Telegram startup messages", () => {
|
|
9
|
+
assert.equal(buildStartupMessage({ languageCode: "es-AR" }), "Arisa esta en linea de nuevo.");
|
|
10
|
+
assert.equal(buildStartupMessage({ languageCode: "pt-BR" }), "Arisa esta online de novo.");
|
|
11
|
+
assert.equal(buildStartupMessage({ languageCode: "en" }), "Arisa is back online.");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("reports whether an incoming artifact required normalization", async () => {
|
|
15
|
+
const result = await normalizeIncomingArtifact({
|
|
16
|
+
artifact: { id: "voice-1", mimeType: "audio/ogg" },
|
|
17
|
+
toolRegistry: { list: () => [] },
|
|
18
|
+
chatArtifactStore: {},
|
|
19
|
+
chatId: "chat-1"
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
assert.equal(result.normalizationRequired, true);
|
|
23
|
+
assert.equal(result.transcript, null);
|
|
24
|
+
assert.match(result.toolResult.error, /No registered tool can normalize audio\/ogg to text\/plain/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("skips normalization metadata when there is no incoming artifact", async () => {
|
|
28
|
+
assert.deepEqual(await normalizeIncomingArtifact({}), {
|
|
29
|
+
transcript: null,
|
|
30
|
+
toolResult: null,
|
|
31
|
+
normalizationRequired: false
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createTelegramTaskDispatcher } from "../src/transport/telegram/task-dispatcher.js";
|
|
4
|
+
|
|
5
|
+
function createHarness(overrides = {}) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const taskStore = {
|
|
8
|
+
async fail(...args) { calls.push(["fail", ...args]); },
|
|
9
|
+
async complete(...args) { calls.push(["complete", ...args]); },
|
|
10
|
+
async claimDue() { return []; },
|
|
11
|
+
...overrides.taskStore
|
|
12
|
+
};
|
|
13
|
+
const dispatcher = createTelegramTaskDispatcher({
|
|
14
|
+
taskStore,
|
|
15
|
+
sendMessage: async (...args) => calls.push(["send", ...args]),
|
|
16
|
+
enqueueAsyncPrompt: async (input) => calls.push(["enqueue", input]),
|
|
17
|
+
artifactStore: { forChat() { throw new Error("unexpected artifact access"); } },
|
|
18
|
+
toolRegistry: {},
|
|
19
|
+
resourceNotes: { async get() { return ""; } },
|
|
20
|
+
agentManager: { async runTool(input) { calls.push(["runTool", input]); } },
|
|
21
|
+
logger: null,
|
|
22
|
+
...overrides.dependencies
|
|
23
|
+
});
|
|
24
|
+
return { calls, taskStore, dispatcher };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("dispatches an agent task into the Telegram prompt queue", async () => {
|
|
28
|
+
const { calls, dispatcher } = createHarness();
|
|
29
|
+
await dispatcher.dispatchTask({
|
|
30
|
+
id: "task-1",
|
|
31
|
+
kind: "agent_task",
|
|
32
|
+
payload: { chatId: 123, prompt: "do the thing", telegramContext: { messageThreadId: 9 } }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(calls[0][0], "enqueue");
|
|
36
|
+
assert.equal(calls[0][1].chatId, 123);
|
|
37
|
+
assert.match(calls[0][1].prompt, /taskId: task-1/);
|
|
38
|
+
assert.match(calls[0][1].prompt, /text: do the thing/);
|
|
39
|
+
assert.deepEqual(calls[1], ["complete", "task-1"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("acknowledges an agent event before queueing it", async () => {
|
|
43
|
+
const { calls, dispatcher } = createHarness();
|
|
44
|
+
await dispatcher.dispatchTask({
|
|
45
|
+
id: "event-1",
|
|
46
|
+
kind: "agent_event",
|
|
47
|
+
payload: { chatId: 123, prompt: "something happened", acknowledgement: "received" }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(calls[0], ["send", 123, "received"]);
|
|
51
|
+
assert.equal(calls[1][0], "enqueue");
|
|
52
|
+
assert.match(calls[1][1].prompt, /event: something happened/);
|
|
53
|
+
assert.match(calls[1][1].prompt, /return exactly NO_REPLY/);
|
|
54
|
+
assert.deepEqual(calls[2], ["complete", "event-1"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("runs poll tools headlessly and completes the checker task", async () => {
|
|
58
|
+
const { calls, dispatcher } = createHarness();
|
|
59
|
+
await dispatcher.dispatchTask({
|
|
60
|
+
id: "poll-1",
|
|
61
|
+
kind: "poll_tool",
|
|
62
|
+
payload: { chatId: 123, toolName: "checker", args: { cursor: "4" } }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(calls, [
|
|
66
|
+
["runTool", { name: "checker", request: { args: { cursor: "4" } }, chatId: 123 }],
|
|
67
|
+
["complete", "poll-1"]
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("fails malformed and unsupported tasks without enqueueing them", async () => {
|
|
72
|
+
const { calls, dispatcher } = createHarness();
|
|
73
|
+
await dispatcher.dispatchTask({ id: "bad-1", kind: "agent_task", payload: {} });
|
|
74
|
+
await dispatcher.dispatchTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(calls, [
|
|
77
|
+
["fail", "bad-1", "Task missing chatId: agent_task"],
|
|
78
|
+
["fail", "bad-2", "Unsupported task: other"]
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("due-task dispatch isolates failures between claimed tasks", async () => {
|
|
83
|
+
const tasks = [
|
|
84
|
+
{ id: "bad", kind: "agent_task", payload: { chatId: 123, prompt: "fail" } },
|
|
85
|
+
{ id: "good", kind: "poll_tool", payload: { chatId: 123, toolName: "checker" } }
|
|
86
|
+
];
|
|
87
|
+
const { calls, dispatcher } = createHarness({
|
|
88
|
+
taskStore: { async claimDue(limit) { calls.push(["claimDue", limit]); return tasks; } },
|
|
89
|
+
dependencies: {
|
|
90
|
+
enqueueAsyncPrompt: async () => { throw new Error("queue unavailable"); }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await dispatcher.dispatchDueTasks();
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(calls, [
|
|
97
|
+
["claimDue", 10],
|
|
98
|
+
["fail", "bad", "queue unavailable"],
|
|
99
|
+
["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
|
|
100
|
+
["complete", "good"]
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
resolveTelegramWorkspaceRoute,
|
|
5
|
+
topicSessionId,
|
|
6
|
+
verifyOwnerWorkspaceGroup
|
|
7
|
+
} from "../src/transport/telegram/workspace-group.js";
|
|
8
|
+
|
|
9
|
+
function api({ count = 2, ownerId = 42, botAdmin = true } = {}) {
|
|
10
|
+
return {
|
|
11
|
+
getChatMemberCount: async () => count,
|
|
12
|
+
getChatAdministrators: async () => [
|
|
13
|
+
{ status: "creator", user: { id: ownerId } },
|
|
14
|
+
...(botAdmin ? [{ status: "administrator", user: { id: 99 } }] : [])
|
|
15
|
+
],
|
|
16
|
+
getMe: async () => ({ id: 99 })
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("general topic reuses the owner session while other topics stay separate", () => {
|
|
21
|
+
assert.equal(topicSessionId({ ownerChatId: 42, groupChatId: -100123, threadId: 1 }), "42");
|
|
22
|
+
assert.equal(
|
|
23
|
+
topicSessionId({ ownerChatId: 42, groupChatId: -100123, threadId: 7 }),
|
|
24
|
+
"42--telegram-group-100123--topic-7"
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("general topic omits Telegram's non-addressable thread id for replies", async () => {
|
|
29
|
+
const route = await resolveTelegramWorkspaceRoute({
|
|
30
|
+
config: { telegram: { ownerWorkspaceGroups: { "-100123": { ownerChatId: 42, generalTopicId: 1 } } } },
|
|
31
|
+
api: api(),
|
|
32
|
+
ctx: {
|
|
33
|
+
chat: { id: -100123, type: "supergroup", is_forum: true },
|
|
34
|
+
from: { id: 42 },
|
|
35
|
+
message: { message_thread_id: 1 }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
assert.equal(route.sessionId, "42");
|
|
39
|
+
assert.equal(route.topicThreadId, 1);
|
|
40
|
+
assert.equal(route.threadId, null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("owner workspace gate accepts bot service events but still blocks a third member", async () => {
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
await verifyOwnerWorkspaceGroup({ api: api(), groupChatId: -100123, ownerChatId: 42, senderId: 99 }),
|
|
46
|
+
{ ok: true, memberCount: 2 }
|
|
47
|
+
);
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
await verifyOwnerWorkspaceGroup({ api: api({ count: 3 }), groupChatId: -100123, ownerChatId: 42, senderId: 99 }),
|
|
50
|
+
{ ok: false, reason: "member-count", memberCount: 3 }
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("workspace route shares owner scope and isolates topic session", async () => {
|
|
55
|
+
const config = {
|
|
56
|
+
telegram: {
|
|
57
|
+
ownerWorkspaceGroups: {
|
|
58
|
+
"-100123": { ownerChatId: 42, generalTopicId: 1 }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const route = await resolveTelegramWorkspaceRoute({
|
|
63
|
+
config,
|
|
64
|
+
api: api(),
|
|
65
|
+
ctx: {
|
|
66
|
+
chat: { id: -100123, type: "supergroup", is_forum: true },
|
|
67
|
+
from: { id: 42 },
|
|
68
|
+
message: { message_thread_id: 8 }
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
assert.equal(route.ok, true);
|
|
72
|
+
assert.equal(route.scopeChatId, 42);
|
|
73
|
+
assert.equal(route.transportChatId, -100123);
|
|
74
|
+
assert.equal(route.threadId, 8);
|
|
75
|
+
assert.equal(route.sessionId, "42--telegram-group-100123--topic-8");
|
|
76
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
inspectToolDependencies,
|
|
5
|
+
normalizeToolDependencies,
|
|
6
|
+
resolveToolDependencyPlan,
|
|
7
|
+
satisfiesToolVersion
|
|
8
|
+
} from "../src/core/tools/tool-dependencies.js";
|
|
9
|
+
|
|
10
|
+
test("normalizes strict tool dependency maps and supports exact and caret versions", () => {
|
|
11
|
+
assert.deepEqual(normalizeToolDependencies({ "mcp-client": "^0.1.0" }), { "mcp-client": "^0.1.0" });
|
|
12
|
+
assert.equal(satisfiesToolVersion("0.1.9", "^0.1.0"), true);
|
|
13
|
+
assert.equal(satisfiesToolVersion("0.2.0", "^0.1.0"), false);
|
|
14
|
+
assert.equal(satisfiesToolVersion("1.4.0", "^1.2.3"), true);
|
|
15
|
+
assert.equal(satisfiesToolVersion("2.0.0", "^1.2.3"), false);
|
|
16
|
+
assert.throws(() => normalizeToolDependencies({ "../bad": "^1.0.0" }), /Invalid tool dependency name/);
|
|
17
|
+
assert.throws(() => normalizeToolDependencies({ valid: ">=1.0.0" }), /Unsupported tool dependency range/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("resolves dependencies before dependents and detects invalid graphs", () => {
|
|
21
|
+
const entries = {
|
|
22
|
+
"mcp-client": { version: "0.1.0", toolDependencies: {} },
|
|
23
|
+
"magnific-mcp": { version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" } }
|
|
24
|
+
};
|
|
25
|
+
assert.deepEqual(resolveToolDependencyPlan(entries, "magnific-mcp"), ["mcp-client", "magnific-mcp"]);
|
|
26
|
+
assert.throws(
|
|
27
|
+
() => resolveToolDependencyPlan({ a: { version: "1.0.0", toolDependencies: { b: "^1.0.0" } } }, "a"),
|
|
28
|
+
/not locked/
|
|
29
|
+
);
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => resolveToolDependencyPlan({
|
|
32
|
+
a: { version: "1.0.0", toolDependencies: { b: "^1.0.0" } },
|
|
33
|
+
b: { version: "1.0.0", toolDependencies: { a: "^1.0.0" } }
|
|
34
|
+
}, "a"),
|
|
35
|
+
/Circular/
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("reports missing and incompatible installed tool dependencies", () => {
|
|
40
|
+
const tools = new Map([
|
|
41
|
+
["magnific-mcp", { name: "magnific-mcp", version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" } }]
|
|
42
|
+
]);
|
|
43
|
+
assert.deepEqual(inspectToolDependencies(tools), [{
|
|
44
|
+
tool: "magnific-mcp",
|
|
45
|
+
type: "missing",
|
|
46
|
+
dependency: "mcp-client",
|
|
47
|
+
range: "^0.1.0"
|
|
48
|
+
}]);
|
|
49
|
+
tools.set("mcp-client", { name: "mcp-client", version: "0.2.0", toolDependencies: {} });
|
|
50
|
+
assert.equal(inspectToolDependencies(tools)[0].type, "incompatible");
|
|
51
|
+
tools.get("mcp-client").version = "0.1.4";
|
|
52
|
+
assert.deepEqual(inspectToolDependencies(tools), []);
|
|
53
|
+
});
|