arisa 5.2.17 → 5.2.20

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 (47) hide show
  1. package/LOW-MEMORY.md +37 -0
  2. package/README.md +6 -3
  3. package/package.json +3 -3
  4. package/pnpm-workspace.yaml +7 -5
  5. package/src/core/agent/agent-manager.js +13 -14
  6. package/src/core/agent/auth-flow.js +6 -6
  7. package/src/core/agent/model-selection.js +4 -3
  8. package/src/core/agent/model-speed.js +13 -3
  9. package/src/core/agent/pi-auth-login.js +28 -28
  10. package/src/core/agent/pi-capability-tools.js +1 -0
  11. package/src/core/agent/pi-runtime.js +14 -21
  12. package/src/core/artifacts/artifact-index.js +107 -0
  13. package/src/core/artifacts/artifact-store.js +15 -84
  14. package/src/core/artifacts/legacy-artifact-reader.js +46 -0
  15. package/src/core/capabilities/capability-service.js +25 -0
  16. package/src/core/tasks/task-store.js +2 -1
  17. package/src/index.js +10 -4
  18. package/src/official-tools.lock.json +25 -18
  19. package/src/platform/paths.js +5 -0
  20. package/src/runtime/bootstrap-cli.js +3 -3
  21. package/src/runtime/bootstrap-telegram.js +7 -7
  22. package/src/runtime/slave-cli.js +20 -10
  23. package/src/runtime/slave-service.js +299 -7
  24. package/src/runtime/tui.js +5 -6
  25. package/src/transport/telegram/bot.js +9 -5
  26. package/src/transport/telegram/model-callback.js +3 -2
  27. package/src/transport/telegram/model-controls.js +4 -4
  28. package/src/transport/telegram/model-picker.js +1 -1
  29. package/src/transport/telegram/task-dispatcher.js +50 -23
  30. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  31. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  32. package/test/agent-turn-coordinator.test.js +5 -2
  33. package/test/artifact-index-memory.test.js +46 -0
  34. package/test/artifact-index-migration.test.js +88 -0
  35. package/test/artifact-store.test.js +3 -3
  36. package/test/auth-flow.test.js +2 -2
  37. package/test/capabilities-security.test.js +36 -0
  38. package/test/cli-memory.test.js +22 -0
  39. package/test/model-selection.test.js +14 -4
  40. package/test/official-tool-installer.test.js +13 -0
  41. package/test/paths.test.js +2 -0
  42. package/test/pi-auth-login.test.js +78 -0
  43. package/test/pi-capability-tools.test.js +3 -0
  44. package/test/pi-speed-integration.test.js +177 -0
  45. package/test/slave-cli.test.js +221 -5
  46. package/test/task-store.test.js +3 -1
  47. package/test/telegram-task-dispatcher.test.js +57 -2
