arisa 5.2.17 → 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 (33) hide show
  1. package/README.md +4 -2
  2. package/package.json +10 -9
  3. package/pnpm-workspace.yaml +7 -5
  4. package/src/core/agent/agent-manager.js +13 -14
  5. package/src/core/agent/auth-flow.js +6 -6
  6. package/src/core/agent/model-speed.js +3 -1
  7. package/src/core/agent/pi-auth-login.js +28 -28
  8. package/src/core/agent/pi-capability-tools.js +1 -0
  9. package/src/core/agent/pi-runtime.js +14 -21
  10. package/src/core/capabilities/capability-service.js +25 -0
  11. package/src/core/tasks/task-store.js +2 -1
  12. package/src/official-tools.lock.json +25 -18
  13. package/src/runtime/bootstrap-cli.js +3 -3
  14. package/src/runtime/bootstrap-telegram.js +7 -7
  15. package/src/runtime/slave-cli.js +20 -10
  16. package/src/runtime/slave-service.js +299 -7
  17. package/src/runtime/tui.js +5 -6
  18. package/src/transport/telegram/bot.js +9 -5
  19. package/src/transport/telegram/model-controls.js +1 -1
  20. package/src/transport/telegram/task-dispatcher.js +50 -23
  21. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  22. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  23. package/test/agent-turn-coordinator.test.js +5 -2
  24. package/test/auth-flow.test.js +2 -2
  25. package/test/capabilities-security.test.js +36 -0
  26. package/test/model-selection.test.js +3 -2
  27. package/test/official-tool-installer.test.js +13 -0
  28. package/test/pi-auth-login.test.js +78 -0
  29. package/test/pi-capability-tools.test.js +3 -0
  30. package/test/pi-speed-integration.test.js +176 -0
  31. package/test/slave-cli.test.js +221 -5
  32. package/test/task-store.test.js +3 -1
  33. package/test/telegram-task-dispatcher.test.js +57 -2
