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.
Files changed (41) hide show
  1. package/AGENTS.md +18 -17
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +27 -0
  18. package/src/index.js +61 -6
  19. package/src/runtime/arisa-capabilities.js +45 -1
  20. package/src/runtime/bootstrap.js +3 -2
  21. package/src/runtime/create-app.js +47 -11
  22. package/src/runtime/doctor.js +307 -0
  23. package/src/runtime/log-viewer.js +165 -0
  24. package/src/runtime/paths.js +4 -1
  25. package/src/runtime/service-manager.js +106 -8
  26. package/src/runtime/tool-process-supervisor.js +107 -10
  27. package/src/transport/telegram/bot.js +533 -99
  28. package/src/transport/telegram/model-picker.js +28 -2
  29. package/test/agent-tool-policy.test.js +26 -1
  30. package/test/auth-flow.test.js +28 -2
  31. package/test/capabilities-security.test.js +37 -0
  32. package/test/context-and-task-bounds.test.js +279 -0
  33. package/test/daemon-runtime.test.js +130 -2
  34. package/test/dependency-warnings.test.js +17 -0
  35. package/test/doctor.test.js +90 -0
  36. package/test/log-viewer.test.js +90 -0
  37. package/test/model-selection.test.js +125 -2
  38. package/test/paths.test.js +8 -0
  39. package/test/pi-compaction.test.js +43 -0
  40. package/test/service-manager.test.js +234 -0
  41. 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 picker defaults in config", () => {
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
+ });
@@ -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
+ });