arisa 5.2.17 → 5.2.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +10 -9
- 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-speed.js +3 -1
- 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/capabilities/capability-service.js +25 -0
- package/src/core/tasks/task-store.js +2 -1
- package/src/official-tools.lock.json +25 -18
- 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-controls.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/auth-flow.test.js +2 -2
- package/test/capabilities-security.test.js +36 -0
- package/test/model-selection.test.js +3 -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-speed-integration.test.js +176 -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
|
@@ -41,7 +41,7 @@ function buildFailureNotice({ task, result, error }) {
|
|
|
41
41
|
if (result?.authBlockedNew === true) {
|
|
42
42
|
return [
|
|
43
43
|
"⚠️ Arisa automation paused for authentication",
|
|
44
|
-
`Tool: ${task.payload?.toolName || "unknown"}`,
|
|
44
|
+
`Tool: ${result.authBlock?.toolName || task.payload?.toolName || "unknown"}`,
|
|
45
45
|
`Reason: ${safeErrorSummary(error)}`,
|
|
46
46
|
`Next authentication check: ${result.runAt}`
|
|
47
47
|
].join("\n");
|
|
@@ -72,17 +72,62 @@ export function createTelegramTaskDispatcher({
|
|
|
72
72
|
const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
|
|
73
73
|
const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
|
|
74
74
|
|
|
75
|
+
const runBackgroundTool = (toolName, chatId, args, label) => agentManager.runTurn({
|
|
76
|
+
priority: "background",
|
|
77
|
+
label
|
|
78
|
+
}, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
|
|
79
|
+
|
|
80
|
+
function throwToolFailure(result, toolName, fallbackResolution) {
|
|
81
|
+
const error = new Error(result?.error || `${toolName} failed`);
|
|
82
|
+
if (result?.status === "blocked_auth" || fallbackResolution) {
|
|
83
|
+
error.retryable = false;
|
|
84
|
+
error.authBlocked = true;
|
|
85
|
+
error.authResolution = {
|
|
86
|
+
...(result?.resolution || fallbackResolution || {}),
|
|
87
|
+
toolName
|
|
88
|
+
};
|
|
89
|
+
} else if (result?.status === "needs_config") {
|
|
90
|
+
error.retryable = false;
|
|
91
|
+
} else if (result?.status === "outcome_uncertain") {
|
|
92
|
+
error.retryable = false;
|
|
93
|
+
error.outcomeUncertain = true;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
async function dispatchAgentTask(task, chatId) {
|
|
76
99
|
if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
|
|
100
|
+
if (task.authBlock?.toolName) {
|
|
101
|
+
const probe = await runBackgroundTool(
|
|
102
|
+
task.authBlock.toolName,
|
|
103
|
+
chatId,
|
|
104
|
+
task.authBlock.probeArgs || {},
|
|
105
|
+
`authentication probe ${task.authBlock.toolName}`
|
|
106
|
+
);
|
|
107
|
+
if (probe?.ok === false) throwToolFailure(probe, task.authBlock.toolName, task.authBlock);
|
|
108
|
+
logger?.log("tasks", `authentication restored for ${task.authBlock.toolName} (task ${task.id})`);
|
|
109
|
+
}
|
|
110
|
+
|
|
77
111
|
logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
|
|
112
|
+
const agentTaskExecution = { blockedAuth: null };
|
|
78
113
|
await enqueueAsyncPrompt({
|
|
79
114
|
chatId,
|
|
80
115
|
prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
|
|
81
116
|
label: `scheduled task ${task.id}`,
|
|
82
117
|
route: task.route,
|
|
83
118
|
timeoutMs: agentTimeoutMs,
|
|
84
|
-
priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background"
|
|
119
|
+
priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background",
|
|
120
|
+
agentTaskExecution
|
|
85
121
|
});
|
|
122
|
+
if (agentTaskExecution.blockedAuth) {
|
|
123
|
+
const blocked = agentTaskExecution.blockedAuth;
|
|
124
|
+
throwToolFailure({
|
|
125
|
+
ok: false,
|
|
126
|
+
status: "blocked_auth",
|
|
127
|
+
error: blocked.error,
|
|
128
|
+
resolution: blocked.resolution
|
|
129
|
+
}, blocked.toolName);
|
|
130
|
+
}
|
|
86
131
|
}
|
|
87
132
|
|
|
88
133
|
async function dispatchAgentEvent(task, chatId) {
|
|
@@ -111,34 +156,16 @@ export function createTelegramTaskDispatcher({
|
|
|
111
156
|
if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
|
|
112
157
|
logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
|
|
113
158
|
|
|
114
|
-
const runTool = (args) =>
|
|
115
|
-
priority: "background",
|
|
116
|
-
label: `poll tool ${toolName}`
|
|
117
|
-
}, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
|
|
118
|
-
|
|
119
|
-
function throwFailure(result, fallbackResolution) {
|
|
120
|
-
const error = new Error(result?.error || `poll_tool ${toolName} failed`);
|
|
121
|
-
if (result?.status === "blocked_auth" || fallbackResolution) {
|
|
122
|
-
error.retryable = false;
|
|
123
|
-
error.authBlocked = true;
|
|
124
|
-
error.authResolution = result?.resolution || fallbackResolution;
|
|
125
|
-
} else if (result?.status === "needs_config") {
|
|
126
|
-
error.retryable = false;
|
|
127
|
-
} else if (result?.status === "outcome_uncertain") {
|
|
128
|
-
error.retryable = false;
|
|
129
|
-
error.outcomeUncertain = true;
|
|
130
|
-
}
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
159
|
+
const runTool = (args) => runBackgroundTool(toolName, chatId, args, `poll tool ${toolName}`);
|
|
133
160
|
|
|
134
161
|
if (task.authBlock) {
|
|
135
162
|
const probe = await runTool(task.authBlock.probeArgs || {});
|
|
136
|
-
if (probe?.ok === false)
|
|
163
|
+
if (probe?.ok === false) throwToolFailure(probe, toolName, task.authBlock);
|
|
137
164
|
logger?.log("tasks", `authentication restored for ${toolName} (task ${task.id})`);
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
const result = await runTool(task.payload.args || {});
|
|
141
|
-
if (result?.ok === false)
|
|
168
|
+
if (result?.ok === false) throwToolFailure(result, toolName);
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
async function dispatchTask(task) {
|
|
@@ -50,7 +50,7 @@ export function createTelegramAuthController({
|
|
|
50
50
|
if (!detected) return false;
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
|
-
await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
|
|
53
|
+
await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
|
|
54
54
|
markPromptErrorNotified(error);
|
|
55
55
|
return true;
|
|
56
56
|
} catch (notifyError) {
|
|
@@ -66,11 +66,11 @@ export function createTelegramAuthController({
|
|
|
66
66
|
agentManager.clearSessionCache(chatId);
|
|
67
67
|
issue = null;
|
|
68
68
|
logger?.log("telegram", `Pi auth renewal completed for chat ${chatId}`);
|
|
69
|
-
await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, verified: true }));
|
|
69
|
+
await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, verified: true }));
|
|
70
70
|
} catch (error) {
|
|
71
71
|
const detected = rememberValidationFailure(error);
|
|
72
72
|
logger?.error("telegram", `Pi auth renewal failed for chat ${chatId}: ${getErrorMessage(error)}`);
|
|
73
|
-
await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
|
|
73
|
+
await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
|
|
74
74
|
logger?.error("telegram", `auth renewal failure notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
|
|
75
75
|
});
|
|
76
76
|
} finally {
|
|
@@ -140,17 +140,17 @@ export function createTelegramAuthController({
|
|
|
140
140
|
const authorization = await authorize(ctx);
|
|
141
141
|
if (!authorization.ok) return;
|
|
142
142
|
|
|
143
|
-
const status = getPiAuthStatus(config, ctx.chat.id);
|
|
143
|
+
const status = await getPiAuthStatus(config, ctx.chat.id);
|
|
144
144
|
if (status.hasApiKey || !status.supportsOAuth) {
|
|
145
145
|
await withTyping(ctx, async () => {
|
|
146
146
|
try {
|
|
147
147
|
await agentManager.validateAgent();
|
|
148
148
|
agentManager.clearSessionCache(ctx.chat.id);
|
|
149
149
|
issue = null;
|
|
150
|
-
await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
|
|
150
|
+
await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
|
|
151
151
|
} catch (error) {
|
|
152
152
|
const detected = rememberValidationFailure(error);
|
|
153
|
-
await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
153
|
+
await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
return;
|
|
@@ -163,7 +163,7 @@ export function createTelegramAuthController({
|
|
|
163
163
|
: "Pi login is already in progress. Paste the redirect URL or code here when you have it.");
|
|
164
164
|
} catch (error) {
|
|
165
165
|
const detected = rememberValidationFailure(error);
|
|
166
|
-
await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
166
|
+
await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
@@ -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
|
});
|
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({
|
|
@@ -355,17 +355,18 @@ test("maps supported model speeds to provider service tiers", () => {
|
|
|
355
355
|
|
|
356
356
|
test("applies Pi speed to every provider request and updates it in place", async () => {
|
|
357
357
|
const calls = [];
|
|
358
|
+
const model = { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.6-sol" };
|
|
358
359
|
const controller = createModelSpeedController((model, context, options) => {
|
|
359
360
|
calls.push({ model, context, options });
|
|
360
361
|
return "stream";
|
|
361
362
|
}, 1);
|
|
362
363
|
|
|
363
|
-
assert.equal(controller.streamFn(
|
|
364
|
+
assert.equal(controller.streamFn(model, "context", {
|
|
364
365
|
signal: "signal",
|
|
365
366
|
onPayload: (payload) => ({ ...payload, preserved: true })
|
|
366
367
|
}), "stream");
|
|
367
368
|
controller.setSpeed(1.5);
|
|
368
|
-
controller.streamFn(
|
|
369
|
+
controller.streamFn(model, "context", { signal: "signal" });
|
|
369
370
|
|
|
370
371
|
assert.equal(calls[0].options.serviceTier, "default");
|
|
371
372
|
assert.equal(calls[1].options.serviceTier, "priority");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -88,9 +89,21 @@ test("every bundled official tool lock matches the catalog source", async () =>
|
|
|
88
89
|
);
|
|
89
90
|
const manifest = JSON.parse(await readFile(path.join(source, "tool.manifest.json"), "utf8"));
|
|
90
91
|
assert.equal(manifest.version ?? null, entry.version ?? null, `${name} version`);
|
|
92
|
+
assert.deepEqual(manifest.toolDependencies || {}, entry.toolDependencies || {}, `${name} tool dependencies`);
|
|
91
93
|
}
|
|
92
94
|
});
|
|
93
95
|
|
|
96
|
+
test("the bundled lock commit contains the exact catalog snapshot", async () => {
|
|
97
|
+
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
98
|
+
const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
99
|
+
execFileSync("git", ["cat-file", "-e", `${lock.commit}^{commit}`], { cwd: repositoryRoot });
|
|
100
|
+
const changedTools = execFileSync("git", ["diff", "--name-only", lock.commit, "--", "tools"], {
|
|
101
|
+
cwd: repositoryRoot,
|
|
102
|
+
encoding: "utf8"
|
|
103
|
+
}).trim();
|
|
104
|
+
assert.equal(changedTools, "", `Bundled lock commit does not match catalog source:\n${changedTools}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
94
107
|
test("rejects symbolic links before deployment", async (t) => {
|
|
95
108
|
const { source, files } = await fixture(t);
|
|
96
109
|
await symlink(path.join(source, "index.js"), path.join(source, "link.js"));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createPiOAuthLogin } from "../src/core/agent/pi-auth-login.js";
|
|
5
|
+
import { createPiRuntime, hasProviderAuth, supportsProviderOAuth } from "../src/core/agent/pi-runtime.js";
|
|
6
|
+
import { piAuthFile } from "../src/platform/paths.js";
|
|
7
|
+
|
|
8
|
+
test("creates the async Pi runtime with Arisa credential storage and awaits runtime API keys", async (t) => {
|
|
9
|
+
const calls = [];
|
|
10
|
+
const runtime = {
|
|
11
|
+
async setRuntimeApiKey(provider, apiKey) {
|
|
12
|
+
await Promise.resolve();
|
|
13
|
+
calls.push([provider, apiKey]);
|
|
14
|
+
},
|
|
15
|
+
getProviderAuthStatus: () => ({ configured: true, source: "stored" }),
|
|
16
|
+
getProvider: () => ({ auth: { oauth: {} } })
|
|
17
|
+
};
|
|
18
|
+
t.mock.method(ModelRuntime, "create", async (options) => {
|
|
19
|
+
assert.equal(options.authPath, piAuthFile);
|
|
20
|
+
return runtime;
|
|
21
|
+
});
|
|
22
|
+
assert.equal(await createPiRuntime({ provider: "openai", apiKey: "test-key" }), runtime);
|
|
23
|
+
assert.deepEqual(calls, [["openai", "test-key"]]);
|
|
24
|
+
assert.equal(hasProviderAuth("openai", runtime), true);
|
|
25
|
+
assert.equal(supportsProviderOAuth("openai", runtime), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("adapts Pi OAuth notifications, selections and manual codes in order", async (t) => {
|
|
29
|
+
const events = [];
|
|
30
|
+
let controller;
|
|
31
|
+
const credential = { type: "oauth", access: "test-access" };
|
|
32
|
+
t.mock.method(ModelRuntime, "create", async () => ({
|
|
33
|
+
getProvider: () => ({ auth: { oauth: {} } }),
|
|
34
|
+
async login(provider, type, interaction) {
|
|
35
|
+
assert.equal(provider, "openai-codex");
|
|
36
|
+
assert.equal(type, "oauth");
|
|
37
|
+
interaction.notify({ type: "auth_url", url: "https://example.com/login" });
|
|
38
|
+
interaction.notify({ type: "device_code", userCode: "ABCD", verificationUri: "https://example.com/device" });
|
|
39
|
+
interaction.notify({ type: "progress", message: "waiting" });
|
|
40
|
+
assert.equal(await interaction.prompt({ type: "select", options: [{ id: "device", label: "Device" }] }), "device");
|
|
41
|
+
assert.deepEqual(events, ["auth", "device", "waiting", "select"]);
|
|
42
|
+
assert.equal(await interaction.prompt({ type: "text", message: "Value" }), "answer");
|
|
43
|
+
const code = interaction.prompt({ type: "manual_code" });
|
|
44
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
45
|
+
assert.equal(controller.manualInputRequested, true);
|
|
46
|
+
assert.equal(controller.submitManualCode(" callback-code "), true);
|
|
47
|
+
assert.equal(await code, "callback-code");
|
|
48
|
+
return credential;
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
controller = createPiOAuthLogin({
|
|
52
|
+
provider: "openai-codex",
|
|
53
|
+
onAuth: async () => { await Promise.resolve(); events.push("auth"); },
|
|
54
|
+
onDeviceCode: async () => { events.push("device"); },
|
|
55
|
+
onProgress: (message) => events.push(message),
|
|
56
|
+
onSelect: ({ options }) => { events.push("select"); return options[0].id; },
|
|
57
|
+
onPrompt: () => "answer"
|
|
58
|
+
});
|
|
59
|
+
assert.equal(await controller.promise, credential);
|
|
60
|
+
assert.equal(controller.manualInputRequested, false);
|
|
61
|
+
assert.equal(controller.submitManualCode("again"), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("OAuth rejects unsupported providers and surfaces notification failures", async (t) => {
|
|
65
|
+
t.mock.method(ModelRuntime, "create", async () => ({ getProvider: () => ({ auth: {} }) }));
|
|
66
|
+
await assert.rejects(createPiOAuthLogin({ provider: "unsupported" }).promise, /No internal OAuth login flow/);
|
|
67
|
+
ModelRuntime.create.mock.restore();
|
|
68
|
+
t.mock.method(ModelRuntime, "create", async () => ({
|
|
69
|
+
getProvider: () => ({ auth: { oauth: {} } }),
|
|
70
|
+
async login(_provider, _type, interaction) {
|
|
71
|
+
interaction.notify({ type: "auth_url", url: "https://example.com/login" });
|
|
72
|
+
await interaction.prompt({ type: "manual_code" });
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
const login = createPiOAuthLogin({ provider: "openai-codex", onAuth: async () => { throw new Error("delivery failed"); } });
|
|
76
|
+
await assert.rejects(login.promise, /delivery failed/);
|
|
77
|
+
assert.equal(login.manualInputRequested, false);
|
|
78
|
+
});
|
|
@@ -5,6 +5,7 @@ import { createPiCapabilityTools } from "../src/core/agent/pi-capability-tools.j
|
|
|
5
5
|
function createHarness() {
|
|
6
6
|
const calls = [];
|
|
7
7
|
let taskContext = { transportChatId: "chat-1", messageThreadId: 10 };
|
|
8
|
+
const agentTaskExecution = { blockedAuth: null };
|
|
8
9
|
const capabilityService = {
|
|
9
10
|
async execute(request) {
|
|
10
11
|
calls.push(request);
|
|
@@ -15,6 +16,7 @@ function createHarness() {
|
|
|
15
16
|
};
|
|
16
17
|
const telegram = {
|
|
17
18
|
getTaskContext: () => taskContext,
|
|
19
|
+
getAgentTaskExecution: () => agentTaskExecution,
|
|
18
20
|
sendMedia: async () => {}
|
|
19
21
|
};
|
|
20
22
|
const tools = createPiCapabilityTools({
|
|
@@ -62,4 +64,5 @@ test("Pi tool execution resolves the current Telegram task context per call", as
|
|
|
62
64
|
|
|
63
65
|
assert.equal(harness.calls[0].context.taskContext.messageThreadId, 10);
|
|
64
66
|
assert.equal(harness.calls[1].context.taskContext.messageThreadId, 20);
|
|
67
|
+
assert.equal(harness.calls[0].context.agentTaskExecution, harness.calls[1].context.agentTaskExecution);
|
|
65
68
|
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
7
|
+
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { createModelSpeedController } from "../src/core/agent/model-speed.js";
|
|
9
|
+
import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
10
|
+
import { resolveChatModelSelection, resolveChatSpeed } from "../src/core/agent/model-selection.js";
|
|
11
|
+
import { createTelegramModelControls } from "../src/transport/telegram/model-controls.js";
|
|
12
|
+
import { createTelegramModelCallbackHandler } from "../src/transport/telegram/model-callback.js";
|
|
13
|
+
|
|
14
|
+
async function createRuntime(t, credential) {
|
|
15
|
+
const directory = await mkdtemp(path.join(tmpdir(), "arisa-pi-speed-"));
|
|
16
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
17
|
+
if (credential) await writeFile(path.join(directory, "auth.json"), JSON.stringify({ "openai-codex": credential }));
|
|
18
|
+
const runtime = await ModelRuntime.create({
|
|
19
|
+
authPath: path.join(directory, "auth.json"),
|
|
20
|
+
modelsPath: null,
|
|
21
|
+
modelsStorePath: path.join(directory, "models-store.json"),
|
|
22
|
+
refreshOnCreate: false
|
|
23
|
+
});
|
|
24
|
+
return { directory, runtime };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("speed picker updates Astra in place, persists per topic, and closes unchanged selections", async (t) => {
|
|
28
|
+
const { runtime } = await createRuntime(t);
|
|
29
|
+
t.mock.method(ModelRuntime, "create", async () => runtime);
|
|
30
|
+
const config = applyConfigDefaults({ pi: { provider: "openai-codex", model: "gpt-6-astra" } });
|
|
31
|
+
const writes = [];
|
|
32
|
+
const updates = [];
|
|
33
|
+
const replies = [];
|
|
34
|
+
const answers = [];
|
|
35
|
+
const controls = createTelegramModelControls({
|
|
36
|
+
config,
|
|
37
|
+
saveConfig: async (value) => writes.push(structuredClone(value)),
|
|
38
|
+
agentManager: { setModelSpeed: async (...args) => updates.push(args) },
|
|
39
|
+
contextRoute: () => ({ sessionId: "123:topic:7" })
|
|
40
|
+
});
|
|
41
|
+
const ctx = {
|
|
42
|
+
chat: { id: 123 },
|
|
43
|
+
reply: async (...args) => replies.push(args),
|
|
44
|
+
api: { editMessageText: async (...args) => replies.push(args) },
|
|
45
|
+
answerCallbackQuery: async (answer) => answers.push(answer)
|
|
46
|
+
};
|
|
47
|
+
await controls.showSpeedPicker(ctx);
|
|
48
|
+
assert.equal(replies[0][1]?.reply_markup.inline_keyboard[1][0].callback_data, "speed:1.5");
|
|
49
|
+
const handler = createTelegramModelCallbackHandler({
|
|
50
|
+
...controls, config,
|
|
51
|
+
authorizeContext: async () => ({ ok: true }),
|
|
52
|
+
contextRoute: () => ({ sessionId: "123:topic:7" }),
|
|
53
|
+
getChatState: () => ({ processing: true })
|
|
54
|
+
});
|
|
55
|
+
ctx.callbackQuery = { data: "speed:1.5", message: { message_id: 456 } };
|
|
56
|
+
await handler(ctx);
|
|
57
|
+
assert.deepEqual(updates, [["123:topic:7", 1.5]]);
|
|
58
|
+
assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 1.5);
|
|
59
|
+
assert.equal(resolveChatSpeed(config, "123:topic:8"), 1);
|
|
60
|
+
assert.equal(resolveChatModelSelection(config, "123:topic:7").sessionRevision, 0);
|
|
61
|
+
await handler(ctx);
|
|
62
|
+
assert.equal(writes.length, 1);
|
|
63
|
+
assert.match(replies.at(-1)[2], /Already using speed 1.5x/);
|
|
64
|
+
ctx.callbackQuery.data = "speed:1";
|
|
65
|
+
await handler(ctx);
|
|
66
|
+
assert.equal(resolveChatSpeed(config, "123:topic:7"), 1);
|
|
67
|
+
assert.equal(answers.at(-1).text, "Speed: 1.0x.");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Pi SDK sends the selected speed in the actual Codex payload across turns", async (t) => {
|
|
71
|
+
const apiKey = `test.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "test-account" } })).toString("base64url")}.test`;
|
|
72
|
+
const { directory, runtime } = await createRuntime(t, {
|
|
73
|
+
type: "oauth", access: apiKey, refresh: "test-refresh", expires: Date.now() + 3_600_000
|
|
74
|
+
});
|
|
75
|
+
const model = runtime.getModel("openai-codex", "gpt-6-astra");
|
|
76
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
77
|
+
cwd: directory, agentDir: directory, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true
|
|
78
|
+
});
|
|
79
|
+
await resourceLoader.reload();
|
|
80
|
+
const { session } = await createAgentSession({
|
|
81
|
+
cwd: directory, agentDir: directory, modelRuntime: runtime, model,
|
|
82
|
+
resourceLoader, settingsManager: SettingsManager.inMemory({ retry: { enabled: false } }),
|
|
83
|
+
sessionManager: SessionManager.inMemory(), tools: []
|
|
84
|
+
});
|
|
85
|
+
t.after(() => session.dispose());
|
|
86
|
+
const requests = [];
|
|
87
|
+
const fetch = async (_url, init) => {
|
|
88
|
+
const body = init.headers.get("content-encoding") === "zstd"
|
|
89
|
+
? zstdDecompressSync(init.body).toString("utf8")
|
|
90
|
+
: init.body;
|
|
91
|
+
requests.push(JSON.parse(body));
|
|
92
|
+
return new Response(`data: ${JSON.stringify({ type: "response.completed", response: {
|
|
93
|
+
id: "test-response", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
|
|
94
|
+
} })}\n\n`, { headers: { "content-type": "text/event-stream" } });
|
|
95
|
+
};
|
|
96
|
+
t.mock.method(globalThis, "fetch", fetch);
|
|
97
|
+
const sdkStream = session.agent.streamFunction;
|
|
98
|
+
const controller = createModelSpeedController((model, context, options) => sdkStream(model, context, {
|
|
99
|
+
...options, transport: "sse", maxRetries: 0
|
|
100
|
+
}), 1);
|
|
101
|
+
session.agent.streamFunction = controller.streamFn;
|
|
102
|
+
for (const speed of [1, 1.5, 1]) {
|
|
103
|
+
controller.setSpeed(speed);
|
|
104
|
+
await session.prompt("Reply OK");
|
|
105
|
+
const message = session.messages.at(-1);
|
|
106
|
+
assert.equal(message.stopReason, "stop", message.errorMessage);
|
|
107
|
+
assert.equal(requests.at(-1).model, model.id);
|
|
108
|
+
assert.equal(requests.at(-1).service_tier, speed === 1.5 ? "priority" : "default");
|
|
109
|
+
}
|
|
110
|
+
assert.equal(requests.length, 3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("speed control leaves unsupported provider payloads and hooks untouched", async () => {
|
|
114
|
+
const options = { onPayload: (payload) => payload };
|
|
115
|
+
let received;
|
|
116
|
+
const controller = createModelSpeedController((_model, _context, nextOptions) => { received = nextOptions; }, 1.5);
|
|
117
|
+
controller.streamFn({ provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-5" }, {}, options);
|
|
118
|
+
assert.equal(received, options);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("Arisa creates and reuses Telegram sessions and opens its TUI with the installed Pi SDK", async (t) => {
|
|
122
|
+
const directory = await mkdtemp(path.join(tmpdir(), "arisa-pi-startup-"));
|
|
123
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
124
|
+
const { execFile } = await import("node:child_process");
|
|
125
|
+
const { promisify } = await import("node:util");
|
|
126
|
+
await promisify(execFile)(process.execPath, ["--input-type=module", "-e", `
|
|
127
|
+
import assert from "node:assert/strict";
|
|
128
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
129
|
+
import path from "node:path";
|
|
130
|
+
import { applyConfigDefaults } from "./src/core/config/config-defaults.js";
|
|
131
|
+
import { AgentManager } from "./src/core/agent/agent-manager.js";
|
|
132
|
+
import { createArisaTuiRuntime } from "./src/runtime/tui.js";
|
|
133
|
+
import { selectChatSpeed } from "./src/core/agent/model-selection.js";
|
|
134
|
+
import { ensureArisaHome, piAuthFile } from "./src/platform/paths.js";
|
|
135
|
+
globalThis.fetch = async () => { throw new Error("Unexpected network request"); };
|
|
136
|
+
await ensureArisaHome();
|
|
137
|
+
await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true });
|
|
138
|
+
await writeFile(piAuthFile, JSON.stringify({ "openai-codex": {
|
|
139
|
+
type: "oauth", access: "test-access", refresh: "test-refresh", expires: Date.now() + 3600000
|
|
140
|
+
} }));
|
|
141
|
+
const config = applyConfigDefaults({
|
|
142
|
+
telegram: { authorizedChatIds: [123] },
|
|
143
|
+
pi: { provider: "openai-codex", model: "gpt-6-astra", speed: 1.5, workspaceDir: process.env.ARISA_HOME }
|
|
144
|
+
});
|
|
145
|
+
const manager = new AgentManager({ config });
|
|
146
|
+
manager.setCapabilityService({ execute: async () => ({}) });
|
|
147
|
+
const context = await manager.getSessionContext("123", {});
|
|
148
|
+
try {
|
|
149
|
+
assert.equal(context.session.model.id, "gpt-6-astra");
|
|
150
|
+
assert.equal(context.session.agent.streamFunction, context.speedController.streamFn);
|
|
151
|
+
assert.equal(context.speedController.speed, 1.5);
|
|
152
|
+
await manager.setModelSpeed("123", 1);
|
|
153
|
+
selectChatSpeed(config, "123", 1);
|
|
154
|
+
const reused = await manager.getSessionContext("123", {});
|
|
155
|
+
assert.equal(reused.session, context.session);
|
|
156
|
+
assert.equal(reused.speedController.speed, 1);
|
|
157
|
+
await reused.release();
|
|
158
|
+
} finally {
|
|
159
|
+
await context.release();
|
|
160
|
+
manager.clearSessionCache("123");
|
|
161
|
+
await Promise.all(manager.sessionClosePromises.values());
|
|
162
|
+
manager.turnCoordinator.close();
|
|
163
|
+
}
|
|
164
|
+
const tui = await createArisaTuiRuntime({ config, client: {} });
|
|
165
|
+
try {
|
|
166
|
+
assert.equal(tui.session.model.id, "gpt-6-astra");
|
|
167
|
+
assert.equal(typeof tui.session.agent.streamFunction, "function");
|
|
168
|
+
} finally {
|
|
169
|
+
await tui.dispose();
|
|
170
|
+
}
|
|
171
|
+
`], {
|
|
172
|
+
cwd: new URL("..", import.meta.url),
|
|
173
|
+
env: { ...process.env, ARISA_HOME: directory, PI_CODING_AGENT_DIR: path.join(directory, "pi"), PI_OFFLINE: "1" },
|
|
174
|
+
timeout: 15_000
|
|
175
|
+
});
|
|
176
|
+
});
|