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.
- package/LOW-MEMORY.md +37 -0
- package/README.md +6 -3
- package/package.json +3 -3
- package/pnpm-workspace.yaml +7 -5
- package/src/core/agent/agent-manager.js +13 -14
- package/src/core/agent/auth-flow.js +6 -6
- package/src/core/agent/model-selection.js +4 -3
- package/src/core/agent/model-speed.js +13 -3
- package/src/core/agent/pi-auth-login.js +28 -28
- package/src/core/agent/pi-capability-tools.js +1 -0
- package/src/core/agent/pi-runtime.js +14 -21
- package/src/core/artifacts/artifact-index.js +107 -0
- package/src/core/artifacts/artifact-store.js +15 -84
- package/src/core/artifacts/legacy-artifact-reader.js +46 -0
- package/src/core/capabilities/capability-service.js +25 -0
- package/src/core/tasks/task-store.js +2 -1
- package/src/index.js +10 -4
- package/src/official-tools.lock.json +25 -18
- package/src/platform/paths.js +5 -0
- package/src/runtime/bootstrap-cli.js +3 -3
- package/src/runtime/bootstrap-telegram.js +7 -7
- package/src/runtime/slave-cli.js +20 -10
- package/src/runtime/slave-service.js +299 -7
- package/src/runtime/tui.js +5 -6
- package/src/transport/telegram/bot.js +9 -5
- package/src/transport/telegram/model-callback.js +3 -2
- package/src/transport/telegram/model-controls.js +4 -4
- package/src/transport/telegram/model-picker.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +50 -23
- package/src/transport/telegram/telegram-auth-controller.js +7 -7
- package/src/transport/telegram/telegram-session-bridge.js +2 -1
- package/test/agent-turn-coordinator.test.js +5 -2
- package/test/artifact-index-memory.test.js +46 -0
- package/test/artifact-index-migration.test.js +88 -0
- package/test/artifact-store.test.js +3 -3
- package/test/auth-flow.test.js +2 -2
- package/test/capabilities-security.test.js +36 -0
- package/test/cli-memory.test.js +22 -0
- package/test/model-selection.test.js +14 -4
- package/test/official-tool-installer.test.js +13 -0
- package/test/paths.test.js +2 -0
- package/test/pi-auth-login.test.js +78 -0
- package/test/pi-capability-tools.test.js +3 -0
- package/test/pi-speed-integration.test.js +177 -0
- package/test/slave-cli.test.js +221 -5
- package/test/task-store.test.js +3 -1
- package/test/telegram-task-dispatcher.test.js +57 -2
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
75
|
+
await mkdir(path.dirname(indexFile), { recursive: true });
|
|
76
76
|
await writeFile(indexFile, "{truncated", "utf8");
|
|
77
77
|
|
|
78
78
|
await assert.rejects(
|
package/test/auth-flow.test.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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(
|
|
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(
|
|
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"));
|
package/test/paths.test.js
CHANGED
|
@@ -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
|
+
});
|