@@ -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
  });
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, open, rm } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ test("migrates a 100 MiB history and repeatedly accesses it with a 48 MiB heap", { timeout: 120_000 }, async (t) => {
9
+ const root = await mkdtemp(path.join(os.tmpdir(), "artifact-memory-"));
10
+ t.after(() => rm(root, { recursive: true, force: true }));
11
+ const options = {chatId:"test",legacyFile:path.join(root,"artifacts.json"),databaseFile:path.join(root,"artifacts.sqlite")};
12
+ const file = await open(options.legacyFile, "wx", 0o600);
13
+ try {
14
+ await file.write("[");
15
+ for (let i = 0; i < 1024; i++) {
16
+ await file.write((i ? "," : "") + JSON.stringify({id:String(i),chatId:"test",text:"x".repeat(100*1024)}));
17
+ }
18
+ await file.write("]");
19
+ } finally { await file.close(); }
20
+ const moduleUrl = new URL("../src/core/artifacts/artifact-index.js", import.meta.url).href;
21
+ const code = `
22
+ import assert from 'node:assert/strict';
23
+ import {withArtifactIndex,getArtifact,appendArtifact,listRecentArtifacts} from ${JSON.stringify(moduleUrl)};
24
+ const f=${JSON.stringify(options)};
25
+ const start=performance.now();
26
+ await withArtifactIndex(f,db=>assert.equal(getArtifact(db,'0').text.length,102400));
27
+ const migrated=performance.now();
28
+ for(let i=0;i<100;i++) {
29
+ await withArtifactIndex(f,db=>appendArtifact(db,{id:'new-'+i,chatId:'test',text:'small'}));
30
+ await withArtifactIndex(f,db=>assert.equal(getArtifact(db,String(i)).text.length,102400));
31
+ await withArtifactIndex(f,db=>assert.equal(listRecentArtifacts(db,20).length,20));
32
+ }
33
+ await withArtifactIndex(f,db=>assert.equal(db.prepare('SELECT count(*) AS n FROM artifacts').get().n,1124));
34
+ console.log(JSON.stringify({migrationMs:Math.round(migrated-start),operationsMs:Math.round(performance.now()-migrated),maxRssKiB:process.resourceUsage().maxRSS,heapMiB:Math.round(process.memoryUsage().heapUsed/1048576)}));
35
+ `;
36
+ const result = await new Promise((resolve, reject) => {
37
+ const child = spawn(process.execPath, ["--max-old-space-size=48", "--input-type=module", "-e", code], {stdio:["ignore","pipe","pipe"]});
38
+ let stdout = "", stderr = "";
39
+ child.stdout.on("data", data => {stdout += data;});
40
+ child.stderr.on("data", data => {stderr += data;});
41
+ child.on("error", reject);
42
+ child.on("exit", code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
43
+ });
44
+ t.diagnostic(JSON.stringify(result));
45
+ assert.ok(result.heapMiB < 48);
46
+ });
@@ -0,0 +1,88 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, writeFile, readFile, rm, stat } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+ import { readLegacyArtifacts } from "../src/core/artifacts/legacy-artifact-reader.js";
9
+ import { withArtifactIndex, getArtifact, appendArtifact, listRecentArtifacts } from "../src/core/artifacts/artifact-index.js";
10
+
11
+ async function fixture(t) {
12
+ const root = await mkdtemp(path.join(os.tmpdir(), "artifact-migration-"));
13
+ t.after(() => rm(root, { recursive: true, force: true }));
14
+ return { chatId: "test", legacyFile: path.join(root, "artifacts.json"), databaseFile: path.join(root, "artifacts.sqlite") };
15
+ }
16
+
17
+ const artifacts = [
18
+ { id: "a", chatId: "test", kind: "text", text: 'ñ🙂[\\\"}]\n', metadata: { nested: [{ x: true }] } },
19
+ { id: "b", chatId: "test", kind: "document", path: "/unchanged/report.pdf", source: { type: "test" } }
20
+ ];
21
+
22
+ test("legacy parser handles UTF-8, escaping and every token crossing chunk boundaries", async (t) => {
23
+ const f = await fixture(t);
24
+ await writeFile(f.legacyFile, JSON.stringify(artifacts, null, 2));
25
+ for (const highWaterMark of [1, 2, 7, 64]) {
26
+ const result = [];
27
+ for await (const item of readLegacyArtifacts(f.legacyFile, { highWaterMark })) result.push(item);
28
+ assert.deepEqual(result, artifacts);
29
+ }
30
+ });
31
+
32
+ test("migration preserves all fields, order and original bytes; runs only once", async (t) => {
33
+ const f = await fixture(t);
34
+ const original = JSON.stringify(artifacts, null, 2);
35
+ await writeFile(f.legacyFile, original);
36
+ assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 20)), artifacts.toReversed());
37
+ assert.equal(await readFile(f.legacyFile, "utf8"), original);
38
+ const next = { id: "c", chatId: "test", text: "new" };
39
+ await withArtifactIndex(f, db => appendArtifact(db, next));
40
+ assert.deepEqual(await withArtifactIndex(f, db => getArtifact(db, "a")), artifacts[0]);
41
+ assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 20)), [next, ...artifacts.toReversed()]);
42
+ assert.equal((await stat(f.databaseFile)).mode & 0o777, 0o600);
43
+ const db = new DatabaseSync(f.databaseFile);
44
+ assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok");
45
+ db.close();
46
+ });
47
+
48
+ test("invalid migrations roll back completely and can be retried after repair", async (t) => {
49
+ const f = await fixture(t);
50
+ for (const broken of ['{}', '[', '[{}]', '[null]', '[1]', '[{"id":"a","chatId":"test"},]', JSON.stringify(artifacts).slice(0, -1), JSON.stringify(artifacts) + 'x', JSON.stringify([artifacts[0], artifacts[0]]), JSON.stringify([{...artifacts[0],chatId:"other"}])]) {
51
+ await writeFile(f.legacyFile, broken);
52
+ await assert.rejects(withArtifactIndex(f, () => {}), /Artifact index is unreadable/);
53
+ assert.equal(await readFile(f.legacyFile, "utf8"), broken);
54
+ const db = new DatabaseSync(f.databaseFile);
55
+ assert.equal(db.prepare("PRAGMA user_version").get().user_version, 0);
56
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='artifacts'").get().n, 0);
57
+ db.close();
58
+ }
59
+ await writeFile(f.legacyFile, JSON.stringify(artifacts));
60
+ assert.deepEqual(await withArtifactIndex(f, db => getArtifact(db, "a")), artifacts[0]);
61
+ });
62
+
63
+ test("separate processes migrate and append without lost writes", async (t) => {
64
+ const f = await fixture(t);
65
+ await writeFile(f.legacyFile, JSON.stringify(artifacts));
66
+ const moduleUrl = new URL("../src/core/artifacts/artifact-index.js", import.meta.url).href;
67
+ await Promise.all(Array.from({length: 3}, (_, n) => new Promise((resolve, reject) => {
68
+ const child = spawn(process.execPath, ["--input-type=module", "-e", `
69
+ import {withArtifactIndex,appendArtifact} from ${JSON.stringify(moduleUrl)};
70
+ for(let i=0;i<20;i++) await withArtifactIndex(${JSON.stringify(f)}, db => appendArtifact(db, {id:'${n}-'+i,chatId:'test',text:'ok'}));
71
+ `], {stdio:["ignore","ignore","pipe"]});
72
+ let stderr = "";
73
+ child.stderr.on("data", data => {stderr += data;});
74
+ child.on("error", reject);
75
+ child.on("exit", code => code === 0 ? resolve() : reject(new Error(stderr)));
76
+ })));
77
+ assert.equal((await withArtifactIndex(f, db => listRecentArtifacts(db, 100))).length, 62);
78
+ });
79
+
80
+ test("recent queries reject oversized results instead of materializing the whole selection", async (t) => {
81
+ const f = await fixture(t);
82
+ await withArtifactIndex(f, db => {
83
+ for (let i = 0; i < 20; i++) appendArtifact(db, {id: String(i), chatId: 'test', text: 'x'.repeat(1024 * 1024)});
84
+ });
85
+ await assert.rejects(withArtifactIndex(f, db => listRecentArtifacts(db, 20)), /exceed 16 MiB/);
86
+ assert.equal((await withArtifactIndex(f, db => listRecentArtifacts(db, 2))).length, 2);
87
+ assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 0)), []);
88
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -57,7 +57,7 @@ test("serializes 100 concurrent artifact writes across store instances", async (
57
57
  })
