arisa 4.2.8 → 4.3.2
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 +15 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +14 -7
- package/src/core/agent/model-selection.js +36 -0
- package/src/core/agent/pi-runtime.js +8 -0
- package/src/core/artifacts/artifact-store.js +32 -2
- package/src/core/config/config-defaults.js +33 -0
- package/src/core/config/config-store.js +4 -3
- package/src/core/tools/daemon-health.js +185 -0
- package/src/core/tools/daemon-policy.js +10 -0
- package/src/core/tools/daemon-processes.js +274 -44
- package/src/core/tools/daemon-runtime.js +264 -49
- package/src/runtime/bootstrap.js +14 -35
- package/src/runtime/create-app.js +1 -1
- package/src/runtime/paths.js +37 -2
- package/src/runtime/tool-process-supervisor.js +38 -26
- package/src/transport/telegram/bot.js +114 -0
- package/src/transport/telegram/model-picker.js +25 -0
- package/src/transport/telegram/paged-inline-keyboard.js +30 -0
- package/test/artifact-store.test.js +39 -2
- package/test/daemon-catalog-conformance.test.js +28 -0
- package/test/daemon-runtime.test.js +220 -0
- package/test/model-selection.test.js +99 -0
- package/test-fixtures/fake-daemon.js +47 -0
|
@@ -3,9 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import { authorizeChat } from "./auth.js";
|
|
4
4
|
import { captureIncomingArtifact, formatLocationText } from "./media.js";
|
|
5
5
|
import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
|
|
6
|
+
import { buildModelPicker, parseModelPickerAction } from "./model-picker.js";
|
|
6
7
|
import { renderTelegramHtml } from "./text-format.js";
|
|
7
8
|
import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
|
|
8
9
|
import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
|
|
10
|
+
import { resolveChatModel, selectChatModel } from "../../core/agent/model-selection.js";
|
|
11
|
+
import { createPiRuntime, listProviderModels } from "../../core/agent/pi-runtime.js";
|
|
9
12
|
import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
|
|
10
13
|
|
|
11
14
|
const slowPromptNoticeMs = 300_000;
|
|
@@ -410,6 +413,49 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
410
413
|
return perChatState.get(chatId);
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
function getProviderModels() {
|
|
417
|
+
const runtime = createPiRuntime({
|
|
418
|
+
provider: config.pi.provider,
|
|
419
|
+
apiKey: config.pi.apiKey
|
|
420
|
+
});
|
|
421
|
+
return listProviderModels(config.pi.provider, runtime);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function showModelPicker(ctx, page = 0) {
|
|
425
|
+
const picker = buildModelPicker({
|
|
426
|
+
provider: config.pi.provider,
|
|
427
|
+
models: getProviderModels(),
|
|
428
|
+
selectedModelId: resolveChatModel(config, ctx.chat.id),
|
|
429
|
+
page,
|
|
430
|
+
pageSize: config.telegram.modelPickerPageSize
|
|
431
|
+
});
|
|
432
|
+
const extra = { reply_markup: picker.replyMarkup };
|
|
433
|
+
const messageId = ctx.callbackQuery?.message?.message_id;
|
|
434
|
+
if (messageId) {
|
|
435
|
+
return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
|
|
436
|
+
}
|
|
437
|
+
return ctx.reply(picker.text, extra);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function persistChatModel(chatId, model) {
|
|
441
|
+
const key = chatKey(chatId);
|
|
442
|
+
const hadSelections = Boolean(config.pi.chatModels);
|
|
443
|
+
const previousSelection = config.pi.chatModels?.[key];
|
|
444
|
+
selectChatModel(config, chatId, model);
|
|
445
|
+
try {
|
|
446
|
+
await saveConfig(config);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (previousSelection) {
|
|
449
|
+
config.pi.chatModels[key] = previousSelection;
|
|
450
|
+
} else {
|
|
451
|
+
delete config.pi.chatModels[key];
|
|
452
|
+
if (!hadSelections) delete config.pi.chatModels;
|
|
453
|
+
}
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
agentManager.resetSession(chatId);
|
|
457
|
+
}
|
|
458
|
+
|
|
413
459
|
async function buildIncomingPrompt(ctx) {
|
|
414
460
|
const chatId = ctx.chat.id;
|
|
415
461
|
logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
|
|
@@ -684,6 +730,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
684
730
|
await handleNewCommand(ctx);
|
|
685
731
|
});
|
|
686
732
|
|
|
733
|
+
bot.command("model", async (ctx) => {
|
|
734
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
735
|
+
if (!auth.ok) return;
|
|
736
|
+
await showModelPicker(ctx);
|
|
737
|
+
});
|
|
738
|
+
|
|
687
739
|
bot.command("auth", async (ctx) => {
|
|
688
740
|
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
689
741
|
if (!auth.ok) return;
|
|
@@ -717,6 +769,67 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
717
769
|
}
|
|
718
770
|
});
|
|
719
771
|
|
|
772
|
+
bot.on("callback_query:data", async (ctx, next) => {
|
|
773
|
+
const action = parseModelPickerAction(ctx.callbackQuery.data);
|
|
774
|
+
if (!action) return next();
|
|
775
|
+
if (action.type === "noop") {
|
|
776
|
+
await ctx.answerCallbackQuery();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
|
|
781
|
+
if (!auth.ok) {
|
|
782
|
+
await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
if (action.type === "page") {
|
|
788
|
+
await showModelPicker(ctx, action.value);
|
|
789
|
+
await ctx.answerCallbackQuery();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (getChatState(ctx.chat.id).processing) {
|
|
794
|
+
await ctx.answerCallbackQuery({
|
|
795
|
+
text: "Wait for the current response before changing models.",
|
|
796
|
+
show_alert: true
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const models = getProviderModels();
|
|
802
|
+
const model = models[action.value];
|
|
803
|
+
if (!model) {
|
|
804
|
+
await ctx.answerCallbackQuery({
|
|
805
|
+
text: "This model list is no longer current. Run /model again.",
|
|
806
|
+
show_alert: true
|
|
807
|
+
});
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const currentModelId = resolveChatModel(config, ctx.chat.id);
|
|
812
|
+
if (model.id === currentModelId) {
|
|
813
|
+
await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
await persistChatModel(ctx.chat.id, model);
|
|
818
|
+
await ctx.api.editMessageText(
|
|
819
|
+
ctx.chat.id,
|
|
820
|
+
ctx.callbackQuery.message.message_id,
|
|
821
|
+
`Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
|
|
822
|
+
);
|
|
823
|
+
await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
|
|
824
|
+
} catch (error) {
|
|
825
|
+
logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
|
|
826
|
+
await ctx.answerCallbackQuery({
|
|
827
|
+
text: "Could not change the model.",
|
|
828
|
+
show_alert: true
|
|
829
|
+
}).catch(() => {});
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
720
833
|
bot.on("message", async (ctx) => {
|
|
721
834
|
const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
|
|
722
835
|
if (!auth.ok) return;
|
|
@@ -753,6 +866,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
753
866
|
config.telegram.chatMeta ||= {};
|
|
754
867
|
await bot.api.setMyCommands([
|
|
755
868
|
{ command: "new", description: "Start a new chat context" },
|
|
869
|
+
{ command: "model", description: "Choose the model for this chat" },
|
|
756
870
|
{ command: "auth", description: "Show Pi authentication status" }
|
|
757
871
|
]);
|
|
758
872
|
if (!taskTimer) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatPiModelOption } from "../../core/agent/pi-runtime.js";
|
|
2
|
+
import { buildPagedInlineKeyboard } from "./paged-inline-keyboard.js";
|
|
3
|
+
|
|
4
|
+
export function parseModelPickerAction(data) {
|
|
5
|
+
if (data === "noop:page") return { type: "noop", value: null };
|
|
6
|
+
const match = /^(model|model-page):(\d+)$/.exec(String(data || ""));
|
|
7
|
+
if (!match) return null;
|
|
8
|
+
return {
|
|
9
|
+
type: match[1] === "model" ? "select" : "page",
|
|
10
|
+
value: Number(match[2])
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildModelPicker({ provider, models, selectedModelId, page, pageSize }) {
|
|
15
|
+
if (!models.length) {
|
|
16
|
+
throw new Error(`No models available for provider ${provider}`);
|
|
17
|
+
}
|
|
18
|
+
const items = models.map((model) => ({
|
|
19
|
+
text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
text: `Current model: ${provider}/${selectedModelId}\nSelect a model for this chat:`,
|
|
23
|
+
replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function requirePositiveInteger(value, name) {
|
|
2
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
3
|
+
throw new Error(`${name} must be a positive integer`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildPagedInlineKeyboard(action, items, { page = 0, pageSize }) {
|
|
8
|
+
requirePositiveInteger(pageSize, "pageSize");
|
|
9
|
+
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
|
|
10
|
+
const currentPage = Math.max(0, Math.min(pageCount - 1, page));
|
|
11
|
+
const startIndex = currentPage * pageSize;
|
|
12
|
+
const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
|
|
13
|
+
text: item.text,
|
|
14
|
+
callback_data: `${action}:${startIndex + index}`
|
|
15
|
+
}]));
|
|
16
|
+
|
|
17
|
+
if (pageCount > 1) {
|
|
18
|
+
const navigation = [];
|
|
19
|
+
if (currentPage > 0) {
|
|
20
|
+
navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
|
|
21
|
+
}
|
|
22
|
+
navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
|
|
23
|
+
if (currentPage < pageCount - 1) {
|
|
24
|
+
navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
|
|
25
|
+
}
|
|
26
|
+
rows.push(navigation);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { inline_keyboard: rows };
|
|
30
|
+
}
|
|
@@ -78,12 +78,12 @@ test("creates generated file artifacts", async () => {
|
|
|
78
78
|
source: { type: "assistant" }
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
assert.equal(await readFile(artifact.path, "utf8"), "# Hello\n");
|
|
81
|
+
assert.equal(await readFile(artifact.path, "utf8"), "\ufeff# Hello\n");
|
|
82
82
|
assert.equal(artifact.kind, "document");
|
|
83
83
|
assert.equal(artifact.mimeType, "text/markdown");
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("writes generated text file artifacts as UTF-8", async () => {
|
|
86
|
+
test("writes generated text file artifacts as BOM UTF-8", async () => {
|
|
87
87
|
await resetHome();
|
|
88
88
|
const content = "# Español\nÑandú\n";
|
|
89
89
|
const artifact = await new ArtifactStore().forChat("chat-1").createGeneratedFile({
|
|
@@ -94,6 +94,43 @@ test("writes generated text file artifacts as UTF-8", async () => {
|
|
|
94
94
|
source: { type: "assistant" }
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
assert.deepEqual(await readFile(artifact.path), Buffer.from(`\ufeff${content}`, "utf8"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("normalizes copied generated text files to BOM UTF-8", async () => {
|
|
101
|
+
await resetHome();
|
|
102
|
+
const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
|
|
103
|
+
const originalPath = path.join(originalDir, "report.json");
|
|
104
|
+
const content = "{\"message\":\"Español\"}\n";
|
|
105
|
+
await writeFile(originalPath, content, "utf8");
|
|
106
|
+
|
|
107
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
|
|
108
|
+
originalPath,
|
|
109
|
+
fileName: "report.json",
|
|
110
|
+
kind: "document",
|
|
111
|
+
mimeType: "application/json",
|
|
112
|
+
source: { type: "tool", toolName: "reporter" }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(await readFile(artifact.path), Buffer.from(`\ufeff${content}`, "utf8"));
|
|
116
|
+
assert.equal(await readFile(originalPath, "utf8"), content);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("does not duplicate an existing UTF-8 BOM", async () => {
|
|
120
|
+
await resetHome();
|
|
121
|
+
const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
|
|
122
|
+
const originalPath = path.join(originalDir, "report.txt");
|
|
123
|
+
const content = "\ufeffAlready marked\n";
|
|
124
|
+
await writeFile(originalPath, content, "utf8");
|
|
125
|
+
|
|
126
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
|
|
127
|
+
originalPath,
|
|
128
|
+
fileName: "report.txt",
|
|
129
|
+
kind: "document",
|
|
130
|
+
mimeType: "text/plain",
|
|
131
|
+
source: { type: "tool", toolName: "reporter" }
|
|
132
|
+
});
|
|
133
|
+
|
|
97
134
|
assert.deepEqual(await readFile(artifact.path), Buffer.from(content, "utf8"));
|
|
98
135
|
});
|
|
99
136
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
const expected = {
|
|
9
|
+
"whispermix-transcribe": { scope: "global", autoStart: false },
|
|
10
|
+
"whatsapp-web": { scope: "chat", autoStart: false },
|
|
11
|
+
"roster-sites": { scope: "global", autoStart: true },
|
|
12
|
+
"turn-server": { scope: "global", autoStart: true },
|
|
13
|
+
"signaling-server": { scope: "global", autoStart: true }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (const [toolName, daemon] of Object.entries(expected)) {
|
|
17
|
+
test(`${toolName} declares the managed daemon contract`, async () => {
|
|
18
|
+
const toolDir = path.join(repositoryRoot, "tools", toolName);
|
|
19
|
+
const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
|
|
20
|
+
const source = await readFile(path.join(toolDir, manifest.entry), "utf8");
|
|
21
|
+
|
|
22
|
+
assert.equal(manifest.daemon.scope, daemon.scope);
|
|
23
|
+
assert.equal(manifest.daemon.autoStart, daemon.autoStart);
|
|
24
|
+
assert.equal(manifest.daemon.health, "internal");
|
|
25
|
+
assert.match(source, /createDaemonRuntime/);
|
|
26
|
+
assert.match(source, /healthCheck/);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, 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
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-daemon-test-"));
|
|
9
|
+
process.env.ARISA_HOME = homeDir;
|
|
10
|
+
|
|
11
|
+
const policy = {
|
|
12
|
+
supervisorIntervalMs: 20,
|
|
13
|
+
heartbeatIntervalMs: 20,
|
|
14
|
+
heartbeatStaleMs: 150,
|
|
15
|
+
healthIntervalMs: 100,
|
|
16
|
+
healthTimeoutMs: 1_000,
|
|
17
|
+
healthRetryLimit: 1,
|
|
18
|
+
healthRetryBackoffMs: 10,
|
|
19
|
+
restartLimit: 2,
|
|
20
|
+
restartBackoffMs: 20,
|
|
21
|
+
restartBackoffMaxMs: 40,
|
|
22
|
+
startupTimeoutMs: 2_000,
|
|
23
|
+
stopTimeoutMs: 300,
|
|
24
|
+
queuePollIntervalMs: 10
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
await mkdir(path.join(homeDir, "state"), { recursive: true });
|
|
28
|
+
await writeFile(
|
|
29
|
+
path.join(homeDir, "state", "config.json"),
|
|
30
|
+
`${JSON.stringify({ daemons: policy }, null, 2)}\n`,
|
|
31
|
+
"utf8"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
daemonPaths,
|
|
36
|
+
isProcessAlive,
|
|
37
|
+
readJson,
|
|
38
|
+
stopManagedDaemon
|
|
39
|
+
} = await import("../src/core/tools/daemon-processes.js");
|
|
40
|
+
const {
|
|
41
|
+
createDaemonRuntime,
|
|
42
|
+
isDaemonReady
|
|
43
|
+
} = await import("../src/core/tools/daemon-runtime.js");
|
|
44
|
+
const { createToolProcessSupervisor } = await import("../src/runtime/tool-process-supervisor.js");
|
|
45
|
+
|
|
46
|
+
const fixtureEntry = fileURLToPath(new URL("../test-fixtures/fake-daemon.js", import.meta.url));
|
|
47
|
+
|
|
48
|
+
function runtimeFor(scope, options = {}) {
|
|
49
|
+
return createDaemonRuntime({
|
|
50
|
+
toolName: "fake-daemon",
|
|
51
|
+
entryPath: fixtureEntry,
|
|
52
|
+
scope,
|
|
53
|
+
startupContext: options.startupContext || { health: "ok" },
|
|
54
|
+
autoStart: options.autoStart ?? false
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitFor(check, timeoutMs = 3_000) {
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
61
|
+
const result = await check();
|
|
62
|
+
if (result) return result;
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Condition was not met after ${timeoutMs}ms`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test.after(async () => {
|
|
69
|
+
for (const scope of [{ type: "global" }, { type: "chat", chatId: "101" }, { type: "chat", chatId: "202" }]) {
|
|
70
|
+
await stopManagedDaemon({ toolName: "fake-daemon", scope }).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("runs health through the queue before accepting jobs", async () => {
|
|
76
|
+
const runtime = runtimeFor({ type: "global" });
|
|
77
|
+
const output = await runtime.submit({ value: "hello" }, { timeoutMs: 1_000 });
|
|
78
|
+
assert.deepEqual(output, { echo: "hello" });
|
|
79
|
+
|
|
80
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
81
|
+
const pid = await runtime.getPid();
|
|
82
|
+
assert.equal(status.state, "ready");
|
|
83
|
+
assert.ok(status.heartbeatAt);
|
|
84
|
+
assert.ok(status.lastHealthSuccessAt);
|
|
85
|
+
assert.ok(status.lastSuccessfulJobAt);
|
|
86
|
+
assert.equal(isDaemonReady(status, pid, policy), true);
|
|
87
|
+
|
|
88
|
+
await assert.rejects(
|
|
89
|
+
() => runtime.submit({ action: "fail" }, { timeoutMs: 1_000 }),
|
|
90
|
+
/synthetic job failure/
|
|
91
|
+
);
|
|
92
|
+
const failedStatus = await readJson(runtime.paths.statusFile, {});
|
|
93
|
+
assert.equal(failedStatus.lastSuccessfulJobAt, status.lastSuccessfulJobAt);
|
|
94
|
+
assert.equal(failedStatus.lastError.phase, "job");
|
|
95
|
+
|
|
96
|
+
await runtime.stop();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("isolates daemon process files and context by chat scope", async () => {
|
|
100
|
+
const first = runtimeFor({ type: "chat", chatId: "101" });
|
|
101
|
+
const second = runtimeFor({ type: "chat", chatId: "202" });
|
|
102
|
+
await first.submit({ value: "first" }, { timeoutMs: 2_000 });
|
|
103
|
+
await second.submit({ value: "second" }, { timeoutMs: 2_000 });
|
|
104
|
+
|
|
105
|
+
const firstPid = await first.getPid();
|
|
106
|
+
const secondPid = await second.getPid();
|
|
107
|
+
assert.notEqual(firstPid, secondPid);
|
|
108
|
+
assert.notEqual(first.paths.root, second.paths.root);
|
|
109
|
+
|
|
110
|
+
const firstMeta = JSON.parse(await readFile(first.paths.metaFile, "utf8"));
|
|
111
|
+
const secondMeta = JSON.parse(await readFile(second.paths.metaFile, "utf8"));
|
|
112
|
+
assert.deepEqual(firstMeta.scope, { type: "chat", chatId: "101" });
|
|
113
|
+
assert.deepEqual(secondMeta.scope, { type: "chat", chatId: "202" });
|
|
114
|
+
assert.deepEqual(firstMeta.startupContext, { health: "ok" });
|
|
115
|
+
|
|
116
|
+
await Promise.all([first.stop(), second.stop()]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("supervisor ignores invalid chat directories and recovers valid daemons", async () => {
|
|
120
|
+
const invalidChatDir = path.join(homeDir, "chats", "24137857-c513-4f53-b39d-1b28f51ebbb6");
|
|
121
|
+
await mkdir(path.join(invalidChatDir, "state", "tools", "orphaned-tool"), { recursive: true });
|
|
122
|
+
|
|
123
|
+
const runtime = runtimeFor({ type: "chat", chatId: "202" }, {
|
|
124
|
+
autoStart: true,
|
|
125
|
+
startupContext: { health: "ok" }
|
|
126
|
+
});
|
|
127
|
+
let supervisor;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await runtime.submit({ value: "before" }, { timeoutMs: 1_000 });
|
|
131
|
+
const oldPid = await runtime.getPid();
|
|
132
|
+
process.kill(oldPid, "SIGKILL");
|
|
133
|
+
await waitFor(() => !isProcessAlive(oldPid));
|
|
134
|
+
|
|
135
|
+
supervisor = createToolProcessSupervisor({ policy });
|
|
136
|
+
await supervisor.start();
|
|
137
|
+
|
|
138
|
+
const newPid = await waitFor(async () => {
|
|
139
|
+
const pid = await runtime.getPid();
|
|
140
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
141
|
+
return pid && pid !== oldPid && status.state === "ready" ? pid : null;
|
|
142
|
+
});
|
|
143
|
+
assert.notEqual(newPid, oldPid);
|
|
144
|
+
} finally {
|
|
145
|
+
await supervisor?.stop();
|
|
146
|
+
await runtime.stop().catch(() => {});
|
|
147
|
+
await rm(invalidChatDir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("rejects stale readiness even when the pid is alive", async () => {
|
|
152
|
+
const status = {
|
|
153
|
+
state: "ready",
|
|
154
|
+
heartbeatAt: new Date(Date.now() - policy.heartbeatStaleMs - 1).toISOString(),
|
|
155
|
+
lastHealthSuccessAt: new Date().toISOString()
|
|
156
|
+
};
|
|
157
|
+
assert.equal(isDaemonReady(status, process.pid, policy), false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("does not auto-start an intentionally stopped on-demand daemon", async () => {
|
|
161
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: false });
|
|
162
|
+
await runtime.submit({ value: "once" }, { timeoutMs: 2_000 });
|
|
163
|
+
await runtime.stop();
|
|
164
|
+
|
|
165
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
166
|
+
await supervisor.start();
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, policy.supervisorIntervalMs * 3));
|
|
168
|
+
assert.equal(isProcessAlive(await runtime.getPid()), false);
|
|
169
|
+
assert.equal((await readJson(runtime.paths.statusFile, {})).state, "stopped");
|
|
170
|
+
await supervisor.stop();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("external supervisor recovers internally before restarting", async () => {
|
|
174
|
+
const runtime = runtimeFor({ type: "global" }, {
|
|
175
|
+
autoStart: true,
|
|
176
|
+
startupContext: { health: "fail", recover: true }
|
|
177
|
+
});
|
|
178
|
+
await runtime.start();
|
|
179
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
180
|
+
await supervisor.start();
|
|
181
|
+
|
|
182
|
+
const status = await waitFor(async () => {
|
|
183
|
+
const current = await readJson(runtime.paths.statusFile, {});
|
|
184
|
+
return current.state === "ready" ? current : null;
|
|
185
|
+
});
|
|
186
|
+
assert.equal(status.state, "ready");
|
|
187
|
+
assert.deepEqual(
|
|
188
|
+
JSON.parse(await readFile(path.join(runtime.paths.root, "recovered.json"), "utf8")),
|
|
189
|
+
{ recovered: true }
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await supervisor.stop();
|
|
193
|
+
await runtime.stop();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("external supervisor recreates a dead process with the same context", async () => {
|
|
197
|
+
const scope = { type: "chat", chatId: "101" };
|
|
198
|
+
const runtime = runtimeFor(scope, {
|
|
199
|
+
autoStart: true,
|
|
200
|
+
startupContext: { health: "ok", marker: "preserved" }
|
|
201
|
+
});
|
|
202
|
+
await runtime.submit({ value: "before" }, { timeoutMs: 1_000 });
|
|
203
|
+
const oldPid = await runtime.getPid();
|
|
204
|
+
process.kill(oldPid, "SIGKILL");
|
|
205
|
+
await waitFor(() => !isProcessAlive(oldPid));
|
|
206
|
+
|
|
207
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
208
|
+
await supervisor.start();
|
|
209
|
+
const newPid = await waitFor(async () => {
|
|
210
|
+
const pid = (await readJson(daemonPaths({ toolName: "fake-daemon", scope }).pidFile, {})).pid;
|
|
211
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
212
|
+
return pid && pid !== oldPid && status.state === "ready" ? pid : null;
|
|
213
|
+
});
|
|
214
|
+
assert.notEqual(newPid, oldPid);
|
|
215
|
+
const meta = await readJson(runtime.paths.metaFile, {});
|
|
216
|
+
assert.deepEqual(meta.startupContext, { health: "ok", marker: "preserved" });
|
|
217
|
+
|
|
218
|
+
await supervisor.stop();
|
|
219
|
+
await runtime.stop();
|
|
220
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { resolveChatModel, selectChatModel } from "../src/core/agent/model-selection.js";
|
|
5
|
+
import { applyConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
6
|
+
import { getChatPiSessionsDir } from "../src/runtime/paths.js";
|
|
7
|
+
import { buildModelPicker, parseModelPickerAction } from "../src/transport/telegram/model-picker.js";
|
|
8
|
+
|
|
9
|
+
function createConfig() {
|
|
10
|
+
return {
|
|
11
|
+
telegram: {},
|
|
12
|
+
pi: {
|
|
13
|
+
provider: "openai-codex",
|
|
14
|
+
model: "gpt-default"
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("resolves the default model until a chat selects one", () => {
|
|
20
|
+
const config = createConfig();
|
|
21
|
+
|
|
22
|
+
assert.equal(resolveChatModel(config, 123), "gpt-default");
|
|
23
|
+
|
|
24
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" });
|
|
25
|
+
|
|
26
|
+
assert.equal(resolveChatModel(config, 123), "gpt-selected");
|
|
27
|
+
assert.equal(resolveChatModel(config, 456), "gpt-default");
|
|
28
|
+
assert.deepEqual(config.pi.chatModels["123"], {
|
|
29
|
+
provider: "openai-codex",
|
|
30
|
+
model: "gpt-selected",
|
|
31
|
+
sessionRevision: 1
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("starts a distinct persisted Pi session revision on every model change", () => {
|
|
36
|
+
const config = createConfig();
|
|
37
|
+
|
|
38
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" });
|
|
39
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" });
|
|
40
|
+
|
|
41
|
+
assert.equal(config.pi.chatModels["123"].sessionRevision, 2);
|
|
42
|
+
assert.equal(
|
|
43
|
+
getChatPiSessionsDir(123, config.pi.chatModels["123"].sessionRevision),
|
|
44
|
+
path.join(getChatPiSessionsDir(123), "2")
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ignores a chat selection from a different active provider", () => {
|
|
49
|
+
const config = createConfig();
|
|
50
|
+
config.pi.chatModels = {
|
|
51
|
+
123: { provider: "anthropic", model: "claude-selected" }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
assert.equal(resolveChatModel(config, 123), "gpt-default");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects selecting a model outside the active provider", () => {
|
|
58
|
+
const config = createConfig();
|
|
59
|
+
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => selectChatModel(config, 123, { provider: "anthropic", id: "claude-selected" }),
|
|
62
|
+
/active provider is openai-codex/
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("builds a paged model picker and marks the current model", () => {
|
|
67
|
+
const models = [
|
|
68
|
+
{ provider: "openai-codex", id: "gpt-a", reasoning: false, input: ["text"] },
|
|
69
|
+
{ provider: "openai-codex", id: "gpt-b", reasoning: true, input: ["text", "image"] },
|
|
70
|
+
{ provider: "openai-codex", id: "gpt-c", reasoning: false, input: ["text"] }
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const picker = buildModelPicker({
|
|
74
|
+
provider: "openai-codex",
|
|
75
|
+
models,
|
|
76
|
+
selectedModelId: "gpt-b",
|
|
77
|
+
page: 0,
|
|
78
|
+
pageSize: 2
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.match(picker.text, /openai-codex\/gpt-b/);
|
|
82
|
+
assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "model:0");
|
|
83
|
+
assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ gpt-b \[reasoning, image\]$/);
|
|
84
|
+
assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("parses only model picker callback data", () => {
|
|
88
|
+
assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
|
|
89
|
+
assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
|
|
90
|
+
assert.deepEqual(parseModelPickerAction("noop:page"), { type: "noop", value: null });
|
|
91
|
+
assert.equal(parseModelPickerAction("provider:1"), null);
|
|
92
|
+
assert.equal(parseModelPickerAction("model:-1"), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("centralizes the model picker page size in Telegram config defaults", () => {
|
|
96
|
+
const config = applyConfigDefaults(createConfig());
|
|
97
|
+
|
|
98
|
+
assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
|
|
99
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import {
|
|
3
|
+
readDaemonLaunchContext,
|
|
4
|
+
writeJson
|
|
5
|
+
} from "../src/core/tools/daemon-processes.js";
|
|
6
|
+
import { createDaemonRuntime } from "../src/core/tools/daemon-runtime.js";
|
|
7
|
+
|
|
8
|
+
const toolName = "fake-daemon";
|
|
9
|
+
const entryPath = fileURLToPath(import.meta.url);
|
|
10
|
+
const launch = await readDaemonLaunchContext({ expectedToolName: toolName });
|
|
11
|
+
const runtime = createDaemonRuntime({
|
|
12
|
+
toolName,
|
|
13
|
+
entryPath,
|
|
14
|
+
scope: launch.scope,
|
|
15
|
+
startupContext: launch.startupContext,
|
|
16
|
+
autoStart: launch.autoStart
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function healthCheck() {
|
|
20
|
+
if (launch.startupContext.health === "fail") {
|
|
21
|
+
throw new Error("synthetic health failure");
|
|
22
|
+
}
|
|
23
|
+
if (launch.startupContext.health === "hang") {
|
|
24
|
+
await new Promise(() => {});
|
|
25
|
+
}
|
|
26
|
+
return { message: "synthetic health passed" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function recover() {
|
|
30
|
+
if (!launch.startupContext.recover) return false;
|
|
31
|
+
await writeJson(`${runtime.paths.root}/recovered.json`, { recovered: true });
|
|
32
|
+
launch.startupContext.health = "ok";
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.argv[2] !== "daemon") {
|
|
37
|
+
throw new Error("fake daemon only supports the daemon command");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await runtime.workLoop({
|
|
41
|
+
healthCheck,
|
|
42
|
+
recover,
|
|
43
|
+
processJob: async (payload) => {
|
|
44
|
+
if (payload.action === "fail") throw new Error("synthetic job failure");
|
|
45
|
+
return { echo: payload.value ?? null };
|
|
46
|
+
}
|
|
47
|
+
});
|