arisa 5.2.7 → 5.2.19

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 (65) hide show
  1. package/README.md +25 -4
  2. package/package.json +10 -9
  3. package/pnpm-workspace.yaml +7 -5
  4. package/src/core/agent/agent-manager.js +92 -18
  5. package/src/core/agent/agent-session-lifecycle.js +29 -6
  6. package/src/core/agent/agent-turn-coordinator.js +143 -0
  7. package/src/core/agent/auth-flow.js +6 -6
  8. package/src/core/agent/model-speed.js +3 -1
  9. package/src/core/agent/pi-auth-login.js +28 -28
  10. package/src/core/agent/pi-capability-tools.js +8 -4
  11. package/src/core/agent/pi-runtime.js +14 -21
  12. package/src/core/agent/session-history-reader.js +168 -0
  13. package/src/core/agent/session-preload-migration.js +162 -0
  14. package/src/core/agent/session-rotation.js +29 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/capabilities/capability-service.js +28 -2
  17. package/src/core/config/config-defaults.js +29 -0
  18. package/src/core/tasks/task-runner.js +8 -4
  19. package/src/core/tasks/task-store.js +48 -4
  20. package/src/core/tools/daemon-processes.js +7 -0
  21. package/src/core/tools/memory-pressure.js +2 -2
  22. package/src/core/tools/tool-registry.js +35 -7
  23. package/src/core/tools/weighted-resource-governor.js +7 -6
  24. package/src/official-tools.lock.json +129 -48
  25. package/src/runtime/bootstrap-cli.js +3 -3
  26. package/src/runtime/bootstrap-telegram.js +7 -7
  27. package/src/runtime/doctor.js +20 -73
  28. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  29. package/src/runtime/process-inspection.js +78 -0
  30. package/src/runtime/slave-cli.js +20 -10
  31. package/src/runtime/slave-service.js +299 -7
  32. package/src/runtime/tool-process-supervisor.js +35 -8
  33. package/src/runtime/tui.js +6 -7
  34. package/src/transport/telegram/bot.js +11 -5
  35. package/src/transport/telegram/chat-queue.js +6 -2
  36. package/src/transport/telegram/model-controls.js +1 -1
  37. package/src/transport/telegram/task-dispatcher.js +67 -15
  38. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  39. package/src/transport/telegram/telegram-prompt-controller.js +17 -4
  40. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  41. package/test/agent-turn-coordinator.test.js +48 -0
  42. package/test/auth-flow.test.js +2 -2
  43. package/test/capabilities-security.test.js +36 -0
  44. package/test/context-and-task-bounds.test.js +2 -1
  45. package/test/daemon-runtime.test.js +2 -4
  46. package/test/doctor.test.js +19 -0
  47. package/test/memory-pressure.test.js +7 -2
  48. package/test/model-selection.test.js +3 -2
  49. package/test/obsolete-daemon-reaper.test.js +61 -0
  50. package/test/official-tool-dependencies.test.js +7 -2
  51. package/test/official-tool-installer.test.js +13 -0
  52. package/test/pi-auth-login.test.js +78 -0
  53. package/test/pi-capability-tools.test.js +3 -0
  54. package/test/pi-compaction.test.js +21 -0
  55. package/test/pi-speed-integration.test.js +176 -0
  56. package/test/session-history-reader.test.js +84 -0
  57. package/test/session-preload-migration.test.js +120 -0
  58. package/test/session-rotation.test.js +110 -0
  59. package/test/slave-cli.test.js +221 -5
  60. package/test/task-store.test.js +34 -0
  61. package/test/telegram-prompt-controller.test.js +2 -1
  62. package/test/telegram-task-dispatcher.test.js +121 -5
  63. package/test/tool-registry-run.test.js +10 -1
  64. package/test/weighted-resource-governor.test.js +28 -0
  65. package/test/worker-tool-fanout.test.js +79 -0
@@ -38,6 +38,14 @@ function failureDestination(task) {
38
38
  }
39
39
 
