arisa 4.3.4 → 5.0.2
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/AGENTS.md +18 -17
- package/README.md +30 -9
- package/package.json +6 -2
- package/pnpm-workspace.yaml +1 -0
- package/src/core/agent/agent-manager.js +288 -29
- package/src/core/agent/auth-flow.js +12 -8
- package/src/core/agent/model-selection.js +54 -14
- package/src/core/agent/model-speed.js +59 -0
- package/src/core/config/config-defaults.js +56 -4
- package/src/core/config/config-store.js +5 -1
- package/src/core/conversation/conversation-history-store.js +142 -0
- package/src/core/tasks/task-store.js +16 -0
- package/src/core/tools/daemon-health.js +11 -2
- package/src/core/tools/daemon-processes.js +92 -2
- package/src/core/tools/daemon-runtime.js +4 -2
- package/src/core/tools/ipc-client.js +15 -3
- package/src/core/tools/tool-registry.js +27 -0
- package/src/index.js +61 -6
- package/src/runtime/arisa-capabilities.js +45 -1
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +47 -11
- package/src/runtime/doctor.js +307 -0
- package/src/runtime/log-viewer.js +165 -0
- package/src/runtime/paths.js +4 -1
- package/src/runtime/service-manager.js +106 -8
- package/src/runtime/tool-process-supervisor.js +107 -10
- package/src/transport/telegram/bot.js +533 -99
- package/src/transport/telegram/model-picker.js +28 -2
- package/test/agent-tool-policy.test.js +26 -1
- package/test/auth-flow.test.js +28 -2
- package/test/capabilities-security.test.js +37 -0
- package/test/context-and-task-bounds.test.js +279 -0
- package/test/daemon-runtime.test.js +130 -2
- package/test/dependency-warnings.test.js +17 -0
- package/test/doctor.test.js +90 -0
- package/test/log-viewer.test.js +90 -0
- package/test/model-selection.test.js +125 -2
- package/test/paths.test.js +8 -0
- package/test/pi-compaction.test.js +43 -0
- package/test/service-manager.test.js +234 -0
- package/test/task-store.test.js +31 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { formatPiModelOption } from "../../core/agent/pi-runtime.js";
|
|
2
2
|
import { buildPagedInlineKeyboard } from "./paged-inline-keyboard.js";
|
|
3
3
|
|
|
4
|
+
export function reverseModelOrder(models) {
|
|
5
|
+
return [...models].reverse();
|
|
6
|
+
}
|
|
7
|
+
|
|
4
8
|
export function parseModelPickerAction(data) {
|
|
5
9
|
if (data === "noop:page") return { type: "noop", value: null };
|
|
6
10
|
const match = /^(model|model-page):(\d+)$/.exec(String(data || ""));
|
|
@@ -31,7 +35,13 @@ export function parseEffortPickerAction(data) {
|
|
|
31
35
|
return null;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
export function
|
|
38
|
+
export function parseSpeedPickerAction(data) {
|
|
39
|
+
if (data === "noop:page") return { type: "noop", value: null };
|
|
40
|
+
const speed = /^speed:(1(?:\.5)?)$/.exec(String(data || ""));
|
|
41
|
+
return speed ? { type: "speed", speed: Number(speed[1]) } : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildModelPicker({ provider, models, selectedModelId, selectedThinkingLevel, selectedSpeed, page, pageSize }) {
|
|
35
45
|
if (!models.length) {
|
|
36
46
|
throw new Error(`No models available for provider ${provider}`);
|
|
37
47
|
}
|
|
@@ -39,12 +49,28 @@ export function buildModelPicker({ provider, models, selectedModelId, selectedTh
|
|
|
39
49
|
text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
|
|
40
50
|
}));
|
|
41
51
|
const effortLine = selectedThinkingLevel ? `\nEffort: ${selectedThinkingLevel}` : "";
|
|
52
|
+
const speedLine = selectedSpeed ? `\nSpeed: ${selectedSpeed.toFixed(1)}x` : "";
|
|
42
53
|
return {
|
|
43
|
-
text: `Current model: ${provider}/${selectedModelId}${effortLine}\nSelect a model for this chat:`,
|
|
54
|
+
text: `Current model: ${provider}/${selectedModelId}${effortLine}${speedLine}\nSelect a model for this chat:`,
|
|
44
55
|
replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
|
|
45
56
|
};
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
export function buildSpeedPicker({ provider, modelId, speeds, selectedSpeed }) {
|
|
60
|
+
if (!speeds.length) {
|
|
61
|
+
throw new Error(`No speed levels available for ${provider}/${modelId}`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
text: `Current model: ${provider}/${modelId}\nSelect speed for this chat:`,
|
|
65
|
+
replyMarkup: {
|
|
66
|
+
inline_keyboard: speeds.map((speed) => ([{
|
|
67
|
+
text: `${speed === selectedSpeed ? "✓ " : ""}${speed.toFixed(1)}x`,
|
|
68
|
+
callback_data: `speed:${speed}`
|
|
69
|
+
}]))
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
export function buildEffortPicker({
|
|
49
75
|
provider,
|
|
50
76
|
modelId,
|
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { buildPiToolPolicy } from "../src/core/agent/core-tools.js";
|
|
7
|
+
import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
|
|
7
8
|
import { appendArisaAgentsFile, arisaAgentsFile } from "../src/core/agent/runtime-context.js";
|
|
8
9
|
import { createSystemShellTool } from "../src/core/agent/system-shell-tool.js";
|
|
9
10
|
import { applyRuntimeOverrides } from "../src/runtime/create-app.js";
|
|
@@ -60,7 +61,8 @@ test("applies runtime overrides without dropping persisted Pi config", () => {
|
|
|
60
61
|
workspaceDir: "/tmp/arisa",
|
|
61
62
|
tools: "read,bash",
|
|
62
63
|
excludeTools: "write",
|
|
63
|
-
shellTimeoutMs: "90000"
|
|
64
|
+
shellTimeoutMs: "90000",
|
|
65
|
+
speed: "1.5"
|
|
64
66
|
}
|
|
65
67
|
});
|
|
66
68
|
|
|
@@ -71,6 +73,29 @@ test("applies runtime overrides without dropping persisted Pi config", () => {
|
|
|
71
73
|
assert.deepEqual(next.pi.tools, ["read", "bash"]);
|
|
72
74
|
assert.deepEqual(next.pi.excludeTools, ["write"]);
|
|
73
75
|
assert.equal(next.pi.shellTimeoutMs, 90000);
|
|
76
|
+
assert.equal(next.pi.speed, 1.5);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects attempts to select another harness", () => {
|
|
80
|
+
const config = {
|
|
81
|
+
pi: { provider: "openai-codex", model: "current", apiKey: "pi-key" }
|
|
82
|
+
};
|
|
83
|
+
assert.throws(
|
|
84
|
+
() => applyRuntimeOverrides(config, { agent: { runtime: "another" } }),
|
|
85
|
+
/Unsupported runtime override namespace: agent/
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("migrates persisted config to Pi-only state", () => {
|
|
90
|
+
const config = applyConfigDefaults({
|
|
91
|
+
agent: { runtime: "legacy" },
|
|
92
|
+
prime: { provider: "legacy-provider", model: "legacy-model" },
|
|
93
|
+
pi: { provider: "openai-codex", model: "gpt-5.6" }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.equal(config.pi.model, "gpt-5.6");
|
|
97
|
+
assert.equal("agent" in config, false);
|
|
98
|
+
assert.equal("prime" in config, false);
|
|
74
99
|
});
|
|
75
100
|
|
|
76
101
|
test("system_shell runs commands from the configured workspace", async () => {
|
package/test/auth-flow.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { getErrorMessage, getPiAuthIssue } from "../src/core/agent/auth-flow.js";
|
|
3
|
+
import { buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue } from "../src/core/agent/auth-flow.js";
|
|
4
4
|
|
|
5
5
|
test("extracts messages from Error instances and other thrown values", () => {
|
|
6
6
|
assert.equal(getErrorMessage(new Error("boom")), "boom");
|
|
@@ -13,7 +13,8 @@ test("classifies invalidated Pi authentication tokens", () => {
|
|
|
13
13
|
new Error("authentication token has been invalidated"),
|
|
14
14
|
new Error("Token invalidated by provider"),
|
|
15
15
|
new Error("Please try signing in again"),
|
|
16
|
-
new Error("auth token expired")
|
|
16
|
+
new Error("auth token expired"),
|
|
17
|
+
new Error("Provided authentication token is expired.")
|
|
17
18
|
]) {
|
|
18
19
|
assert.deepEqual(getPiAuthIssue(error), {
|
|
19
20
|
kind: "invalidated-token",
|
|
@@ -40,3 +41,28 @@ test("ignores unrelated Pi errors", () => {
|
|
|
40
41
|
assert.equal(getPiAuthIssue(new Error("model rate limit exceeded")), null);
|
|
41
42
|
assert.equal(getPiAuthIssue(new Error("")), null);
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
test("reports the active chat model after authentication", () => {
|
|
46
|
+
const config = {
|
|
47
|
+
pi: {
|
|
48
|
+
provider: "openai-codex",
|
|
49
|
+
model: "gpt-5.5",
|
|
50
|
+
apiKey: "",
|
|
51
|
+
chatModels: {
|
|
52
|
+
"123": {
|
|
53
|
+
provider: "openai-codex",
|
|
54
|
+
model: "gpt-5.6",
|
|
55
|
+
thinkingLevel: "high",
|
|
56
|
+
speed: 1,
|
|
57
|
+
sessionRevision: 4
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const message = buildPiAuthTelegramMessage({ config, chatId: 123, verified: true });
|
|
64
|
+
|
|
65
|
+
assert.match(message, /^Pi authentication is working for openai-codex\/gpt-5\.6\./);
|
|
66
|
+
assert.doesNotMatch(message, /gpt-5\.5/);
|
|
67
|
+
assert.equal(config.pi.chatModels["123"].sessionRevision, 4);
|
|
68
|
+
});
|
|
@@ -120,9 +120,11 @@ test("requires chatId for chat-scoped IPC methods", async () => {
|
|
|
120
120
|
"artifacts.createText",
|
|
121
121
|
"artifacts.listRecent",
|
|
122
122
|
"artifacts.get",
|
|
123
|
+
"artifacts.deliver",
|
|
123
124
|
"tasks.add",
|
|
124
125
|
"tasks.list",
|
|
125
126
|
"tasks.cancel",
|
|
127
|
+
"tasks.cancelAll",
|
|
126
128
|
"agent.enqueueEvent",
|
|
127
129
|
"paths.getChatToolStateDir",
|
|
128
130
|
"paths.getChatToolTmpDir",
|
|
@@ -140,6 +142,41 @@ test("requires chatId for chat-scoped IPC methods", async () => {
|
|
|
140
142
|
}
|
|
141
143
|
});
|
|
142
144
|
|
|
145
|
+
test("delivers only artifacts resolved from the requesting chat", async () => {
|
|
146
|
+
const artifact = { id: "artifact-1", chatId: "chat-a", path: "/safe/chat-a/file.txt" };
|
|
147
|
+
const deliveries = [];
|
|
148
|
+
const capabilities = createCapabilities({
|
|
149
|
+
artifactStore: {
|
|
150
|
+
forChat: (chatId) => ({
|
|
151
|
+
get: async (artifactId) => String(chatId) === "chat-a" && artifactId === artifact.id ? artifact : null
|
|
152
|
+
})
|
|
153
|
+
},
|
|
154
|
+
agentManager: {
|
|
155
|
+
deliverArtifact: async (payload) => {
|
|
156
|
+
deliveries.push(payload);
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await assert.rejects(() => capabilities.dispatch({
|
|
163
|
+
method: "artifacts.deliver",
|
|
164
|
+
toolName: "ipc-tool",
|
|
165
|
+
chatId: "chat-b",
|
|
166
|
+
params: { artifactId: "artifact-1", path: artifact.path }
|
|
167
|
+
}), /Artifact not found/);
|
|
168
|
+
assert.equal(deliveries.length, 0);
|
|
169
|
+
|
|
170
|
+
await capabilities.dispatch({
|
|
171
|
+
method: "artifacts.deliver",
|
|
172
|
+
toolName: "ipc-tool",
|
|
173
|
+
chatId: "chat-a",
|
|
174
|
+
params: { artifactId: "artifact-1", path: "/attacker/chosen/path" }
|
|
175
|
+
});
|
|
176
|
+
assert.equal(deliveries.length, 1);
|
|
177
|
+
assert.equal(deliveries[0].artifact.path, artifact.path);
|
|
178
|
+
});
|
|
179
|
+
|
|
143
180
|
test("normalizes tool run args and rejects arrays", async () => {
|
|
144
181
|
const calls = [];
|
|
145
182
|
const capabilities = createCapabilities({
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
collectText,
|
|
5
|
+
createChatStateStore,
|
|
6
|
+
drainChatPromptQueue,
|
|
7
|
+
isSilentReply,
|
|
8
|
+
queueChatPrompt,
|
|
9
|
+
resolveTelegramBusyMessageMode,
|
|
10
|
+
routeBusyPrompt
|
|
11
|
+
} from "../src/transport/telegram/bot.js";
|
|
12
|
+
import { selectScheduledTasks } from "../src/core/agent/agent-manager.js";
|
|
13
|
+
|
|
14
|
+
function createSession(events) {
|
|
15
|
+
const listeners = new Set();
|
|
16
|
+
return {
|
|
17
|
+
subscribe(listener) {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
async prompt() {
|
|
22
|
+
for (const event of events) {
|
|
23
|
+
for (const listener of listeners) listener(event);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("collectText ignores a transient error after a successful retry", async () => {
|
|
30
|
+
const session = createSession([
|
|
31
|
+
{
|
|
32
|
+
type: "message_end",
|
|
33
|
+
message: {
|
|
34
|
+
role: "assistant",
|
|
35
|
+
stopReason: "error",
|
|
36
|
+
errorMessage: "Codex error: Your input exceeds the context window of this model."
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{ type: "message_start", message: { role: "assistant" } },
|
|
40
|
+
{
|
|
41
|
+
type: "message_update",
|
|
42
|
+
message: { role: "assistant" },
|
|
43
|
+
assistantMessageEvent: { type: "text_delta", delta: "Recovered response" }
|
|
44
|
+
},
|
|
45
|
+
{ type: "message_end", message: { role: "assistant", stopReason: "stop" } }
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
assert.equal(await collectText(session, "hello"), "Recovered response");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("collectText preserves the final assistant error", async () => {
|
|
52
|
+
const session = createSession([
|
|
53
|
+
{
|
|
54
|
+
type: "message_end",
|
|
55
|
+
message: { role: "assistant", stopReason: "error", errorMessage: "terminal failure" }
|
|
56
|
+
}
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
await assert.rejects(() => collectText(session, "hello"), /terminal failure/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("recognizes standalone silent reply markers", () => {
|
|
63
|
+
assert.equal(isSilentReply("NO_REPLY"), true);
|
|
64
|
+
assert.equal(isSilentReply("No reply needed."), true);
|
|
65
|
+
assert.equal(isSilentReply("No action needed."), true);
|
|
66
|
+
assert.equal(isSilentReply("\nNO_REPLY\n\nNO_REPLY\n"), true);
|
|
67
|
+
assert.equal(isSilentReply("No reply needed.\n\nNo action needed."), true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("does not suppress real text that mentions a silent reply marker", () => {
|
|
71
|
+
assert.equal(isSilentReply("NO_REPLY means no notification was needed."), false);
|
|
72
|
+
assert.equal(isSilentReply("NO_REPLY\n\nThere is an important message."), false);
|
|
73
|
+
assert.equal(isSilentReply("No action needed unless the token expires."), false);
|
|
74
|
+
assert.equal(isSilentReply("No reply needed"), false);
|
|
75
|
+
assert.equal(isSilentReply("no_reply"), false);
|
|
76
|
+
assert.equal(isSilentReply(""), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("chat state uses one queue for numeric and string chat IDs", () => {
|
|
80
|
+
const states = createChatStateStore();
|
|
81
|
+
const telegramState = states.get(879964957);
|
|
82
|
+
telegramState.processing = true;
|
|
83
|
+
queueChatPrompt(telegramState, "queued prompt");
|
|
84
|
+
|
|
85
|
+
assert.strictEqual(states.get("879964957"), telegramState);
|
|
86
|
+
assert.equal(states.get("879964957").processing, true);
|
|
87
|
+
assert.deepEqual(states.get("879964957").pendingPrompts, ["queued prompt"]);
|
|
88
|
+
|
|
89
|
+
const resetState = states.reset("879964957");
|
|
90
|
+
assert.strictEqual(states.get(879964957), resetState);
|
|
91
|
+
assert.deepEqual(resetState, {
|
|
92
|
+
processing: false,
|
|
93
|
+
pendingPrompts: [],
|
|
94
|
+
continueAfterClose: false,
|
|
95
|
+
historyRevision: 0,
|
|
96
|
+
beforeNextPrompt: null,
|
|
97
|
+
activeSession: null,
|
|
98
|
+
activeSteers: []
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("queued prompts retain their message boundaries and order", async () => {
|
|
103
|
+
const chatState = createChatStateStore().get("chat");
|
|
104
|
+
chatState.processing = true;
|
|
105
|
+
queueChatPrompt(chatState, "second request");
|
|
106
|
+
queueChatPrompt(chatState, "third request");
|
|
107
|
+
const processed = [];
|
|
108
|
+
|
|
109
|
+
await drainChatPromptQueue({
|
|
110
|
+
chatState,
|
|
111
|
+
initialPrompt: "first request",
|
|
112
|
+
processPrompt: async ({ prompt }) => processed.push(prompt)
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(processed, ["first request", "second request", "third request"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a queued /new continues after the active session closes", async () => {
|
|
119
|
+
const chatState = createChatStateStore().get("chat");
|
|
120
|
+
chatState.processing = true;
|
|
121
|
+
const processed = [];
|
|
122
|
+
const interruption = new Error("active session closed");
|
|
123
|
+
|
|
124
|
+
await drainChatPromptQueue({
|
|
125
|
+
chatState,
|
|
126
|
+
initialPrompt: "old request",
|
|
127
|
+
processPrompt: async ({ prompt }) => {
|
|
128
|
+
processed.push(prompt);
|
|
129
|
+
if (prompt === "old request") {
|
|
130
|
+
queueChatPrompt(chatState, "new session confirmation", { replace: true });
|
|
131
|
+
chatState.continueAfterClose = true;
|
|
132
|
+
throw interruption;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(processed, ["old request", "new session confirmation"]);
|
|
138
|
+
assert.equal(chatState.processing, false);
|
|
139
|
+
assert.deepEqual(chatState.pendingPrompts, []);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("exclusive pre-prompt work keeps concurrent messages queued", async () => {
|
|
143
|
+
const chatState = createChatStateStore().get("chat");
|
|
144
|
+
chatState.processing = true;
|
|
145
|
+
const processed = [];
|
|
146
|
+
let releasePreparation;
|
|
147
|
+
const preparation = new Promise((resolve) => { releasePreparation = resolve; });
|
|
148
|
+
|
|
149
|
+
const draining = drainChatPromptQueue({
|
|
150
|
+
chatState,
|
|
151
|
+
initialPrompt: "new session confirmation",
|
|
152
|
+
beforeInitialPrompt: () => preparation,
|
|
153
|
+
processPrompt: async ({ prompt }) => processed.push(prompt)
|
|
154
|
+
});
|
|
155
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
156
|
+
queueChatPrompt(chatState, "message received during handoff");
|
|
157
|
+
releasePreparation();
|
|
158
|
+
await draining;
|
|
159
|
+
|
|
160
|
+
assert.deepEqual(processed, ["new session confirmation", "message received during handoff"]);
|
|
161
|
+
assert.equal(chatState.processing, false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a queued /new supersedes the initial prompt after exclusive preparation", async () => {
|
|
165
|
+
const chatState = createChatStateStore().get("chat");
|
|
166
|
+
chatState.processing = true;
|
|
167
|
+
const processed = [];
|
|
168
|
+
let releasePreparation;
|
|
169
|
+
const preparation = new Promise((resolve) => { releasePreparation = resolve; });
|
|
170
|
+
|
|
171
|
+
const draining = drainChatPromptQueue({
|
|
172
|
+
chatState,
|
|
173
|
+
initialPrompt: "first new session confirmation",
|
|
174
|
+
beforeInitialPrompt: () => preparation,
|
|
175
|
+
processPrompt: async ({ prompt }) => processed.push(prompt)
|
|
176
|
+
});
|
|
177
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
178
|
+
queueChatPrompt(chatState, "latest new session confirmation", { replace: true });
|
|
179
|
+
chatState.continueAfterClose = true;
|
|
180
|
+
releasePreparation();
|
|
181
|
+
await draining;
|
|
182
|
+
|
|
183
|
+
assert.deepEqual(processed, ["latest new session confirmation"]);
|
|
184
|
+
assert.equal(chatState.processing, false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("busy message mode supports global defaults and per-chat overrides", () => {
|
|
188
|
+
const config = {
|
|
189
|
+
telegram: {
|
|
190
|
+
busyMessageMode: "queue",
|
|
191
|
+
chatMeta: { "879964957": { busyMessageMode: "steer" } }
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
assert.equal(resolveTelegramBusyMessageMode(config, 879964957), "steer");
|
|
196
|
+
assert.equal(resolveTelegramBusyMessageMode(config, 123), "queue");
|
|
197
|
+
assert.equal(resolveTelegramBusyMessageMode({ telegram: { busyMessageMode: "invalid" } }, 123), "queue");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("steer mode sends text to the active Pi session", async () => {
|
|
201
|
+
const received = [];
|
|
202
|
+
const chatState = createChatStateStore().get("chat");
|
|
203
|
+
chatState.activeSession = {
|
|
204
|
+
isStreaming: true,
|
|
205
|
+
async steer(prompt) { received.push(prompt); }
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const result = await routeBusyPrompt({ chatState, prompt: "change direction", mode: "steer" });
|
|
209
|
+
|
|
210
|
+
assert.equal(result.disposition, "steered");
|
|
211
|
+
assert.deepEqual(received, ["change direction"]);
|
|
212
|
+
assert.deepEqual(chatState.activeSteers, ["change direction"]);
|
|
213
|
+
assert.deepEqual(chatState.pendingPrompts, []);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("failed or unavailable steering falls back to the ordered queue", async () => {
|
|
217
|
+
const chatState = createChatStateStore().get("chat");
|
|
218
|
+
chatState.activeSession = {
|
|
219
|
+
isStreaming: true,
|
|
220
|
+
async steer() { throw new Error("stream ended"); }
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const failed = await routeBusyPrompt({ chatState, prompt: "keep this", mode: "steer" });
|
|
224
|
+
chatState.activeSession = null;
|
|
225
|
+
const unavailable = await routeBusyPrompt({ chatState, prompt: "and this", mode: "steer" });
|
|
226
|
+
|
|
227
|
+
assert.equal(failed.disposition, "queued");
|
|
228
|
+
assert.match(failed.steerError.message, /stream ended/);
|
|
229
|
+
assert.equal(unavailable.disposition, "queued");
|
|
230
|
+
assert.deepEqual(chatState.pendingPrompts, ["keep this", "and this"]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a pending /new forces later text into the replacement queue", async () => {
|
|
234
|
+
const steered = [];
|
|
235
|
+
const chatState = createChatStateStore().get("chat");
|
|
236
|
+
chatState.continueAfterClose = true;
|
|
237
|
+
chatState.activeSession = {
|
|
238
|
+
isStreaming: true,
|
|
239
|
+
async steer(prompt) { steered.push(prompt); }
|
|
240
|
+
};
|
|
241
|
+
queueChatPrompt(chatState, "new session confirmation", { replace: true });
|
|
242
|
+
|
|
243
|
+
const result = await routeBusyPrompt({ chatState, prompt: "message after new", mode: "steer" });
|
|
244
|
+
|
|
245
|
+
assert.equal(result.disposition, "queued");
|
|
246
|
+
assert.deepEqual(steered, []);
|
|
247
|
+
assert.deepEqual(chatState.pendingPrompts, ["new session confirmation", "message after new"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("selectScheduledTasks bounds history while keeping active tasks", () => {
|
|
251
|
+
const tasks = Array.from({ length: 55 }, (_, index) => ({
|
|
252
|
+
id: `done-${index}`,
|
|
253
|
+
status: "done"
|
|
254
|
+
}));
|
|
255
|
+
tasks[0] = { id: "pending-1", status: "pending" };
|
|
256
|
+
|
|
257
|
+
const result = selectScheduledTasks(tasks);
|
|
258
|
+
|
|
259
|
+
assert.equal(result.total, 55);
|
|
260
|
+
assert.equal(result.returned, 50);
|
|
261
|
+
assert.equal(result.limit, 50);
|
|
262
|
+
assert.equal(result.truncated, true);
|
|
263
|
+
assert.equal(result.tasks[0].id, "pending-1");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("selectScheduledTasks honors an explicit status and limit", () => {
|
|
267
|
+
const tasks = [
|
|
268
|
+
{ id: "done-1", status: "done" },
|
|
269
|
+
{ id: "done-2", status: "done" }
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
const result = selectScheduledTasks(tasks, { status: "done", limit: 1 });
|
|
273
|
+
|
|
274
|
+
assert.deepEqual(result.tasks.map((task) => task.id), ["done-2"]);
|
|
275
|
+
assert.equal(result.total, 2);
|
|
276
|
+
assert.equal(result.returned, 1);
|
|
277
|
+
assert.equal(result.limit, 1);
|
|
278
|
+
assert.equal(result.truncated, true);
|
|
279
|
+
});
|
|
@@ -34,14 +34,19 @@ await writeFile(
|
|
|
34
34
|
const {
|
|
35
35
|
daemonPaths,
|
|
36
36
|
isProcessAlive,
|
|
37
|
+
readDaemonDiagnostic,
|
|
37
38
|
readJson,
|
|
38
|
-
stopManagedDaemon
|
|
39
|
+
stopManagedDaemon,
|
|
40
|
+
unregisterManagedDaemon,
|
|
41
|
+
writeDaemonStatus
|
|
39
42
|
} = await import("../src/core/tools/daemon-processes.js");
|
|
40
43
|
const {
|
|
41
44
|
createDaemonRuntime,
|
|
42
45
|
isDaemonReady
|
|
43
46
|
} = await import("../src/core/tools/daemon-runtime.js");
|
|
44
|
-
const { createToolProcessSupervisor } = await import("../src/runtime/tool-process-supervisor.js");
|
|
47
|
+
const { createToolProcessSupervisor, formatDaemonOutcome } = await import("../src/runtime/tool-process-supervisor.js");
|
|
48
|
+
const { superviseDaemon } = await import("../src/core/tools/daemon-health.js");
|
|
49
|
+
const { ToolRegistry } = await import("../src/core/tools/tool-registry.js");
|
|
45
50
|
|
|
46
51
|
const fixtureEntry = fileURLToPath(new URL("../test-fixtures/fake-daemon.js", import.meta.url));
|
|
47
52
|
|
|
@@ -148,6 +153,37 @@ test("supervisor ignores invalid chat directories and recovers valid daemons", a
|
|
|
148
153
|
}
|
|
149
154
|
});
|
|
150
155
|
|
|
156
|
+
test("does not restart registrations whose scope no longer matches the tool manifest", async () => {
|
|
157
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: true });
|
|
158
|
+
const registry = new ToolRegistry();
|
|
159
|
+
registry.tools.set("fake-daemon", {
|
|
160
|
+
name: "fake-daemon",
|
|
161
|
+
entry: fixtureEntry,
|
|
162
|
+
daemon: { scope: "chat", autoStart: false, health: "internal" }
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await runtime.start();
|
|
166
|
+
await runtime.stop();
|
|
167
|
+
await writeDaemonStatus(runtime.paths, {
|
|
168
|
+
state: "failed",
|
|
169
|
+
pid: null,
|
|
170
|
+
restartAttempts: policy.restartLimit + 1,
|
|
171
|
+
restartRequested: false,
|
|
172
|
+
message: "Legacy global registration"
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const supervisor = createToolProcessSupervisor({ policy, toolRegistry: registry });
|
|
176
|
+
const results = await supervisor.repair();
|
|
177
|
+
const result = results.find((item) => item.record.toolName === "fake-daemon" && item.record.instanceId === "global");
|
|
178
|
+
|
|
179
|
+
assert.equal(result.outcome, "stale-registration");
|
|
180
|
+
assert.match(result.reason, /global scope does not match manifest chat scope/);
|
|
181
|
+
assert.equal(isProcessAlive(await runtime.getPid()), false);
|
|
182
|
+
|
|
183
|
+
await unregisterManagedDaemon({ toolName: "fake-daemon", scope: { type: "global" } });
|
|
184
|
+
assert.deepEqual(await readJson(runtime.paths.metaFile, null), null);
|
|
185
|
+
});
|
|
186
|
+
|
|
151
187
|
test("rejects stale readiness even when the pid is alive", async () => {
|
|
152
188
|
const status = {
|
|
153
189
|
state: "ready",
|
|
@@ -170,6 +206,98 @@ test("does not auto-start an intentionally stopped on-demand daemon", async () =
|
|
|
170
206
|
await supervisor.stop();
|
|
171
207
|
});
|
|
172
208
|
|
|
209
|
+
test("describes an intentionally stopped on-demand daemon without treating it as a failure", async () => {
|
|
210
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: false });
|
|
211
|
+
await writeDaemonStatus(runtime.paths, {
|
|
212
|
+
state: "stopped",
|
|
213
|
+
pid: null,
|
|
214
|
+
message: "Idle timeout reached",
|
|
215
|
+
restartAttempts: 0,
|
|
216
|
+
restartRequested: false
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const diagnostic = await readDaemonDiagnostic(runtime.registration);
|
|
220
|
+
assert.equal(diagnostic.state, "stopped");
|
|
221
|
+
assert.equal(diagnostic.disposition, "leave-stopped");
|
|
222
|
+
assert.equal(diagnostic.lastError, null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("keeps a terminal daemon failure stable until it receives explicit attention", async () => {
|
|
226
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: true });
|
|
227
|
+
await writeDaemonStatus(runtime.paths, {
|
|
228
|
+
state: "failed",
|
|
229
|
+
pid: null,
|
|
230
|
+
message: "Daemon restart limit reached: synthetic crash",
|
|
231
|
+
restartAttempts: policy.restartLimit + 1,
|
|
232
|
+
restartRequested: false,
|
|
233
|
+
lastError: { phase: "restart", message: "synthetic crash token=private-value", code: "SYNTHETIC" }
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
assert.equal(await superviseDaemon(runtime.registration, policy), "failed");
|
|
237
|
+
assert.equal(await superviseDaemon(runtime.registration, policy), "failed");
|
|
238
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
239
|
+
assert.equal(status.restartAttempts, policy.restartLimit + 1);
|
|
240
|
+
|
|
241
|
+
const diagnostic = await readDaemonDiagnostic(runtime.registration);
|
|
242
|
+
assert.equal(diagnostic.disposition, "requires-attention");
|
|
243
|
+
assert.equal(diagnostic.lastError.code, "SYNTHETIC");
|
|
244
|
+
assert.equal(diagnostic.lastError.message, "synthetic crash token=[redacted]");
|
|
245
|
+
assert.match(
|
|
246
|
+
formatDaemonOutcome(runtime.registration, "failed", diagnostic, policy),
|
|
247
|
+
/failed \| error\[restart\]=synthetic crash token=\[redacted\] \| code=SYNTHETIC \| restarts=3\/2 \| action=requires-attention \| log=/
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("doctor repair gives a terminal daemon one explicit restart attempt", async () => {
|
|
252
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: true });
|
|
253
|
+
await writeDaemonStatus(runtime.paths, {
|
|
254
|
+
state: "failed",
|
|
255
|
+
pid: null,
|
|
256
|
+
message: "Daemon restart limit reached",
|
|
257
|
+
restartAttempts: policy.restartLimit + 1,
|
|
258
|
+
restartRequested: false
|
|
259
|
+
});
|
|
260
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const results = await supervisor.repair();
|
|
264
|
+
const result = results.find((item) => item.record.toolName === "fake-daemon" && item.record.instanceId === "global");
|
|
265
|
+
assert.equal(result.outcome, "started");
|
|
266
|
+
assert.equal((await readJson(runtime.paths.statusFile, {})).restartAttempts, 0);
|
|
267
|
+
assert.equal(isProcessAlive(await runtime.getPid()), true);
|
|
268
|
+
} finally {
|
|
269
|
+
await runtime.stop().catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("includes scoped daemon diagnostics when Arisa lists tools", async () => {
|
|
274
|
+
const runtime = runtimeFor({ type: "chat", chatId: "101" }, { autoStart: false });
|
|
275
|
+
await writeDaemonStatus(runtime.paths, {
|
|
276
|
+
state: "stopped",
|
|
277
|
+
pid: null,
|
|
278
|
+
message: "Idle timeout reached",
|
|
279
|
+
restartAttempts: 0,
|
|
280
|
+
restartRequested: false
|
|
281
|
+
});
|
|
282
|
+
const registry = new ToolRegistry();
|
|
283
|
+
registry.tools.set("fake-daemon", {
|
|
284
|
+
name: "fake-daemon",
|
|
285
|
+
description: "Fake daemon",
|
|
286
|
+
input: [],
|
|
287
|
+
output: [],
|
|
288
|
+
configSchema: {},
|
|
289
|
+
category: null,
|
|
290
|
+
keywords: [],
|
|
291
|
+
skillHints: [],
|
|
292
|
+
daemon: { scope: "chat", autoStart: false, health: "internal" }
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const [tool] = await registry.listWithRuntime("101");
|
|
296
|
+
assert.equal(tool.daemon.scope, "chat");
|
|
297
|
+
assert.equal(tool.daemon.runtime.state, "stopped");
|
|
298
|
+
assert.equal(tool.daemon.runtime.disposition, "leave-stopped");
|
|
299
|
+
});
|
|
300
|
+
|
|
173
301
|
test("external supervisor recovers internally before restarting", async () => {
|
|
174
302
|
const runtime = runtimeFor({ type: "global" }, {
|
|
175
303
|
autoStart: true,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageDir = fileURLToPath(new URL("..", import.meta.url));
|
|
7
|
+
|
|
8
|
+
test("imports grammy without the deprecated built-in punycode module", () => {
|
|
9
|
+
const result = spawnSync(
|
|
10
|
+
process.execPath,
|
|
11
|
+
["--trace-deprecation", "--input-type=module", "--eval", "await import('grammy')"],
|
|
12
|
+
{ cwd: packageDir, encoding: "utf8" },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
assert.equal(result.status, 0, result.stderr);
|
|
16
|
+
assert.doesNotMatch(result.stderr, /DEP0040|punycode.*deprecated/i);
|
|
17
|
+
});
|