arisa 5.1.24 → 5.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -3
- package/src/core/agent/agent-manager.js +132 -70
- package/src/core/agent/pi-runtime.js +0 -8
- package/src/core/agent/system-shell-tool.js +13 -2
- package/src/core/artifacts/artifact-store.js +17 -18
- package/src/core/config/config-defaults.js +2 -2
- package/src/core/conversation/session-seed-store.js +85 -0
- package/src/core/tools/ipc-client.js +0 -2
- package/src/core/tools/tool-output-materializer.js +41 -0
- package/src/core/tools/tool-registry.js +96 -23
- package/src/official-tools.lock.json +145 -93
- package/src/runtime/doctor.js +1 -4
- package/src/runtime/headless-tool-executor.js +2 -32
- package/src/runtime/paths.js +7 -1
- package/src/runtime/restart-receipt.js +90 -0
- package/src/transport/telegram/bot.js +390 -1034
- package/src/transport/telegram/chat-queue.js +132 -0
- package/src/transport/telegram/media.js +2 -2
- package/src/transport/telegram/model-callback.js +211 -0
- package/src/transport/telegram/model-controls.js +164 -0
- package/src/transport/telegram/prompt-builders.js +372 -0
- package/src/transport/telegram/task-dispatcher.js +94 -0
- package/src/transport/telegram/update-command.js +1 -1
- package/src/transport/telegram/workspace-group.js +83 -0
- package/test/agent-tool-policy.test.js +7 -1
- package/test/context-and-task-bounds.test.js +9 -7
- package/test/doctor.test.js +2 -4
- package/test/model-selection.test.js +47 -1
- package/test/official-tool-dependencies.test.js +2 -0
- package/test/paths.test.js +4 -4
- package/test/restart-receipt.test.js +39 -0
- package/test/session-start-operational-notes.test.js +47 -0
- package/test/telegram-prompt-builders.test.js +33 -0
- package/test/telegram-task-dispatcher.test.js +102 -0
- package/test/telegram-workspace-group.test.js +76 -0
- package/test/tool-registry-run.test.js +62 -0
- package/test/topic-initialization.test.js +66 -0
- package/src/core/conversation/conversation-history-store.js +0 -142
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
parseSpeedPickerAction,
|
|
28
28
|
reverseModelOrder
|
|
29
29
|
} from "../src/transport/telegram/model-picker.js";
|
|
30
|
-
import { closeModelPicker } from "../src/transport/telegram/
|
|
30
|
+
import { closeModelPicker, createTelegramModelCallbackHandler } from "../src/transport/telegram/model-callback.js";
|
|
31
31
|
|
|
32
32
|
function createConfig() {
|
|
33
33
|
return applyConfigDefaults({
|
|
@@ -221,6 +221,52 @@ test("closes the picker after selecting the already active model and effort", as
|
|
|
221
221
|
]);
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
test("model callback handler delegates unrelated callbacks", async () => {
|
|
225
|
+
let delegated = false;
|
|
226
|
+
const handler = createTelegramModelCallbackHandler({
|
|
227
|
+
config: createConfig(),
|
|
228
|
+
authorizeContext: async () => { throw new Error("must not authorize"); },
|
|
229
|
+
contextRoute: () => ({ sessionId: "123" }),
|
|
230
|
+
getChatState: () => ({ processing: false }),
|
|
231
|
+
logger: null
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await handler({ callbackQuery: { data: "other:action" } }, async () => { delegated = true; });
|
|
235
|
+
assert.equal(delegated, true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("model callback handler persists a non-reasoning model selection", async () => {
|
|
239
|
+
const config = createConfig();
|
|
240
|
+
const calls = [];
|
|
241
|
+
const handler = createTelegramModelCallbackHandler({
|
|
242
|
+
config,
|
|
243
|
+
authorizeContext: async () => ({ ok: true }),
|
|
244
|
+
contextRoute: () => ({ sessionId: "123" }),
|
|
245
|
+
getChatState: () => ({ processing: false }),
|
|
246
|
+
getProviderModels: async () => [{ provider: "openai-codex", id: "gpt-next", reasoning: false }],
|
|
247
|
+
showModelPicker: async () => {},
|
|
248
|
+
showEffortPicker: async () => {},
|
|
249
|
+
persistChatModel: async (...args) => calls.push(["persist", ...args]),
|
|
250
|
+
persistChatEffort: async () => {},
|
|
251
|
+
persistChatSpeed: async () => {},
|
|
252
|
+
logger: null
|
|
253
|
+
});
|
|
254
|
+
const ctx = {
|
|
255
|
+
chat: { id: 123 },
|
|
256
|
+
callbackQuery: { data: "model:0", message: { message_id: 456 } },
|
|
257
|
+
api: { async editMessageText(...args) { calls.push(["edit", ...args]); } },
|
|
258
|
+
async answerCallbackQuery(...args) { calls.push(["answer", ...args]); }
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
await handler(ctx, async () => {});
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(calls, [
|
|
264
|
+
["persist", "123", { provider: "openai-codex", id: "gpt-next", reasoning: false }, "off"],
|
|
265
|
+
["edit", 123, 456, "Model changed to openai-codex/gpt-next.\nA new chat context will start with your next message."],
|
|
266
|
+
["answer", { text: "Using gpt-next." }]
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
224
270
|
test("parses only model picker callback data", () => {
|
|
225
271
|
assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
|
|
226
272
|
assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
|
|
@@ -13,6 +13,8 @@ test("official orchestrators declare their hard tool dependencies", async () =>
|
|
|
13
13
|
"gmail-workspace": "^0.1.0"
|
|
14
14
|
});
|
|
15
15
|
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.2.0" });
|
|
16
|
+
assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
17
|
+
assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
|
|
16
18
|
assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
|
|
17
19
|
});
|
|
18
20
|
|
package/test/paths.test.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
chatsDir,
|
|
10
10
|
createIpcSocketPath,
|
|
11
11
|
getChatArtifactsDir,
|
|
12
|
-
|
|
12
|
+
getChatSessionSeedFile,
|
|
13
13
|
getChatToolConfigPath,
|
|
14
14
|
getChatToolUsageFile,
|
|
15
15
|
getChatToolStateDir,
|
|
@@ -25,10 +25,10 @@ test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
|
25
25
|
assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
test("keeps
|
|
28
|
+
test("keeps pending session seeds scoped below the chat state directory", () => {
|
|
29
29
|
assert.equal(
|
|
30
|
-
|
|
31
|
-
path.join(chatsDir, "chat-1", "state", "
|
|
30
|
+
getChatSessionSeedFile("chat-1"),
|
|
31
|
+
path.join(chatsDir, "chat-1", "state", "session-seed.jsonl")
|
|
32
32
|
);
|
|
33
33
|
});
|
|
34
34
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { access, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { cancelRestartReceipt, deliverRestartReceipt, prepareRestartReceipt } from "../src/runtime/restart-receipt.js";
|
|
7
|
+
|
|
8
|
+
const identity = async () => ({ version: "5.1.30", commit: "abc123" });
|
|
9
|
+
|
|
10
|
+
test("restart receipt returns to the originating Telegram topic exactly once", async () => {
|
|
11
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-restart-receipt-"));
|
|
12
|
+
const receiptFile = path.join(directory, "receipt.json");
|
|
13
|
+
try {
|
|
14
|
+
await prepareRestartReceipt({ transportChatId: -1001, threadId: 23 }, { reason: "test" }, { receiptFile, getIdentity: identity });
|
|
15
|
+
const sent = [];
|
|
16
|
+
const result = await deliverRestartReceipt((...args) => sent.push(args), { receiptFile, getIdentity: identity });
|
|
17
|
+
assert.equal(result.verified, true);
|
|
18
|
+
assert.deepEqual(sent[0].slice(0, 1), [-1001]);
|
|
19
|
+
assert.equal(sent[0][2].message_thread_id, 23);
|
|
20
|
+
assert.match(sent[0][1], /Restart completed\.\ntest\nArisa 5\.1\.30 is running/);
|
|
21
|
+
assert.match(sent[0][1], /at commit abc123/);
|
|
22
|
+
assert.equal(await deliverRestartReceipt(() => {}, { receiptFile, getIdentity: identity }), null);
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a failed handoff can cancel only its own restart receipt", async () => {
|
|
29
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-restart-cancel-"));
|
|
30
|
+
const receiptFile = path.join(directory, "receipt.json");
|
|
31
|
+
try {
|
|
32
|
+
const receipt = await prepareRestartReceipt({ transportChatId: 42 }, {}, { receiptFile, getIdentity: identity });
|
|
33
|
+
assert.equal(await cancelRestartReceipt("another-id", { receiptFile }), false);
|
|
34
|
+
assert.equal(await cancelRestartReceipt(receipt.id, { receiptFile }), true);
|
|
35
|
+
await assert.rejects(access(receiptFile));
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(directory, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
async function loadNotesWithHome(homeDir, notesPayload) {
|
|
12
|
+
await writeFile(
|
|
13
|
+
path.join(homeDir, "state", "session-start-operational-notes.json"),
|
|
14
|
+
JSON.stringify(notesPayload),
|
|
15
|
+
"utf8"
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const script = `
|
|
19
|
+
const mod = await import(${JSON.stringify(new URL("../src/core/agent/agent-manager.js", import.meta.url).href)});
|
|
20
|
+
process.stdout.write(JSON.stringify(mod.loadSessionStartOperationalNotes()));
|
|
21
|
+
`;
|
|
22
|
+
const { stdout } = await execFileAsync(process.execPath, ["--input-type=module", "--eval", script], {
|
|
23
|
+
env: { ...process.env, ARISA_HOME: homeDir }
|
|
24
|
+
});
|
|
25
|
+
return JSON.parse(stdout);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("loads bounded durable operational notes at session start", async (t) => {
|
|
29
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-operational-notes-"));
|
|
30
|
+
t.after(() => rm(homeDir, { recursive: true, force: true }));
|
|
31
|
+
await rm(path.join(homeDir, "state"), { recursive: true, force: true }).catch(() => {});
|
|
32
|
+
await mkdir(path.join(homeDir, "state"), { recursive: true });
|
|
33
|
+
|
|
34
|
+
const notes = await loadNotesWithHome(homeDir, {
|
|
35
|
+
notes: [
|
|
36
|
+
" Responde al owner en español por defecto. ",
|
|
37
|
+
{ text: "Incluye reportes CBPR y tareas programadas." },
|
|
38
|
+
"",
|
|
39
|
+
null
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(notes, [
|
|
44
|
+
"Responde al owner en español por defecto.",
|
|
45
|
+
"Incluye reportes CBPR y tareas programadas."
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildStartupMessage,
|
|
5
|
+
normalizeIncomingArtifact
|
|
6
|
+
} from "../src/transport/telegram/prompt-builders.js";
|
|
7
|
+
|
|
8
|
+
test("builds localized Telegram startup messages", () => {
|
|
9
|
+
assert.equal(buildStartupMessage({ languageCode: "es-AR" }), "Arisa esta en linea de nuevo.");
|
|
10
|
+
assert.equal(buildStartupMessage({ languageCode: "pt-BR" }), "Arisa esta online de novo.");
|
|
11
|
+
assert.equal(buildStartupMessage({ languageCode: "en" }), "Arisa is back online.");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("reports whether an incoming artifact required normalization", async () => {
|
|
15
|
+
const result = await normalizeIncomingArtifact({
|
|
16
|
+
artifact: { id: "voice-1", mimeType: "audio/ogg" },
|
|
17
|
+
toolRegistry: { list: () => [] },
|
|
18
|
+
chatArtifactStore: {},
|
|
19
|
+
chatId: "chat-1"
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
assert.equal(result.normalizationRequired, true);
|
|
23
|
+
assert.equal(result.transcript, null);
|
|
24
|
+
assert.match(result.toolResult.error, /No registered tool can normalize audio\/ogg to text\/plain/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("skips normalization metadata when there is no incoming artifact", async () => {
|
|
28
|
+
assert.deepEqual(await normalizeIncomingArtifact({}), {
|
|
29
|
+
transcript: null,
|
|
30
|
+
toolResult: null,
|
|
31
|
+
normalizationRequired: false
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createTelegramTaskDispatcher } from "../src/transport/telegram/task-dispatcher.js";
|
|
4
|
+
|
|
5
|
+
function createHarness(overrides = {}) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const taskStore = {
|
|
8
|
+
async fail(...args) { calls.push(["fail", ...args]); },
|
|
9
|
+
async complete(...args) { calls.push(["complete", ...args]); },
|
|
10
|
+
async claimDue() { return []; },
|
|
11
|
+
...overrides.taskStore
|
|
12
|
+
};
|
|
13
|
+
const dispatcher = createTelegramTaskDispatcher({
|
|
14
|
+
taskStore,
|
|
15
|
+
sendMessage: async (...args) => calls.push(["send", ...args]),
|
|
16
|
+
enqueueAsyncPrompt: async (input) => calls.push(["enqueue", input]),
|
|
17
|
+
artifactStore: { forChat() { throw new Error("unexpected artifact access"); } },
|
|
18
|
+
toolRegistry: {},
|
|
19
|
+
resourceNotes: { async get() { return ""; } },
|
|
20
|
+
agentManager: { async runTool(input) { calls.push(["runTool", input]); } },
|
|
21
|
+
logger: null,
|
|
22
|
+
...overrides.dependencies
|
|
23
|
+
});
|
|
24
|
+
return { calls, taskStore, dispatcher };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("dispatches an agent task into the Telegram prompt queue", async () => {
|
|
28
|
+
const { calls, dispatcher } = createHarness();
|
|
29
|
+
await dispatcher.dispatchTask({
|
|
30
|
+
id: "task-1",
|
|
31
|
+
kind: "agent_task",
|
|
32
|
+
payload: { chatId: 123, prompt: "do the thing", telegramContext: { messageThreadId: 9 } }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(calls[0][0], "enqueue");
|
|
36
|
+
assert.equal(calls[0][1].chatId, 123);
|
|
37
|
+
assert.match(calls[0][1].prompt, /taskId: task-1/);
|
|
38
|
+
assert.match(calls[0][1].prompt, /text: do the thing/);
|
|
39
|
+
assert.deepEqual(calls[1], ["complete", "task-1"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("acknowledges an agent event before queueing it", async () => {
|
|
43
|
+
const { calls, dispatcher } = createHarness();
|
|
44
|
+
await dispatcher.dispatchTask({
|
|
45
|
+
id: "event-1",
|
|
46
|
+
kind: "agent_event",
|
|
47
|
+
payload: { chatId: 123, prompt: "something happened", acknowledgement: "received" }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(calls[0], ["send", 123, "received"]);
|
|
51
|
+
assert.equal(calls[1][0], "enqueue");
|
|
52
|
+
assert.match(calls[1][1].prompt, /event: something happened/);
|
|
53
|
+
assert.match(calls[1][1].prompt, /return exactly NO_REPLY/);
|
|
54
|
+
assert.deepEqual(calls[2], ["complete", "event-1"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("runs poll tools headlessly and completes the checker task", async () => {
|
|
58
|
+
const { calls, dispatcher } = createHarness();
|
|
59
|
+
await dispatcher.dispatchTask({
|
|
60
|
+
id: "poll-1",
|
|
61
|
+
kind: "poll_tool",
|
|
62
|
+
payload: { chatId: 123, toolName: "checker", args: { cursor: "4" } }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(calls, [
|
|
66
|
+
["runTool", { name: "checker", request: { args: { cursor: "4" } }, chatId: 123 }],
|
|
67
|
+
["complete", "poll-1"]
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("fails malformed and unsupported tasks without enqueueing them", async () => {
|
|
72
|
+
const { calls, dispatcher } = createHarness();
|
|
73
|
+
await dispatcher.dispatchTask({ id: "bad-1", kind: "agent_task", payload: {} });
|
|
74
|
+
await dispatcher.dispatchTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(calls, [
|
|
77
|
+
["fail", "bad-1", "Task missing chatId: agent_task"],
|
|
78
|
+
["fail", "bad-2", "Unsupported task: other"]
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("due-task dispatch isolates failures between claimed tasks", async () => {
|
|
83
|
+
const tasks = [
|
|
84
|
+
{ id: "bad", kind: "agent_task", payload: { chatId: 123, prompt: "fail" } },
|
|
85
|
+
{ id: "good", kind: "poll_tool", payload: { chatId: 123, toolName: "checker" } }
|
|
86
|
+
];
|
|
87
|
+
const { calls, dispatcher } = createHarness({
|
|
88
|
+
taskStore: { async claimDue(limit) { calls.push(["claimDue", limit]); return tasks; } },
|
|
89
|
+
dependencies: {
|
|
90
|
+
enqueueAsyncPrompt: async () => { throw new Error("queue unavailable"); }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await dispatcher.dispatchDueTasks();
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(calls, [
|
|
97
|
+
["claimDue", 10],
|
|
98
|
+
["fail", "bad", "queue unavailable"],
|
|
99
|
+
["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
|
|
100
|
+
["complete", "good"]
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
resolveTelegramWorkspaceRoute,
|
|
5
|
+
topicSessionId,
|
|
6
|
+
verifyOwnerWorkspaceGroup
|
|
7
|
+
} from "../src/transport/telegram/workspace-group.js";
|
|
8
|
+
|
|
9
|
+
function api({ count = 2, ownerId = 42, botAdmin = true } = {}) {
|
|
10
|
+
return {
|
|
11
|
+
getChatMemberCount: async () => count,
|
|
12
|
+
getChatAdministrators: async () => [
|
|
13
|
+
{ status: "creator", user: { id: ownerId } },
|
|
14
|
+
...(botAdmin ? [{ status: "administrator", user: { id: 99 } }] : [])
|
|
15
|
+
],
|
|
16
|
+
getMe: async () => ({ id: 99 })
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("general topic reuses the owner session while other topics stay separate", () => {
|
|
21
|
+
assert.equal(topicSessionId({ ownerChatId: 42, groupChatId: -100123, threadId: 1 }), "42");
|
|
22
|
+
assert.equal(
|
|
23
|
+
topicSessionId({ ownerChatId: 42, groupChatId: -100123, threadId: 7 }),
|
|
24
|
+
"42--telegram-group-100123--topic-7"
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("general topic omits Telegram's non-addressable thread id for replies", async () => {
|
|
29
|
+
const route = await resolveTelegramWorkspaceRoute({
|
|
30
|
+
config: { telegram: { ownerWorkspaceGroups: { "-100123": { ownerChatId: 42, generalTopicId: 1 } } } },
|
|
31
|
+
api: api(),
|
|
32
|
+
ctx: {
|
|
33
|
+
chat: { id: -100123, type: "supergroup", is_forum: true },
|
|
34
|
+
from: { id: 42 },
|
|
35
|
+
message: { message_thread_id: 1 }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
assert.equal(route.sessionId, "42");
|
|
39
|
+
assert.equal(route.topicThreadId, 1);
|
|
40
|
+
assert.equal(route.threadId, null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("owner workspace gate accepts bot service events but still blocks a third member", async () => {
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
await verifyOwnerWorkspaceGroup({ api: api(), groupChatId: -100123, ownerChatId: 42, senderId: 99 }),
|
|
46
|
+
{ ok: true, memberCount: 2 }
|
|
47
|
+
);
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
await verifyOwnerWorkspaceGroup({ api: api({ count: 3 }), groupChatId: -100123, ownerChatId: 42, senderId: 99 }),
|
|
50
|
+
{ ok: false, reason: "member-count", memberCount: 3 }
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("workspace route shares owner scope and isolates topic session", async () => {
|
|
55
|
+
const config = {
|
|
56
|
+
telegram: {
|
|
57
|
+
ownerWorkspaceGroups: {
|
|
58
|
+
"-100123": { ownerChatId: 42, generalTopicId: 1 }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const route = await resolveTelegramWorkspaceRoute({
|
|
63
|
+
config,
|
|
64
|
+
api: api(),
|
|
65
|
+
ctx: {
|
|
66
|
+
chat: { id: -100123, type: "supergroup", is_forum: true },
|
|
67
|
+
from: { id: 42 },
|
|
68
|
+
message: { message_thread_id: 8 }
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
assert.equal(route.ok, true);
|
|
72
|
+
assert.equal(route.scopeChatId, 42);
|
|
73
|
+
assert.equal(route.transportChatId, -100123);
|
|
74
|
+
assert.equal(route.threadId, 8);
|
|
75
|
+
assert.equal(route.sessionId, "42--telegram-group-100123--topic-8");
|
|
76
|
+
});
|
|
@@ -89,6 +89,15 @@ for (const frame of frames) {
|
|
|
89
89
|
return dir;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
async function createHangingTool(name = "hanging-tool") {
|
|
93
|
+
const dir = await createFakeTool(name);
|
|
94
|
+
await writeFile(path.join(dir, "index.js"), `
|
|
95
|
+
process.on("SIGTERM", () => {});
|
|
96
|
+
setInterval(() => {}, 1_000);
|
|
97
|
+
`, "utf8");
|
|
98
|
+
return dir;
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
test("loads and lists installed tools from the user tools directory", async () => {
|
|
93
102
|
await resetHome();
|
|
94
103
|
await createFakeTool("fake-tool", {
|
|
@@ -117,6 +126,28 @@ test("loads and lists installed tools from the user tools directory", async () =
|
|
|
117
126
|
}]);
|
|
118
127
|
});
|
|
119
128
|
|
|
129
|
+
test("keeps the previous complete snapshot visible while a reload is in progress", async () => {
|
|
130
|
+
const registry = new ToolRegistry();
|
|
131
|
+
const previous = { name: "stable-tool" };
|
|
132
|
+
const replacement = { name: "replacement-tool" };
|
|
133
|
+
registry.tools = new Map([[previous.name, previous]]);
|
|
134
|
+
|
|
135
|
+
let releaseSnapshot;
|
|
136
|
+
registry.buildSnapshot = () => new Promise((resolve) => {
|
|
137
|
+
releaseSnapshot = () => resolve(new Map([[replacement.name, replacement]]));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const loading = registry.load();
|
|
141
|
+
for (let attempt = 0; attempt < 1_000; attempt += 1) {
|
|
142
|
+
assert.equal(registry.get(previous.name), previous);
|
|
143
|
+
}
|
|
144
|
+
releaseSnapshot();
|
|
145
|
+
await loading;
|
|
146
|
+
|
|
147
|
+
assert.equal(registry.get(previous.name), null);
|
|
148
|
+
assert.equal(registry.get(replacement.name), replacement);
|
|
149
|
+
});
|
|
150
|
+
|
|
120
151
|
test("lists optional semantic metadata with stable defaults", async () => {
|
|
121
152
|
await resetHome();
|
|
122
153
|
await createFakeTool("fake-tool");
|
|
@@ -234,6 +265,37 @@ test("rejects unknown tools", async () => {
|
|
|
234
265
|
);
|
|
235
266
|
});
|
|
236
267
|
|
|
268
|
+
test("terminates timed-out tool runs and requires a status check before retry", async () => {
|
|
269
|
+
await resetHome();
|
|
270
|
+
await createHangingTool();
|
|
271
|
+
const registry = new ToolRegistry({ runTimeoutMs: 20, killGraceMs: 20 });
|
|
272
|
+
await registry.load();
|
|
273
|
+
|
|
274
|
+
const result = await registry.run({
|
|
275
|
+
name: "hanging-tool",
|
|
276
|
+
chatId: "chat-1",
|
|
277
|
+
request: { args: {} }
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
assert.equal(result.ok, false);
|
|
281
|
+
assert.equal(result.status, "outcome_uncertain");
|
|
282
|
+
assert.equal(result.resolution.type, "status_check_required");
|
|
283
|
+
assert.equal(result.resolution.retry, false);
|
|
284
|
+
assert.match(result.error, /timed out after 20ms/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("terminates timed-out tool help processes", async () => {
|
|
288
|
+
await resetHome();
|
|
289
|
+
await createHangingTool();
|
|
290
|
+
const registry = new ToolRegistry({ helpTimeoutMs: 20, killGraceMs: 20 });
|
|
291
|
+
await registry.load();
|
|
292
|
+
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
() => registry.help("hanging-tool"),
|
|
295
|
+
/Tool help for hanging-tool timed out after 20ms/
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
237
299
|
test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only", async () => {
|
|
238
300
|
await resetHome();
|
|
239
301
|
await createStreamingTool();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { buildTopicInitializationHandoff, isProcessableTelegramMessage, startTelegramTyping } from "../src/transport/telegram/bot.js";
|
|
7
|
+
import { SessionSeedStore } from "../src/core/conversation/session-seed-store.js";
|
|
8
|
+
|
|
9
|
+
test("Telegram typing action stays inside the message topic", async () => {
|
|
10
|
+
const calls = [];
|
|
11
|
+
const stop = await startTelegramTyping({
|
|
12
|
+
chat: { id: "chat-1" },
|
|
13
|
+
message: { message_thread_id: 42 },
|
|
14
|
+
api: {
|
|
15
|
+
sendChatAction: async (chatId, action, options) => {
|
|
16
|
+
calls.push({ chatId, action, options });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
stop();
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(calls, [
|
|
23
|
+
{ chatId: "chat-1", action: "typing", options: { message_thread_id: 42 } }
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Telegram service messages do not become empty agent prompts", () => {
|
|
28
|
+
assert.equal(isProcessableTelegramMessage({ forum_topic_created: { name: "Stories" } }), false);
|
|
29
|
+
assert.equal(isProcessableTelegramMessage({ forum_topic_edited: { name: "Stories" } }), false);
|
|
30
|
+
assert.equal(isProcessableTelegramMessage({ text: "hello" }), true);
|
|
31
|
+
assert.equal(isProcessableTelegramMessage({ voice: { file_id: "voice" } }), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("topic initialization context is explicit and bounded to the topic", () => {
|
|
35
|
+
const handoff = buildTopicInitializationHandoff({ name: "Stories", context: "Draft first-person development stories for arisa.sh." });
|
|
36
|
+
assert.match(handoff, /Telegram topic: Stories/);
|
|
37
|
+
assert.match(handoff, /isolated conversation session/);
|
|
38
|
+
assert.match(handoff, /Draft first-person development stories/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a persisted session seed is consumed exactly once and can be replaced", async () => {
|
|
42
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-topic-seed-"));
|
|
43
|
+
const seedFile = (chatId) => path.join(directory, `${chatId}.jsonl`);
|
|
44
|
+
const legacyFile = (chatId) => path.join(directory, `${chatId}.legacy.jsonl`);
|
|
45
|
+
const store = new SessionSeedStore({ seedFile, legacyFile });
|
|
46
|
+
try {
|
|
47
|
+
await store.set("topic", "Stories context");
|
|
48
|
+
assert.equal(await store.consume("topic"), "Stories context");
|
|
49
|
+
assert.equal(await store.consume("topic"), "");
|
|
50
|
+
|
|
51
|
+
await store.set("topic", "Replacement context");
|
|
52
|
+
assert.equal(await store.consume("topic"), "Replacement context");
|
|
53
|
+
|
|
54
|
+
await writeFile(legacyFile("pending-legacy"), `${JSON.stringify({ kind: "seed", history: "Pending legacy context" })}\n`, "utf8");
|
|
55
|
+
assert.equal(await store.consume("pending-legacy"), "Pending legacy context");
|
|
56
|
+
|
|
57
|
+
await writeFile(legacyFile("legacy"), [
|
|
58
|
+
JSON.stringify({ kind: "seed", history: "Old portable context" }),
|
|
59
|
+
JSON.stringify({ kind: "turn", prompt: "stale", response: "stale" }),
|
|
60
|
+
""
|
|
61
|
+
].join("\n"), "utf8");
|
|
62
|
+
assert.equal(await store.consume("legacy"), "");
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(directory, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|