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 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 caption are derived from the artifact (its `delivery` hint, `kind`, and stored name); internal local paths are never exposed. 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.1.10",
3
+ "version": "4.1.14",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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({ caption, output, method }) {
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({ caption, output: { fileName }, method: resolvedMethod });
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, caption, and filename are derived from the artifact (its delivery hint, kind, and stored name); internal local paths are never exposed. Set `caption` for a visible label, or `method` to override the delivery method. The artifact is not deleted.",
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 buildPrompt({ ctx, artifact, transcript, toolResult }) {
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?.path) parts.push(`artifactPath: ${artifact.path}`);
64
- if (artifact?.id) parts.push(`artifactId: ${artifact.id}`);
65
- if (artifact?.mimeType) parts.push(`mimeType: ${artifact.mimeType}`);
66
- if (artifact?.kind) parts.push(`kind: ${artifact.kind}`);
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
- task.payload.prompt ? `text: ${task.payload.prompt}` : null
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
- parts.push(`artifactPath: ${artifact.path || ""}`);
106
- parts.push(`artifactId: ${artifact.id}`);
107
- parts.push(`mimeType: ${artifact.mimeType}`);
108
- parts.push(`kind: ${artifact.kind}`);
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
+ });