arisa 4.3.4 → 5.0.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 +18 -17
- package/README.md +30 -9
- package/package.json +6 -2
- package/pnpm-workspace.yaml +1 -0
- package/src/core/agent/agent-manager.js +288 -29
- package/src/core/agent/auth-flow.js +12 -8
- package/src/core/agent/model-selection.js +54 -14
- package/src/core/agent/model-speed.js +59 -0
- package/src/core/config/config-defaults.js +56 -4
- package/src/core/config/config-store.js +5 -1
- package/src/core/conversation/conversation-history-store.js +142 -0
- package/src/core/tasks/task-store.js +16 -0
- package/src/core/tools/daemon-health.js +11 -2
- package/src/core/tools/daemon-processes.js +92 -2
- package/src/core/tools/daemon-runtime.js +4 -2
- package/src/core/tools/ipc-client.js +15 -3
- package/src/core/tools/tool-registry.js +27 -0
- package/src/index.js +61 -6
- package/src/runtime/arisa-capabilities.js +45 -1
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +47 -11
- package/src/runtime/doctor.js +307 -0
- package/src/runtime/log-viewer.js +165 -0
- package/src/runtime/paths.js +4 -1
- package/src/runtime/service-manager.js +106 -8
- package/src/runtime/tool-process-supervisor.js +107 -10
- package/src/transport/telegram/bot.js +533 -99
- package/src/transport/telegram/model-picker.js +28 -2
- package/test/agent-tool-policy.test.js +26 -1
- package/test/auth-flow.test.js +28 -2
- package/test/capabilities-security.test.js +37 -0
- package/test/context-and-task-bounds.test.js +279 -0
- package/test/daemon-runtime.test.js +130 -2
- package/test/dependency-warnings.test.js +17 -0
- package/test/doctor.test.js +90 -0
- package/test/log-viewer.test.js +90 -0
- package/test/model-selection.test.js +125 -2
- package/test/paths.test.js +8 -0
- package/test/pi-compaction.test.js +43 -0
- package/test/service-manager.test.js +234 -0
- package/test/task-store.test.js +31 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { formatDoctorReport, runDoctor } from "../src/runtime/doctor.js";
|
|
4
|
+
import { serviceEntryFile } from "../src/runtime/service-manager.js";
|
|
5
|
+
|
|
6
|
+
const doctorPolicy = {
|
|
7
|
+
contextInspectionTimeoutMs: 1_000,
|
|
8
|
+
contextWarningPercent: 70,
|
|
9
|
+
contextCriticalPercent: 90,
|
|
10
|
+
contextInefficientMinTokens: 32_000,
|
|
11
|
+
contextToolResultWarningPercent: 60,
|
|
12
|
+
contextSingleMessageWarningPercent: 50
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const daemonPolicy = {
|
|
16
|
+
healthTimeoutMs: 1_000,
|
|
17
|
+
stopTimeoutMs: 1_000
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function runtime(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
harness: "pi",
|
|
23
|
+
sessions: 1,
|
|
24
|
+
closingSessions: 0,
|
|
25
|
+
managedProcessIds: [],
|
|
26
|
+
contexts: [],
|
|
27
|
+
...overrides
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function run({ diagnostic = runtime(), processes = [], service = { running: false }, repairs = [] } = {}) {
|
|
32
|
+
const stopped = [];
|
|
33
|
+
const report = await runDoctor({
|
|
34
|
+
agentManager: { getRuntimeDiagnostic: async () => diagnostic },
|
|
35
|
+
toolProcessSupervisor: { repair: async () => repairs },
|
|
36
|
+
daemonPolicy,
|
|
37
|
+
doctorPolicy,
|
|
38
|
+
listProcesses: async () => processes,
|
|
39
|
+
serviceStatus: async () => service,
|
|
40
|
+
stopProcess: async (pid) => { stopped.push(pid); },
|
|
41
|
+
stopDaemon: async () => {},
|
|
42
|
+
unregisterDaemon: async () => {}
|
|
43
|
+
});
|
|
44
|
+
return { report, stopped };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("reports Pi context size and retained-content inefficiency", async () => {
|
|
48
|
+
const { report } = await run({
|
|
49
|
+
diagnostic: runtime({
|
|
50
|
+
contexts: [{
|
|
51
|
+
chatId: "42",
|
|
52
|
+
messages: 20,
|
|
53
|
+
estimatedTokens: 40_000,
|
|
54
|
+
toolResultPercent: 70,
|
|
55
|
+
largestMessagePercent: 10,
|
|
56
|
+
tokens: 80_000,
|
|
57
|
+
contextWindow: 100_000,
|
|
58
|
+
percent: 80
|
|
59
|
+
}]
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(report.contexts[0].level, "warning");
|
|
64
|
+
assert.match(report.attention.join("\n"), /80,000\/100,000 tokens/);
|
|
65
|
+
assert.match(report.attention.join("\n"), /tool results occupy 70\.0%/);
|
|
66
|
+
assert.match(formatDoctorReport(report), /Core: Pi, 1 active session/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("stops only a registered duplicate Arisa service with verified identity", async () => {
|
|
70
|
+
const duplicatePid = 321;
|
|
71
|
+
const { report, stopped } = await run({
|
|
72
|
+
processes: [{ pid: duplicatePid, command: `${process.execPath} ${serviceEntryFile} --service-runner` }],
|
|
73
|
+
service: { running: true, pid: duplicatePid }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(stopped, [duplicatePid]);
|
|
77
|
+
assert.match(report.repairs.join("\n"), /Stopped duplicate Arisa service process 321/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("requires complete positive doctor context policy", async () => {
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
runDoctor({
|
|
83
|
+
agentManager: { getRuntimeDiagnostic: async () => runtime() },
|
|
84
|
+
toolProcessSupervisor: { repair: async () => [] },
|
|
85
|
+
daemonPolicy,
|
|
86
|
+
doctorPolicy: { ...doctorPolicy, contextInspectionTimeoutMs: 0 }
|
|
87
|
+
}),
|
|
88
|
+
/positive contextInspectionTimeoutMs/
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { appendFile, mkdir, mkdtemp, readFile, rename, 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
|
+
import { followLogFile, readRecentLogLines } from "../src/runtime/log-viewer.js";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
async function waitFor(check, timeoutMs = 1_000) {
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
while (!check()) {
|
|
15
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
16
|
+
throw new Error(`Condition was not met after ${timeoutMs}ms`);
|
|
17
|
+
}
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("reads only the requested most recent log lines", async (t) => {
|
|
23
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-log-"));
|
|
24
|
+
const logFile = path.join(directory, "arisa.log");
|
|
25
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
26
|
+
await writeFile(logFile, "one\ntwo\nthree\nfour\n", "utf8");
|
|
27
|
+
|
|
28
|
+
const result = await readRecentLogLines(logFile, 2);
|
|
29
|
+
|
|
30
|
+
assert.equal(result.text, "three\nfour");
|
|
31
|
+
assert.equal(result.endsWithNewline, true);
|
|
32
|
+
assert.equal(result.size, 19);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("preserves an unterminated final log line", async (t) => {
|
|
36
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-log-"));
|
|
37
|
+
const logFile = path.join(directory, "arisa.log");
|
|
38
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
39
|
+
await writeFile(logFile, "one\ntwo", "utf8");
|
|
40
|
+
|
|
41
|
+
const result = await readRecentLogLines(logFile, 1);
|
|
42
|
+
|
|
43
|
+
assert.equal(result.text, "two");
|
|
44
|
+
assert.equal(result.endsWithNewline, false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("follows appended logs and resumes from a replaced log file", async (t) => {
|
|
48
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-log-"));
|
|
49
|
+
const logFile = path.join(directory, "arisa.log");
|
|
50
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
51
|
+
await writeFile(logFile, "existing\n", "utf8");
|
|
52
|
+
const initial = await readRecentLogLines(logFile, 10);
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
let output = "";
|
|
55
|
+
const following = followLogFile({
|
|
56
|
+
logFile,
|
|
57
|
+
initialSize: initial.size,
|
|
58
|
+
initialIno: initial.ino,
|
|
59
|
+
write: (content) => { output += content; },
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
pollIntervalMs: 10
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await appendFile(logFile, "new\n", "utf8");
|
|
65
|
+
await waitFor(() => output === "new\n");
|
|
66
|
+
await rename(logFile, `${logFile}.old`);
|
|
67
|
+
await writeFile(logFile, "rotated and longer\n", "utf8");
|
|
68
|
+
await waitFor(() => output === "new\nrotated and longer\n");
|
|
69
|
+
controller.abort();
|
|
70
|
+
await following;
|
|
71
|
+
|
|
72
|
+
assert.equal(output, "new\nrotated and longer\n");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("arisa log prints the active package version and recent logs", async (t) => {
|
|
76
|
+
const arisaHome = await mkdtemp(path.join(os.tmpdir(), "arisa-home-"));
|
|
77
|
+
const stateDir = path.join(arisaHome, "state");
|
|
78
|
+
t.after(() => rm(arisaHome, { recursive: true, force: true }));
|
|
79
|
+
await mkdir(stateDir, { recursive: true });
|
|
80
|
+
await writeFile(path.join(stateDir, "arisa.log"), "first\nlatest\n", "utf8");
|
|
81
|
+
|
|
82
|
+
const { stdout } = await execFileAsync(process.execPath, ["src/index.js", "log", "--no-follow"], {
|
|
83
|
+
cwd: path.resolve(import.meta.dirname, ".."),
|
|
84
|
+
env: { ...process.env, ARISA_HOME: arisaHome }
|
|
85
|
+
});
|
|
86
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
87
|
+
|
|
88
|
+
assert.equal(stdout.split("\n")[0], `Arisa v${packageJson.version} | Recent logs`);
|
|
89
|
+
assert.match(stdout, /first\nlatest\n$/);
|
|
90
|
+
});
|
|
@@ -4,8 +4,10 @@ import test from "node:test";
|
|
|
4
4
|
import {
|
|
5
5
|
resolveChatModel,
|
|
6
6
|
resolveChatModelSelection,
|
|
7
|
+
resolveChatSpeed,
|
|
7
8
|
resolveChatThinkingLevel,
|
|
8
9
|
selectChatModel,
|
|
10
|
+
selectChatSpeed,
|
|
9
11
|
selectChatThinkingLevel
|
|
10
12
|
} from "../src/core/agent/model-selection.js";
|
|
11
13
|
import { applyConfigDefaults, piConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
@@ -14,13 +16,18 @@ import {
|
|
|
14
16
|
listModelThinkingLevels,
|
|
15
17
|
modelSupportsThinking
|
|
16
18
|
} from "../src/core/agent/pi-runtime.js";
|
|
19
|
+
import { clampModelSpeed, createModelSpeedController, modelSupportsSpeed, normalizeModelSpeed, speedToServiceTier } from "../src/core/agent/model-speed.js";
|
|
17
20
|
import { getChatPiSessionsDir } from "../src/runtime/paths.js";
|
|
18
21
|
import {
|
|
19
22
|
buildEffortPicker,
|
|
20
23
|
buildModelPicker,
|
|
24
|
+
buildSpeedPicker,
|
|
21
25
|
parseEffortPickerAction,
|
|
22
|
-
parseModelPickerAction
|
|
26
|
+
parseModelPickerAction,
|
|
27
|
+
parseSpeedPickerAction,
|
|
28
|
+
reverseModelOrder
|
|
23
29
|
} from "../src/transport/telegram/model-picker.js";
|
|
30
|
+
import { closeModelPicker } from "../src/transport/telegram/bot.js";
|
|
24
31
|
|
|
25
32
|
function createConfig() {
|
|
26
33
|
return applyConfigDefaults({
|
|
@@ -47,6 +54,7 @@ test("resolves the default model until a chat selects one", () => {
|
|
|
47
54
|
provider: "openai-codex",
|
|
48
55
|
model: "gpt-selected",
|
|
49
56
|
thinkingLevel: "high",
|
|
57
|
+
speed: 1,
|
|
50
58
|
sessionRevision: 1
|
|
51
59
|
});
|
|
52
60
|
});
|
|
@@ -74,10 +82,22 @@ test("updates effort without bumping the session revision", () => {
|
|
|
74
82
|
provider: "openai-codex",
|
|
75
83
|
model: "gpt-a",
|
|
76
84
|
thinkingLevel: "high",
|
|
85
|
+
speed: 1,
|
|
77
86
|
sessionRevision: 1
|
|
78
87
|
});
|
|
79
88
|
});
|
|
80
89
|
|
|
90
|
+
test("updates Pi speed without bumping the session revision", () => {
|
|
91
|
+
const config = createConfig();
|
|
92
|
+
|
|
93
|
+
selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-5.6-sol" }, { thinkingLevel: "high" });
|
|
94
|
+
selectChatSpeed(config, 123, 1.5);
|
|
95
|
+
|
|
96
|
+
assert.equal(resolveChatSpeed(config, 123), 1.5);
|
|
97
|
+
assert.equal(config.pi.chatModels["123"].sessionRevision, 1);
|
|
98
|
+
assert.equal(config.pi.chatModels["123"].thinkingLevel, "high");
|
|
99
|
+
});
|
|
100
|
+
|
|
81
101
|
test("ignores a chat selection from a different active provider", () => {
|
|
82
102
|
const config = createConfig();
|
|
83
103
|
config.pi.chatModels = {
|
|
@@ -120,6 +140,25 @@ test("builds a paged model picker and marks the current model", () => {
|
|
|
120
140
|
assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
|
|
121
141
|
});
|
|
122
142
|
|
|
143
|
+
test("reverses model order without mutating the provider list", () => {
|
|
144
|
+
const models = [
|
|
145
|
+
{ provider: "openai-codex", id: "gpt-old" },
|
|
146
|
+
{ provider: "openai-codex", id: "gpt-current" },
|
|
147
|
+
{ provider: "openai-codex", id: "gpt-new" }
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
assert.deepEqual(reverseModelOrder(models).map((model) => model.id), [
|
|
151
|
+
"gpt-new",
|
|
152
|
+
"gpt-current",
|
|
153
|
+
"gpt-old"
|
|
154
|
+
]);
|
|
155
|
+
assert.deepEqual(models.map((model) => model.id), [
|
|
156
|
+
"gpt-old",
|
|
157
|
+
"gpt-current",
|
|
158
|
+
"gpt-new"
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
123
162
|
test("builds an effort picker for the current model or pending model choice", () => {
|
|
124
163
|
const current = buildEffortPicker({
|
|
125
164
|
provider: "openai-codex",
|
|
@@ -142,6 +181,46 @@ test("builds an effort picker for the current model or pending model choice", ()
|
|
|
142
181
|
assert.equal(pending.replyMarkup.inline_keyboard[1][0].callback_data, "model-effort:4:high");
|
|
143
182
|
});
|
|
144
183
|
|
|
184
|
+
test("builds and parses the speed picker", () => {
|
|
185
|
+
const picker = buildSpeedPicker({
|
|
186
|
+
provider: "openai-codex",
|
|
187
|
+
modelId: "gpt-5.6-sol",
|
|
188
|
+
speeds: [1, 1.5],
|
|
189
|
+
selectedSpeed: 1.5
|
|
190
|
+
});
|
|
191
|
+
assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "speed:1");
|
|
192
|
+
assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ 1\.5x$/);
|
|
193
|
+
assert.deepEqual(parseSpeedPickerAction("speed:1.5"), { type: "speed", speed: 1.5 });
|
|
194
|
+
assert.deepEqual(parseSpeedPickerAction("speed:1"), { type: "speed", speed: 1 });
|
|
195
|
+
assert.equal(parseSpeedPickerAction("speed:2"), null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("closes the picker after selecting the already active model and effort", async () => {
|
|
199
|
+
const calls = [];
|
|
200
|
+
const ctx = {
|
|
201
|
+
chat: { id: 123 },
|
|
202
|
+
callbackQuery: { message: { message_id: 456 } },
|
|
203
|
+
api: {
|
|
204
|
+
async editMessageText(...args) {
|
|
205
|
+
calls.push(["editMessageText", ...args]);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async answerCallbackQuery(...args) {
|
|
209
|
+
calls.push(["answerCallbackQuery", ...args]);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
await closeModelPicker(ctx, {
|
|
214
|
+
messageText: "Already using openai-codex/gpt-b (effort: high).",
|
|
215
|
+
callbackText: "Already using gpt-b at high."
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.deepEqual(calls, [
|
|
219
|
+
["editMessageText", 123, 456, "Already using openai-codex/gpt-b (effort: high)."],
|
|
220
|
+
["answerCallbackQuery", { text: "Already using gpt-b at high." }]
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
145
224
|
test("parses only model picker callback data", () => {
|
|
146
225
|
assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
|
|
147
226
|
assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
|
|
@@ -162,14 +241,16 @@ test("parses effort picker callback data", () => {
|
|
|
162
241
|
assert.equal(parseEffortPickerAction("model:1"), null);
|
|
163
242
|
});
|
|
164
243
|
|
|
165
|
-
test("centralizes
|
|
244
|
+
test("centralizes Telegram and Pi defaults in config", () => {
|
|
166
245
|
const config = applyConfigDefaults({
|
|
167
246
|
telegram: {},
|
|
168
247
|
pi: { provider: "openai-codex", model: "gpt-default" }
|
|
169
248
|
});
|
|
170
249
|
|
|
171
250
|
assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
|
|
251
|
+
assert.equal(config.telegram.busyMessageMode, "steer");
|
|
172
252
|
assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
|
|
253
|
+
assert.equal(config.pi.speed, piConfigDefaults.speed);
|
|
173
254
|
});
|
|
174
255
|
|
|
175
256
|
test("lists and clamps thinking levels from model capabilities", () => {
|
|
@@ -193,3 +274,45 @@ test("lists and clamps thinking levels from model capabilities", () => {
|
|
|
193
274
|
assert.equal(clampModelThinkingLevel(plain, "high"), "off");
|
|
194
275
|
assert.equal(modelSupportsThinking(plain), false);
|
|
195
276
|
});
|
|
277
|
+
|
|
278
|
+
test("maps supported model speeds to provider service tiers", () => {
|
|
279
|
+
const fastModel = {
|
|
280
|
+
provider: "openai-codex",
|
|
281
|
+
api: "openai-codex-responses",
|
|
282
|
+
id: "gpt-5.6-sol"
|
|
283
|
+
};
|
|
284
|
+
assert.equal(modelSupportsSpeed(fastModel), true);
|
|
285
|
+
assert.equal(clampModelSpeed(fastModel, 1.5), 1.5);
|
|
286
|
+
assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-5.3" }, 1.5), 1);
|
|
287
|
+
assert.equal(speedToServiceTier(1), "default");
|
|
288
|
+
assert.equal(speedToServiceTier(1.5), "priority");
|
|
289
|
+
assert.throws(() => normalizeModelSpeed(2), /Invalid model speed/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("applies Pi speed to every provider request and updates it in place", async () => {
|
|
293
|
+
const calls = [];
|
|
294
|
+
const controller = createModelSpeedController((model, context, options) => {
|
|
295
|
+
calls.push({ model, context, options });
|
|
296
|
+
return "stream";
|
|
297
|
+
}, 1);
|
|
298
|
+
|
|
299
|
+
assert.equal(controller.streamFn("model", "context", {
|
|
300
|
+
signal: "signal",
|
|
301
|
+
onPayload: (payload) => ({ ...payload, preserved: true })
|
|
302
|
+
}), "stream");
|
|
303
|
+
controller.setSpeed(1.5);
|
|
304
|
+
controller.streamFn("model", "context", { signal: "signal" });
|
|
305
|
+
|
|
306
|
+
assert.equal(calls[0].options.serviceTier, "default");
|
|
307
|
+
assert.equal(calls[1].options.serviceTier, "priority");
|
|
308
|
+
assert.equal(calls[1].options.signal, "signal");
|
|
309
|
+
assert.deepEqual(await calls[0].options.onPayload({ model: "gpt" }, "model"), {
|
|
310
|
+
model: "gpt",
|
|
311
|
+
preserved: true,
|
|
312
|
+
service_tier: "default"
|
|
313
|
+
});
|
|
314
|
+
assert.deepEqual(await calls[1].options.onPayload({ model: "gpt" }, "model"), {
|
|
315
|
+
model: "gpt",
|
|
316
|
+
service_tier: "priority"
|
|
317
|
+
});
|
|
318
|
+
});
|
package/test/paths.test.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
chatsDir,
|
|
10
10
|
createIpcSocketPath,
|
|
11
11
|
getChatArtifactsDir,
|
|
12
|
+
getChatConversationHistoryFile,
|
|
12
13
|
getChatToolConfigPath,
|
|
13
14
|
getChatToolStateDir,
|
|
14
15
|
getToolStateDir,
|
|
@@ -23,6 +24,13 @@ test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
|
23
24
|
assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
test("keeps portable conversation history scoped below the chat state directory", () => {
|
|
28
|
+
assert.equal(
|
|
29
|
+
getChatConversationHistoryFile("chat-1"),
|
|
30
|
+
path.join(chatsDir, "chat-1", "state", "conversation.jsonl")
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
26
34
|
test("keeps chat tool state and config paths scoped below the chat directory for normal names", () => {
|
|
27
35
|
assert.equal(
|
|
28
36
|
getChatToolStateDir("chat-1", "strudel-agent"),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createPiSettingsManager } from "../src/core/agent/agent-manager.js";
|
|
4
|
+
import { applyConfigDefaults, piConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
5
|
+
|
|
6
|
+
test("provides Pi compaction defaults through Arisa config", () => {
|
|
7
|
+
const config = applyConfigDefaults({ pi: {} });
|
|
8
|
+
|
|
9
|
+
assert.deepEqual(config.pi.compaction, {
|
|
10
|
+
enabled: true,
|
|
11
|
+
reserveTokens: 16_384,
|
|
12
|
+
keepRecentTokens: 20_000
|
|
13
|
+
});
|
|
14
|
+
assert.deepEqual(config.pi.compaction, piConfigDefaults.compaction);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("merges partial Pi compaction overrides with defaults", () => {
|
|
18
|
+
const config = applyConfigDefaults({
|
|
19
|
+
pi: { compaction: { reserveTokens: 8_192 } }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(config.pi.compaction, {
|
|
23
|
+
enabled: true,
|
|
24
|
+
reserveTokens: 8_192,
|
|
25
|
+
keepRecentTokens: 20_000
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("passes Arisa compaction config to Pi settings", () => {
|
|
30
|
+
const config = applyConfigDefaults({
|
|
31
|
+
pi: {
|
|
32
|
+
compaction: {
|
|
33
|
+
enabled: false,
|
|
34
|
+
reserveTokens: 12_000,
|
|
35
|
+
keepRecentTokens: 18_000
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const settingsManager = createPiSettingsManager(config);
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(settingsManager.getCompactionSettings(), config.pi.compaction);
|
|
43
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createTelegramRestartHandler, telegramCommands } from "../src/transport/telegram/bot.js";
|
|
4
|
+
import { handoffServiceRestart, restartService, serviceEntryFile, waitForServiceStop } from "../src/runtime/service-manager.js";
|
|
5
|
+
|
|
6
|
+
test("registers /restart as a native Telegram command", () => {
|
|
7
|
+
assert.equal(
|
|
8
|
+
telegramCommands.some((command) => command.command === "restart"),
|
|
9
|
+
true
|
|
10
|
+
);
|
|
11
|
+
assert.equal(telegramCommands.some((command) => command.command === "harness"), false);
|
|
12
|
+
assert.equal(telegramCommands.some((command) => command.command === "login"), false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("replies before handing restart to a detached CLI process", async () => {
|
|
16
|
+
const calls = [];
|
|
17
|
+
let unreferenced = false;
|
|
18
|
+
let logClosed = false;
|
|
19
|
+
const environment = { ARISA_TEST: "restart" };
|
|
20
|
+
|
|
21
|
+
const result = await handoffServiceRestart({
|
|
22
|
+
verbose: false,
|
|
23
|
+
cliArgs: ["--pi.model", "example/model"]
|
|
24
|
+
}, {
|
|
25
|
+
ensureHome: async () => { calls.push("ensure-home"); },
|
|
26
|
+
getStatus: async () => {
|
|
27
|
+
calls.push("get-status");
|
|
28
|
+
return { running: true, pid: 41 };
|
|
29
|
+
},
|
|
30
|
+
openLog: async (file, mode) => {
|
|
31
|
+
calls.push(["open-log", file, mode]);
|
|
32
|
+
return {
|
|
33
|
+
fd: 17,
|
|
34
|
+
close: async () => { logClosed = true; }
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
spawnProcess: (command, args, options) => {
|
|
38
|
+
calls.push(["spawn", command, args, options]);
|
|
39
|
+
return {
|
|
40
|
+
pid: 84,
|
|
41
|
+
unref: () => { unreferenced = true; }
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
environment,
|
|
45
|
+
currentPid: 41
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(calls[0], "ensure-home");
|
|
49
|
+
assert.equal(calls[1], "get-status");
|
|
50
|
+
assert.equal(calls[2][0], "open-log");
|
|
51
|
+
assert.equal(calls[2][2], "a");
|
|
52
|
+
assert.equal(calls[3][0], "spawn");
|
|
53
|
+
assert.equal(calls[3][1], process.execPath);
|
|
54
|
+
assert.deepEqual(calls[3][2], [
|
|
55
|
+
serviceEntryFile,
|
|
56
|
+
"restart",
|
|
57
|
+
"--pi.model",
|
|
58
|
+
"example/model",
|
|
59
|
+
"--silent"
|
|
60
|
+
]);
|
|
61
|
+
assert.deepEqual(calls[3][3], {
|
|
62
|
+
detached: true,
|
|
63
|
+
stdio: ["ignore", 17, 17],
|
|
64
|
+
env: environment
|
|
65
|
+
});
|
|
66
|
+
assert.equal(unreferenced, true);
|
|
67
|
+
assert.equal(logClosed, true);
|
|
68
|
+
assert.equal(result.pid, 84);
|
|
69
|
+
|
|
70
|
+
const handlerCalls = [];
|
|
71
|
+
const handler = createTelegramRestartHandler({
|
|
72
|
+
authorize: async () => ({ ok: true }),
|
|
73
|
+
requestRestart: async () => {
|
|
74
|
+
handlerCalls.push("handoff");
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const ctx = {
|
|
79
|
+
reply: async (text) => { handlerCalls.push(["reply", text]); }
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
await handler(ctx);
|
|
83
|
+
await handler(ctx);
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(handlerCalls, [
|
|
86
|
+
["reply", "Arisa is restarting. I'll be back shortly."],
|
|
87
|
+
"handoff",
|
|
88
|
+
["reply", "An Arisa restart is already in progress."]
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("refuses Telegram restart handoff outside the active background service", async () => {
|
|
93
|
+
let spawned = false;
|
|
94
|
+
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
handoffServiceRestart({}, {
|
|
97
|
+
ensureHome: async () => {},
|
|
98
|
+
getStatus: async () => ({ running: true, pid: 99 }),
|
|
99
|
+
spawnProcess: () => {
|
|
100
|
+
spawned = true;
|
|
101
|
+
},
|
|
102
|
+
currentPid: 41
|
|
103
|
+
}),
|
|
104
|
+
/requires the active background service process/
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
assert.equal(spawned, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("reports a failed Telegram restart handoff and permits retry", async () => {
|
|
111
|
+
const replies = [];
|
|
112
|
+
let attempts = 0;
|
|
113
|
+
const handler = createTelegramRestartHandler({
|
|
114
|
+
authorize: async () => ({ ok: true }),
|
|
115
|
+
requestRestart: async () => {
|
|
116
|
+
attempts += 1;
|
|
117
|
+
if (attempts === 1) throw new Error("synthetic handoff failure");
|
|
118
|
+
return { pid: 84 };
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
const ctx = { reply: async (text) => { replies.push(text); } };
|
|
122
|
+
|
|
123
|
+
await handler(ctx);
|
|
124
|
+
await handler(ctx);
|
|
125
|
+
|
|
126
|
+
assert.equal(attempts, 2);
|
|
127
|
+
assert.deepEqual(replies, [
|
|
128
|
+
"Arisa is restarting. I'll be back shortly.",
|
|
129
|
+
"Arisa could not be restarted: synthetic handoff failure",
|
|
130
|
+
"Arisa is restarting. I'll be back shortly."
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("waits until the stopped service releases its PID before restarting", async () => {
|
|
135
|
+
const calls = [];
|
|
136
|
+
const statuses = [
|
|
137
|
+
{ running: true, pid: 41 },
|
|
138
|
+
{ running: true, pid: 41 },
|
|
139
|
+
{ running: false, pid: null }
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const result = await restartService({
|
|
143
|
+
verbose: false,
|
|
144
|
+
cliArgs: ["--pi.model", "example/model"],
|
|
145
|
+
shutdownTimeoutMs: 1_000,
|
|
146
|
+
shutdownPollIntervalMs: 1
|
|
147
|
+
}, {
|
|
148
|
+
stop: async () => {
|
|
149
|
+
calls.push("stop");
|
|
150
|
+
return { ok: true, pid: 41 };
|
|
151
|
+
},
|
|
152
|
+
getStatus: async () => {
|
|
153
|
+
calls.push("status");
|
|
154
|
+
return statuses.shift();
|
|
155
|
+
},
|
|
156
|
+
start: async (options) => {
|
|
157
|
+
calls.push("start");
|
|
158
|
+
assert.deepEqual(options, {
|
|
159
|
+
verbose: false,
|
|
160
|
+
cliArgs: ["--pi.model", "example/model"]
|
|
161
|
+
});
|
|
162
|
+
return { ok: true, pid: 84, logFile: "/tmp/arisa.log" };
|
|
163
|
+
},
|
|
164
|
+
sleep: async () => {
|
|
165
|
+
calls.push("sleep");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.deepEqual(calls, ["stop", "status", "sleep", "status", "sleep", "status", "start"]);
|
|
170
|
+
assert.deepEqual(result, {
|
|
171
|
+
ok: true,
|
|
172
|
+
pid: 84,
|
|
173
|
+
previousPid: 41,
|
|
174
|
+
wasRunning: true,
|
|
175
|
+
logFile: "/tmp/arisa.log"
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("restart starts Arisa when it is not running", async () => {
|
|
180
|
+
let statusChecks = 0;
|
|
181
|
+
const result = await restartService({
|
|
182
|
+
shutdownTimeoutMs: 1_000,
|
|
183
|
+
shutdownPollIntervalMs: 1
|
|
184
|
+
}, {
|
|
185
|
+
stop: async () => ({ ok: false, reason: "not-running", pid: null }),
|
|
186
|
+
getStatus: async () => {
|
|
187
|
+
statusChecks += 1;
|
|
188
|
+
return { running: false, pid: null };
|
|
189
|
+
},
|
|
190
|
+
start: async () => ({ ok: true, pid: 84, logFile: "/tmp/arisa.log" })
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
assert.equal(statusChecks, 0);
|
|
194
|
+
assert.deepEqual(result, {
|
|
195
|
+
ok: true,
|
|
196
|
+
pid: 84,
|
|
197
|
+
previousPid: null,
|
|
198
|
+
wasRunning: false,
|
|
199
|
+
logFile: "/tmp/arisa.log"
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("restart does not start a second service when shutdown times out", async () => {
|
|
204
|
+
let started = false;
|
|
205
|
+
|
|
206
|
+
await assert.rejects(
|
|
207
|
+
restartService({
|
|
208
|
+
shutdownTimeoutMs: 2,
|
|
209
|
+
shutdownPollIntervalMs: 1
|
|
210
|
+
}, {
|
|
211
|
+
stop: async () => ({ ok: true, pid: 41 }),
|
|
212
|
+
getStatus: async () => ({ running: true, pid: 41 }),
|
|
213
|
+
start: async () => {
|
|
214
|
+
started = true;
|
|
215
|
+
return { ok: true, pid: 84 };
|
|
216
|
+
},
|
|
217
|
+
sleep: async () => {}
|
|
218
|
+
}),
|
|
219
|
+
/did not stop within 2ms/
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
assert.equal(started, false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("requires explicit positive shutdown timing policy", async () => {
|
|
226
|
+
await assert.rejects(
|
|
227
|
+
waitForServiceStop({ timeoutMs: 0, pollIntervalMs: 1 }),
|
|
228
|
+
/positive shutdownTimeoutMs/
|
|
229
|
+
);
|
|
230
|
+
await assert.rejects(
|
|
231
|
+
waitForServiceStop({ timeoutMs: 1, pollIntervalMs: 0 }),
|
|
232
|
+
/positive shutdownPollIntervalMs/
|
|
233
|
+
);
|
|
234
|
+
});
|