40
40
  function buildFailureNotice({ task, result, error }) {
41
+ if (result?.authBlockedNew === true) {
42
+ return [
43
+ "⚠️ Arisa automation paused for authentication",
44
+ `Tool: ${result.authBlock?.toolName || task.payload?.toolName || "unknown"}`,
45
+ `Reason: ${safeErrorSummary(error)}`,
46
+ `Next authentication check: ${result.runAt}`
47
+ ].join("\n");
48
+ }
41
49
  const uncertain = result?.status === "outcome_uncertain" || result?.lastOutcome === "outcome_uncertain";
42
50
  const recurring = result?.terminalFailure === true && result?.status === "pending";
43
51
  const lines = [
@@ -64,16 +72,62 @@ export function createTelegramTaskDispatcher({
64
72
  const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
65
73
  const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
66
74
 
75
+ const runBackgroundTool = (toolName, chatId, args, label) => agentManager.runTurn({
76
+ priority: "background",
77
+ label
78
+ }, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
79
+
80
+ function throwToolFailure(result, toolName, fallbackResolution) {
81
+ const error = new Error(result?.error || `${toolName} failed`);
82
+ if (result?.status === "blocked_auth" || fallbackResolution) {
83
+ error.retryable = false;
84
+ error.authBlocked = true;
85
+ error.authResolution = {
86
+ ...(result?.resolution || fallbackResolution || {}),
87
+ toolName
88
+ };
89
+ } else if (result?.status === "needs_config") {
90
+ error.retryable = false;
91
+ } else if (result?.status === "outcome_uncertain") {
92
+ error.retryable = false;
93
+ error.outcomeUncertain = true;
94
+ }
95
+ throw error;
96
+ }
97
+
67
98
  async function dispatchAgentTask(task, chatId) {
68
99
  if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
100
+ if (task.authBlock?.toolName) {
101
+ const probe = await runBackgroundTool(
102
+ task.authBlock.toolName,
103
+ chatId,
104
+ task.authBlock.probeArgs || {},
105
+ `authentication probe ${task.authBlock.toolName}`
106
+ );
107
+ if (probe?.ok === false) throwToolFailure(probe, task.authBlock.toolName, task.authBlock);
108
+ logger?.log("tasks", `authentication restored for ${task.authBlock.toolName} (task ${task.id})`);
109
+ }
110
+
69
111
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
112
+ const agentTaskExecution = { blockedAuth: null };
70
113
  await enqueueAsyncPrompt({
71
114
  chatId,
72
115
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
73
116
  label: `scheduled task ${task.id}`,
74
117
  route: task.route,
75
- timeoutMs: agentTimeoutMs
118
+ timeoutMs: agentTimeoutMs,
119
+ priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background",
120
+ agentTaskExecution
76
121
  });
122
+ if (agentTaskExecution.blockedAuth) {
123
+ const blocked = agentTaskExecution.blockedAuth;
124
+ throwToolFailure({
125
+ ok: false,
126
+ status: "blocked_auth",
127
+ error: blocked.error,
128
+ resolution: blocked.resolution
129
+ }, blocked.toolName);
130
+ }
77
131
  }
78
132
 
79
133
  async function dispatchAgentEvent(task, chatId) {
@@ -92,7 +146,8 @@ export function createTelegramTaskDispatcher({
92
146
  prompt: await buildAsyncEventPrompt(task, resourceNotes),
93
147
  label: `agent event ${task.id}`,
94
148
  route: task.route,
95
- timeoutMs: eventTimeoutMs
149
+ timeoutMs: eventTimeoutMs,
150
+ priority: task.source?.toolName === "process-retrospective" ? "background" : "interactive"
96
151
  });
97
152
  }
98
153
 
@@ -100,20 +155,17 @@ export function createTelegramTaskDispatcher({
100
155
  const toolName = task.payload?.toolName;
101
156
  if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
102
157
  logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
103
- const result = await agentManager.runTool({
104
- name: toolName,
105
- request: { args: task.payload.args || {} },
106
- chatId
107
- });
108
- if (result?.ok === false) {
109
- const error = new Error(result.error || `poll_tool ${toolName} failed`);
110
- if (result.status === "needs_config") error.retryable = false;
111
- if (result.status === "outcome_uncertain") {
112
- error.retryable = false;
113
- error.outcomeUncertain = true;
114
- }
115
- throw error;
158
+
159
+ const runTool = (args) => runBackgroundTool(toolName, chatId, args, `poll tool ${toolName}`);
160
+
161
+ if (task.authBlock) {
162
+ const probe = await runTool(task.authBlock.probeArgs || {});
163
+ if (probe?.ok === false) throwToolFailure(probe, toolName, task.authBlock);
164
+ logger?.log("tasks", `authentication restored for ${toolName} (task ${task.id})`);
116
165
  }
166
+
167
+ const result = await runTool(task.payload.args || {});
168
+ if (result?.ok === false) throwToolFailure(result, toolName);
117
169
  }
118
170
 
119
171
  async function dispatchTask(task) {
@@ -50,7 +50,7 @@ export function createTelegramAuthController({
50
50
  if (!detected) return false;
51
51
 
52
52
  try {
53
- await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
53
+ await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
54
54
  markPromptErrorNotified(error);
55
55
  return true;
56
56
  } catch (notifyError) {
@@ -66,11 +66,11 @@ export function createTelegramAuthController({
66
66
  agentManager.clearSessionCache(chatId);
67
67
  issue = null;
68
68
  logger?.log("telegram", `Pi auth renewal completed for chat ${chatId}`);
69
- await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, verified: true }));
69
+ await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, verified: true }));
70
70
  } catch (error) {
71
71
  const detected = rememberValidationFailure(error);
72
72
  logger?.error("telegram", `Pi auth renewal failed for chat ${chatId}: ${getErrorMessage(error)}`);
73
- await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
73
+ await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
74
74
  logger?.error("telegram", `auth renewal failure notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
75
75
  });
76
76
  } finally {
@@ -140,17 +140,17 @@ export function createTelegramAuthController({
140
140
  const authorization = await authorize(ctx);
141
141
  if (!authorization.ok) return;
142
142
 
143
- const status = getPiAuthStatus(config, ctx.chat.id);
143
+ const status = await getPiAuthStatus(config, ctx.chat.id);
144
144
  if (status.hasApiKey || !status.supportsOAuth) {
145
145
  await withTyping(ctx, async () => {
146
146
  try {
147
147
  await agentManager.validateAgent();
148
148
  agentManager.clearSessionCache(ctx.chat.id);
149
149
  issue = null;
150
- await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
150
+ await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
151
151
  } catch (error) {
152
152
  const detected = rememberValidationFailure(error);
153
- await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
153
+ await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
154
154
  }
155
155
  });
156
156
  return;
@@ -163,7 +163,7 @@ export function createTelegramAuthController({
163
163
  : "Pi login is already in progress. Paste the redirect URL or code here when you have it.");
164
164
  } catch (error) {
165
165
  const detected = rememberValidationFailure(error);
166
- await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
166
+ await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
167
167
  }
168
168
  }
169
169
 
@@ -157,8 +157,14 @@ export function createTelegramPromptController({
157
157
  });
158
158
  };
159
159
 
160
- if (ctx) return withTyping(ctx, work);
161
- return work();
160
+ return agentManager.runTurn({
161
+ priority: executionReceipt?.priority || "interactive",
162
+ label: executionReceipt?.label || `interactive prompt for ${sessionId}`,
163
+ queueTtlMs: executionReceipt?.queueTtlMs
164
+ }, () => {
165
+ executionReceipt?.start?.();
166
+ return ctx ? withTyping(ctx, work) : work();
167
+ });
162
168
  }
163
169
 
164
170
  function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt, initialReceipt = null }) {
@@ -193,10 +199,17 @@ export function createTelegramPromptController({
193
199
  busyMessageMode = "queue",
194
200
  waitForExecution = false,
195
201
  onExecutionStart = null,
196
- coalesceQueued = false
202
+ coalesceQueued = false,
203
+ turnPriority = "interactive",
204
+ turnQueueTtlMs = undefined
197
205
  }) {
198
206
  const chatState = getChatState(chatId);
199
- const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart) : null;
207
+ const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart, {
208
+ priority: turnPriority,
209
+ label,
210
+ queueTtlMs: turnQueueTtlMs,
211
+ deferStart: true
212
+ }) : null;
200
213
 
201
214
  if (chatState.processing) {
202
215
  const incomingRoute = ctx ? contextRoute(ctx) : null;
@@ -113,7 +113,8 @@ export function createTelegramSessionBridgeController({
113
113
  chatId: route.transportChatId,
114
114
  threadId: route.topicThreadId
115
115
  }
116
- } : null
116
+ } : null,
117
+ getAgentTaskExecution: () => route.agentTaskExecution || null
117
118
  };
118
119
  }
119
120
 
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AgentTurnCoordinator } from "../src/core/agent/agent-turn-coordinator.js";
4
+
5
+ test("interactive turns run before queued background turns without overlapping", async () => {
6
+ const coordinator = new AgentTurnCoordinator();
7
+ const releaseActive = await coordinator.acquire({ priority: "background", label: "active background" });
8
+ let backgroundStarted = false;
9
+ const background = coordinator.acquire({ priority: "background", label: "queued background" }).then((release) => {
10
+ backgroundStarted = true;
11
+ return release;
12
+ });
13
+ const interactive = coordinator.acquire({ priority: "interactive", label: "interactive" });
14
+
15
+ releaseActive();
16
+ const releaseInteractive = await interactive;
17
+ assert.equal(backgroundStarted, false);
18
+ assert.equal(coordinator.diagnostic().active.priority, "interactive");
19
+ releaseInteractive();
20
+ const releaseBackground = await background;
21
+ assert.equal(backgroundStarted, true);
22
+ releaseBackground();
23
+ await new Promise((resolve) => setImmediate(resolve));
24
+ assert.equal(coordinator.diagnostic().active, null);
25
+ assert.equal(coordinator.diagnostic().completed, 3);
26
+ });
27
+
28
+ test("background turns expire safely before execution when their queue TTL elapses", async (t) => {
29
+ t.mock.timers.enable({ apis: ["setTimeout"] });
30
+ const coordinator = new AgentTurnCoordinator();
31
+ const releaseActive = await coordinator.acquire({ priority: "interactive", label: "active" });
32
+ const expired = assert.rejects(
33
+ coordinator.acquire({ priority: "background", label: "stale batch", queueTtlMs: 10 }),
34
+ (error) => error.code === "AGENT_TURN_QUEUE_EXPIRED" && error.retryable === true && error.outcomeUncertain === false
35
+ );
36
+ t.mock.timers.tick(10);
37
+ await expired;
38
+ assert.equal(coordinator.diagnostic().expired, 1);
39
+ releaseActive();
40
+ });
41
+
42
+ test("run releases exclusive admission after failures", async () => {
43
+ const coordinator = new AgentTurnCoordinator();
44
+ await assert.rejects(coordinator.run({ priority: "background" }, async () => { throw new Error("failed"); }), /failed/);
45
+ const result = await coordinator.run({ priority: "interactive" }, async () => "ok");
46
+ assert.equal(result, "ok");
47
+ assert.equal(coordinator.diagnostic().completed, 2);
48
+ });
@@ -42,7 +42,7 @@ test("ignores unrelated Pi errors", () => {
42
42
  assert.equal(getPiAuthIssue(new Error("")), null);
43
43
  });
44
44
 
45
- test("reports the active chat model after authentication", () => {
45
+ test("reports the active chat model after authentication", async () => {
46
46
  const config = {
47
47
  pi: {
48
48
  provider: "openai-codex",
@@ -60,7 +60,7 @@ test("reports the active chat model after authentication", () => {
60
60
  }
61
61
  };
62
62
 
63
- const message = buildPiAuthTelegramMessage({ config, chatId: 123, verified: true });
63
+ const message = await buildPiAuthTelegramMessage({ config, chatId: 123, verified: true });
64
64
 
65
65
  assert.match(message, /^Pi authentication is working for openai-codex\/gpt-5\.6\./);
66
66
  assert.doesNotMatch(message, /gpt-5\.5/);
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createArisaCapabilities } from "../src/runtime/arisa-capabilities.js";
4
+ import { createCapabilityService } from "../src/core/capabilities/capability-service.js";
4
5
 
5
6
  function createFakeArtifactStore() {
6
7
  const stores = new Map();
@@ -294,6 +295,41 @@ test("normalizes tool run args and rejects arrays", async () => {
294
295
  );
295
296
  });
296
297
 
298
+ test("records blocked authentication and stops later tools inside an agent task", async () => {
299
+ const execution = { blockedAuth: null };
300
+ const resolution = { retryAfterSeconds: 3600, probeArgs: { action: "status" } };
301
+ let executions = 0;
302
+ const service = createCapabilityService({
303
+ artifactStore: createFakeArtifactStore(),
304
+ toolRegistry: { async load() {} },
305
+ toolExecutor: {
306
+ async runTool() {
307
+ executions += 1;
308
+ return { ok: false, status: "blocked_auth", error: "authentication expired", resolution };
309
+ }
310
+ }
311
+ });
312
+ const run = (name) => service.execute({
313
+ method: "tools.run",
314
+ actorToolName: "run_tool",
315
+ chatId: "chat-1",
316
+ params: { name, args: {} },
317
+ context: { agentTaskExecution: execution }
318
+ });
319
+
320
+ await run("creator-scout");
321
+ assert.deepEqual(execution.blockedAuth, {
322
+ toolName: "creator-scout",
323
+ error: "authentication expired",
324
+ resolution
325
+ });
326
+
327
+ const skipped = await run("campaign-draft-runner");
328
+ assert.equal(skipped.status, "blocked_prerequisite");
329
+ assert.equal(skipped.resolution.prerequisiteStatus, "blocked_auth");
330
+ assert.equal(executions, 1);
331
+ });
332
+
297
333
  test("rejects missing artifact input before running a tool", async () => {
298
334
  const calls = [];
299
335
  const capabilities = createCapabilities({
@@ -395,6 +395,7 @@ test("selectScheduledTasks bounds history while keeping active tasks", () => {
395
395
  status: "done"
396
396
  }));
397
397
  tasks[0] = { id: "pending-1", status: "pending" };
398
+ tasks[1] = { id: "blocked-1", status: "blocked_auth" };
398
399
 
399
400
  const result = selectScheduledTasks(tasks);
400
401
 
@@ -402,7 +403,7 @@ test("selectScheduledTasks bounds history while keeping active tasks", () => {
402
403
  assert.equal(result.returned, 50);
403
404
  assert.equal(result.limit, 50);
404
405
  assert.equal(result.truncated, true);
405
- assert.equal(result.tasks[0].id, "pending-1");
406
+ assert.deepEqual(result.tasks.slice(0, 2).map((task) => task.id), ["blocked-1", "pending-1"]);
406
407
  });
407
408
 
408
409
  test("selectScheduledTasks honors an explicit status and limit", () => {
@@ -244,7 +244,7 @@ test("supervisor ignores invalid chat directories and recovers valid daemons", a
244
244
  }
245
245
  });
246
246
 
247
- test("does not restart registrations whose scope no longer matches the tool manifest", async () => {
247
+ test("removes stopped registrations whose scope no longer matches the tool manifest", async () => {
248
248
  const runtime = runtimeFor({ type: "global" }, { autoStart: true });
249
249
  const registry = new ToolRegistry();
250
250
  registry.tools.set("fake-daemon", {
@@ -267,11 +267,9 @@ test("does not restart registrations whose scope no longer matches the tool mani
267
267
  const results = await supervisor.repair();
268
268
  const result = results.find((item) => item.record.toolName === "fake-daemon" && item.record.instanceId === "global");
269
269
 
270
- assert.equal(result.outcome, "stale-registration");
270
+ assert.equal(result.outcome, "obsolete-removed");
271
271
  assert.match(result.reason, /global scope does not match manifest chat scope/);
272
272
  assert.equal(isProcessAlive(await runtime.getPid()), false);
273
-
274
- await unregisterManagedDaemon({ toolName: "fake-daemon", scope: { type: "global" } });
275
273
  assert.deepEqual(await readJson(runtime.paths.metaFile, null), null);
276
274
  });
277
275
 
@@ -121,6 +121,25 @@ test("lists each checked daemon with its scope and state", async () => {
121
121
  assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
122
122
  });
123
123
 
124
+ test("reports automatic obsolete daemon cleanup and unverifiable leftovers", async () => {
125
+ const { report } = await run({
126
+ repairs: [
127
+ {
128
+ record: { toolName: "removed", instanceId: "global", scope: { type: "global" } },
129
+ outcome: "obsolete-removed",
130
+ reason: "tool is no longer installed"
131
+ },
132
+ {
133
+ record: { toolName: "unknown-pid", instanceId: "global", scope: { type: "global" } },
134
+ outcome: "obsolete-unverified"
135
+ }
136
+ ]
137
+ });
138
+
139
+ assert.match(report.repairs.join("\n"), /Removed obsolete daemon removed/);
140
+ assert.match(report.attention.join("\n"), /unknown-pid.*could not be verified/);
141
+ });
142
+
124
143
  test("reports missing tool dependencies as attention items", async () => {
125
144
  const report = await runDoctor({
126
145
  agentManager: { getRuntimeDiagnostic: async () => runtime() },
@@ -28,9 +28,14 @@ test("classifies each configured memory pressure boundary", () => {
28
28
  swapTotalBytes: 0,
29
29
  swapUsedPercent: 0
30
30
  }, policy), /worker RSS/);
31
+ assert.equal(memoryPressureReason({
32
+ workerRssBytes: 400 * 1024 * 1024,
33
+ swapTotalBytes: 0,
34
+ swapUsedPercent: 0
35
+ }, policy, { ignoreWorkerRss: true }), "");
31
36
  assert.match(memoryPressureReason({
32
- workerRssBytes: 100 * 1024 * 1024,
37
+ workerRssBytes: 400 * 1024 * 1024,
33
38
  swapTotalBytes: 100,
34
39
  swapUsedPercent: 96
35
- }, policy), /swap use/);
40
+ }, policy, { ignoreWorkerRss: true }), /swap use/);
36
41
  });
@@ -355,17 +355,18 @@ test("maps supported model speeds to provider service tiers", () => {
355
355
 
356
356
  test("applies Pi speed to every provider request and updates it in place", async () => {
357
357
  const calls = [];
358
+ const model = { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.6-sol" };
358
359
  const controller = createModelSpeedController((model, context, options) => {
359
360
  calls.push({ model, context, options });
360
361
  return "stream";
361
362
  }, 1);
362
363
 
363
- assert.equal(controller.streamFn("model", "context", {
364
+ assert.equal(controller.streamFn(model, "context", {
364
365
  signal: "signal",
365
366
  onPayload: (payload) => ({ ...payload, preserved: true })
366
367
  }), "stream");
367
368
  controller.setSpeed(1.5);
368
- controller.streamFn("model", "context", { signal: "signal" });
369
+ controller.streamFn(model, "context", { signal: "signal" });
369
370
 
370
371
  assert.equal(calls[0].options.serviceTier, "default");
371
372
  assert.equal(calls[1].options.serviceTier, "priority");
@@ -0,0 +1,61 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { reapObsoleteDaemon } from "../src/runtime/obsolete-daemon-reaper.js";
4
+
5
+ const record = {
6
+ toolName: "removed-tool",
7
+ instanceId: "global",
8
+ entryPath: "/tools/removed-tool/index.js",
9
+ scope: { type: "global" }
10
+ };
11
+
12
+ test("purges an obsolete daemon with no live process", async () => {
13
+ const purged = [];
14
+ const result = await reapObsoleteDaemon({
15
+ record,
16
+ diagnostic: { pid: null },
17
+ reason: "tool is no longer installed",
18
+ purgeDaemon: async (identity) => purged.push(identity)
19
+ });
20
+
21
+ assert.equal(result.outcome, "obsolete-removed");
22
+ assert.deepEqual(purged, [{ toolName: "removed-tool", scope: { type: "global" } }]);
23
+ });
24
+
25
+ test("terminates only a verified obsolete daemon process before purging", async () => {
26
+ const stopped = [];
27
+ const purged = [];
28
+ const result = await reapObsoleteDaemon({
29
+ record,
30
+ diagnostic: { pid: 321 },
31
+ reason: "tool is no longer installed",
32
+ timeoutMs: 100,
33
+ stopTimeoutMs: 50,
34
+ inspectProcesses: async () => [{ pid: 321, command: `${process.execPath} ${record.entryPath} daemon` }],
35
+ stopProcess: async (pid, options) => stopped.push([pid, options]),
36
+ purgeDaemon: async (identity) => purged.push(identity)
37
+ });
38
+
39
+ assert.equal(result.outcome, "obsolete-removed");
40
+ assert.deepEqual(stopped, [[321, { forceAfterMs: 50 }]]);
41
+ assert.equal(purged.length, 1);
42
+ });
43
+
44
+ test("leaves an unverifiable live PID untouched", async () => {
45
+ let stopped = false;
46
+ let purged = false;
47
+ const result = await reapObsoleteDaemon({
48
+ record,
49
+ diagnostic: { pid: 321 },
50
+ reason: "registered entry does not match the installed tool",
51
+ timeoutMs: 100,
52
+ stopTimeoutMs: 50,
53
+ inspectProcesses: async () => [{ pid: 321, command: "node /some/other/process.js daemon" }],
54
+ stopProcess: async () => { stopped = true; },
55
+ purgeDaemon: async () => { purged = true; }
56
+ });
57
+
58
+ assert.equal(result.outcome, "obsolete-unverified");
59
+ assert.equal(stopped, false);
60
+ assert.equal(purged, false);
61
+ });
@@ -10,9 +10,14 @@ test("official orchestrators declare their hard tool dependencies", async () =>
10
10
  assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.2.0" });
11
11
  assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
12
12
  "pr-campaign": "^0.1.0",
13
- "gmail-workspace": "^0.1.0"
13
+ "gmail-workspace": "^0.1.0",
14
+ "lightpanda-browser": "^0.10.0"
15
+ });
16
+ assert.deepEqual((await manifest("browser-session-bridge")).toolDependencies, { "lightpanda-browser": "^0.11.5" });
17
+ assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, {
18
+ "x-dm": "^0.4.0",
19
+ "lightpanda-browser": "^0.11.0"
14
20
  });
15
- assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.4.0" });
16
21
  assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
17
22
  assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
18
23
  assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import crypto from "node:crypto";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -88,9 +89,21 @@ test("every bundled official tool lock matches the catalog source", async () =>
88
89
  );
89
90
  const manifest = JSON.parse(await readFile(path.join(source, "tool.manifest.json"), "utf8"));
90
91
  assert.equal(manifest.version ?? null, entry.version ?? null, `${name} version`);
92
+ assert.deepEqual(manifest.toolDependencies || {}, entry.toolDependencies || {}, `${name} tool dependencies`);
91
93
  }
92
94
  });
93
95
 
96
+ test("the bundled lock commit contains the exact catalog snapshot", async () => {
97
+ const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
98
+ const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url));
99
+ execFileSync("git", ["cat-file", "-e", `${lock.commit}^{commit}`], { cwd: repositoryRoot });
100
+ const changedTools = execFileSync("git", ["diff", "--name-only", lock.commit, "--", "tools"], {
101
+ cwd: repositoryRoot,
102
+ encoding: "utf8"
103
+ }).trim();
104
+ assert.equal(changedTools, "", `Bundled lock commit does not match catalog source:\n${changedTools}`);
105
+ });
106
+
94
107
  test("rejects symbolic links before deployment", async (t) => {
95
108
  const { source, files } = await fixture(t);
96
109
  await symlink(path.join(source, "index.js"), path.join(source, "link.js"));
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
+ import { createPiOAuthLogin } from "../src/core/agent/pi-auth-login.js";
5
+ import { createPiRuntime, hasProviderAuth, supportsProviderOAuth } from "../src/core/agent/pi-runtime.js";
6
+ import { piAuthFile } from "../src/platform/paths.js";
7
+
8
+ test("creates the async Pi runtime with Arisa credential storage and awaits runtime API keys", async (t) => {
9
+ const calls = [];
10
+ const runtime = {
11
+ async setRuntimeApiKey(provider, apiKey) {
12
+ await Promise.resolve();
13
+ calls.push([provider, apiKey]);
14
+ },
15
+ getProviderAuthStatus: () => ({ configured: true, source: "stored" }),
16
+ getProvider: () => ({ auth: { oauth: {} } })
17
+ };
18
+ t.mock.method(ModelRuntime, "create", async (options) => {
19
+ assert.equal(options.authPath, piAuthFile);
20
+ return runtime;
21
+ });
22
+ assert.equal(await createPiRuntime({ provider: "openai", apiKey: "test-key" }), runtime);
23
+ assert.deepEqual(calls, [["openai", "test-key"]]);
24
+ assert.equal(hasProviderAuth("openai", runtime), true);
25
+ assert.equal(supportsProviderOAuth("openai", runtime), true);
26
+ });
27
+
28
+ test("adapts Pi OAuth notifications, selections and manual codes in order", async (t) => {
29
+ const events = [];
30
+ let controller;
31
+ const credential = { type: "oauth", access: "test-access" };
32
+ t.mock.method(ModelRuntime, "create", async () => ({
33
+ getProvider: () => ({ auth: { oauth: {} } }),
34
+ async login(provider, type, interaction) {
35
+ assert.equal(provider, "openai-codex");
36
+ assert.equal(type, "oauth");
37
+ interaction.notify({ type: "auth_url", url: "https://example.com/login" });
38
+ interaction.notify({ type: "device_code", userCode: "ABCD", verificationUri: "https://example.com/device" });
39
+ interaction.notify({ type: "progress", message: "waiting" });
40
+ assert.equal(await interaction.prompt({ type: "select", options: [{ id: "device", label: "Device" }] }), "device");
41
+ assert.deepEqual(events, ["auth", "device", "waiting", "select"]);
42
+ assert.equal(await interaction.prompt({ type: "text", message: "Value" }), "answer");
43
+ const code = interaction.prompt({ type: "manual_code" });
44
+ await new Promise((resolve) => setImmediate(resolve));
45
+ assert.equal(controller.manualInputRequested, true);
46
+ assert.equal(controller.submitManualCode(" callback-code "), true);
47
+ assert.equal(await code, "callback-code");
48
+ return credential;
49
+ }
50
+ }));
51
+ controller = createPiOAuthLogin({
52
+ provider: "openai-codex",
53
+ onAuth: async () => { await Promise.resolve(); events.push("auth"); },
54
+ onDeviceCode: async () => { events.push("device"); },
55
+ onProgress: (message) => events.push(message),
56
+ onSelect: ({ options }) => { events.push("select"); return options[0].id; },
57
+ onPrompt: () => "answer"
58
+ });
59
+ assert.equal(await controller.promise, credential);
60
+ assert.equal(controller.manualInputRequested, false);
61
+ assert.equal(controller.submitManualCode("again"), false);
62
+ });
63
+
64
+ test("OAuth rejects unsupported providers and surfaces notification failures", async (t) => {
65
+ t.mock.method(ModelRuntime, "create", async () => ({ getProvider: () => ({ auth: {} }) }));
66
+ await assert.rejects(createPiOAuthLogin({ provider: "unsupported" }).promise, /No internal OAuth login flow/);
67
+ ModelRuntime.create.mock.restore();
68
+ t.mock.method(ModelRuntime, "create", async () => ({
69
+ getProvider: () => ({ auth: { oauth: {} } }),
70
+ async login(_provider, _type, interaction) {
71
+ interaction.notify({ type: "auth_url", url: "https://example.com/login" });
72
+ await interaction.prompt({ type: "manual_code" });
73
+ }
74
+ }));
75
+ const login = createPiOAuthLogin({ provider: "openai-codex", onAuth: async () => { throw new Error("delivery failed"); } });
76
+ await assert.rejects(login.promise, /delivery failed/);
77
+ assert.equal(login.manualInputRequested, false);
78
+ });
@@ -5,6 +5,7 @@ import { createPiCapabilityTools } from "../src/core/agent/pi-capability-tools.j
5
5
  function createHarness() {
6
6
  const calls = [];
7
7
  let taskContext = { transportChatId: "chat-1", messageThreadId: 10 };
8
+ const agentTaskExecution = { blockedAuth: null };
8
9
  const capabilityService = {
9
10
  async execute(request) {
10
11
  calls.push(request);
@@ -15,6 +16,7 @@ function createHarness() {
15
16
  };
16
17
  const telegram = {
17
18
  getTaskContext: () => taskContext,
19
+ getAgentTaskExecution: () => agentTaskExecution,
18
20
  sendMedia: async () => {}
19
21
  };
20
22
  const tools = createPiCapabilityTools({
@@ -62,4 +64,5 @@ test("Pi tool execution resolves the current Telegram task context per call", as
62
64
 
63
65
  assert.equal(harness.calls[0].context.taskContext.messageThreadId, 10);
64
66
  assert.equal(harness.calls[1].context.taskContext.messageThreadId, 20);
67
+ assert.equal(harness.calls[0].context.agentTaskExecution, harness.calls[1].context.agentTaskExecution);
65
68
  });