@@ -41,7 +41,7 @@ function buildFailureNotice({ task, result, error }) {
41
41
  if (result?.authBlockedNew === true) {
42
42
  return [
43
43
  "⚠️ Arisa automation paused for authentication",
44
- `Tool: ${task.payload?.toolName || "unknown"}`,
44
+ `Tool: ${result.authBlock?.toolName || task.payload?.toolName || "unknown"}`,
45
45
  `Reason: ${safeErrorSummary(error)}`,
46
46
  `Next authentication check: ${result.runAt}`
47
47
  ].join("\n");
@@ -72,17 +72,62 @@ export function createTelegramTaskDispatcher({
72
72
  const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
73
73
  const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
74
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
+
75
98
  async function dispatchAgentTask(task, chatId) {
76
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
+
77
111
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
112
+ const agentTaskExecution = { blockedAuth: null };
78
113
  await enqueueAsyncPrompt({
79
114
  chatId,
80
115
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
81
116
  label: `scheduled task ${task.id}`,
82
117
  route: task.route,
83
118
  timeoutMs: agentTimeoutMs,
84
- priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background"
119
+ priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background",
120
+ agentTaskExecution
85
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
+ }
86
131
  }
87
132
 
88
133
  async function dispatchAgentEvent(task, chatId) {
@@ -111,34 +156,16 @@ export function createTelegramTaskDispatcher({
111
156
  if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
112
157
  logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
113
158
 
114
- const runTool = (args) => agentManager.runTurn({
115
- priority: "background",
116
- label: `poll tool ${toolName}`
117
- }, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
118
-
119
- function throwFailure(result, fallbackResolution) {
120
- const error = new Error(result?.error || `poll_tool ${toolName} failed`);
121
- if (result?.status === "blocked_auth" || fallbackResolution) {
122
- error.retryable = false;
123
- error.authBlocked = true;
124
- error.authResolution = result?.resolution || fallbackResolution;
125
- } else if (result?.status === "needs_config") {
126
- error.retryable = false;
127
- } else if (result?.status === "outcome_uncertain") {
128
- error.retryable = false;
129
- error.outcomeUncertain = true;
130
- }
131
- throw error;
132
- }
159
+ const runTool = (args) => runBackgroundTool(toolName, chatId, args, `poll tool ${toolName}`);
133
160
 
134
161
  if (task.authBlock) {
135
162
  const probe = await runTool(task.authBlock.probeArgs || {});
136
- if (probe?.ok === false) throwFailure(probe, task.authBlock);
163
+ if (probe?.ok === false) throwToolFailure(probe, toolName, task.authBlock);
137
164
  logger?.log("tasks", `authentication restored for ${toolName} (task ${task.id})`);
138
165
  }
139
166
 
140
167
  const result = await runTool(task.payload.args || {});
141
- if (result?.ok === false) throwFailure(result);
168
+ if (result?.ok === false) throwToolFailure(result, toolName);
142
169
  }
143
170
 
144
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
 
@@ -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
 
@@ -25,13 +25,16 @@ test("interactive turns run before queued background turns without overlapping",
25
25
  assert.equal(coordinator.diagnostic().completed, 3);
26
26
  });
27
27
 
28
- test("background turns expire safely before execution when their queue TTL elapses", async () => {
28
+ test("background turns expire safely before execution when their queue TTL elapses", async (t) => {
29
+ t.mock.timers.enable({ apis: ["setTimeout"] });
29
30
  const coordinator = new AgentTurnCoordinator();
30
31
  const releaseActive = await coordinator.acquire({ priority: "interactive", label: "active" });
31
- await assert.rejects(
32
+ const expired = assert.rejects(
32
33
  coordinator.acquire({ priority: "background", label: "stale batch", queueTtlMs: 10 }),
33
34
  (error) => error.code === "AGENT_TURN_QUEUE_EXPIRED" && error.retryable === true && error.outcomeUncertain === false
34
35
  );
36
+ t.mock.timers.tick(10);
37
+ await expired;
35
38
  assert.equal(coordinator.diagnostic().expired, 1);
36
39
  releaseActive();
37
40
  });
@@ -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({
@@ -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");
@@ -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
  });
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { zstdDecompressSync } from "node:zlib";
7
+ import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
8
+ import { createModelSpeedController } from "../src/core/agent/model-speed.js";
9
+ import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
10
+ import { resolveChatModelSelection, resolveChatSpeed } from "../src/core/agent/model-selection.js";
11
+ import { createTelegramModelControls } from "../src/transport/telegram/model-controls.js";
12
+ import { createTelegramModelCallbackHandler } from "../src/transport/telegram/model-callback.js";
13
+
14
+ async function createRuntime(t, credential) {
15
+ const directory = await mkdtemp(path.join(tmpdir(), "arisa-pi-speed-"));
16
+ t.after(() => rm(directory, { recursive: true, force: true }));
17
+ if (credential) await writeFile(path.join(directory, "auth.json"), JSON.stringify({ "openai-codex": credential }));
18
+ const runtime = await ModelRuntime.create({
19
+ authPath: path.join(directory, "auth.json"),
20
+ modelsPath: null,
21
+ modelsStorePath: path.join(directory, "models-store.json"),
22
+ refreshOnCreate: false
23
+ });
24
+ return { directory, runtime };
25
+ }
26
+
27
+ test("speed picker updates Astra in place, persists per topic, and closes unchanged selections", async (t) => {
28
+ const { runtime } = await createRuntime(t);
29
+ t.mock.method(ModelRuntime, "create", async () => runtime);
30
+ const config = applyConfigDefaults({ pi: { provider: "openai-codex", model: "gpt-6-astra" } });
31
+ const writes = [];
32
+ const updates = [];
33
+ const replies = [];
34
+ const answers = [];
35
+ const controls = createTelegramModelControls({
36
+ config,
37
+ saveConfig: async (value) => writes.push(structuredClone(value)),
38
+ agentManager: { setModelSpeed: async (...args) => updates.push(args) },
39
+ contextRoute: () => ({ sessionId: "123:topic:7" })
40
+ });
41
+ const ctx = {
42
+ chat: { id: 123 },
43
+ reply: async (...args) => replies.push(args),
44
+ api: { editMessageText: async (...args) => replies.push(args) },
45
+ answerCallbackQuery: async (answer) => answers.push(answer)
46
+ };
47
+ await controls.showSpeedPicker(ctx);
48
+ assert.equal(replies[0][1]?.reply_markup.inline_keyboard[1][0].callback_data, "speed:1.5");
49
+ const handler = createTelegramModelCallbackHandler({
50
+ ...controls, config,
51
+ authorizeContext: async () => ({ ok: true }),
52
+ contextRoute: () => ({ sessionId: "123:topic:7" }),
53
+ getChatState: () => ({ processing: true })
54
+ });
55
+ ctx.callbackQuery = { data: "speed:1.5", message: { message_id: 456 } };
56
+ await handler(ctx);
57
+ assert.deepEqual(updates, [["123:topic:7", 1.5]]);
58
+ assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 1.5);
59
+ assert.equal(resolveChatSpeed(config, "123:topic:8"), 1);
60
+ assert.equal(resolveChatModelSelection(config, "123:topic:7").sessionRevision, 0);
61
+ await handler(ctx);
62
+ assert.equal(writes.length, 1);
63
+ assert.match(replies.at(-1)[2], /Already using speed 1.5x/);
64
+ ctx.callbackQuery.data = "speed:1";
65
+ await handler(ctx);
66
+ assert.equal(resolveChatSpeed(config, "123:topic:7"), 1);
67
+ assert.equal(answers.at(-1).text, "Speed: 1.0x.");
68
+ });
69
+
70
+ test("Pi SDK sends the selected speed in the actual Codex payload across turns", async (t) => {
71
+ const apiKey = `test.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "test-account" } })).toString("base64url")}.test`;
72
+ const { directory, runtime } = await createRuntime(t, {
73
+ type: "oauth", access: apiKey, refresh: "test-refresh", expires: Date.now() + 3_600_000
74
+ });
75
+ const model = runtime.getModel("openai-codex", "gpt-6-astra");
76
+ const resourceLoader = new DefaultResourceLoader({
77
+ cwd: directory, agentDir: directory, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true
78
+ });
79
+ await resourceLoader.reload();
80
+ const { session } = await createAgentSession({
81
+ cwd: directory, agentDir: directory, modelRuntime: runtime, model,
82
+ resourceLoader, settingsManager: SettingsManager.inMemory({ retry: { enabled: false } }),
83
+ sessionManager: SessionManager.inMemory(), tools: []
84
+ });
85
+ t.after(() => session.dispose());
86
+ const requests = [];
87
+ const fetch = async (_url, init) => {
88
+ const body = init.headers.get("content-encoding") === "zstd"
89
+ ? zstdDecompressSync(init.body).toString("utf8")
90
+ : init.body;
91
+ requests.push(JSON.parse(body));
92
+ return new Response(`data: ${JSON.stringify({ type: "response.completed", response: {
93
+ id: "test-response", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
94
+ } })}\n\n`, { headers: { "content-type": "text/event-stream" } });
95
+ };
96
+ t.mock.method(globalThis, "fetch", fetch);
97
+ const sdkStream = session.agent.streamFunction;
98
+ const controller = createModelSpeedController((model, context, options) => sdkStream(model, context, {
99
+ ...options, transport: "sse", maxRetries: 0
100
+ }), 1);
101
+ session.agent.streamFunction = controller.streamFn;
102
+ for (const speed of [1, 1.5, 1]) {
103
+ controller.setSpeed(speed);
104
+ await session.prompt("Reply OK");
105
+ const message = session.messages.at(-1);
106
+ assert.equal(message.stopReason, "stop", message.errorMessage);
107
+ assert.equal(requests.at(-1).model, model.id);
108
+ assert.equal(requests.at(-1).service_tier, speed === 1.5 ? "priority" : "default");
109
+ }
110
+ assert.equal(requests.length, 3);
111
+ });
112
+
113
+ test("speed control leaves unsupported provider payloads and hooks untouched", async () => {
114
+ const options = { onPayload: (payload) => payload };
115
+ let received;
116
+ const controller = createModelSpeedController((_model, _context, nextOptions) => { received = nextOptions; }, 1.5);
117
+ controller.streamFn({ provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-5" }, {}, options);
118
+ assert.equal(received, options);
119
+ });
120
+
121
+ test("Arisa creates and reuses Telegram sessions and opens its TUI with the installed Pi SDK", async (t) => {
122
+ const directory = await mkdtemp(path.join(tmpdir(), "arisa-pi-startup-"));
123
+ t.after(() => rm(directory, { recursive: true, force: true }));
124
+ const { execFile } = await import("node:child_process");
125
+ const { promisify } = await import("node:util");
126
+ await promisify(execFile)(process.execPath, ["--input-type=module", "-e", `
127
+ import assert from "node:assert/strict";
128
+ import { mkdir, writeFile } from "node:fs/promises";
129
+ import path from "node:path";
130
+ import { applyConfigDefaults } from "./src/core/config/config-defaults.js";
131
+ import { AgentManager } from "./src/core/agent/agent-manager.js";
132
+ import { createArisaTuiRuntime } from "./src/runtime/tui.js";
133
+ import { selectChatSpeed } from "./src/core/agent/model-selection.js";
134
+ import { ensureArisaHome, piAuthFile } from "./src/platform/paths.js";
135
+ globalThis.fetch = async () => { throw new Error("Unexpected network request"); };
136
+ await ensureArisaHome();
137
+ await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true });
138
+ await writeFile(piAuthFile, JSON.stringify({ "openai-codex": {
139
+ type: "oauth", access: "test-access", refresh: "test-refresh", expires: Date.now() + 3600000
140
+ } }));
141
+ const config = applyConfigDefaults({
142
+ telegram: { authorizedChatIds: [123] },
143
+ pi: { provider: "openai-codex", model: "gpt-6-astra", speed: 1.5, workspaceDir: process.env.ARISA_HOME }
144
+ });
145
+ const manager = new AgentManager({ config });
146
+ manager.setCapabilityService({ execute: async () => ({}) });
147
+ const context = await manager.getSessionContext("123", {});
148
+ try {
149
+ assert.equal(context.session.model.id, "gpt-6-astra");
150
+ assert.equal(context.session.agent.streamFunction, context.speedController.streamFn);
151
+ assert.equal(context.speedController.speed, 1.5);
152
+ await manager.setModelSpeed("123", 1);
153
+ selectChatSpeed(config, "123", 1);
154
+ const reused = await manager.getSessionContext("123", {});
155
+ assert.equal(reused.session, context.session);
156
+ assert.equal(reused.speedController.speed, 1);
157
+ await reused.release();
158
+ } finally {
159
+ await context.release();
160
+ manager.clearSessionCache("123");
161
+ await Promise.all(manager.sessionClosePromises.values());
162
+ manager.turnCoordinator.close();
163
+ }
164
+ const tui = await createArisaTuiRuntime({ config, client: {} });
165
+ try {
166
+ assert.equal(tui.session.model.id, "gpt-6-astra");
167
+ assert.equal(typeof tui.session.agent.streamFunction, "function");
168
+ } finally {
169
+ await tui.dispose();
170
+ }
171
+ `], {
172
+ cwd: new URL("..", import.meta.url),
173
+ env: { ...process.env, ARISA_HOME: directory, PI_CODING_AGENT_DIR: path.join(directory, "pi"), PI_OFFLINE: "1" },
174
+ timeout: 15_000
175
+ });
176
+ });