arisa 4.1.10 → 4.1.14
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 +5 -6
- package/src/transport/telegram/bot.js +33 -10
- package/src/transport/telegram/media.js +1 -1
- 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/telegram-text-artifact.test.js +81 -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
|
@@ -57,6 +57,8 @@ async function promptAndThrowOnAssistantError(session, prompt) {
|
|
|
57
57
|
|
|
58
58
|
function inferDeliveryMethod(artifact) {
|
|
59
59
|
if (artifact.kind === "audio" || (artifact.mimeType || "").startsWith("audio/")) return "audio";
|
|
60
|
+
if (artifact.kind === "image" || (artifact.mimeType || "").startsWith("image/")) return "photo";
|
|
61
|
+
if (artifact.kind === "video" || (artifact.mimeType || "").startsWith("video/")) return "video";
|
|
60
62
|
return "document";
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -65,18 +67,15 @@ function containsAbsolutePath(value) {
|
|
|
65
67
|
return /(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(value);
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
function resolveMediaCaption(
|
|
70
|
+
export function resolveMediaCaption(caption) {
|
|
69
71
|
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
72
|
return undefined;
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
async function deliverArtifactToChat({ artifact, telegram, caption, method, logger }) {
|
|
77
76
|
const resolvedMethod = method || artifact.metadata?.delivery?.method || inferDeliveryMethod(artifact);
|
|
78
77
|
const fileName = path.basename(artifact.path);
|
|
79
|
-
const resolvedCaption = resolveMediaCaption(
|
|
78
|
+
const resolvedCaption = resolveMediaCaption(caption);
|
|
80
79
|
logger?.log("agent", `deliver artifact ${artifact.id} as ${resolvedMethod}`);
|
|
81
80
|
await telegram.sendMedia(artifact.path, { method: resolvedMethod, caption: resolvedCaption, filename: fileName });
|
|
82
81
|
return { method: resolvedMethod, fileName, artifactId: artifact.id };
|
|
@@ -428,7 +427,7 @@ export class AgentManager {
|
|
|
428
427
|
defineTool({
|
|
429
428
|
name: "send_artifact",
|
|
430
429
|
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
|
|
430
|
+
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
431
|
parameters: Type.Object({
|
|
433
432
|
artifactId: Type.String(),
|
|
434
433
|
caption: Type.Optional(Type.String()),
|
|
@@ -48,7 +48,23 @@ function getIncomingMessageText(message) {
|
|
|
48
48
|
return message?.text || message?.caption || "";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function baseMimeType(mimeType = "") {
|
|
52
|
+
return mimeType.split(";")[0].trim().toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isInlineTextArtifact(artifact, messageText) {
|
|
56
|
+
return artifact?.kind === "text"
|
|
57
|
+
&& baseMimeType(artifact.mimeType) === "text/plain"
|
|
58
|
+
&& typeof artifact.text === "string"
|
|
59
|
+
&& artifact.text === messageText;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function shouldIncludeArtifactReference({ artifact, messageText = "" } = {}) {
|
|
63
|
+
if (!artifact) return false;
|
|
64
|
+
return !isInlineTextArtifact(artifact, messageText);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildPrompt({ ctx, artifact, transcript, toolResult }) {
|
|
52
68
|
const parts = [
|
|
53
69
|
`Incoming Telegram message.`,
|
|
54
70
|
`chatId: ${ctx.chat.id}`,
|
|
@@ -60,10 +76,12 @@ function buildPrompt({ ctx, artifact, transcript, toolResult }) {
|
|
|
60
76
|
const messageText = getIncomingMessageText(ctx.message);
|
|
61
77
|
if (messageText) parts.push(`text: ${messageText}`);
|
|
62
78
|
parts.push(...quotedMessageSummary(ctx.message?.reply_to_message));
|
|
63
|
-
if (artifact
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
if (shouldIncludeArtifactReference({ artifact, messageText })) {
|
|
80
|
+
if (artifact?.path) parts.push(`artifactPath: ${artifact.path}`);
|
|
81
|
+
if (artifact?.id) parts.push(`artifactId: ${artifact.id}`);
|
|
82
|
+
if (artifact?.mimeType) parts.push(`mimeType: ${artifact.mimeType}`);
|
|
83
|
+
if (artifact?.kind) parts.push(`kind: ${artifact.kind}`);
|
|
84
|
+
}
|
|
67
85
|
if (transcript) {
|
|
68
86
|
parts.push(`transcriptArtifactId: ${transcript.id}`);
|
|
69
87
|
parts.push(`transcriptText: ${transcript.text}`);
|
|
@@ -91,21 +109,24 @@ function buildNewSessionPrompt(ctx) {
|
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger }) {
|
|
112
|
+
const taskText = task.payload.prompt || "";
|
|
94
113
|
const parts = [
|
|
95
114
|
"Scheduled task fired.",
|
|
96
115
|
`taskId: ${task.id}`,
|
|
97
116
|
`chatId: ${task.payload.chatId}`,
|
|
98
|
-
|
|
117
|
+
taskText ? `text: ${taskText}` : null
|
|
99
118
|
];
|
|
100
119
|
|
|
101
120
|
if (task.payload.artifactId) {
|
|
102
121
|
const chatArtifactStore = artifactStore.forChat(task.payload.chatId);
|
|
103
122
|
const artifact = await chatArtifactStore.get(task.payload.artifactId);
|
|
104
123
|
if (artifact) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
if (shouldIncludeArtifactReference({ artifact, messageText: taskText })) {
|
|
125
|
+
parts.push(`artifactPath: ${artifact.path || ""}`);
|
|
126
|
+
parts.push(`artifactId: ${artifact.id}`);
|
|
127
|
+
parts.push(`mimeType: ${artifact.mimeType}`);
|
|
128
|
+
parts.push(`kind: ${artifact.kind}`);
|
|
129
|
+
}
|
|
109
130
|
|
|
110
131
|
const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
|
|
111
132
|
artifact,
|
|
@@ -431,6 +452,8 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
431
452
|
const input = new InputFile(filePath, filename || undefined);
|
|
432
453
|
if (method === "voice") return bot.api.sendVoice(chatId, input, { caption });
|
|
433
454
|
if (method === "document") return bot.api.sendDocument(chatId, input, { caption });
|
|
455
|
+
if (method === "photo" || method === "image") return bot.api.sendPhoto(chatId, input, { caption });
|
|
456
|
+
if (method === "video") return bot.api.sendVideo(chatId, input, { caption });
|
|
434
457
|
return bot.api.sendAudio(chatId, input, { caption });
|
|
435
458
|
}
|
|
436
459
|
};
|
|
@@ -128,7 +128,7 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
|
|
|
128
128
|
return store.createText({
|
|
129
129
|
text: ctx.message.text,
|
|
130
130
|
source: baseSource,
|
|
131
|
-
metadata: {}
|
|
131
|
+
metadata: { visibility: "internal", representation: "inline-message" }
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -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
|
+
});
|