arisa 5.2.7 → 5.2.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/package.json +10 -9
- package/pnpm-workspace.yaml +7 -5
- package/src/core/agent/agent-manager.js +92 -18
- package/src/core/agent/agent-session-lifecycle.js +29 -6
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/auth-flow.js +6 -6
- package/src/core/agent/model-speed.js +3 -1
- package/src/core/agent/pi-auth-login.js +28 -28
- package/src/core/agent/pi-capability-tools.js +8 -4
- package/src/core/agent/pi-runtime.js +14 -21
- package/src/core/agent/session-history-reader.js +168 -0
- package/src/core/agent/session-preload-migration.js +162 -0
- package/src/core/agent/session-rotation.js +29 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/capabilities/capability-service.js +28 -2
- package/src/core/config/config-defaults.js +29 -0
- package/src/core/tasks/task-runner.js +8 -4
- package/src/core/tasks/task-store.js +48 -4
- package/src/core/tools/daemon-processes.js +7 -0
- package/src/core/tools/memory-pressure.js +2 -2
- package/src/core/tools/tool-registry.js +35 -7
- package/src/core/tools/weighted-resource-governor.js +7 -6
- package/src/official-tools.lock.json +129 -48
- package/src/runtime/bootstrap-cli.js +3 -3
- package/src/runtime/bootstrap-telegram.js +7 -7
- package/src/runtime/doctor.js +20 -73
- package/src/runtime/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/process-inspection.js +78 -0
- package/src/runtime/slave-cli.js +20 -10
- package/src/runtime/slave-service.js +299 -7
- package/src/runtime/tool-process-supervisor.js +35 -8
- package/src/runtime/tui.js +6 -7
- package/src/transport/telegram/bot.js +11 -5
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/model-controls.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +67 -15
- package/src/transport/telegram/telegram-auth-controller.js +7 -7
- package/src/transport/telegram/telegram-prompt-controller.js +17 -4
- package/src/transport/telegram/telegram-session-bridge.js +2 -1
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/auth-flow.test.js +2 -2
- package/test/capabilities-security.test.js +36 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-runtime.test.js +2 -4
- package/test/doctor.test.js +19 -0
- package/test/memory-pressure.test.js +7 -2
- package/test/model-selection.test.js +3 -2
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- package/test/official-tool-installer.test.js +13 -0
- package/test/pi-auth-login.test.js +78 -0
- package/test/pi-capability-tools.test.js +3 -0
- package/test/pi-compaction.test.js +21 -0
- package/test/pi-speed-integration.test.js +176 -0
- package/test/session-history-reader.test.js +84 -0
- package/test/session-preload-migration.test.js +120 -0
- package/test/session-rotation.test.js +110 -0
- package/test/slave-cli.test.js +221 -5
- package/test/task-store.test.js +34 -0
- package/test/telegram-prompt-controller.test.js +2 -1
- package/test/telegram-task-dispatcher.test.js +121 -5
- package/test/tool-registry-run.test.js +10 -1
- package/test/weighted-resource-governor.test.js +28 -0
- package/test/worker-tool-fanout.test.js +79 -0
|
@@ -23,6 +23,27 @@ test("merges partial resident session cache overrides with defaults", () => {
|
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("merges partial session rotation overrides with defaults", () => {
|
|
27
|
+
const config = applyConfigDefaults({ pi: { sessionRotation: { enabled: false } } });
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(config.pi.sessionRotation, {
|
|
30
|
+
enabled: false,
|
|
31
|
+
compactAtPersistedBytes: 24 * 1024 * 1024,
|
|
32
|
+
maxPersistedBytes: 32 * 1024 * 1024
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("merges partial exclusive turn coordinator overrides with defaults", () => {
|
|
37
|
+
const config = applyConfigDefaults({ pi: { turnCoordinator: { backgroundQueueTtlMs: 30_000 } } });
|
|
38
|
+
|
|
39
|
+
assert.deepEqual(config.pi.turnCoordinator, {
|
|
40
|
+
enabled: true,
|
|
41
|
+
backgroundQueueTtlMs: 30_000,
|
|
42
|
+
interactiveQueueTtlMs: 0,
|
|
43
|
+
maxQueued: 100
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
26
47
|
test("merges partial Pi compaction overrides with defaults", () => {
|
|
27
48
|
const config = applyConfigDefaults({
|
|
28
49
|
pi: { compaction: { reserveTokens: 8_192 } }
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { inspectSessionForPreloadMigration } from "../src/core/agent/session-history-reader.js";
|
|
7
|
+
|
|
8
|
+
function writeSession(entries) {
|
|
9
|
+
const dir = mkdtempSync(path.join(tmpdir(), "arisa-session-reader-"));
|
|
10
|
+
const file = path.join(dir, "session.jsonl");
|
|
11
|
+
writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
12
|
+
return { dir, file };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const header = {
|
|
16
|
+
type: "session",
|
|
17
|
+
version: 3,
|
|
18
|
+
id: "session-id",
|
|
19
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
20
|
+
cwd: "/workspace"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function message(id, parentId, text) {
|
|
24
|
+
return {
|
|
25
|
+
type: "message",
|
|
26
|
+
id,
|
|
27
|
+
parentId,
|
|
28
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
29
|
+
message: { role: "user", content: [{ type: "text", text }] }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("discovers the latest valid compaction on the active branch without retaining divergent payloads", () => {
|
|
34
|
+
const entries = [
|
|
35
|
+
header,
|
|
36
|
+
message("kept", null, "kept context"),
|
|
37
|
+
{
|
|
38
|
+
type: "compaction",
|
|
39
|
+
id: "active-compaction",
|
|
40
|
+
parentId: "kept",
|
|
41
|
+
firstKeptEntryId: "kept",
|
|
42
|
+
summary: "active summary"
|
|
43
|
+
},
|
|
44
|
+
message("after", "active-compaction", "recent context"),
|
|
45
|
+
{
|
|
46
|
+
type: "compaction",
|
|
47
|
+
id: "divergent-compaction",
|
|
48
|
+
parentId: "kept",
|
|
49
|
+
firstKeptEntryId: "kept",
|
|
50
|
+
summary: "divergent summary"
|
|
51
|
+
},
|
|
52
|
+
message("leaf", "after", "active leaf")
|
|
53
|
+
];
|
|
54
|
+
const { dir, file } = writeSession(entries);
|
|
55
|
+
try {
|
|
56
|
+
const result = inspectSessionForPreloadMigration(file, 1);
|
|
57
|
+
assert.equal(result.compactionId, "active-compaction");
|
|
58
|
+
assert.equal(result.summary, "active summary");
|
|
59
|
+
assert.deepEqual(result.contextEntries.map((entry) => entry.id), ["kept", "after", "leaf"]);
|
|
60
|
+
} finally {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("does not migrate a small, unsupported, or structurally invalid session", () => {
|
|
66
|
+
const cases = [
|
|
67
|
+
[header, message("one", null, "small")],
|
|
68
|
+
[{ ...header, version: 2 }, message("kept", null, "old"), {
|
|
69
|
+
type: "compaction", id: "comp", parentId: "kept", firstKeptEntryId: "kept", summary: "summary"
|
|
70
|
+
}],
|
|
71
|
+
[header, message("leaf", "missing-parent", "broken"), {
|
|
72
|
+
type: "compaction", id: "comp", parentId: "leaf", firstKeptEntryId: "leaf", summary: "summary"
|
|
73
|
+
}]
|
|
74
|
+
];
|
|
75
|
+
for (const entries of cases) {
|
|
76
|
+
const { dir, file } = writeSession(entries);
|
|
77
|
+
try {
|
|
78
|
+
const threshold = entries === cases[0] ? 1024 * 1024 : 1;
|
|
79
|
+
assert.equal(inspectSessionForPreloadMigration(file, threshold), null);
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
createPreloadMigrationChild,
|
|
9
|
+
migrateRecentSessionBeforeLoad
|
|
10
|
+
} from "../src/core/agent/session-preload-migration.js";
|
|
11
|
+
|
|
12
|
+
function sourceMessage(id, parentId, role, text) {
|
|
13
|
+
return {
|
|
14
|
+
type: "message",
|
|
15
|
+
id,
|
|
16
|
+
parentId,
|
|
17
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
18
|
+
message: { role, content: [{ type: "text", text }] }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeEntries(file, entries) {
|
|
23
|
+
writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("atomically creates a valid child session with a durable parent reference", () => {
|
|
27
|
+
const sessionDir = mkdtempSync(path.join(tmpdir(), "arisa-session-migration-"));
|
|
28
|
+
try {
|
|
29
|
+
const result = createPreloadMigrationChild({
|
|
30
|
+
sessionDir,
|
|
31
|
+
cwd: "/workspace",
|
|
32
|
+
migration: {
|
|
33
|
+
sourceFile: path.join(sessionDir, "historical.jsonl"),
|
|
34
|
+
sourceBytes: 80 * 1024 * 1024,
|
|
35
|
+
summary: "compacted history",
|
|
36
|
+
contextEntries: [
|
|
37
|
+
sourceMessage("old-user", null, "user", "recent question"),
|
|
38
|
+
{ type: "label", id: "ignored", parentId: "old-user", targetId: "old-user", label: "old" },
|
|
39
|
+
sourceMessage("old-assistant", "old-user", "assistant", "recent answer")
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
operationalNotes: "Durable operating notes:\n- keep history",
|
|
43
|
+
now: new Date("2026-08-28T12:00:00.000Z")
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
assert.equal(result.copiedEntries, 2);
|
|
47
|
+
assert.equal(readdirSync(sessionDir).some((name) => name.endsWith(".tmp")), false);
|
|
48
|
+
const entries = readFileSync(result.targetFile, "utf8").trim().split("\n").map(JSON.parse);
|
|
49
|
+
assert.equal(entries[0].parentSession, result.sourceFile);
|
|
50
|
+
assert.deepEqual(entries.slice(1).map((entry) => entry.parentId), [
|
|
51
|
+
null,
|
|
52
|
+
entries[1].id,
|
|
53
|
+
entries[2].id,
|
|
54
|
+
entries[3].id
|
|
55
|
+
]);
|
|
56
|
+
assert.equal(entries[2].details.source, "preload-migration");
|
|
57
|
+
|
|
58
|
+
const manager = SessionManager.open(result.targetFile, sessionDir, "/workspace");
|
|
59
|
+
const messages = manager.buildSessionContext().messages;
|
|
60
|
+
assert.deepEqual(messages.map((message) => message.role), ["custom", "custom", "user", "assistant"]);
|
|
61
|
+
assert.match(messages[1].content, /compacted history/);
|
|
62
|
+
} finally {
|
|
63
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("pre-load migration commits once and a restart selects the compact child", () => {
|
|
68
|
+
const sessionDir = mkdtempSync(path.join(tmpdir(), "arisa-session-recovery-"));
|
|
69
|
+
const sourceFile = path.join(sessionDir, "historical.jsonl");
|
|
70
|
+
try {
|
|
71
|
+
writeEntries(sourceFile, [
|
|
72
|
+
{ type: "session", version: 3, id: "old", timestamp: "2026-08-28T00:00:00.000Z", cwd: "/workspace" },
|
|
73
|
+
sourceMessage("historical", null, "user", "x".repeat(8_000)),
|
|
74
|
+
sourceMessage("kept", "historical", "user", "recent"),
|
|
75
|
+
{
|
|
76
|
+
type: "compaction",
|
|
77
|
+
id: "compaction",
|
|
78
|
+
parentId: "kept",
|
|
79
|
+
firstKeptEntryId: "kept",
|
|
80
|
+
summary: "bounded summary"
|
|
81
|
+
},
|
|
82
|
+
sourceMessage("leaf", "compaction", "assistant", "answer")
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const first = migrateRecentSessionBeforeLoad({
|
|
86
|
+
sessionDir,
|
|
87
|
+
cwd: "/workspace",
|
|
88
|
+
policy: { maxPersistedBytes: 2_000 },
|
|
89
|
+
operationalNotes: "notes"
|
|
90
|
+
});
|
|
91
|
+
assert.equal(first.sourceFile, sourceFile);
|
|
92
|
+
assert.ok(first.targetBytes < 2_000);
|
|
93
|
+
assert.equal(migrateRecentSessionBeforeLoad({
|
|
94
|
+
sessionDir,
|
|
95
|
+
cwd: "/workspace",
|
|
96
|
+
policy: { maxPersistedBytes: 2_000 }
|
|
97
|
+
}), null);
|
|
98
|
+
const resumed = SessionManager.continueRecent("/workspace", sessionDir);
|
|
99
|
+
assert.equal(resumed.getSessionFile(), first.targetFile);
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("refuses to load an oversized session when no safe compaction checkpoint exists", () => {
|
|
106
|
+
const sessionDir = mkdtempSync(path.join(tmpdir(), "arisa-session-refusal-"));
|
|
107
|
+
try {
|
|
108
|
+
writeEntries(path.join(sessionDir, "unsafe.jsonl"), [
|
|
109
|
+
{ type: "session", version: 3, id: "unsafe", timestamp: "2026-08-28T00:00:00.000Z", cwd: "/workspace" },
|
|
110
|
+
sourceMessage("leaf", null, "user", "x".repeat(4_000))
|
|
111
|
+
]);
|
|
112
|
+
assert.throws(() => migrateRecentSessionBeforeLoad({
|
|
113
|
+
sessionDir,
|
|
114
|
+
cwd: "/workspace",
|
|
115
|
+
policy: { maxPersistedBytes: 1_000 }
|
|
116
|
+
}), (error) => error.code === "PI_SESSION_PRELOAD_MIGRATION_UNAVAILABLE");
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
4
|
+
import { AgentManager } from "../src/core/agent/agent-manager.js";
|
|
5
|
+
import { compactionRotationRequest, normalizeSessionRotationPolicy } from "../src/core/agent/session-rotation.js";
|
|
6
|
+
|
|
7
|
+
const mebibyte = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function compactionEvent(summary = "checkpoint") {
|
|
10
|
+
return {
|
|
11
|
+
type: "compaction_end",
|
|
12
|
+
aborted: false,
|
|
13
|
+
errorMessage: "",
|
|
14
|
+
result: { summary }
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("normalizes the automatic session rotation policy", () => {
|
|
19
|
+
assert.deepEqual(normalizeSessionRotationPolicy(), {
|
|
20
|
+
enabled: true,
|
|
21
|
+
compactAtPersistedBytes: 24 * mebibyte,
|
|
22
|
+
maxPersistedBytes: 32 * mebibyte
|
|
23
|
+
});
|
|
24
|
+
assert.deepEqual(normalizeSessionRotationPolicy({
|
|
25
|
+
enabled: false,
|
|
26
|
+
compactAtPersistedBytes: 8,
|
|
27
|
+
maxPersistedBytes: 12
|
|
28
|
+
}), {
|
|
29
|
+
enabled: false,
|
|
30
|
+
compactAtPersistedBytes: 8,
|
|
31
|
+
maxPersistedBytes: 12
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("requests rotation only after a successful oversized compaction", () => {
|
|
36
|
+
assert.equal(compactionRotationRequest(compactionEvent(), 24 * mebibyte), null);
|
|
37
|
+
assert.equal(compactionRotationRequest({ ...compactionEvent(), aborted: true }, 25 * mebibyte), null);
|
|
38
|
+
assert.equal(compactionRotationRequest(compactionEvent(), 25 * mebibyte, { enabled: false }), null);
|
|
39
|
+
const request = compactionRotationRequest(compactionEvent("latest summary"), 25 * mebibyte);
|
|
40
|
+
assert.equal(request.persistedBytes, 25 * mebibyte);
|
|
41
|
+
assert.match(request.handoff, /latest summary/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("compacts at the preventive persisted-size threshold and then rotates", async () => {
|
|
45
|
+
const config = applyConfigDefaults({ telegram: {}, pi: { provider: "test", model: "test" } });
|
|
46
|
+
const manager = new AgentManager({
|
|
47
|
+
config,
|
|
48
|
+
artifactStore: {},
|
|
49
|
+
toolRegistry: {},
|
|
50
|
+
taskStore: {},
|
|
51
|
+
logger: null
|
|
52
|
+
});
|
|
53
|
+
let compactions = 0;
|
|
54
|
+
const context = {
|
|
55
|
+
activeUsers: 1,
|
|
56
|
+
session: {
|
|
57
|
+
sessionFile: "/sessions/preventive.jsonl",
|
|
58
|
+
async compact() {
|
|
59
|
+
compactions += 1;
|
|
60
|
+
manager.scheduleCompactionRotationCheck("chat", context, compactionEvent("preventive summary"));
|
|
61
|
+
},
|
|
62
|
+
async close() {}
|
|
63
|
+
},
|
|
64
|
+
rotationCheckPromise: Promise.resolve(),
|
|
65
|
+
rotationRequest: null
|
|
66
|
+
};
|
|
67
|
+
manager.sessions.set("chat", context);
|
|
68
|
+
manager.estimatePersistedSessionBytes = async () => 25 * mebibyte;
|
|
69
|
+
|
|
70
|
+
await manager.releaseSessionContext("chat", context);
|
|
71
|
+
|
|
72
|
+
assert.equal(compactions, 1);
|
|
73
|
+
assert.equal(manager.sessions.has("chat"), false);
|
|
74
|
+
assert.equal(manager.pendingNewSessions.has("chat"), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rotates after active work releases and preserves the parent session path", async () => {
|
|
78
|
+
const config = applyConfigDefaults({ telegram: {}, pi: { provider: "test", model: "test" } });
|
|
79
|
+
let closes = 0;
|
|
80
|
+
const manager = new AgentManager({
|
|
81
|
+
config,
|
|
82
|
+
artifactStore: {},
|
|
83
|
+
toolRegistry: {},
|
|
84
|
+
taskStore: {},
|
|
85
|
+
logger: null
|
|
86
|
+
});
|
|
87
|
+
const context = {
|
|
88
|
+
activeUsers: 1,
|
|
89
|
+
session: {
|
|
90
|
+
sessionFile: "/sessions/oversized.jsonl",
|
|
91
|
+
async close() { closes += 1; }
|
|
92
|
+
},
|
|
93
|
+
rotationCheckPromise: Promise.resolve(),
|
|
94
|
+
rotationRequest: null
|
|
95
|
+
};
|
|
96
|
+
manager.sessions.set("chat", context);
|
|
97
|
+
manager.estimatePersistedSessionBytes = async () => 65 * mebibyte;
|
|
98
|
+
|
|
99
|
+
manager.scheduleCompactionRotationCheck("chat", context, compactionEvent("handoff summary"));
|
|
100
|
+
await manager.releaseSessionContext("chat", context);
|
|
101
|
+
|
|
102
|
+
assert.equal(closes, 1);
|
|
103
|
+
assert.equal(manager.sessions.has("chat"), false);
|
|
104
|
+
assert.equal(manager.pendingNewSessions.has("chat"), true);
|
|
105
|
+
assert.deepEqual(manager.pendingSessionHandoffs.get("chat"), {
|
|
106
|
+
text: "Automatic session rotation after compaction. Continue from this checkpoint:\n\nhandoff summary",
|
|
107
|
+
parentSession: "/sessions/oversized.jsonl",
|
|
108
|
+
source: "compaction-rotation"
|
|
109
|
+
});
|
|
110
|
+
});
|