58
58
  )));
59
59
 
60
- const persisted = JSON.parse(await readFile(getChatArtifactsIndexFile(chatId), "utf8"));
60
+ const persisted = await new ArtifactStore().forChat(chatId).listRecent(100);
61
61
  assert.equal(persisted.length, 100);
62
62
  assert.equal(new Set(persisted.map((artifact) => artifact.id)).size, 100);
63
63
  assert.deepEqual(
@@ -72,7 +72,7 @@ test("refuses to overwrite a corrupt artifact index", async () => {
72
72
  await resetHome();
73
73
  const chatId = "corrupt-chat";
74
74
  const indexFile = getChatArtifactsIndexFile(chatId);
75
- await new ArtifactStore().forChat(chatId).createText({ text: "safe", source: { type: "test" } });
75
+ await mkdir(path.dirname(indexFile), { recursive: true });
76
76
  await writeFile(indexFile, "{truncated", "utf8");
77
77
 
78
78
  await assert.rejects(
@@ -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({
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
4
+ import { promisify } from "node:util";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ test("status runs with a 24 MiB heap without loading the agent, SQLite or TUI", async (t) => {
12
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-cli-memory-"));
13
+ t.after(() => rm(home, { recursive: true, force: true }));
14
+ const entry = new URL("../src/index.js", import.meta.url);
15
+ const {stdout, stderr} = await exec(process.execPath, ["--max-old-space-size=24", entry.pathname, "status"], {
16
+ env: {...process.env, ARISA_HOME: home}, timeout: 15_000
17
+ });
18
+ assert.match(stdout, /Arisa is not running/);
19
+ assert.equal(stderr, "");
20
+ const source = await readFile(entry, "utf8");
21
+ assert.doesNotMatch(source, /^import .*from .*runtime\/(create-app|bootstrap|tui|slave-cli)\.js/m);
22
+ });
@@ -197,7 +197,7 @@ test("builds and parses the speed picker", () => {
197
197
  assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ 1\.5x$/);
198
198
  assert.deepEqual(parseSpeedPickerAction("speed:1.5"), { type: "speed", speed: 1.5 });
199
199
  assert.deepEqual(parseSpeedPickerAction("speed:1"), { type: "speed", speed: 1 });
200
- assert.equal(parseSpeedPickerAction("speed:2"), null);
200
+ assert.equal(parseSpeedPickerAction("speed:3"), null);
201
201
  });
202
202
 
203
203
  test("closes the picker after selecting the already active model and effort", async () => {
@@ -350,22 +350,32 @@ test("maps supported model speeds to provider service tiers", () => {
350
350
  assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-5.3" }, 1.5), 1);
351
351
  assert.equal(speedToServiceTier(1), "default");
352
352
  assert.equal(speedToServiceTier(1.5), "priority");
353
- assert.throws(() => normalizeModelSpeed(2), /Invalid model speed/);
353
+ assert.equal(normalizeModelSpeed(2), 2);
354
+ assert.equal(speedToServiceTier(2), "priority");
355
+ const legacyConfig = { pi: { provider: "openai-codex", model: "gpt-6-astra", speed: 1.5 } };
356
+ assert.equal(resolveChatSpeed(legacyConfig, "legacy"), 2);
357
+ assert.equal(legacyConfig.pi.speed, 1.5);
358
+ assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-6-astra" }, 1.5), 2);
359
+ assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-6-astra" }, 2), 2);
360
+ assert.equal(clampModelSpeed(fastModel, 2), 1.5);
361
+ assert.deepEqual(parseSpeedPickerAction("speed:2"), { type: "speed", speed: 2 });
362
+ assert.throws(() => normalizeModelSpeed(3), /Invalid model speed/);
354
363
  });
355
364
 
356
365
  test("applies Pi speed to every provider request and updates it in place", async () => {
357
366
  const calls = [];
367
+ const model = { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.6-sol" };
358
368
  const controller = createModelSpeedController((model, context, options) => {
359
369
  calls.push({ model, context, options });
360
370
  return "stream";
361
371
  }, 1);
362
372
 
363
- assert.equal(controller.streamFn("model", "context", {
373
+ assert.equal(controller.streamFn(model, "context", {
364
374
  signal: "signal",
365
375
  onPayload: (payload) => ({ ...payload, preserved: true })
366
376
  }), "stream");
367
377
  controller.setSpeed(1.5);
368
- controller.streamFn("model", "context", { signal: "signal" });
378
+ controller.streamFn(model, "context", { signal: "signal" });
369
379
 
370
380
  assert.equal(calls[0].options.serviceTier, "default");
371
381
  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"));
@@ -9,6 +9,7 @@ import {
9
9
  chatsDir,
10
10
  createIpcSocketPath,
11
11
  getChatArtifactsDir,
12
+ getChatArtifactsDatabaseFile,
12
13
  getChatSessionSeedFile,
13
14
  getChatTelegramWorkspacesFile,
14
15
  getChatToolConfigPath,
@@ -31,6 +32,7 @@ test("keeps chat artifact paths scoped below the chat directory", () => {
31
32
  const artifactsDir = getChatArtifactsDir("chat-1");
32
33
 
33
34
  assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
35
+ assert.equal(getChatArtifactsDatabaseFile("chat-1"), path.join(chatsDir, "chat-1", "state", "artifacts.sqlite"));
34
36
  });
35
37
 
36
38
  test("keeps pending session seeds scoped below the chat state directory", () => {
@@ -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,177 @@
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:2");
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", 2]]);
58
+ assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 2);
59
+ assert.equal(resolveChatSpeed(config, "123:topic:8"), 1);
60
+ assert.equal(resolveChatModelSelection(config, "123:topic:7").sessionRevision, 0);
61
+ ctx.callbackQuery.data = "speed:2";
62
+ await handler(ctx);
63
+ assert.equal(writes.length, 1);
64
+ assert.match(replies.at(-1)[2], /Already using speed 2.0x/);
65
+ ctx.callbackQuery.data = "speed:1";
66
+ await handler(ctx);
67
+ assert.equal(resolveChatSpeed(config, "123:topic:7"), 1);
68
+ assert.equal(answers.at(-1).text, "Speed: 1.0x.");
69
+ });
70
+
71
+ test("Pi SDK sends the selected speed in the actual Codex payload across turns", async (t) => {
72
+ const apiKey = `test.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "test-account" } })).toString("base64url")}.test`;
73
+ const { directory, runtime } = await createRuntime(t, {
74
+ type: "oauth", access: apiKey, refresh: "test-refresh", expires: Date.now() + 3_600_000
75
+ });
76
+ const model = runtime.getModel("openai-codex", "gpt-6-astra");
77
+ const resourceLoader = new DefaultResourceLoader({
78
+ cwd: directory, agentDir: directory, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true
79
+ });
80
+ await resourceLoader.reload();
81
+ const { session } = await createAgentSession({
82
+ cwd: directory, agentDir: directory, modelRuntime: runtime, model,
83
+ resourceLoader, settingsManager: SettingsManager.inMemory({ retry: { enabled: false } }),
84
+ sessionManager: SessionManager.inMemory(), tools: []
85
+ });
86
+ t.after(() => session.dispose());
87
+ const requests = [];
88
+ const fetch = async (_url, init) => {
89
+ const body = init.headers.get("content-encoding") === "zstd"
90
+ ? zstdDecompressSync(init.body).toString("utf8")
91
+ : init.body;
92
+ requests.push(JSON.parse(body));
93
+ return new Response(`data: ${JSON.stringify({ type: "response.completed", response: {
94
+ id: "test-response", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
95
+ } })}\n\n`, { headers: { "content-type": "text/event-stream" } });
96
+ };
97
+ t.mock.method(globalThis, "fetch", fetch);
98
+ const sdkStream = session.agent.streamFunction;
99
+ const controller = createModelSpeedController((model, context, options) => sdkStream(model, context, {
100
+ ...options, transport: "sse", maxRetries: 0
101
+ }), 1);
102
+ session.agent.streamFunction = controller.streamFn;
103
+ for (const speed of [1, 1.5, 2, 1]) {
104
+ controller.setSpeed(speed);
105
+ await session.prompt("Reply OK");
106
+ const message = session.messages.at(-1);
107
+ assert.equal(message.stopReason, "stop", message.errorMessage);
108
+ assert.equal(requests.at(-1).model, model.id);
109
+ assert.equal(requests.at(-1).service_tier, speed > 1 ? "priority" : "default");
110
+ }
111
+ assert.equal(requests.length, 4);
112
+ });
113
+
114
+ test("speed control leaves unsupported provider payloads and hooks untouched", async () => {
115
+ const options = { onPayload: (payload) => payload };
116
+ let received;
117
+ const controller = createModelSpeedController((_model, _context, nextOptions) => { received = nextOptions; }, 1.5);
118
+ controller.streamFn({ provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-5" }, {}, options);
119
+ assert.equal(received, options);
120
+ });
121
+
122
+ test("Arisa creates and reuses Telegram sessions and opens its TUI with the installed Pi SDK", async (t) => {
123
+ const directory = await mkdtemp(path.join(tmpdir(), "arisa-pi-startup-"));
124
+ t.after(() => rm(directory, { recursive: true, force: true }));
125
+ const { execFile } = await import("node:child_process");
126
+ const { promisify } = await import("node:util");
127
+ await promisify(execFile)(process.execPath, ["--input-type=module", "-e", `
128
+ import assert from "node:assert/strict";
129
+ import { mkdir, writeFile } from "node:fs/promises";
130
+ import path from "node:path";
131
+ import { applyConfigDefaults } from "./src/core/config/config-defaults.js";
132
+ import { AgentManager } from "./src/core/agent/agent-manager.js";
133
+ import { createArisaTuiRuntime } from "./src/runtime/tui.js";
134
+ import { selectChatSpeed } from "./src/core/agent/model-selection.js";
135
+ import { ensureArisaHome, piAuthFile } from "./src/platform/paths.js";
136
+ globalThis.fetch = async () => { throw new Error("Unexpected network request"); };
137
+ await ensureArisaHome();
138
+ await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true });
139
+ await writeFile(piAuthFile, JSON.stringify({ "openai-codex": {
140
+ type: "oauth", access: "test-access", refresh: "test-refresh", expires: Date.now() + 3600000
141
+ } }));
142
+ const config = applyConfigDefaults({
143
+ telegram: { authorizedChatIds: [123] },
144
+ pi: { provider: "openai-codex", model: "gpt-6-astra", speed: 1.5, workspaceDir: process.env.ARISA_HOME }
145
+ });
146
+ const manager = new AgentManager({ config });
147
+ manager.setCapabilityService({ execute: async () => ({}) });
148
+ const context = await manager.getSessionContext("123", {});
149
+ try {
150
+ assert.equal(context.session.model.id, "gpt-6-astra");
151
+ assert.equal(context.session.agent.streamFunction, context.speedController.streamFn);
152
+ assert.equal(context.speedController.speed, 2);
153
+ await manager.setModelSpeed("123", 1);
154
+ selectChatSpeed(config, "123", 1);
155
+ const reused = await manager.getSessionContext("123", {});
156
+ assert.equal(reused.session, context.session);
157
+ assert.equal(reused.speedController.speed, 1);
158
+ await reused.release();
159
+ } finally {
160
+ await context.release();
161
+ manager.clearSessionCache("123");
162
+ await Promise.all(manager.sessionClosePromises.values());
163
+ manager.turnCoordinator.close();
164
+ }
165
+ const tui = await createArisaTuiRuntime({ config, client: {} });
166
+ try {
167
+ assert.equal(tui.session.model.id, "gpt-6-astra");
168
+ assert.equal(typeof tui.session.agent.streamFunction, "function");
169
+ } finally {
170
+ await tui.dispose();
171
+ }
172
+ `], {
173
+ cwd: new URL("..", import.meta.url),
174
+ env: { ...process.env, ARISA_HOME: directory, PI_CODING_AGENT_DIR: path.join(directory, "pi"), PI_OFFLINE: "1" },
175
+ timeout: 15_000
176
+ });
177
+ });