arisa 4.1.10 → 4.1.12
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 +1 -1
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +3 -6
- package/test/artifact-store.test.js +99 -0
- package/test/auth-flow.test.js +40 -0
- package/test/auth.test.js +101 -0
- package/test/capabilities-security.test.js +249 -0
- package/test/media-caption.test.js +15 -0
- package/test/normalize-for-reasoning.test.js +173 -0
- package/test/paths.test.js +54 -0
- package/test/task-store.test.js +119 -0
- package/test/text-format.test.js +61 -0
- package/test/tool-config.test.js +72 -0
- package/test/tool-registry-run.test.js +136 -0
- package/test/tool-result.test.js +127 -0
package/AGENTS.md
CHANGED
|
@@ -123,7 +123,7 @@ Example manual pipe:
|
|
|
123
123
|
2. take the returned text `artifactId`
|
|
124
124
|
3. `run_tool(openai-tts, artifact text)` to generate audio, then `send_artifact(artifactId)` to deliver it (or `run_tool(openai-tts, artifact text, deliver: true)` to generate and send in one step)
|
|
125
125
|
|
|
126
|
-
Delivery is generic: any `run_tool` output that produces a file becomes an artifact, and `send_artifact(artifactId)` delivers it to the chat. The delivery method and
|
|
126
|
+
Delivery is generic: any `run_tool` output that produces a file becomes an artifact, and `send_artifact(artifactId)` delivers it to the chat. The delivery method and filename are derived from the artifact (its `delivery` hint, `kind`, and stored name); internal local paths are never exposed. No caption is sent by default: the filename already appears on the attachment, so it is never duplicated into the caption text (which would let Telegram autolink a filename like `example.md` as a URL). A caption is shown only when an explicit one is passed. Tools declare their delivery intent by returning `delivery: { method }` in their output; they do not deliver to the transport themselves. As a shortcut, `run_tool` accepts `deliver: true` to generate and deliver in a single step; use it only when the user should receive the file now, not for intermediate pipe steps.
|
|
127
127
|
|
|
128
128
|
## Async event queue flow
|
|
129
129
|
Beyond time-based scheduling, tools can drive an event queue that wakes the agent only when there is something to evaluate. Everything goes through the `asyncTask` (single) or `asyncTasks` (array) field the pipeline already supports; no new Pi tools are needed. The 1s poller drains tasks by `kind`:
|
package/package.json
CHANGED
|
@@ -65,18 +65,15 @@ function containsAbsolutePath(value) {
|
|
|
65
65
|
return /(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(value);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
function resolveMediaCaption(
|
|
68
|
+
export function resolveMediaCaption(caption) {
|
|
69
69
|
if (caption && !containsAbsolutePath(caption)) return caption;
|
|
70
|
-
if (method === "document" && output?.fileName) return output.fileName;
|
|
71
|
-
if (output?.text && !containsAbsolutePath(output.text)) return output.text;
|
|
72
|
-
if (output?.fileName) return output.fileName;
|
|
73
70
|
return undefined;
|
|
74
71
|
}
|
|
75
72
|
|
|
76
73
|
async function deliverArtifactToChat({ artifact, telegram, caption, method, logger }) {
|
|
77
74
|
const resolvedMethod = method || artifact.metadata?.delivery?.method || inferDeliveryMethod(artifact);
|
|
78
75
|
const fileName = path.basename(artifact.path);
|
|
79
|
-
const resolvedCaption = resolveMediaCaption(
|
|
76
|
+
const resolvedCaption = resolveMediaCaption(caption);
|
|
80
77
|
logger?.log("agent", `deliver artifact ${artifact.id} as ${resolvedMethod}`);
|
|
81
78
|
await telegram.sendMedia(artifact.path, { method: resolvedMethod, caption: resolvedCaption, filename: fileName });
|
|
82
79
|
return { method: resolvedMethod, fileName, artifactId: artifact.id };
|
|
@@ -428,7 +425,7 @@ export class AgentManager {
|
|
|
428
425
|
defineTool({
|
|
429
426
|
name: "send_artifact",
|
|
430
427
|
label: "Send artifact",
|
|
431
|
-
description: "Deliver an existing chat artifact to the current Telegram chat. Pass the `artifactId` returned by run_tool or from an inbound file. The delivery method
|
|
428
|
+
description: "Deliver an existing chat artifact to the current Telegram chat. Pass the `artifactId` returned by run_tool or from an inbound file. The delivery method and filename are derived from the artifact (its delivery hint, kind, and stored name); internal local paths are never exposed. No caption is shown by default, since the filename already appears on the attachment; set `caption` only to add a separate visible label, or `method` to override the delivery method. The artifact is not deleted.",
|
|
432
429
|
parameters: Type.Object({
|
|
433
430
|
artifactId: Type.String(),
|
|
434
431
|
caption: Type.Optional(Type.String()),
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-artifact-store-home-"));
|
|
8
|
+
process.env.HOME = homeDir;
|
|
9
|
+
process.env.USERPROFILE = homeDir;
|
|
10
|
+
|
|
11
|
+
const { ArtifactStore } = await import("../src/core/artifacts/artifact-store.js");
|
|
12
|
+
const { arisaHomeDir } = await import("../src/runtime/paths.js");
|
|
13
|
+
|
|
14
|
+
async function resetHome() {
|
|
15
|
+
await rm(arisaHomeDir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("creates, persists, reads, and lists text artifacts by recency", async () => {
|
|
19
|
+
await resetHome();
|
|
20
|
+
const store = new ArtifactStore();
|
|
21
|
+
const chatStore = store.forChat("chat-1");
|
|
22
|
+
|
|
23
|
+
const first = await chatStore.createText({
|
|
24
|
+
text: "first",
|
|
25
|
+
source: { type: "telegram" },
|
|
26
|
+
metadata: { index: 1 }
|
|
27
|
+
});
|
|
28
|
+
const second = await chatStore.createText({
|
|
29
|
+
text: "second",
|
|
30
|
+
mimeType: "text/markdown",
|
|
31
|
+
source: { type: "tool", toolName: "writer" },
|
|
32
|
+
metadata: { index: 2 }
|
|
33
|
+
});
|
|
34
|
+
const third = await chatStore.createText({
|
|
35
|
+
text: "third",
|
|
36
|
+
source: { type: "tool", toolName: "writer" }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(first.chatId, "chat-1");
|
|
40
|
+
assert.equal(first.kind, "text");
|
|
41
|
+
assert.equal(first.mimeType, "text/plain");
|
|
42
|
+
assert.deepEqual(await chatStore.get(second.id), second);
|
|
43
|
+
assert.deepEqual((await chatStore.listRecent(2)).map((artifact) => artifact.id), [third.id, second.id]);
|
|
44
|
+
|
|
45
|
+
const reloadedStore = new ArtifactStore().forChat("chat-1");
|
|
46
|
+
assert.deepEqual(await reloadedStore.get(first.id), first);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("copies file artifacts into the chat artifact directory", async () => {
|
|
50
|
+
await resetHome();
|
|
51
|
+
const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
|
|
52
|
+
const originalPath = path.join(originalDir, "voice.ogg");
|
|
53
|
+
await writeFile(originalPath, "audio-bytes", "utf8");
|
|
54
|
+
|
|
55
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
|
|
56
|
+
originalPath,
|
|
57
|
+
fileName: "voice.ogg",
|
|
58
|
+
kind: "audio",
|
|
59
|
+
mimeType: "audio/ogg",
|
|
60
|
+
source: { type: "telegram", fileId: "telegram-file" },
|
|
61
|
+
metadata: { duration: 3 }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(artifact.chatId, "chat-1");
|
|
65
|
+
assert.equal(artifact.kind, "audio");
|
|
66
|
+
assert.equal(artifact.mimeType, "audio/ogg");
|
|
67
|
+
assert.equal(path.basename(artifact.path), "voice.ogg");
|
|
68
|
+
assert.equal(await readFile(artifact.path, "utf8"), "audio-bytes");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("creates generated file artifacts", async () => {
|
|
72
|
+
await resetHome();
|
|
73
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createGeneratedFile({
|
|
74
|
+
fileName: "reply.md",
|
|
75
|
+
content: "# Hello\n",
|
|
76
|
+
kind: "document",
|
|
77
|
+
mimeType: "text/markdown",
|
|
78
|
+
source: { type: "assistant" }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(await readFile(artifact.path, "utf8"), "# Hello\n");
|
|
82
|
+
assert.equal(artifact.kind, "document");
|
|
83
|
+
assert.equal(artifact.mimeType, "text/markdown");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("keeps artifact indexes isolated by chat", async () => {
|
|
87
|
+
await resetHome();
|
|
88
|
+
const store = new ArtifactStore();
|
|
89
|
+
const chatA = store.forChat("chat-a");
|
|
90
|
+
const chatB = store.forChat("chat-b");
|
|
91
|
+
|
|
92
|
+
const aArtifact = await chatA.createText({ text: "for A", source: { type: "test" } });
|
|
93
|
+
const bArtifact = await chatB.createText({ text: "for B", source: { type: "test" } });
|
|
94
|
+
|
|
95
|
+
assert.deepEqual((await chatA.listRecent()).map((artifact) => artifact.id), [aArtifact.id]);
|
|
96
|
+
assert.deepEqual((await chatB.listRecent()).map((artifact) => artifact.id), [bArtifact.id]);
|
|
97
|
+
assert.equal(await chatA.get(bArtifact.id), null);
|
|
98
|
+
assert.equal(await chatB.get(aArtifact.id), null);
|
|
99
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { getErrorMessage, getPiAuthIssue } from "../src/core/agent/auth-flow.js";
|
|
4
|
+
|
|
5
|
+
test("extracts messages from Error instances and other thrown values", () => {
|
|
6
|
+
assert.equal(getErrorMessage(new Error("boom")), "boom");
|
|
7
|
+
assert.equal(getErrorMessage("plain failure"), "plain failure");
|
|
8
|
+
assert.equal(getErrorMessage(404), "404");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("classifies invalidated Pi authentication tokens", () => {
|
|
12
|
+
for (const error of [
|
|
13
|
+
new Error("authentication token has been invalidated"),
|
|
14
|
+
new Error("Token invalidated by provider"),
|
|
15
|
+
new Error("Please try signing in again"),
|
|
16
|
+
new Error("auth token expired")
|
|
17
|
+
]) {
|
|
18
|
+
assert.deepEqual(getPiAuthIssue(error), {
|
|
19
|
+
kind: "invalidated-token",
|
|
20
|
+
message: error.message
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("classifies missing Pi authentication", () => {
|
|
26
|
+
for (const error of [
|
|
27
|
+
new Error("No auth found for provider"),
|
|
28
|
+
new Error("authentication credentials are missing")
|
|
29
|
+
]) {
|
|
30
|
+
assert.deepEqual(getPiAuthIssue(error), {
|
|
31
|
+
kind: "missing-auth",
|
|
32
|
+
message: error.message
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("ignores unrelated Pi errors", () => {
|
|
38
|
+
assert.equal(getPiAuthIssue(new Error("model rate limit exceeded")), null);
|
|
39
|
+
assert.equal(getPiAuthIssue(new Error("")), null);
|
|
40
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { authorizeChat } from "../src/transport/telegram/auth.js";
|
|
4
|
+
|
|
5
|
+
function createConfig(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
telegram: {
|
|
8
|
+
authorizedChatIds: [],
|
|
9
|
+
maxChatIds: 2,
|
|
10
|
+
chatMeta: {},
|
|
11
|
+
...(overrides.telegram || {})
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createSaveConfigSpy() {
|
|
17
|
+
const calls = [];
|
|
18
|
+
const saveConfig = async (config) => {
|
|
19
|
+
calls.push(JSON.parse(JSON.stringify(config)));
|
|
20
|
+
};
|
|
21
|
+
return { saveConfig, calls };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("authorizes a new chat below the configured limit", async () => {
|
|
25
|
+
const config = createConfig();
|
|
26
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
27
|
+
|
|
28
|
+
const result = await authorizeChat({ config, chatId: 123, saveConfig });
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(result, { ok: true, firstTime: true });
|
|
31
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123]);
|
|
32
|
+
assert.equal(calls.length, 1);
|
|
33
|
+
assert.deepEqual(calls[0].telegram.authorizedChatIds, [123]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("keeps an authorized chat id unique", async () => {
|
|
37
|
+
const config = createConfig({
|
|
38
|
+
telegram: { authorizedChatIds: [123] }
|
|
39
|
+
});
|
|
40
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
41
|
+
|
|
42
|
+
const result = await authorizeChat({ config, chatId: 123, saveConfig });
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(result, { ok: true, firstTime: false });
|
|
45
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123]);
|
|
46
|
+
assert.equal(calls.length, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects a new chat when the chat id limit is reached", async () => {
|
|
50
|
+
const config = createConfig({
|
|
51
|
+
telegram: { authorizedChatIds: [123, 456], maxChatIds: 2 }
|
|
52
|
+
});
|
|
53
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
54
|
+
|
|
55
|
+
const result = await authorizeChat({ config, chatId: 789, saveConfig });
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(result, { ok: false, reason: "max-chat-ids" });
|
|
58
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123, 456]);
|
|
59
|
+
assert.equal(calls.length, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("enforces the maxChatIds off-by-one boundary", async () => {
|
|
63
|
+
const config = createConfig({
|
|
64
|
+
telegram: { authorizedChatIds: [123], maxChatIds: 1 }
|
|
65
|
+
});
|
|
66
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
67
|
+
|
|
68
|
+
const result = await authorizeChat({ config, chatId: 456, saveConfig });
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(result, { ok: false, reason: "max-chat-ids" });
|
|
71
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123]);
|
|
72
|
+
assert.equal(calls.length, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("merges chat metadata for an existing authorized chat and persists it", async () => {
|
|
76
|
+
const config = createConfig({
|
|
77
|
+
telegram: {
|
|
78
|
+
authorizedChatIds: [123],
|
|
79
|
+
chatMeta: {
|
|
80
|
+
123: { username: "old-name", firstName: "Ada" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
85
|
+
|
|
86
|
+
const result = await authorizeChat({
|
|
87
|
+
config,
|
|
88
|
+
chatId: 123,
|
|
89
|
+
saveConfig,
|
|
90
|
+
chatMeta: { username: "new-name", lastName: "Lovelace" }
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(result, { ok: true, firstTime: false });
|
|
94
|
+
assert.deepEqual(config.telegram.chatMeta[123], {
|
|
95
|
+
username: "new-name",
|
|
96
|
+
firstName: "Ada",
|
|
97
|
+
lastName: "Lovelace"
|
|
98
|
+
});
|
|
99
|
+
assert.equal(calls.length, 1);
|
|
100
|
+
assert.deepEqual(calls[0].telegram.chatMeta[123], config.telegram.chatMeta[123]);
|
|
101
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createArisaCapabilities } from "../src/runtime/arisa-capabilities.js";
|
|
4
|
+
|
|
5
|
+
function createFakeArtifactStore() {
|
|
6
|
+
const stores = new Map();
|
|
7
|
+
return {
|
|
8
|
+
forChat(chatId) {
|
|
9
|
+
const key = String(chatId);
|
|
10
|
+
if (!stores.has(key)) {
|
|
11
|
+
const items = [];
|
|
12
|
+
stores.set(key, {
|
|
13
|
+
createText: async ({ text, mimeType, source, metadata }) => {
|
|
14
|
+
const artifact = {
|
|
15
|
+
id: `artifact-${items.length + 1}`,
|
|
16
|
+
chatId: key,
|
|
17
|
+
kind: "text",
|
|
18
|
+
mimeType,
|
|
19
|
+
text,
|
|
20
|
+
source,
|
|
21
|
+
metadata
|
|
22
|
+
};
|
|
23
|
+
items.push(artifact);
|
|
24
|
+
return artifact;
|
|
25
|
+
},
|
|
26
|
+
listRecent: async (limit) => [...items].slice(-limit).reverse(),
|
|
27
|
+
get: async (artifactId) => items.find((item) => item.id === artifactId) || null
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return stores.get(key);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createFakeTaskStore(initialTasks = []) {
|
|
36
|
+
const tasks = [...initialTasks];
|
|
37
|
+
return {
|
|
38
|
+
add: async (task, defaults = {}) => {
|
|
39
|
+
const created = {
|
|
40
|
+
id: `task-${tasks.length + 1}`,
|
|
41
|
+
...task,
|
|
42
|
+
payload: { ...(defaults.payload || {}), ...(task.payload || {}) },
|
|
43
|
+
source: { ...(defaults.source || {}), ...(task.source || {}) }
|
|
44
|
+
};
|
|
45
|
+
tasks.push(created);
|
|
46
|
+
return created;
|
|
47
|
+
},
|
|
48
|
+
list: async (filter = {}) => tasks.filter((task) => {
|
|
49
|
+
if (filter.chatId && task.payload?.chatId !== filter.chatId) return false;
|
|
50
|
+
if (filter.status && task.status !== filter.status) return false;
|
|
51
|
+
if (filter.kind && task.kind !== filter.kind) return false;
|
|
52
|
+
return true;
|
|
53
|
+
}),
|
|
54
|
+
get: async (taskId) => tasks.find((task) => task.id === taskId) || null,
|
|
55
|
+
cancel: async (taskId) => {
|
|
56
|
+
const index = tasks.findIndex((task) => task.id === taskId);
|
|
57
|
+
if (index === -1) return null;
|
|
58
|
+
const [removed] = tasks.splice(index, 1);
|
|
59
|
+
return removed;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createCapabilities(overrides = {}) {
|
|
65
|
+
return createArisaCapabilities({
|
|
66
|
+
artifactStore: overrides.artifactStore || createFakeArtifactStore(),
|
|
67
|
+
taskStore: overrides.taskStore || createFakeTaskStore(),
|
|
68
|
+
agentManager: overrides.agentManager
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test("rejects cancellation of tasks from another chat", async () => {
|
|
73
|
+
const capabilities = createCapabilities({
|
|
74
|
+
taskStore: createFakeTaskStore([
|
|
75
|
+
{ id: "task-1", status: "pending", payload: { chatId: "chat-a" } }
|
|
76
|
+
])
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await assert.rejects(
|
|
80
|
+
() => capabilities.dispatch({
|
|
81
|
+
method: "tasks.cancel",
|
|
82
|
+
toolName: "poller",
|
|
83
|
+
chatId: "chat-b",
|
|
84
|
+
params: { taskId: "task-1" }
|
|
85
|
+
}),
|
|
86
|
+
/task does not belong to chatId/
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("requires a non-empty toolName for all IPC dispatches", async () => {
|
|
91
|
+
const capabilities = createCapabilities();
|
|
92
|
+
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
() => capabilities.dispatch({
|
|
95
|
+
method: "paths.getToolStateDir",
|
|
96
|
+
toolName: " ",
|
|
97
|
+
params: {}
|
|
98
|
+
}),
|
|
99
|
+
/toolName is required/
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
() => capabilities.dispatch({
|
|
104
|
+
method: "paths.getToolStateDir",
|
|
105
|
+
toolName: null,
|
|
106
|
+
params: {}
|
|
107
|
+
}),
|
|
108
|
+
/toolName is required/
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("requires chatId for chat-scoped IPC methods", async () => {
|
|
113
|
+
const capabilities = createCapabilities({
|
|
114
|
+
agentManager: {
|
|
115
|
+
runTool: async () => ({ ok: true, status: "ok" })
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
for (const method of [
|
|
120
|
+
"artifacts.createText",
|
|
121
|
+
"artifacts.listRecent",
|
|
122
|
+
"artifacts.get",
|
|
123
|
+
"tasks.add",
|
|
124
|
+
"tasks.list",
|
|
125
|
+
"tasks.cancel",
|
|
126
|
+
"agent.enqueueEvent",
|
|
127
|
+
"paths.getChatToolStateDir",
|
|
128
|
+
"paths.getChatToolTmpDir",
|
|
129
|
+
"paths.getChatArtifactsDir",
|
|
130
|
+
"tools.run"
|
|
131
|
+
]) {
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => capabilities.dispatch({
|
|
134
|
+
method,
|
|
135
|
+
toolName: "ipc-tool",
|
|
136
|
+
params: method === "tasks.cancel" ? { taskId: "task-1" } : {}
|
|
137
|
+
}),
|
|
138
|
+
new RegExp(`${method.replace(".", "\\.")} requires chatId`)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("normalizes tool run args and rejects arrays", async () => {
|
|
144
|
+
const calls = [];
|
|
145
|
+
const capabilities = createCapabilities({
|
|
146
|
+
agentManager: {
|
|
147
|
+
runTool: async (request) => {
|
|
148
|
+
calls.push(request);
|
|
149
|
+
return { ok: true, status: "ok" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await capabilities.dispatch({
|
|
155
|
+
method: "tools.run",
|
|
156
|
+
toolName: "caller",
|
|
157
|
+
chatId: "chat-1",
|
|
158
|
+
params: { name: "worker", args: null }
|
|
159
|
+
});
|
|
160
|
+
assert.deepEqual(calls.at(-1).request.args, {});
|
|
161
|
+
|
|
162
|
+
await capabilities.dispatch({
|
|
163
|
+
method: "tools.run",
|
|
164
|
+
toolName: "caller",
|
|
165
|
+
chatId: "chat-1",
|
|
166
|
+
params: { name: "worker", args: { bpm: 128 } }
|
|
167
|
+
});
|
|
168
|
+
assert.deepEqual(calls.at(-1).request.args, { bpm: 128 });
|
|
169
|
+
|
|
170
|
+
await assert.rejects(
|
|
171
|
+
() => capabilities.dispatch({
|
|
172
|
+
method: "tools.run",
|
|
173
|
+
toolName: "caller",
|
|
174
|
+
chatId: "chat-1",
|
|
175
|
+
params: { name: "worker", args: ["not", "an", "object"] }
|
|
176
|
+
}),
|
|
177
|
+
/args must be an object/
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("rejects missing artifact input before running a tool", async () => {
|
|
182
|
+
const calls = [];
|
|
183
|
+
const capabilities = createCapabilities({
|
|
184
|
+
agentManager: {
|
|
185
|
+
runTool: async (request) => {
|
|
186
|
+
calls.push(request);
|
|
187
|
+
return { ok: true, status: "ok" };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
() => capabilities.dispatch({
|
|
194
|
+
method: "tools.run",
|
|
195
|
+
toolName: "caller",
|
|
196
|
+
chatId: "chat-1",
|
|
197
|
+
params: { name: "worker", artifactId: "missing" }
|
|
198
|
+
}),
|
|
199
|
+
/Artifact not found: missing/
|
|
200
|
+
);
|
|
201
|
+
assert.equal(calls.length, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("normalizes list limits for artifact reads", async () => {
|
|
205
|
+
const observedLimits = [];
|
|
206
|
+
const artifactStore = {
|
|
207
|
+
forChat: () => ({
|
|
208
|
+
listRecent: async (limit) => {
|
|
209
|
+
observedLimits.push(limit);
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
};
|
|
214
|
+
const capabilities = createCapabilities({ artifactStore });
|
|
215
|
+
|
|
216
|
+
await capabilities.dispatch({
|
|
217
|
+
method: "artifacts.listRecent",
|
|
218
|
+
toolName: "viewer",
|
|
219
|
+
chatId: "chat-1",
|
|
220
|
+
params: { limit: 0 }
|
|
221
|
+
});
|
|
222
|
+
await capabilities.dispatch({
|
|
223
|
+
method: "artifacts.listRecent",
|
|
224
|
+
toolName: "viewer",
|
|
225
|
+
chatId: "chat-1",
|
|
226
|
+
params: { limit: "not-a-number" }
|
|
227
|
+
});
|
|
228
|
+
await capabilities.dispatch({
|
|
229
|
+
method: "artifacts.listRecent",
|
|
230
|
+
toolName: "viewer",
|
|
231
|
+
chatId: "chat-1",
|
|
232
|
+
params: { limit: 1000 }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
assert.deepEqual(observedLimits, [20, 20, 100]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("rejects unknown IPC methods", async () => {
|
|
239
|
+
const capabilities = createCapabilities();
|
|
240
|
+
|
|
241
|
+
await assert.rejects(
|
|
242
|
+
() => capabilities.dispatch({
|
|
243
|
+
method: "unknown.method",
|
|
244
|
+
toolName: "caller",
|
|
245
|
+
params: {}
|
|
246
|
+
}),
|
|
247
|
+
/unknown IPC method: unknown\.method/
|
|
248
|
+
);
|
|
249
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolveMediaCaption } from "../src/core/agent/agent-manager.js";
|
|
4
|
+
|
|
5
|
+
test("does not turn a domain-like filename into a caption", () => {
|
|
6
|
+
assert.equal(resolveMediaCaption(undefined), undefined);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("keeps an explicit caption that is not a local path", () => {
|
|
10
|
+
assert.equal(resolveMediaCaption("Here is the report"), "Here is the report");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("drops an explicit caption that leaks an absolute path", () => {
|
|
14
|
+
assert.equal(resolveMediaCaption("/Users/me/.arisa/artifacts/report.md"), undefined);
|
|
15
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
normalizeArtifactForReasoning,
|
|
5
|
+
selectPipeTool,
|
|
6
|
+
shouldNormalizeArtifactToText
|
|
7
|
+
} from "../src/core/artifacts/normalize-for-reasoning.js";
|
|
8
|
+
|
|
9
|
+
function createToolRegistry(tools, run = async () => ({ ok: true, output: { text: "transcript" } })) {
|
|
10
|
+
return {
|
|
11
|
+
list: () => tools,
|
|
12
|
+
run
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("normalizes only audio and video artifacts to text", () => {
|
|
17
|
+
assert.equal(shouldNormalizeArtifactToText({ mimeType: "audio/ogg; codecs=opus" }), true);
|
|
18
|
+
assert.equal(shouldNormalizeArtifactToText({ mimeType: "video/mp4" }), true);
|
|
19
|
+
assert.equal(shouldNormalizeArtifactToText({ mimeType: "text/plain" }), false);
|
|
20
|
+
assert.equal(shouldNormalizeArtifactToText({ mimeType: "image/png" }), false);
|
|
21
|
+
assert.equal(shouldNormalizeArtifactToText({ mimeType: "audio/ogg" }, "application/json"), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("selects an audio transcription pipe by MIME input/output and tool description", () => {
|
|
25
|
+
const toolRegistry = createToolRegistry([
|
|
26
|
+
{
|
|
27
|
+
name: "generic-converter",
|
|
28
|
+
description: "Converts things",
|
|
29
|
+
input: ["audio/*"],
|
|
30
|
+
output: ["text/plain"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "whisper-transcribe",
|
|
34
|
+
description: "Speech to text for voice notes",
|
|
35
|
+
input: ["audio/*"],
|
|
36
|
+
output: ["text/plain"]
|
|
37
|
+
}
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const tool = selectPipeTool({
|
|
41
|
+
toolRegistry,
|
|
42
|
+
artifact: { mimeType: "audio/ogg; codecs=opus" },
|
|
43
|
+
desiredMimeType: "text/plain"
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
assert.equal(tool.name, "whisper-transcribe");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns no pipe when no transcription-like tool can produce the desired output", () => {
|
|
50
|
+
const toolRegistry = createToolRegistry([
|
|
51
|
+
{
|
|
52
|
+
name: "generic-converter",
|
|
53
|
+
description: "Converts audio",
|
|
54
|
+
input: ["audio/*"],
|
|
55
|
+
output: ["application/json"]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "text-maker",
|
|
59
|
+
description: "Writes text",
|
|
60
|
+
input: ["text/plain"],
|
|
61
|
+
output: ["text/plain"]
|
|
62
|
+
}
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const tool = selectPipeTool({
|
|
66
|
+
toolRegistry,
|
|
67
|
+
artifact: { mimeType: "audio/ogg" },
|
|
68
|
+
desiredMimeType: "text/plain"
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(tool, null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("does not run tools for artifacts that do not need normalization", async () => {
|
|
75
|
+
let runCount = 0;
|
|
76
|
+
const result = await normalizeArtifactForReasoning({
|
|
77
|
+
artifact: { id: "artifact-1", mimeType: "text/plain", text: "hello" },
|
|
78
|
+
toolRegistry: createToolRegistry([], async () => {
|
|
79
|
+
runCount += 1;
|
|
80
|
+
return { ok: true, output: { text: "unused" } };
|
|
81
|
+
}),
|
|
82
|
+
chatArtifactStore: {},
|
|
83
|
+
chatId: "chat-1"
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(result, { normalizedArtifact: null, toolResult: null, toolName: "" });
|
|
87
|
+
assert.equal(runCount, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns a failed normalization result when no pipe is registered", async () => {
|
|
91
|
+
const result = await normalizeArtifactForReasoning({
|
|
92
|
+
artifact: { id: "artifact-1", mimeType: "audio/ogg" },
|
|
93
|
+
toolRegistry: createToolRegistry([]),
|
|
94
|
+
chatArtifactStore: {},
|
|
95
|
+
chatId: "chat-1"
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.equal(result.normalizedArtifact, null);
|
|
99
|
+
assert.equal(result.toolName, "");
|
|
100
|
+
assert.equal(result.toolResult.ok, false);
|
|
101
|
+
assert.match(result.toolResult.error, /No registered tool can normalize audio\/ogg to text\/plain/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("creates a text artifact from successful normalization output", async () => {
|
|
105
|
+
const calls = [];
|
|
106
|
+
const createdArtifacts = [];
|
|
107
|
+
const artifact = { id: "voice-1", mimeType: "audio/ogg" };
|
|
108
|
+
const toolRegistry = createToolRegistry([
|
|
109
|
+
{
|
|
110
|
+
name: "openai-transcribe",
|
|
111
|
+
description: "Transcription for audio",
|
|
112
|
+
input: ["audio/*"],
|
|
113
|
+
output: ["text/plain"]
|
|
114
|
+
}
|
|
115
|
+
], async (request) => {
|
|
116
|
+
calls.push(request);
|
|
117
|
+
return { ok: true, output: { text: "hello from voice" } };
|
|
118
|
+
});
|
|
119
|
+
const chatArtifactStore = {
|
|
120
|
+
createText: async (request) => {
|
|
121
|
+
const created = { id: "text-1", ...request };
|
|
122
|
+
createdArtifacts.push(created);
|
|
123
|
+
return created;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const result = await normalizeArtifactForReasoning({
|
|
128
|
+
artifact,
|
|
129
|
+
toolRegistry,
|
|
130
|
+
chatArtifactStore,
|
|
131
|
+
chatId: "chat-1"
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.equal(calls.length, 1);
|
|
135
|
+
assert.deepEqual(calls[0], {
|
|
136
|
+
name: "openai-transcribe",
|
|
137
|
+
request: { artifact, args: {} },
|
|
138
|
+
chatId: "chat-1"
|
|
139
|
+
});
|
|
140
|
+
assert.deepEqual(result.normalizedArtifact, createdArtifacts[0]);
|
|
141
|
+
assert.equal(result.toolName, "openai-transcribe");
|
|
142
|
+
assert.deepEqual(createdArtifacts[0], {
|
|
143
|
+
id: "text-1",
|
|
144
|
+
text: "hello from voice",
|
|
145
|
+
mimeType: "text/plain",
|
|
146
|
+
source: { type: "tool", toolName: "openai-transcribe" },
|
|
147
|
+
metadata: { fromArtifactId: "voice-1", tool: "openai-transcribe", normalization: true }
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("fails normalization when the selected tool returns no text", async () => {
|
|
152
|
+
const result = await normalizeArtifactForReasoning({
|
|
153
|
+
artifact: { id: "voice-1", mimeType: "audio/ogg" },
|
|
154
|
+
toolRegistry: createToolRegistry([
|
|
155
|
+
{
|
|
156
|
+
name: "openai-transcribe",
|
|
157
|
+
description: "Transcription for audio",
|
|
158
|
+
input: ["audio/*"],
|
|
159
|
+
output: ["text/plain"]
|
|
160
|
+
}
|
|
161
|
+
], async () => ({ ok: true, output: {} })),
|
|
162
|
+
chatArtifactStore: {},
|
|
163
|
+
chatId: "chat-1"
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.equal(result.normalizedArtifact, null);
|
|
167
|
+
assert.equal(result.toolName, "openai-transcribe");
|
|
168
|
+
assert.deepEqual(result.toolResult, {
|
|
169
|
+
ok: false,
|
|
170
|
+
status: "failed",
|
|
171
|
+
error: "Normalization returned no text."
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
chatsDir,
|
|
6
|
+
createIpcSocketPath,
|
|
7
|
+
getChatArtifactsDir,
|
|
8
|
+
getChatToolConfigPath,
|
|
9
|
+
getChatToolStateDir,
|
|
10
|
+
getToolStateDir,
|
|
11
|
+
stateDir
|
|
12
|
+
} from "../src/runtime/paths.js";
|
|
13
|
+
|
|
14
|
+
test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
15
|
+
const artifactsDir = getChatArtifactsDir("chat-1");
|
|
16
|
+
|
|
17
|
+
assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("keeps chat tool state and config paths scoped below the chat directory for normal names", () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
getChatToolStateDir("chat-1", "strudel-agent"),
|
|
23
|
+
path.join(chatsDir, "chat-1", "state", "tools", "strudel-agent")
|
|
24
|
+
);
|
|
25
|
+
assert.equal(
|
|
26
|
+
getChatToolConfigPath("chat-1", "strudel-agent"),
|
|
27
|
+
path.join(chatsDir, "chat-1", "config", "tools", "strudel-agent", "config.js")
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("documents current traversal behavior for unsanitized tool names", () => {
|
|
32
|
+
const expectedRoot = path.join(chatsDir, "chat-1", "state", "tools");
|
|
33
|
+
const traversed = getChatToolStateDir("chat-1", "../../evil");
|
|
34
|
+
|
|
35
|
+
assert.equal(path.resolve(traversed), path.join(chatsDir, "chat-1", "evil"));
|
|
36
|
+
assert.equal(path.resolve(traversed).startsWith(`${expectedRoot}${path.sep}`), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("documents current traversal behavior for global tool state paths", () => {
|
|
40
|
+
const expectedRoot = path.join(stateDir, "tools");
|
|
41
|
+
const traversed = getToolStateDir("../../evil");
|
|
42
|
+
|
|
43
|
+
assert.equal(path.resolve(traversed), path.join(path.dirname(stateDir), "evil"));
|
|
44
|
+
assert.equal(path.resolve(traversed).startsWith(`${expectedRoot}${path.sep}`), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("creates POSIX IPC socket paths under the state directory", () => {
|
|
48
|
+
const socketPath = createIpcSocketPath({
|
|
49
|
+
homeDir: "/tmp/arisa-home",
|
|
50
|
+
platform: "darwin"
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(socketPath, path.join("/tmp/arisa-home", "state", "arisa.sock"));
|
|
54
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-task-store-home-"));
|
|
8
|
+
process.env.HOME = homeDir;
|
|
9
|
+
process.env.USERPROFILE = homeDir;
|
|
10
|
+
|
|
11
|
+
const { TaskStore } = await import("../src/core/tasks/task-store.js");
|
|
12
|
+
const { arisaHomeDir } = await import("../src/runtime/paths.js");
|
|
13
|
+
|
|
14
|
+
async function resetHome() {
|
|
15
|
+
await rm(arisaHomeDir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("adds tasks and lists them by chat, status, and kind", async () => {
|
|
19
|
+
await resetHome();
|
|
20
|
+
const store = new TaskStore();
|
|
21
|
+
|
|
22
|
+
await store.add({ id: "chat-1-pending", kind: "agent_task" }, { payload: { chatId: "chat-1" } });
|
|
23
|
+
await store.add({ id: "chat-1-event", kind: "agent_event" }, { payload: { chatId: "chat-1" } });
|
|
24
|
+
await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
|
|
25
|
+
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
(await store.list({ chatId: "chat-1" })).map((task) => task.id),
|
|
28
|
+
["chat-1-pending", "chat-1-event"]
|
|
29
|
+
);
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
(await store.list({ kind: "agent_task" })).map((task) => task.id),
|
|
32
|
+
["chat-1-pending", "chat-2-pending"]
|
|
33
|
+
);
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
(await store.list({ status: "pending" })).map((task) => task.id),
|
|
36
|
+
["chat-1-pending", "chat-1-event", "chat-2-pending"]
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("claims only due pending tasks and marks them running", async () => {
|
|
41
|
+
await resetHome();
|
|
42
|
+
const store = new TaskStore();
|
|
43
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
44
|
+
const future = new Date(Date.now() + 60_000).toISOString();
|
|
45
|
+
|
|
46
|
+
await store.add({ id: "due-1", kind: "agent_task", runAt: past });
|
|
47
|
+
await store.add({ id: "due-2", kind: "agent_task", runAt: past });
|
|
48
|
+
await store.add({ id: "future", kind: "agent_task", runAt: future });
|
|
49
|
+
await store.add({ id: "done", kind: "agent_task", runAt: past, status: "done" });
|
|
50
|
+
await store.add({ id: "invalid", kind: "agent_task", runAt: "not-a-date" });
|
|
51
|
+
|
|
52
|
+
const claimed = await store.claimDue(1);
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(claimed.map((task) => task.id), ["due-1"]);
|
|
55
|
+
assert.equal(claimed[0].status, "running");
|
|
56
|
+
assert.equal((await store.get("due-1")).status, "running");
|
|
57
|
+
assert.equal((await store.get("due-2")).status, "pending");
|
|
58
|
+
assert.equal((await store.get("future")).status, "pending");
|
|
59
|
+
assert.equal((await store.get("done")).status, "done");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("completes one-off tasks and re-schedules recurring interval tasks", async () => {
|
|
63
|
+
await resetHome();
|
|
64
|
+
const store = new TaskStore();
|
|
65
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
66
|
+
|
|
67
|
+
await store.add({ id: "once", kind: "agent_task", status: "running" });
|
|
68
|
+
await store.add({
|
|
69
|
+
id: "repeat",
|
|
70
|
+
kind: "poll_tool",
|
|
71
|
+
status: "running",
|
|
72
|
+
runAt: past,
|
|
73
|
+
recurrence: { type: "interval", everySeconds: 60 }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const once = await store.complete("once");
|
|
77
|
+
const repeat = await store.complete("repeat");
|
|
78
|
+
|
|
79
|
+
assert.equal(once.status, "done");
|
|
80
|
+
assert.ok(once.completedAt);
|
|
81
|
+
assert.equal(repeat.status, "pending");
|
|
82
|
+
assert.ok(Date.parse(repeat.runAt) > Date.now());
|
|
83
|
+
assert.ok(repeat.lastRunAt);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("fails and cancels tasks by id", async () => {
|
|
87
|
+
await resetHome();
|
|
88
|
+
const store = new TaskStore();
|
|
89
|
+
|
|
90
|
+
await store.add({ id: "will-fail", kind: "agent_task" });
|
|
91
|
+
await store.add({ id: "will-cancel", kind: "agent_task" });
|
|
92
|
+
|
|
93
|
+
const failed = await store.fail("will-fail", "boom");
|
|
94
|
+
const canceled = await store.cancel("will-cancel");
|
|
95
|
+
|
|
96
|
+
assert.equal(failed.status, "failed");
|
|
97
|
+
assert.equal(failed.error, "boom");
|
|
98
|
+
assert.equal(canceled.id, "will-cancel");
|
|
99
|
+
assert.equal(await store.get("will-cancel"), null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("cancelAll preserves done and failed tasks and respects chat filters", async () => {
|
|
103
|
+
await resetHome();
|
|
104
|
+
const store = new TaskStore();
|
|
105
|
+
|
|
106
|
+
await store.add({ id: "chat-1-pending", kind: "agent_task" }, { payload: { chatId: "chat-1" } });
|
|
107
|
+
await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
|
|
108
|
+
await store.add({ id: "chat-1-done", kind: "agent_task", status: "done" }, { payload: { chatId: "chat-1" } });
|
|
109
|
+
await store.add({ id: "chat-1-failed", kind: "agent_task", status: "failed" }, { payload: { chatId: "chat-1" } });
|
|
110
|
+
|
|
111
|
+
const removed = await store.cancelAll({ chatId: "chat-1" });
|
|
112
|
+
const remaining = await store.list();
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(removed.map((task) => task.id), ["chat-1-pending"]);
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
remaining.map((task) => task.id),
|
|
117
|
+
["chat-2-pending", "chat-1-done", "chat-1-failed"]
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { renderTelegramHtml, splitTelegramText } from "../src/transport/telegram/text-format.js";
|
|
4
|
+
|
|
5
|
+
test("escapes HTML-sensitive characters in inline text", () => {
|
|
6
|
+
const rendered = renderTelegramHtml("<script>alert(\"x\")</script> & done");
|
|
7
|
+
|
|
8
|
+
assert.equal(rendered, "<script>alert("x")</script> & done");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("escapes inline formatting content before rendering Telegram HTML tags", () => {
|
|
12
|
+
const rendered = renderTelegramHtml("**<b>bold & safe</b>** and `<tag attr=\"x\">`");
|
|
13
|
+
|
|
14
|
+
assert.equal(
|
|
15
|
+
rendered,
|
|
16
|
+
"<b><b>bold & safe</b></b> and <code><tag attr="x"></code>"
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("escapes fenced code content", () => {
|
|
21
|
+
const rendered = renderTelegramHtml("before\n```js\nconst x = \"<tag>\" && value;\n```\nafter");
|
|
22
|
+
|
|
23
|
+
assert.equal(
|
|
24
|
+
rendered,
|
|
25
|
+
"before\n<pre><code language=\"js\">const x = "<tag>" && value;</code></pre>\nafter"
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("escapes fenced code language attributes", () => {
|
|
30
|
+
const rendered = renderTelegramHtml("```js\" onmouseover=\"alert(1)\nconsole.log(\"safe\");\n```");
|
|
31
|
+
|
|
32
|
+
assert.match(rendered, /<pre><code language="js" onmouseover="alert\(1\)">/);
|
|
33
|
+
assert.doesNotMatch(rendered, /onmouseover="/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("leaves unterminated fences as escaped inline text", () => {
|
|
37
|
+
const rendered = renderTelegramHtml("```html\n<b>not code");
|
|
38
|
+
|
|
39
|
+
assert.equal(rendered, "```html\n<b>not code");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("splits empty or whitespace-only Telegram text into no chunks", () => {
|
|
43
|
+
assert.deepEqual(splitTelegramText(""), []);
|
|
44
|
+
assert.deepEqual(splitTelegramText(" \n\t "), []);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("splits Telegram text at readable boundaries without exceeding the limit", () => {
|
|
48
|
+
const source = "alpha beta gamma delta epsilon zeta";
|
|
49
|
+
const chunks = splitTelegramText(source, 12);
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(chunks, ["alpha beta", "gamma delta", "epsilon zeta"]);
|
|
52
|
+
assert.ok(chunks.every((chunk) => chunk.length <= 12));
|
|
53
|
+
assert.equal(chunks.join(" "), source);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("falls back to hard splits when no readable boundary exists", () => {
|
|
57
|
+
const chunks = splitTelegramText("abcdefghijklmnop", 5);
|
|
58
|
+
|
|
59
|
+
assert.deepEqual(chunks, ["abcde", "fghij", "klmno", "p"]);
|
|
60
|
+
assert.ok(chunks.every((chunk) => chunk.length <= 5));
|
|
61
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-config-home-"));
|
|
8
|
+
process.env.HOME = homeDir;
|
|
9
|
+
process.env.USERPROFILE = homeDir;
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
loadToolConfig,
|
|
13
|
+
parseConfigModule,
|
|
14
|
+
serializeConfigModule,
|
|
15
|
+
writeToolConfig
|
|
16
|
+
} = await import("../src/core/tools/tool-config.js");
|
|
17
|
+
|
|
18
|
+
test("parses and serializes config modules", () => {
|
|
19
|
+
const config = parseConfigModule("export default {\n apiKey: \"secret\",\n enabled: true\n};\n");
|
|
20
|
+
const serialized = serializeConfigModule(config);
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(config, { apiKey: "secret", enabled: true });
|
|
23
|
+
assert.equal(serialized, "export default {\n apiKey: \"secret\",\n enabled: true\n};\n");
|
|
24
|
+
assert.deepEqual(parseConfigModule(serialized), config);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("loads tool config with defaults, global config, and chat config precedence", async () => {
|
|
28
|
+
await writeToolConfig("demo-tool", {
|
|
29
|
+
apiKey: "global-key",
|
|
30
|
+
mode: "global",
|
|
31
|
+
retries: 3
|
|
32
|
+
});
|
|
33
|
+
await writeToolConfig("demo-tool", {
|
|
34
|
+
mode: "chat",
|
|
35
|
+
chatOnly: true
|
|
36
|
+
}, "chat-1");
|
|
37
|
+
|
|
38
|
+
const config = await loadToolConfig("demo-tool", {
|
|
39
|
+
apiKey: "default-key",
|
|
40
|
+
mode: "default",
|
|
41
|
+
timeoutMs: 1000
|
|
42
|
+
}, "chat-1");
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(config, {
|
|
45
|
+
apiKey: "global-key",
|
|
46
|
+
mode: "chat",
|
|
47
|
+
timeoutMs: 1000,
|
|
48
|
+
retries: 3,
|
|
49
|
+
chatOnly: true
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("writes config modules to the selected scope", async () => {
|
|
54
|
+
const { getChatToolConfigPath } = await import("../src/runtime/paths.js");
|
|
55
|
+
const configPath = await writeToolConfig("scoped-tool", { token: "chat-token" }, "chat-2");
|
|
56
|
+
const source = await readFile(configPath, "utf8");
|
|
57
|
+
|
|
58
|
+
assert.equal(configPath, getChatToolConfigPath("chat-2", "scoped-tool"));
|
|
59
|
+
assert.equal(source, "export default {\n token: \"chat-token\"\n};\n");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("documents that config parsing executes JavaScript expressions", () => {
|
|
63
|
+
delete globalThis.__arisaConfigExecuted;
|
|
64
|
+
|
|
65
|
+
const config = parseConfigModule(
|
|
66
|
+
"export default (() => { globalThis.__arisaConfigExecuted = true; return { enabled: true }; })();"
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(config, { enabled: true });
|
|
70
|
+
assert.equal(globalThis.__arisaConfigExecuted, true);
|
|
71
|
+
delete globalThis.__arisaConfigExecuted;
|
|
72
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-registry-home-"));
|
|
8
|
+
process.env.HOME = homeDir;
|
|
9
|
+
process.env.USERPROFILE = homeDir;
|
|
10
|
+
|
|
11
|
+
const { ToolRegistry } = await import("../src/core/tools/tool-registry.js");
|
|
12
|
+
const {
|
|
13
|
+
arisaHomeDir,
|
|
14
|
+
arisaPackageDir,
|
|
15
|
+
arisaIpcSocketFile,
|
|
16
|
+
toolsDir
|
|
17
|
+
} = await import("../src/runtime/paths.js");
|
|
18
|
+
|
|
19
|
+
async function resetHome() {
|
|
20
|
+
await rm(arisaHomeDir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function createFakeTool(name = "fake-tool") {
|
|
24
|
+
const dir = path.join(toolsDir, name);
|
|
25
|
+
await mkdir(dir, { recursive: true });
|
|
26
|
+
await writeFile(path.join(dir, "tool.manifest.json"), `${JSON.stringify({
|
|
27
|
+
name,
|
|
28
|
+
description: "Fake test tool",
|
|
29
|
+
entry: "index.js",
|
|
30
|
+
input: ["text/plain"],
|
|
31
|
+
output: ["text/plain"],
|
|
32
|
+
configSchema: {
|
|
33
|
+
apiKey: { type: "string", required: false }
|
|
34
|
+
},
|
|
35
|
+
skillHints: [{ name: "missing-skill", when: "testing" }]
|
|
36
|
+
}, null, 2)}\n`, "utf8");
|
|
37
|
+
await writeFile(path.join(dir, "config.js"), "export default {\n apiKey: \"default-key\"\n};\n", "utf8");
|
|
38
|
+
await writeFile(path.join(dir, "index.js"), `import { readFile } from "node:fs/promises";
|
|
39
|
+
|
|
40
|
+
const requestFileIndex = process.argv.indexOf("--request-file");
|
|
41
|
+
if (process.argv.includes("--help")) {
|
|
42
|
+
process.stdout.write("Fake test tool help\\n");
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
if (requestFileIndex === -1) {
|
|
46
|
+
process.stdout.write(JSON.stringify({ ok: false, error: "missing request file" }));
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const requestFile = process.argv[requestFileIndex + 1];
|
|
51
|
+
const request = JSON.parse(await readFile(requestFile, "utf8"));
|
|
52
|
+
process.stdout.write(JSON.stringify({
|
|
53
|
+
ok: true,
|
|
54
|
+
output: {
|
|
55
|
+
text: "tool completed",
|
|
56
|
+
request,
|
|
57
|
+
requestFile,
|
|
58
|
+
env: {
|
|
59
|
+
ARISA_PACKAGE_DIR: process.env.ARISA_PACKAGE_DIR,
|
|
60
|
+
ARISA_IPC_SOCKET: process.env.ARISA_IPC_SOCKET
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
`, "utf8");
|
|
65
|
+
return dir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("loads and lists installed tools from the user tools directory", async () => {
|
|
69
|
+
await resetHome();
|
|
70
|
+
await createFakeTool("fake-tool");
|
|
71
|
+
|
|
72
|
+
const registry = new ToolRegistry();
|
|
73
|
+
await registry.load();
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(registry.list(), [{
|
|
76
|
+
name: "fake-tool",
|
|
77
|
+
description: "Fake test tool",
|
|
78
|
+
input: ["text/plain"],
|
|
79
|
+
output: ["text/plain"],
|
|
80
|
+
configSchema: {
|
|
81
|
+
apiKey: { type: "string", required: false }
|
|
82
|
+
},
|
|
83
|
+
skillHints: [{ name: "missing-skill", when: "testing" }]
|
|
84
|
+
}]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("runs a registered tool process with an enriched request and cleans up request files", async () => {
|
|
88
|
+
await resetHome();
|
|
89
|
+
await createFakeTool("fake-tool");
|
|
90
|
+
|
|
91
|
+
const registry = new ToolRegistry();
|
|
92
|
+
await registry.load();
|
|
93
|
+
|
|
94
|
+
const result = await registry.run({
|
|
95
|
+
name: "fake-tool",
|
|
96
|
+
chatId: "chat-1",
|
|
97
|
+
request: {
|
|
98
|
+
text: "hello",
|
|
99
|
+
args: { count: 2 }
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(result.ok, true);
|
|
104
|
+
assert.equal(result.status, "ok");
|
|
105
|
+
assert.equal(result.output.text, "tool completed");
|
|
106
|
+
assert.deepEqual(result.output.request, {
|
|
107
|
+
text: "hello",
|
|
108
|
+
args: { count: 2 },
|
|
109
|
+
chatId: "chat-1",
|
|
110
|
+
skills: [{
|
|
111
|
+
name: "missing-skill",
|
|
112
|
+
when: "testing",
|
|
113
|
+
found: false,
|
|
114
|
+
description: "",
|
|
115
|
+
path: "",
|
|
116
|
+
content: ""
|
|
117
|
+
}]
|
|
118
|
+
});
|
|
119
|
+
assert.equal(result.output.env.ARISA_PACKAGE_DIR, arisaPackageDir);
|
|
120
|
+
assert.equal(result.output.env.ARISA_IPC_SOCKET, arisaIpcSocketFile);
|
|
121
|
+
|
|
122
|
+
const requestFile = result.output.requestFile;
|
|
123
|
+
await assert.rejects(() => access(requestFile), { code: "ENOENT" });
|
|
124
|
+
await assert.rejects(() => access(path.dirname(requestFile)), { code: "ENOENT" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("rejects unknown tools", async () => {
|
|
128
|
+
await resetHome();
|
|
129
|
+
const registry = new ToolRegistry();
|
|
130
|
+
await registry.load();
|
|
131
|
+
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => registry.run({ name: "missing-tool", request: {}, chatId: "chat-1" }),
|
|
134
|
+
/Tool not found: missing-tool/
|
|
135
|
+
);
|
|
136
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
normalizeToolResult,
|
|
5
|
+
toolError,
|
|
6
|
+
toolNeedsConfig,
|
|
7
|
+
toolOk
|
|
8
|
+
} from "../src/core/tools/tool-result.js";
|
|
9
|
+
|
|
10
|
+
test("builds standard successful tool responses", () => {
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
toolOk({ text: "done" }, { asyncTasks: [{ id: "task-1" }] }),
|
|
13
|
+
{ ok: true, output: { text: "done" }, asyncTasks: [{ id: "task-1" }] }
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("builds standard failed tool responses", () => {
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
toolError("boom", { status: "bad_request", details: { field: "name" } }),
|
|
20
|
+
{
|
|
21
|
+
ok: false,
|
|
22
|
+
status: "bad_request",
|
|
23
|
+
error: "boom",
|
|
24
|
+
details: { field: "name" }
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("builds missing-config tool responses", () => {
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
toolNeedsConfig({
|
|
32
|
+
tool: "demo",
|
|
33
|
+
missingConfig: ["apiKey"],
|
|
34
|
+
configPath: "/tmp/config.js"
|
|
35
|
+
}),
|
|
36
|
+
{
|
|
37
|
+
ok: false,
|
|
38
|
+
status: "needs_config",
|
|
39
|
+
error: "Missing tool configuration for demo.",
|
|
40
|
+
missingConfig: ["apiKey"],
|
|
41
|
+
configPath: "/tmp/config.js",
|
|
42
|
+
resolution: {
|
|
43
|
+
type: "user_config_required",
|
|
44
|
+
tool: "demo",
|
|
45
|
+
missingConfig: ["apiKey"],
|
|
46
|
+
configPath: "/tmp/config.js"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("normalizes non-object responses into failed results", () => {
|
|
53
|
+
const result = normalizeToolResult("demo", "not-json");
|
|
54
|
+
|
|
55
|
+
assert.equal(result.ok, false);
|
|
56
|
+
assert.equal(result.status, "failed");
|
|
57
|
+
assert.equal(result.error, "Invalid tool response for demo");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("normalizes missing-config failures", () => {
|
|
61
|
+
const result = normalizeToolResult("demo", {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: "Need API key",
|
|
64
|
+
missingConfig: ["apiKey"],
|
|
65
|
+
configPath: "/tmp/config.js"
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.equal(result.ok, false);
|
|
69
|
+
assert.equal(result.status, "needs_config");
|
|
70
|
+
assert.equal(result.error, "Need API key");
|
|
71
|
+
assert.deepEqual(result.resolution, {
|
|
72
|
+
type: "user_config_required",
|
|
73
|
+
tool: "demo",
|
|
74
|
+
missingConfig: ["apiKey"],
|
|
75
|
+
configPath: "/tmp/config.js"
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("preserves explicit missing-config status and resolution", () => {
|
|
80
|
+
const result = normalizeToolResult("demo", {
|
|
81
|
+
ok: false,
|
|
82
|
+
status: "blocked",
|
|
83
|
+
error: "Need consent",
|
|
84
|
+
missingConfig: ["consent"],
|
|
85
|
+
resolution: { type: "manual", url: "https://example.com" }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
assert.equal(result.status, "blocked");
|
|
89
|
+
assert.deepEqual(result.resolution, { type: "manual", url: "https://example.com" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("normalizes regular failures", () => {
|
|
93
|
+
const result = normalizeToolResult("demo", {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: "Tool exploded",
|
|
96
|
+
stdout: "",
|
|
97
|
+
stderr: "stack"
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert.equal(result.ok, false);
|
|
101
|
+
assert.equal(result.status, "failed");
|
|
102
|
+
assert.equal(result.error, "Tool exploded");
|
|
103
|
+
assert.equal(result.stderr, "stack");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("normalizes successful responses", () => {
|
|
107
|
+
const result = normalizeToolResult("demo", {
|
|
108
|
+
ok: true,
|
|
109
|
+
output: { text: "hello" },
|
|
110
|
+
delivery: { method: "document" }
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.equal(result.ok, true);
|
|
114
|
+
assert.equal(result.status, "ok");
|
|
115
|
+
assert.deepEqual(result.output, { text: "hello" });
|
|
116
|
+
assert.deepEqual(result.delivery, { method: "document" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("rejects object responses without an ok contract", () => {
|
|
120
|
+
const rawResult = { output: { text: "hello" } };
|
|
121
|
+
const result = normalizeToolResult("demo", rawResult);
|
|
122
|
+
|
|
123
|
+
assert.equal(result.ok, false);
|
|
124
|
+
assert.equal(result.status, "failed");
|
|
125
|
+
assert.equal(result.error, "Invalid tool response for demo");
|
|
126
|
+
assert.deepEqual(result.rawResult, rawResult);
|
|
127
|
+
});
|