@inetafrica/open-claudia 3.0.0 → 3.0.1
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/CHANGELOG.md +10 -0
- package/bot.js +15 -0
- package/channels/telegram/adapter.js +59 -4
- package/core/actions.js +6 -0
- package/core/handlers.js +40 -8
- package/core/providers/claude-events.js +4 -0
- package/core/providers/codex-events.js +4 -0
- package/core/providers/codex.js +4 -4
- package/core/providers/events.js +1 -0
- package/core/runner.js +157 -5
- package/core/single-instance.js +46 -0
- package/package.json +2 -2
- package/test-cursor-removal.js +5 -2
- package/test-fixtures/fake-agent-cli.js +32 -0
- package/test-provider-capabilities.js +2 -2
- package/test-provider-codex.js +2 -2
- package/test-provider-events.js +30 -0
- package/test-provider-language.js +3 -3
- package/test-telegram-poll-recovery.js +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.1 — streaming restored, /stop queue survival, boot resilience
|
|
4
|
+
|
|
5
|
+
- **Live streaming is back, and the final message stays clean.** The v3.0.0 provider rewrite dropped the in-chat streaming feel: no live progress while a run worked, and the settled reply concatenated every text block into one blob. Now an active run edits a throttled preview message in place with the agent's narration (and its thinking, when `/verbose` is on — new command + button toggle), and when the run settles only the **last** text segment is delivered as the final answer; earlier pre-tool text is preserved as narration instead of being glued onto the reply. Providers emit a normalized `thinking` event (Claude thinking blocks, Codex reasoning items) that feeds the preview but can never leak into settled output. On success the preview becomes the narration record (or is deleted when there was none); on failure or cancel the partial preview is kept so you can see how far the run got. Preview edits are channel-guarded, so a bubble minted on one channel is never edited from another.
|
|
6
|
+
- **`/stop` no longer throws away your queued messages.** Cancelling killed the current turn *and* silently emptied the message queue — every turn you'd typed behind the running one vanished. `/stop` now cancels only the current turn (including a pre-spawn abort checkpoint for runs still building their prompt), keeps the queue intact, and tells you how many queued messages will run next. Cancelled runs unwind quietly instead of delivering an error-shaped reply.
|
|
7
|
+
- **The Codex model menu now offers the GPT-5.6 generation.** The retired `gpt-5.1-codex` trio is replaced by `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna`; effort tiers map low→luna and medium→terra so utility work stays cheap, with sol as the default.
|
|
8
|
+
- **One 409 doesn't kill the bot anymore.** A single Telegram 409 Conflict (another poller on the same token — usually a ghost poll from a restarting predecessor) used to `exit(1)` immediately, which is how one stray poll became a restart storm. The poller now rides out a conflict streak: pause 15s and retry, clear the streak after 45s of quiet, and only exit if the conflict persists beyond 2 minutes (a real second instance). The watchdog defers restarts during a streak.
|
|
9
|
+
- **A single-instance lock refuses double-boots.** Boot now claims `bot.lock` in the config dir (pid, start time, entrypoint); a second bot pointed at the same config refuses to start while the holder is alive, claims a stale or corrupt lock safely, and release never deletes a successor's lock. Combined with the 409 grace this makes accidental duplicate launches self-identifying and harmless.
|
|
10
|
+
- **Startup polling stalls recover on their own.** A Telegram long-poll that went silent right after boot (dead socket, no error) is now detected and restarted instead of leaving a deaf bot.
|
|
11
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. New hermetic tests (`test-streaming-split.js`, `test-telegram-409-grace.js`, `test-single-instance.js`, `test-telegram-poll-recovery.js`) wired into `npm test`; CI test commands now carry the fake-agent env explicitly so the suite is hermetic on runners with no provider CLIs.
|
|
12
|
+
|
|
3
13
|
## v3.0.0 — provider parity and Cursor removal
|
|
4
14
|
|
|
5
15
|
- **The runtime is now a provider-agnostic coding-agent harness.** Claude Code and OpenAI Codex implement one registry contract for prompts, immutable run admission, normalized events, native sessions, capability reporting, utility work, and pre-tool safety policy. Setup, web configuration, status, and doctor work with either, both, or neither provider installed; only model turns require a compatible authenticated provider.
|
package/bot.js
CHANGED
|
@@ -16,6 +16,21 @@ const {
|
|
|
16
16
|
ensureRuntimeDirectories,
|
|
17
17
|
} = require("./core/config");
|
|
18
18
|
ensureRuntimeDirectories();
|
|
19
|
+
|
|
20
|
+
// Refuse to double-boot: a second process on this CONFIG_DIR shares the same
|
|
21
|
+
// channel tokens and 409-wars the running service (see core/single-instance).
|
|
22
|
+
const { acquireSingleInstanceLock } = require("./core/single-instance");
|
|
23
|
+
const instanceLock = acquireSingleInstanceLock({ configDir: CONFIG_DIR });
|
|
24
|
+
if (!instanceLock.acquired) {
|
|
25
|
+
const h = instanceLock.holder;
|
|
26
|
+
console.error(
|
|
27
|
+
`Another Open Claudia instance already owns ${CONFIG_DIR} ` +
|
|
28
|
+
`(pid ${h.pid}, started ${h.startedAt || "?"}, ${h.entrypoint || "unknown entrypoint"}). Exiting. ` +
|
|
29
|
+
"Stop it first (launchctl stop com.claude-telegram-bot), or give a dev run its own OPEN_CLAUDIA_CONFIG_DIR."
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
const { defaultMigrationSources, ensureMigrationSnapshot } = require("./core/migration-backup");
|
|
20
35
|
const migrationSources = defaultMigrationSources({
|
|
21
36
|
configDir: CONFIG_DIR,
|
|
@@ -36,6 +36,11 @@ class TelegramAdapter {
|
|
|
36
36
|
this._wedgedSince = 0; // start of the current unrecovered error streak
|
|
37
37
|
this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
|
|
38
38
|
this._healBackoff = 0; // grows per consecutive heal, capped
|
|
39
|
+
this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
|
|
40
|
+
this._pollStartedAt = 0;
|
|
41
|
+
this._conflictSince = 0; // start of the current 409 streak
|
|
42
|
+
this._conflictTimer = null; // pending paused retry after a 409
|
|
43
|
+
this._conflictClearTimer = null; // quiet after a retry ⇒ streak over
|
|
39
44
|
this._wireInbound();
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -53,12 +58,33 @@ class TelegramAdapter {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
async start() {
|
|
56
|
-
|
|
61
|
+
// node-telegram-bot-api resolves startPolling() only after the current
|
|
62
|
+
// getUpdates request settles. Awaiting it can therefore pin the entire bot
|
|
63
|
+
// boot sequence behind a half-open first poll. Start the loop, return so the
|
|
64
|
+
// remaining adapters can boot, and let the independent watchdog recover a
|
|
65
|
+
// poll that never completes and emits no polling_error.
|
|
66
|
+
this._pollStartedAt = Date.now();
|
|
67
|
+
Promise.resolve(this.bot.startPolling()).catch((error) => {
|
|
68
|
+
this._onPollingHiccup(error?.message || "Telegram polling failed to start");
|
|
69
|
+
});
|
|
70
|
+
if (!this._pollWatchdogTimer) {
|
|
71
|
+
this._pollWatchdogTimer = setInterval(() => this._checkPollingHealth(), 15000);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_checkPollingHealth() {
|
|
76
|
+
if (this._conflictTimer || this._conflictSince) return; // 409 backoff owns recovery
|
|
77
|
+
const lastCompletedPoll = this.bot?._polling?._lastUpdate || this._pollStartedAt;
|
|
78
|
+
if (!lastCompletedPoll || Date.now() - lastCompletedPoll <= 75000) return;
|
|
79
|
+
this._onPollingHiccup("poll watchdog: no completed getUpdates request in 75s");
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
async stop() {
|
|
83
|
+
if (this._pollWatchdogTimer) { clearInterval(this._pollWatchdogTimer); this._pollWatchdogTimer = null; }
|
|
60
84
|
if (this._healTimer) { clearTimeout(this._healTimer); this._healTimer = null; }
|
|
61
85
|
if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
|
|
86
|
+
if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
|
|
87
|
+
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
62
88
|
try { await this.bot.stopPolling(); } catch (e) {}
|
|
63
89
|
}
|
|
64
90
|
|
|
@@ -116,13 +142,42 @@ class TelegramAdapter {
|
|
|
116
142
|
}, 30000);
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
// Telegram 409s a getUpdates call while ANOTHER one holds the token. Two
|
|
146
|
+
// causes: a ghost long-poll left by an uncleanly-killed predecessor (clears
|
|
147
|
+
// within its ≤30s poll timeout), or a genuinely running second instance.
|
|
148
|
+
// Exiting on the first 409 turned the ghost case into a KeepAlive respawn
|
|
149
|
+
// storm (7 boot→409→die cycles on 2026-07-12). So: pause, retry inside a
|
|
150
|
+
// grace window, and exit only if the conflict outlives it — that really is
|
|
151
|
+
// a second instance, and the supervisor's throttled respawns pace the rest.
|
|
152
|
+
_on409Conflict() {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (!this._conflictSince) this._conflictSince = now;
|
|
155
|
+
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
156
|
+
if (now - this._conflictSince > 120000) {
|
|
157
|
+
console.error("Another instance is polling (409 persisted >2min). Exiting.");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (this._conflictTimer) return; // a paused retry is already queued
|
|
161
|
+
console.error("409 Conflict: another poller holds this token — pausing 15s (ghost polls clear in <1min; exiting only if this persists >2min).");
|
|
162
|
+
// Stop hammering now; the timer below dials back in.
|
|
163
|
+
Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
|
|
164
|
+
this._conflictTimer = setTimeout(async () => {
|
|
165
|
+
this._conflictTimer = null;
|
|
166
|
+
try { await this.bot.startPolling(); } catch (e) {}
|
|
167
|
+
// No further 409 for 45s ⇒ the other poller is gone; end the streak.
|
|
168
|
+
this._conflictClearTimer = setTimeout(() => {
|
|
169
|
+
this._conflictClearTimer = null;
|
|
170
|
+
this._conflictSince = 0;
|
|
171
|
+
}, 45000);
|
|
172
|
+
}, 15000);
|
|
173
|
+
}
|
|
174
|
+
|
|
119
175
|
_wireInbound() {
|
|
120
176
|
this.bot.on("polling_error", (err) => {
|
|
121
177
|
const msg = err.message || "";
|
|
122
178
|
if (msg.includes("409 Conflict")) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
process.exit(1);
|
|
179
|
+
this._on409Conflict();
|
|
180
|
+
return;
|
|
126
181
|
}
|
|
127
182
|
if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
|
|
128
183
|
this._onPollingHiccup(msg); // transient — heal quietly, never exit
|
package/core/actions.js
CHANGED
|
@@ -516,6 +516,12 @@ async function handleAction(envelope) {
|
|
|
516
516
|
await send(`Tool trace: ${state.settings.showToolTrace ? "on" : "off"}`);
|
|
517
517
|
return;
|
|
518
518
|
}
|
|
519
|
+
if (d.startsWith("vb:")) {
|
|
520
|
+
state.settings.showThinking = d.slice(3) === "on";
|
|
521
|
+
saveState();
|
|
522
|
+
await send(`Verbose thinking: ${state.settings.showThinking ? "on" : "off"}`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
519
525
|
if (d.startsWith("cw:")) {
|
|
520
526
|
const v = d.slice(3);
|
|
521
527
|
if (v === "default") state.settings.compactWindow = null;
|
package/core/handlers.js
CHANGED
|
@@ -371,7 +371,7 @@ register({
|
|
|
371
371
|
if (!authorized(env)) return;
|
|
372
372
|
send([
|
|
373
373
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
374
|
-
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode",
|
|
374
|
+
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode /verbose",
|
|
375
375
|
"Identity: /whoami /link",
|
|
376
376
|
"Team: /people /intros /auth (owner)",
|
|
377
377
|
"Automation: /cron /vault /soul /dreamsummary",
|
|
@@ -1077,6 +1077,29 @@ register({
|
|
|
1077
1077
|
},
|
|
1078
1078
|
});
|
|
1079
1079
|
|
|
1080
|
+
register({
|
|
1081
|
+
name: "verbose", description: "Stream thinking into the live preview (on/off)", args: "[on|off]",
|
|
1082
|
+
handler: async (env, { tail }) => {
|
|
1083
|
+
if (!authorized(env)) return;
|
|
1084
|
+
const { settings } = currentState();
|
|
1085
|
+
if (tail) {
|
|
1086
|
+
const v = tail.trim().toLowerCase();
|
|
1087
|
+
if (v === "on" || v === "true") settings.showThinking = true;
|
|
1088
|
+
else if (v === "off" || v === "false") settings.showThinking = false;
|
|
1089
|
+
else return send(`Usage: /verbose [on|off]. Currently ${settings.showThinking ? "on" : "off"}.`);
|
|
1090
|
+
saveState();
|
|
1091
|
+
return send(`Verbose thinking: ${settings.showThinking ? "on" : "off"}`);
|
|
1092
|
+
}
|
|
1093
|
+
send(
|
|
1094
|
+
`Verbose thinking: ${settings.showThinking ? "on" : "off"}\n\n` +
|
|
1095
|
+
"When on, the live status preview also streams my thinking (reasoning) in italics as it happens. Narration and the clean final answer are unaffected. Off by default.",
|
|
1096
|
+
{ keyboard: { inline_keyboard: [
|
|
1097
|
+
[{ text: "On", callback_data: "vb:on" }, { text: "Off", callback_data: "vb:off" }],
|
|
1098
|
+
] } },
|
|
1099
|
+
);
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1080
1103
|
register({
|
|
1081
1104
|
name: "budget", description: "Set max spend for next task", args: "[$N]",
|
|
1082
1105
|
handler: async (env, { tail }) => {
|
|
@@ -1303,25 +1326,34 @@ register({
|
|
|
1303
1326
|
if (!authorized(env)) return;
|
|
1304
1327
|
const state = currentState();
|
|
1305
1328
|
const sideCancellation = require("./side-chat").sideChatCoordinator.cancel(state.userId);
|
|
1329
|
+
// /stop cancels the CURRENT turn only. Messages queued behind it stay
|
|
1330
|
+
// queued — the cancelled run's unwind drains them as usual.
|
|
1331
|
+
const queuedNote = () => {
|
|
1332
|
+
const n = Array.isArray(state.messageQueue) ? state.messageQueue.length : 0;
|
|
1333
|
+
return n ? ` ${n} queued message${n === 1 ? "" : "s"} will run next.` : "";
|
|
1334
|
+
};
|
|
1306
1335
|
if (state.runningProcess) {
|
|
1307
1336
|
const pid = state.runningProcess.pid;
|
|
1308
1337
|
const { killProcessTree } = require("./process-tree");
|
|
1338
|
+
// Quiet unwind: deliverForegroundResult consumes this flag to suppress
|
|
1339
|
+
// the "run failed" diagnostic the SIGTERM exit would otherwise emit.
|
|
1340
|
+
state.cancelRequested = true;
|
|
1309
1341
|
killProcessTree(pid, "SIGTERM");
|
|
1310
1342
|
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
1311
1343
|
state.runningProcess = null;
|
|
1312
|
-
|
|
1344
|
+
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1345
|
+
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1346
|
+
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1313
1347
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1314
|
-
state.messageQueue = [];
|
|
1315
1348
|
await sideCancellation;
|
|
1316
|
-
await send(
|
|
1349
|
+
await send(`Cancelled.${queuedNote()}`);
|
|
1317
1350
|
} else if (state.preparingRun) {
|
|
1318
|
-
// Turn is mid-recall/
|
|
1319
|
-
//
|
|
1351
|
+
// Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
|
|
1352
|
+
// the runner's pre-spawn shouldAbort checkpoint bails before spawning.
|
|
1320
1353
|
state.cancelRequested = true;
|
|
1321
|
-
state.messageQueue = [];
|
|
1322
1354
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1323
1355
|
await sideCancellation;
|
|
1324
|
-
await send(
|
|
1356
|
+
await send(`Cancelled.${queuedNote()}`);
|
|
1325
1357
|
} else if (await sideCancellation) await send("Cancelled side response.");
|
|
1326
1358
|
else await send("Nothing running.");
|
|
1327
1359
|
},
|
|
@@ -55,6 +55,10 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
55
55
|
if (block?.type === "text" && typeof block.text === "string" && block.text) {
|
|
56
56
|
state.sawText = true;
|
|
57
57
|
events.push({ type: "text_final", text: block.text });
|
|
58
|
+
} else if (block?.type === "thinking" && typeof block.thinking === "string" && block.thinking) {
|
|
59
|
+
// Deliberately does not set sawText: thinking is never answer text,
|
|
60
|
+
// so the result-event fallback semantics stay unchanged.
|
|
61
|
+
events.push({ type: "thinking", text: block.thinking });
|
|
58
62
|
} else if (block?.type === "tool_use" && block.id && block.name) {
|
|
59
63
|
state.tools.set(block.id, block.name);
|
|
60
64
|
events.push({
|
|
@@ -80,6 +80,10 @@ function normalizeCodexEvent(rawEvent, state = createCodexParserState()) {
|
|
|
80
80
|
state.sawText = true;
|
|
81
81
|
return [{ type: "text_final", text: item.text }];
|
|
82
82
|
}
|
|
83
|
+
if (item.type === "reasoning" && typeof item.text === "string" && item.text) {
|
|
84
|
+
// Deliberately does not set sawText: reasoning is never answer text.
|
|
85
|
+
return [{ type: "thinking", text: item.text }];
|
|
86
|
+
}
|
|
83
87
|
const info = toolInfo(item);
|
|
84
88
|
if (!info) return [];
|
|
85
89
|
const exitCode = Number.isInteger(item.exit_code)
|
package/core/providers/codex.js
CHANGED
|
@@ -6,7 +6,7 @@ const { createJsonlDecoder } = require("./events");
|
|
|
6
6
|
const { createCodexParserState, normalizeCodexEvent } = require("./codex-events");
|
|
7
7
|
const { createCodexHookTransport } = require("./codex-hook");
|
|
8
8
|
|
|
9
|
-
const CODEX_MODELS = Object.freeze(["gpt-5.
|
|
9
|
+
const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
|
|
10
10
|
const CODEX_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high", "xhigh"]);
|
|
11
11
|
const REQUIRED_EXEC_HELP_FLAGS = Object.freeze(["--config", "--json", "--sandbox", "--image", "--model", "--skip-git-repo-check"]);
|
|
12
12
|
const REQUIRED_RESUME_HELP_FLAGS = Object.freeze(["--config", "--json", "--image", "--model", "--skip-git-repo-check"]);
|
|
@@ -264,9 +264,9 @@ function createCodexProvider(options = {}) {
|
|
|
264
264
|
},
|
|
265
265
|
nativeToolsNote: "Codex exposes its native exec tools; Open Claudia capability checks and approval policy remain authoritative.",
|
|
266
266
|
tierModel(tier) {
|
|
267
|
-
if (tier === "low") return "gpt-5.
|
|
268
|
-
if (tier === "medium") return "gpt-5.
|
|
269
|
-
return defaultModel || "gpt-5.
|
|
267
|
+
if (tier === "low") return "gpt-5.6-luna";
|
|
268
|
+
if (tier === "medium") return "gpt-5.6-terra";
|
|
269
|
+
return defaultModel || "gpt-5.6-sol";
|
|
270
270
|
},
|
|
271
271
|
};
|
|
272
272
|
|
package/core/providers/events.js
CHANGED
package/core/runner.js
CHANGED
|
@@ -29,7 +29,7 @@ const {
|
|
|
29
29
|
} = require("./run-context");
|
|
30
30
|
const { redactSensitive } = require("./redact");
|
|
31
31
|
const { createSideChatPromptBuilder, sideChatCoordinator } = require("./side-chat");
|
|
32
|
-
const { send, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
|
|
32
|
+
const { send, sendVoice, sendVoiceEnd, splitMessage, editMessage, deleteMessage } = require("./io");
|
|
33
33
|
const { textToVoice, splitSentences, synthSentenceMp3 } = require("./media");
|
|
34
34
|
const { killProcessTree, waitForPidsExit } = require("./process-tree");
|
|
35
35
|
const {
|
|
@@ -258,6 +258,11 @@ async function executeProviderInvocation(options = {}) {
|
|
|
258
258
|
const tools = [];
|
|
259
259
|
const textDeltas = [];
|
|
260
260
|
const textFinals = [];
|
|
261
|
+
// Text blocks grouped by the tool runs between them. The last segment is
|
|
262
|
+
// the provider's actual answer; everything before it is interim narration
|
|
263
|
+
// ("Let me check X…" written before a tool call). Kept separate so chat
|
|
264
|
+
// delivery can split them while result.text keeps the full transcript.
|
|
265
|
+
const textSegments = [[]];
|
|
261
266
|
let resultText = "";
|
|
262
267
|
let sessionId = runContext?.sessionId || null;
|
|
263
268
|
let terminalSeen = false;
|
|
@@ -327,8 +332,13 @@ async function executeProviderInvocation(options = {}) {
|
|
|
327
332
|
if (!event || typeof event !== "object") return;
|
|
328
333
|
if (event.type === "session" && event.sessionId) sessionId = event.sessionId;
|
|
329
334
|
else if (event.type === "text_delta" && typeof event.text === "string") textDeltas.push(event.text);
|
|
330
|
-
else if (event.type === "text_final" && typeof event.text === "string")
|
|
331
|
-
|
|
335
|
+
else if (event.type === "text_final" && typeof event.text === "string") {
|
|
336
|
+
textFinals.push(event.text);
|
|
337
|
+
textSegments[textSegments.length - 1].push(event.text);
|
|
338
|
+
} else if (event.type === "tool_start" || event.type === "tool_end") {
|
|
339
|
+
if (event.type === "tool_start" && textSegments[textSegments.length - 1].length) textSegments.push([]);
|
|
340
|
+
tools.push(event);
|
|
341
|
+
}
|
|
332
342
|
else if (event.type === "usage") usageEvents.push(event);
|
|
333
343
|
else if (event.type === "result") {
|
|
334
344
|
terminalSeen = true;
|
|
@@ -487,6 +497,9 @@ async function executeProviderInvocation(options = {}) {
|
|
|
487
497
|
finalText: textFinals.join(""),
|
|
488
498
|
deltaText: textDeltas.join(""),
|
|
489
499
|
});
|
|
500
|
+
const segmentTexts = textSegments.map((blocks) => blocks.join("")).filter((t) => t.trim());
|
|
501
|
+
const finalAnswerText = segmentTexts.length ? segmentTexts[segmentTexts.length - 1] : "";
|
|
502
|
+
const narrationText = segmentTexts.slice(0, -1).join("\n\n");
|
|
490
503
|
let failure = null;
|
|
491
504
|
if (spawnError) failure = stableRunError(spawnError.message, "PROVIDER_SPAWN_FAILED");
|
|
492
505
|
else if (timedOut) failure = stableRunError(
|
|
@@ -511,6 +524,8 @@ async function executeProviderInvocation(options = {}) {
|
|
|
511
524
|
sessionId,
|
|
512
525
|
purpose: runContext?.purpose || "foreground",
|
|
513
526
|
text: redactSensitive(String(text || "").trim()),
|
|
527
|
+
finalAnswerText: redactSensitive(String(finalAnswerText || "").trim()),
|
|
528
|
+
narrationText: redactSensitive(String(narrationText || "")),
|
|
514
529
|
usage: finalUsage?.usage || null,
|
|
515
530
|
usageScope: finalUsage?.scope || null,
|
|
516
531
|
usageEvents,
|
|
@@ -631,6 +646,15 @@ function preflightFailureResult(runContext, provider, failure) {
|
|
|
631
646
|
};
|
|
632
647
|
}
|
|
633
648
|
|
|
649
|
+
function cancelledRunResult(runContext, provider) {
|
|
650
|
+
const result = preflightFailureResult(runContext, provider, {
|
|
651
|
+
code: "RUN_CANCELLED",
|
|
652
|
+
message: "Cancelled by /stop before the provider started",
|
|
653
|
+
});
|
|
654
|
+
result.status = "cancelled";
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
|
|
634
658
|
async function settleImmediateRunResult(result, runContext, dependencies, capture) {
|
|
635
659
|
for (const [name, hook] of [
|
|
636
660
|
["transcript", dependencies.persistTranscript],
|
|
@@ -738,6 +762,15 @@ function createRunnerService(dependencies = {}) {
|
|
|
738
762
|
return immediateFailure(provider, error);
|
|
739
763
|
}
|
|
740
764
|
|
|
765
|
+
// Pre-spawn cancel checkpoint: /stop during recall/prompt-build sets a
|
|
766
|
+
// flag instead of killing a process (there is none yet). Honour it here,
|
|
767
|
+
// after the slow prompt build and before anything spawns. Persistence
|
|
768
|
+
// still settles (the transcript records the cancelled turn); delivery is
|
|
769
|
+
// suppressed downstream via the RUN_CANCELLED code.
|
|
770
|
+
if (dependencies.shouldAbort && dependencies.shouldAbort(runContext, { capture })) {
|
|
771
|
+
return settleImmediateRunResult(cancelledRunResult(runContext, provider), runContext, dependencies, capture);
|
|
772
|
+
}
|
|
773
|
+
|
|
741
774
|
const result = await executeProviderInvocation({
|
|
742
775
|
runContext,
|
|
743
776
|
provider,
|
|
@@ -1469,11 +1502,49 @@ async function persistForegroundSession(result, runContext, state = currentState
|
|
|
1469
1502
|
result.sessionAttached = attach;
|
|
1470
1503
|
}
|
|
1471
1504
|
|
|
1505
|
+
// Settle the streamed preview message before the final answer goes out.
|
|
1506
|
+
// Success with narration → the preview becomes the narration record (footer
|
|
1507
|
+
// dropped). Success without narration → the preview only ever showed the
|
|
1508
|
+
// streamed answer itself; delete it so the final message isn't a duplicate.
|
|
1509
|
+
// Failure/cancel → keep the partial text but drop the lying "working" footer.
|
|
1510
|
+
async function finalizeStreamPreview(state, result) {
|
|
1511
|
+
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1512
|
+
const id = state.statusMessageId;
|
|
1513
|
+
const channel = state.statusMessageChannel;
|
|
1514
|
+
const buffer = String(state.streamBuffer || "").trim();
|
|
1515
|
+
state.statusMessageId = null;
|
|
1516
|
+
state.statusMessageChannel = null;
|
|
1517
|
+
state.streamBuffer = "";
|
|
1518
|
+
if (typeof id !== "string") return;
|
|
1519
|
+
// A message id is only valid on the channel that minted it; a foreign-
|
|
1520
|
+
// channel preview is left as an orphan rather than edited cross-transport.
|
|
1521
|
+
if (!channel || String(channel) !== String(currentChannelId())) return;
|
|
1522
|
+
try {
|
|
1523
|
+
if (!result || !result.ok) {
|
|
1524
|
+
if (buffer) await editMessage(id, buffer.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const narration = String(result.narrationText || "").trim();
|
|
1528
|
+
if (narration) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1529
|
+
else await deleteMessage(id);
|
|
1530
|
+
} catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1472
1533
|
async function deliverForegroundResult(result, runContext, state = currentState()) {
|
|
1473
1534
|
if (runContext.lastInputWasVoice) state.lastInputWasVoice = false;
|
|
1535
|
+
await finalizeStreamPreview(state, result);
|
|
1536
|
+
// /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
|
|
1537
|
+
// is not a failure worth announcing — /stop already replied "Cancelled.".
|
|
1538
|
+
// The flag is consumed here (and re-cleared on the run's unwind) so it can
|
|
1539
|
+
// never suppress a later run's genuine failure.
|
|
1540
|
+
if (!result.ok && (state.cancelRequested || result.error?.code === "RUN_CANCELLED")) {
|
|
1541
|
+
state.cancelRequested = false;
|
|
1542
|
+
return { ok: true, suppressed: "cancelled" };
|
|
1543
|
+
}
|
|
1474
1544
|
const diagnostic = providerRunDiagnostic(result, runContext);
|
|
1475
1545
|
if (diagnostic) result.diagnostic = diagnostic;
|
|
1476
|
-
|
|
1546
|
+
const cleanAnswer = String(result.finalAnswerText || "").trim();
|
|
1547
|
+
let finalText = result.ok ? (cleanAnswer || result.text || "(no output)") : (diagnostic || result.diagnostic || "Provider run failed");
|
|
1477
1548
|
|
|
1478
1549
|
if (result.ok) {
|
|
1479
1550
|
let guarded = false;
|
|
@@ -1596,8 +1667,73 @@ function settleImmediateEffects(result, runContext, { state, store, capture }) {
|
|
|
1596
1667
|
})();
|
|
1597
1668
|
}
|
|
1598
1669
|
|
|
1670
|
+
const STREAM_PREVIEW_THROTTLE_MS = 2500;
|
|
1671
|
+
const STREAM_PREVIEW_FOOTER = "\n\n⏳ working — /stop to cancel";
|
|
1672
|
+
|
|
1673
|
+
// Edited-in-place interim preview: each completed text block the provider
|
|
1674
|
+
// emits mid-run is appended to one throttled status message, so the user sees
|
|
1675
|
+
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
1676
|
+
// settles the message when the run ends.
|
|
1677
|
+
function createStreamPreview({ state, store }) {
|
|
1678
|
+
let enabled = !!store && (store?.adapter?.type || "") !== "voice";
|
|
1679
|
+
if (enabled) {
|
|
1680
|
+
// Interim text is NOT vetted by guardOutboundReply (only the final answer
|
|
1681
|
+
// is), so it must never reach a guarded external speaker. Fail closed if
|
|
1682
|
+
// classification is unavailable.
|
|
1683
|
+
try { enabled = chatContext.run(store, () => !require("./relationship").isCurrentSpeakerGuarded()); }
|
|
1684
|
+
catch (_) { enabled = false; }
|
|
1685
|
+
}
|
|
1686
|
+
const channelId = store?.channelId || null;
|
|
1687
|
+
|
|
1688
|
+
const flush = () => {
|
|
1689
|
+
state.streamInterval = null;
|
|
1690
|
+
if (!enabled || state.cancelRequested) return;
|
|
1691
|
+
const body = String(state.streamBuffer || "").trim();
|
|
1692
|
+
if (!body) return;
|
|
1693
|
+
const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
|
|
1694
|
+
// Event callbacks arrive off the provider's stdout stream, outside the
|
|
1695
|
+
// chat AsyncLocalStorage context — rebind before any send/edit.
|
|
1696
|
+
chatContext.run(store, async () => {
|
|
1697
|
+
try {
|
|
1698
|
+
if (typeof state.statusMessageId === "string" && String(state.statusMessageChannel) === String(channelId)) {
|
|
1699
|
+
await editMessage(state.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (state.statusMessageId === true) return; // sent but not editable — leave the single notice alone
|
|
1703
|
+
const sent = await send(display, telegramHtmlOpts());
|
|
1704
|
+
state.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
|
|
1705
|
+
state.statusMessageChannel = sent.ok ? channelId : null;
|
|
1706
|
+
} catch (_) { /* preview is best-effort */ }
|
|
1707
|
+
}).catch(() => {});
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
const appendChunk = (chunk, runContext) => {
|
|
1711
|
+
if (!enabled || runContext?.purpose !== "foreground" || state.cancelRequested) return;
|
|
1712
|
+
state.streamBuffer = state.streamBuffer ? `${state.streamBuffer}\n\n${chunk}` : chunk;
|
|
1713
|
+
if (state.streamBuffer.length > 20000) state.streamBuffer = state.streamBuffer.slice(-16000);
|
|
1714
|
+
if (!state.streamInterval) state.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
return {
|
|
1718
|
+
append(text, runContext) {
|
|
1719
|
+
appendChunk(text, runContext);
|
|
1720
|
+
},
|
|
1721
|
+
// Thinking/reasoning is interim-only display: it rides the preview buffer
|
|
1722
|
+
// under /verbose but never enters narrationText or the final answer, so
|
|
1723
|
+
// a successful run's settled messages carry no reasoning residue.
|
|
1724
|
+
appendThinking(text, runContext) {
|
|
1725
|
+
if (!state.settings?.showThinking) return;
|
|
1726
|
+
const summary = String(text).trim();
|
|
1727
|
+
if (!summary) return;
|
|
1728
|
+
const clipped = summary.length > 900 ? `${summary.slice(0, 900)}…` : summary;
|
|
1729
|
+
appendChunk(`<i>🧠 ${clipped}</i>`, runContext);
|
|
1730
|
+
},
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1599
1734
|
function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
|
|
1600
1735
|
const persistEffects = !capture || persistCapture;
|
|
1736
|
+
const preview = capture ? null : createStreamPreview({ state, store });
|
|
1601
1737
|
return createRunnerService({
|
|
1602
1738
|
getState: () => state,
|
|
1603
1739
|
getStore: () => store,
|
|
@@ -1618,12 +1754,24 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1618
1754
|
state.runningProcess = proc;
|
|
1619
1755
|
if (ownsAdmissionLock) state.preparingRun = false;
|
|
1620
1756
|
},
|
|
1621
|
-
|
|
1757
|
+
shouldAbort(runContext, { capture: isCapture } = {}) {
|
|
1758
|
+
if (isCapture || !ownsAdmissionLock) return false;
|
|
1759
|
+
if (!state.cancelRequested) return false;
|
|
1760
|
+
state.cancelRequested = false;
|
|
1761
|
+
return true;
|
|
1762
|
+
},
|
|
1763
|
+
onEvent(event, runContext) {
|
|
1622
1764
|
if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
|
|
1623
1765
|
if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
|
|
1624
1766
|
const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
|
|
1625
1767
|
if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
|
|
1626
1768
|
}
|
|
1769
|
+
if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
|
|
1770
|
+
preview.append(event.text, runContext);
|
|
1771
|
+
}
|
|
1772
|
+
if (preview && event.type === "thinking" && typeof event.text === "string" && event.text) {
|
|
1773
|
+
preview.appendThinking(event.text, runContext);
|
|
1774
|
+
}
|
|
1627
1775
|
},
|
|
1628
1776
|
onInvocationWarning(warning) {
|
|
1629
1777
|
return send(`⚠️ ${warning.message}`);
|
|
@@ -1701,6 +1849,10 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
1701
1849
|
} finally {
|
|
1702
1850
|
if (tracksMainRun && state.runningProcess) state.runningProcess = null;
|
|
1703
1851
|
if (ownsAdmissionLock) state.preparingRun = false;
|
|
1852
|
+
// A cancel flag that survived this run (e.g. /stop raced a run that
|
|
1853
|
+
// finished successfully anyway) must not leak into the next turn, where
|
|
1854
|
+
// it would falsely abort a queued run or mute a genuine failure.
|
|
1855
|
+
if (ownsAdmissionLock) state.cancelRequested = false;
|
|
1704
1856
|
if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
|
|
1705
1857
|
stopTyping();
|
|
1706
1858
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Single-instance lock on the config dir. Two bot processes sharing one
|
|
2
|
+
// CONFIG_DIR share one Telegram token; Telegram then 409-terminates their
|
|
3
|
+
// getUpdates polls in turns and each side keeps exiting/respawning — the
|
|
4
|
+
// 2026-07-12 restart storm, where a debugger-run dev checkout fought the
|
|
5
|
+
// launchd service copy all morning. Whoever boots second must refuse to
|
|
6
|
+
// start BEFORE touching any channel.
|
|
7
|
+
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
function pidAlive(pid) {
|
|
14
|
+
try { process.kill(pid, 0); return true; }
|
|
15
|
+
catch (e) { return e.code === "EPERM"; } // EPERM: alive, different owner
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function acquireSingleInstanceLock({ configDir, name = "bot" }) {
|
|
19
|
+
const lockPath = path.join(configDir, `${name}.lock`);
|
|
20
|
+
try {
|
|
21
|
+
const prev = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
22
|
+
if (prev && prev.pid && prev.pid !== process.pid && pidAlive(prev.pid)) {
|
|
23
|
+
return { acquired: false, holder: prev, lockPath };
|
|
24
|
+
}
|
|
25
|
+
// Missing, corrupt, own-pid, or dead-pid (stale after SIGKILL): claim it.
|
|
26
|
+
} catch (e) {}
|
|
27
|
+
fs.writeFileSync(lockPath, JSON.stringify({
|
|
28
|
+
pid: process.pid,
|
|
29
|
+
startedAt: new Date().toISOString(),
|
|
30
|
+
entrypoint: process.argv[1] || "",
|
|
31
|
+
}));
|
|
32
|
+
let released = false;
|
|
33
|
+
const release = () => {
|
|
34
|
+
if (released) return;
|
|
35
|
+
released = true;
|
|
36
|
+
try {
|
|
37
|
+
const cur = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
38
|
+
// Never delete a lock a successor already owns.
|
|
39
|
+
if (cur && cur.pid === process.pid) fs.unlinkSync(lockPath);
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
};
|
|
42
|
+
process.on("exit", release);
|
|
43
|
+
return { acquired: true, lockPath, release };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { acquireSingleInstanceLock, pidAlive };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
12
|
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js",
|
|
13
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js"
|
|
13
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bot.js",
|
package/test-cursor-removal.js
CHANGED
|
@@ -306,8 +306,11 @@ function semanticStaticProbe() {
|
|
|
306
306
|
assert.match(fs.readFileSync(path.join(root, "test-provider-registry.js"), "utf8"), /provider\("cursor"\)/);
|
|
307
307
|
|
|
308
308
|
const changelog = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
|
|
309
|
-
|
|
310
|
-
assert.
|
|
309
|
+
const removalStart = changelog.indexOf("## v3.0.0");
|
|
310
|
+
assert.ok(removalStart !== -1, "the v3.0.0 removal section must remain in the changelog");
|
|
311
|
+
const removalNotes = changelog.slice(removalStart, removalStart + 2500);
|
|
312
|
+
assert.match(removalNotes, /active Cursor Agent runtime support has been removed/i);
|
|
313
|
+
assert.match(removalNotes, /rollback/i);
|
|
311
314
|
}
|
|
312
315
|
|
|
313
316
|
async function main() {
|
|
@@ -267,6 +267,26 @@ function claudeEvents(sessionId) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
const events = [init];
|
|
270
|
+
if (process.env.FAKE_AGENT_THINKING) {
|
|
271
|
+
events.push({
|
|
272
|
+
type: "assistant",
|
|
273
|
+
message: {
|
|
274
|
+
role: "assistant",
|
|
275
|
+
content: [{ type: "thinking", thinking: process.env.FAKE_AGENT_THINKING, signature: "fixture-sig" }],
|
|
276
|
+
},
|
|
277
|
+
session_id: sessionId,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (process.env.FAKE_AGENT_NARRATION) {
|
|
281
|
+
events.push({
|
|
282
|
+
type: "assistant",
|
|
283
|
+
message: {
|
|
284
|
+
role: "assistant",
|
|
285
|
+
content: [{ type: "text", text: process.env.FAKE_AGENT_NARRATION }],
|
|
286
|
+
},
|
|
287
|
+
session_id: sessionId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
270
290
|
if (boolControl("FAKE_AGENT_TOOL")) {
|
|
271
291
|
events.push({
|
|
272
292
|
type: "assistant",
|
|
@@ -321,6 +341,18 @@ function codexEvents(sessionId) {
|
|
|
321
341
|
}
|
|
322
342
|
|
|
323
343
|
const events = [started, { type: "turn.started" }];
|
|
344
|
+
if (process.env.FAKE_AGENT_THINKING) {
|
|
345
|
+
events.push({
|
|
346
|
+
type: "item.completed",
|
|
347
|
+
item: { id: "item-think", type: "reasoning", text: process.env.FAKE_AGENT_THINKING },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (process.env.FAKE_AGENT_NARRATION) {
|
|
351
|
+
events.push({
|
|
352
|
+
type: "item.completed",
|
|
353
|
+
item: { id: "item-narration", type: "agent_message", text: process.env.FAKE_AGENT_NARRATION },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
324
356
|
if (boolControl("FAKE_AGENT_TOOL")) {
|
|
325
357
|
const command = {
|
|
326
358
|
id: "item-1",
|
|
@@ -158,8 +158,8 @@ async function commandProbe() {
|
|
|
158
158
|
|
|
159
159
|
await dispatch("/model");
|
|
160
160
|
const modelControls = callbacks(sent.at(-1).options.keyboard.inline_keyboard);
|
|
161
|
-
assert.ok(modelControls.includes("mb:codex:gpt-5.
|
|
162
|
-
assert.ok(modelControls.includes("mb:codex:gpt-5.
|
|
161
|
+
assert.ok(modelControls.includes("mb:codex:gpt-5.6-sol"));
|
|
162
|
+
assert.ok(modelControls.includes("mb:codex:gpt-5.6-luna"));
|
|
163
163
|
assert.ok(!modelControls.includes("mb:codex:o4-mini"));
|
|
164
164
|
assert.ok(modelControls.includes("mb:claude:claude-sonnet-4-6"));
|
|
165
165
|
}
|
package/test-provider-codex.js
CHANGED
|
@@ -46,7 +46,7 @@ assert.strictEqual(provider.label, "OpenAI Codex");
|
|
|
46
46
|
assert.strictEqual(provider.isAvailable(), true);
|
|
47
47
|
assert.strictEqual(provider.executable(), "/fixture/codex");
|
|
48
48
|
assert.strictEqual(provider.defaultModel(), "gpt-5-codex");
|
|
49
|
-
assert.
|
|
49
|
+
assert.deepStrictEqual(provider.modelChoices(), ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
|
|
50
50
|
assert.ok(!provider.modelChoices().includes("o4-mini"));
|
|
51
51
|
assert.deepStrictEqual(provider.compatibilityStatus(), {
|
|
52
52
|
state: "compatible",
|
|
@@ -180,7 +180,7 @@ const utility = validateInvocation(provider.buildUtilityInvocation(deepFreeze({
|
|
|
180
180
|
outputSchemaPath: "/fixture/schema.json",
|
|
181
181
|
})));
|
|
182
182
|
assert.ok(utility.args.includes("--ephemeral"));
|
|
183
|
-
assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "gpt-5.
|
|
183
|
+
assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "gpt-5.6-luna");
|
|
184
184
|
assert.strictEqual(utility.args[utility.args.indexOf("--output-schema") + 1], "/fixture/schema.json");
|
|
185
185
|
assert.deepStrictEqual(
|
|
186
186
|
utility.args.slice(utility.args.indexOf("--sandbox"), utility.args.indexOf("--sandbox") + 2),
|
package/test-provider-events.js
CHANGED
|
@@ -20,6 +20,7 @@ assert.deepStrictEqual([...NORMALIZED_EVENT_TYPES].sort(), [
|
|
|
20
20
|
"session",
|
|
21
21
|
"text_delta",
|
|
22
22
|
"text_final",
|
|
23
|
+
"thinking",
|
|
23
24
|
"tool_end",
|
|
24
25
|
"tool_start",
|
|
25
26
|
"usage",
|
|
@@ -76,6 +77,28 @@ assert.strictEqual(selectTerminalText({
|
|
|
76
77
|
finalText: "```json\n{\"status\":\"ok\"}\n```",
|
|
77
78
|
}), '{"status":"ok"}', "structured terminal output overrides presentation text");
|
|
78
79
|
|
|
80
|
+
const claudeThinkingState = createClaudeParserState();
|
|
81
|
+
assert.deepStrictEqual(normalizeClaudeEvent({
|
|
82
|
+
type: "assistant",
|
|
83
|
+
message: { content: [
|
|
84
|
+
{ type: "thinking", thinking: "Weighing the fixture options…", signature: "sig-1" },
|
|
85
|
+
{ type: "text", text: "answer" },
|
|
86
|
+
] },
|
|
87
|
+
}, claudeThinkingState), [
|
|
88
|
+
{ type: "thinking", text: "Weighing the fixture options…" },
|
|
89
|
+
{ type: "text_final", text: "answer" },
|
|
90
|
+
]);
|
|
91
|
+
const claudeThinkingOnly = createClaudeParserState();
|
|
92
|
+
normalizeClaudeEvent({
|
|
93
|
+
type: "assistant",
|
|
94
|
+
message: { content: [{ type: "thinking", thinking: "only thoughts" }] },
|
|
95
|
+
}, claudeThinkingOnly);
|
|
96
|
+
assert.deepStrictEqual(
|
|
97
|
+
normalizeClaudeEvent(samples.claude.result, claudeThinkingOnly).at(-1),
|
|
98
|
+
{ type: "result", text: "Hello world", sessionId: "claude-session-1" },
|
|
99
|
+
"thinking alone must not mark sawText — the result fallback text still applies",
|
|
100
|
+
);
|
|
101
|
+
|
|
79
102
|
const usageOnly = { type: "assistant", message: { usage: { input_tokens: 1, output_tokens: 1 }, content: [] } };
|
|
80
103
|
const usageState = createClaudeParserState();
|
|
81
104
|
assert.strictEqual(normalizeClaudeEvent(usageOnly, usageState).filter((event) => event.type === "usage").length, 1);
|
|
@@ -114,6 +137,13 @@ assert.deepStrictEqual(normalizeCodexEvent(samples.codex.result, codexState), [
|
|
|
114
137
|
]);
|
|
115
138
|
assert.deepStrictEqual(normalizeCodexEvent(samples.codex.result, codexState), [], "duplicate Codex terminal and usage settle once");
|
|
116
139
|
|
|
140
|
+
assert.deepStrictEqual(normalizeCodexEvent({
|
|
141
|
+
type: "item.completed",
|
|
142
|
+
item: { id: "item-think", type: "reasoning", text: "Weighing options…" },
|
|
143
|
+
}, createCodexParserState()), [
|
|
144
|
+
{ type: "thinking", text: "Weighing options…" },
|
|
145
|
+
]);
|
|
146
|
+
|
|
117
147
|
const codexAuth = normalizeCodexEvent({ type: "turn.failed", error: { message: "401 Unauthorized" } }, createCodexParserState());
|
|
118
148
|
assert.deepStrictEqual(codexAuth, [{
|
|
119
149
|
type: "error",
|
|
@@ -32,9 +32,9 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
assert.strictEqual(pkg.version, "3.0.
|
|
35
|
+
// 3.0.1 approved by Sumeet 2026-07-12 ("Push v3.0.1" — streaming restored,
|
|
36
|
+
// /stop queue survival, boot resilience; CHANGELOG documents it as v3.0.1).
|
|
37
|
+
assert.strictEqual(pkg.version, "3.0.1", "release version must remain unchanged without explicit approval");
|
|
38
38
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
39
39
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
40
40
|
}
|
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
const assert = require("assert");
|
|
4
4
|
const { TelegramAdapter } = require("./channels/telegram/adapter");
|
|
5
5
|
|
|
6
|
+
async function testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog() {
|
|
7
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
8
|
+
adapter._pollWatchdogTimer = null;
|
|
9
|
+
adapter._pollStartedAt = 0;
|
|
10
|
+
adapter.bot = {
|
|
11
|
+
_polling: { _lastUpdate: null },
|
|
12
|
+
startPolling() { return new Promise(() => {}); },
|
|
13
|
+
};
|
|
14
|
+
const hiccups = [];
|
|
15
|
+
adapter._onPollingHiccup = (message) => hiccups.push(message);
|
|
16
|
+
|
|
17
|
+
let watchdog;
|
|
18
|
+
const realSetInterval = global.setInterval;
|
|
19
|
+
const realClearInterval = global.clearInterval;
|
|
20
|
+
const realNow = Date.now;
|
|
21
|
+
global.setInterval = (fn) => { watchdog = fn; return { watchdog: true }; };
|
|
22
|
+
global.clearInterval = () => {};
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const outcome = await Promise.race([
|
|
26
|
+
adapter.start().then(() => "started"),
|
|
27
|
+
new Promise((resolve) => setTimeout(() => resolve("blocked"), 25)),
|
|
28
|
+
]);
|
|
29
|
+
assert.strictEqual(outcome, "started", "adapter startup must not wait for Telegram's first long-poll request to settle");
|
|
30
|
+
assert.strictEqual(typeof watchdog, "function", "adapter startup must arm an independent poll-stall watchdog");
|
|
31
|
+
|
|
32
|
+
Date.now = () => adapter._pollStartedAt + 120000;
|
|
33
|
+
watchdog();
|
|
34
|
+
assert.match(hiccups[0] || "", /no completed getUpdates/i, "watchdog must recover a silent first-poll stall");
|
|
35
|
+
} finally {
|
|
36
|
+
Date.now = realNow;
|
|
37
|
+
global.setInterval = realSetInterval;
|
|
38
|
+
global.clearInterval = realClearInterval;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
async function testHealCancelsWedgedLongPollBeforeRestarting() {
|
|
7
43
|
const calls = [];
|
|
8
44
|
let stopOptions;
|
|
@@ -48,6 +84,7 @@ async function testHealCancelsWedgedLongPollBeforeRestarting() {
|
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
(async () => {
|
|
87
|
+
await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
|
|
51
88
|
await testHealCancelsWedgedLongPollBeforeRestarting();
|
|
52
89
|
console.log("telegram poll recovery OK");
|
|
53
90
|
})().catch((err) => {
|