arisa 4.1.8 → 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 +5 -1
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +47 -52
- package/src/transport/telegram/bot.js +3 -3
- 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
|
@@ -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
|
+
});
|