@inetafrica/open-claudia 3.0.42 → 3.0.44
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 +13 -0
- package/channels/telegram/adapter.js +122 -31
- package/core/providers/claude-events.js +36 -3
- package/core/providers/events.js +11 -0
- package/core/run-context.js +10 -4
- package/core/runner.js +90 -2
- package/core/state.js +48 -0
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +29 -1
- package/test-fixtures/sim-no-sqlite.js +14 -0
- package/test-provider-events.js +71 -0
- package/test-provider-language.js +26 -1
- package/test-provider-runner.js +50 -0
- package/test-telegram-poll-recovery.js +121 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.44 — Safety refusals explain themselves, and one refusal no longer poisons every turn after it
|
|
4
|
+
|
|
5
|
+
- **A Claude safety refusal now tells you why instead of dumping a raw API error.** When the model declines a request (`stop_reason: "refusal"`), the API attaches a category and sometimes an explanation — but all you saw was "API Error … appears to violate our Usage Policy" with a request id buried inside. The refusal is now detected structurally from the stream (`stop_details` category/explanation, with a text-parse fallback when the CLI flattens it into the error string) and surfaced as a clear ⛔ diagnostic: the reason, the request ID (for support), and your options (/new, rephrase, /model or /backend).
|
|
6
|
+
- **One refusal no longer bricks the whole conversation.** The safety classifier scores the *entire* conversation, so resuming a session whose context tripped it re-sent the flagged material and refused again — every turn, even for innocent new messages; the only escape was knowing to type /new. Now the poisoned session is **quarantined** the moment a resumed turn is refused: its id is recorded in user state (bounded to 20, survives restarts), the stale active pointer is healed, and nothing resumes it again — the natural next turn and even an explicit `/continue <id>` are forced fresh. Session ids the CLI rotates mid-run are quarantined too.
|
|
7
|
+
- **Your refused message is retried for you, once, in a fresh session.** After quarantining, the same prompt is automatically replayed in a brand-new session — you see "♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one" and then the actual answer, so you keep working without re-typing anything. Strictly one-shot: if the fresh run is also refused, the new message itself is the problem and you get the full diagnostic with no retry loop; capture/background runs never auto-retry.
|
|
8
|
+
- **Agent-space / OpenClaw:** no new deps, env, or schema change (a `quarantinedSessions` map is added to per-user state; old state files load cleanly); Docker image builds FROM the existing `:base`. Changes span `core/providers/events.js`/`claude-events.js` (detection), `core/state.js` (quarantine bookkeeping), `core/run-context.js` (resume guard), `core/runner.js` (diagnostic + one-shot retry); covered by new `test-provider-refusal-recovery.js`, a fake-agent `refusal` scenario, and runner e2e cases. Existing pods pick it up on their next approved upgrade.
|
|
9
|
+
|
|
10
|
+
## v3.0.43 — Telegram stops rebooting into a dead network: outages now pause-and-wait instead of restart-storming
|
|
11
|
+
|
|
12
|
+
- **A host-wide network blip no longer triggers a reboot storm.** v3.0.42 fixed the *in-loop* poll wedge (a poisoned reused poller) so it heals in place — but it still kept the 10-minute `process.exit(1)` backstop, which fired even when the *entire host* had lost the network (a laptop sleep/wake, or pod egress starvation where Telegram poll+send, Kazee WS and Spaces WS all drop together). Exiting into a dead network just relaunched straight back into the same outage, so you'd see "Back online and ready! Running v3.0.42" every ~14 minutes, busting the prompt cache and spamming the chat. Now the self-exit is **reachability-gated**: before it ever restarts the process it runs a fresh, connection-fresh HEAD probe to `api.telegram.org`, and only exits if that probe genuinely **succeeds** — proving the API is reachable and it's a real in-process wedge worth a clean restart, not the host being offline.
|
|
13
|
+
- **When the host is actually offline, the bot now pauses and waits instead of hammering.** On a confirmed-unreachable probe the adapter enters an *outage pause*: it **stops polling entirely** (so `node-telegram-bot-api` no longer respins `getUpdates` every ~300ms into a dead socket — the "not waiting between reconnects" hammering) and re-probes on a calm **30-second** cadence. The instant connectivity returns it resumes polling cleanly from a fresh poller (the update offset is preserved, so nothing is refetched or skipped) — no process restart, no "Back online" spam. The pause also suspends the health watchdog and hiccup handler so they don't fight the slow re-probe. Poll-health transitions are now timestamped for easier post-mortem.
|
|
14
|
+
- **Agent-space / OpenClaw:** no new deps, env, or schema change; Docker image builds FROM the existing `:base`. Change is confined to `channels/telegram/adapter.js` (the `_heal` backstop plus the new `_enterOutagePause`/`_armHealthyCheck` paths); covered by new cases in `test-telegram-poll-recovery.js` (the gated exit defers on a live host, pauses instead of rebooting on a dead one, and resumes when the network returns). Existing pods pick it up on their next approved upgrade.
|
|
15
|
+
|
|
3
16
|
## v3.0.42 — Telegram stops rebooting itself: the long-poll wedge now heals in place
|
|
4
17
|
|
|
5
18
|
- **The bot no longer restarts the whole process to recover from a stuck Telegram long-poll.** When the network stalls a poll mid-flight (a half-open socket after a laptop sleep/wake, or a NAT/IPv6 connection eviction), `node-telegram-bot-api` was left holding one reused internal poller whose abort flag got stuck "on". Restarting polling *in place* revived that same poisoned object, which fired a single `getUpdates` and then silently refused to schedule the next one — a dead loop that threw no error, so the heal looked like it "worked" while nothing was actually polling. The only thing that ever truly recovered was the 10-minute backstop calling `process.exit(1)` and letting the supervisor relaunch — i.e. the reboot storm you'd see. The heal now **discards the poisoned poller so a fresh one is built** (the update offset is preserved, so no messages are refetched or skipped), and it only declares polling "healthy again" once a `getUpdates` has genuinely **completed since the restart** — otherwise it re-arms another heal instead of blindly declaring victory. Net effect: transient poll wedges recover in seconds without a process restart; the 10-min self-exit becomes the rare true-last-resort it was meant to be.
|
|
@@ -44,6 +44,8 @@ class TelegramAdapter {
|
|
|
44
44
|
this._deferredExitLogAt = 0; // throttle the "deferring restart, turn in progress" log (1/min)
|
|
45
45
|
this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
|
|
46
46
|
this._healBackoff = 0; // grows per consecutive heal, capped
|
|
47
|
+
this._pausedForOutage = false; // host-network outage: poll loop stopped, slow re-probe owns recovery
|
|
48
|
+
this._outageProbeTimer = null; // the ~30s re-probe loop that runs while paused
|
|
47
49
|
this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
|
|
48
50
|
this._pollStartedAt = 0;
|
|
49
51
|
this._conflictSince = 0; // start of the current 409 streak
|
|
@@ -83,6 +85,7 @@ class TelegramAdapter {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
_checkPollingHealth() {
|
|
88
|
+
if (this._pausedForOutage) return; // outage pause runs its own slow re-probe
|
|
86
89
|
if (this._conflictTimer || this._conflictSince) return; // 409 backoff owns recovery
|
|
87
90
|
const lastCompletedPoll = this.bot?._polling?._lastUpdate || this._pollStartedAt;
|
|
88
91
|
if (!lastCompletedPoll || Date.now() - lastCompletedPoll <= 75000) return;
|
|
@@ -95,6 +98,8 @@ class TelegramAdapter {
|
|
|
95
98
|
if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
|
|
96
99
|
if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
|
|
97
100
|
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
101
|
+
if (this._outageProbeTimer) { clearTimeout(this._outageProbeTimer); this._outageProbeTimer = null; }
|
|
102
|
+
this._pausedForOutage = false;
|
|
98
103
|
try { await this._stopPollingClean("adapter stop"); } catch (e) {}
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -107,6 +112,7 @@ class TelegramAdapter {
|
|
|
107
112
|
// old exit-for-relaunch *was* the restart the bot kept doing on every idle
|
|
108
113
|
// timeout (a sleep/wake burst could exhaust all 6 attempts in seconds).
|
|
109
114
|
_onPollingHiccup(msg) {
|
|
115
|
+
if (this._pausedForOutage) return; // paused for a host outage; the slow re-probe owns recovery
|
|
110
116
|
// During a 409 streak the conflict handler owns the poll lifecycle; a heal
|
|
111
117
|
// here would startPolling straight back into the conflict and its errors
|
|
112
118
|
// would keep _wedgedSince pinned until the 10-min backstop killed us (the
|
|
@@ -116,7 +122,7 @@ class TelegramAdapter {
|
|
|
116
122
|
if (!this._wedgedSince) this._wedgedSince = now;
|
|
117
123
|
if (now - this._lastHiccupLog > 60000) {
|
|
118
124
|
this._lastHiccupLog = now;
|
|
119
|
-
console.log(
|
|
125
|
+
console.log(`${this._stamp()} Polling hiccup (${msg}) — retrying on a fresh connection.`);
|
|
120
126
|
}
|
|
121
127
|
if (this._healTimer) return; // a heal is already scheduled; let it run
|
|
122
128
|
this._healBackoff = Math.min(this._healBackoff + 1, 5);
|
|
@@ -148,31 +154,74 @@ class TelegramAdapter {
|
|
|
148
154
|
catch (e) { return false; }
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
_stamp() {
|
|
158
|
+
return new Date().toISOString();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Can this HOST reach Telegram AT ALL right now? A one-shot HEAD-ish probe on
|
|
162
|
+
// a brand-new connection (agent:false so it never rides our possibly-wedged
|
|
163
|
+
// poll agent), bounded so the probe itself can't hang. Any HTTP response —
|
|
164
|
+
// even a 404 — proves the TCP+TLS path is alive; a reset/timeout means it
|
|
165
|
+
// isn't. This is the fresh-process-equivalent check that lets the wedge
|
|
166
|
+
// backstop tell a genuine in-loop poll wedge (a restart fixes it) from a
|
|
167
|
+
// host-wide network outage (every outbound connection is failing together —
|
|
168
|
+
// Telegram polls + sends AND the Kazee/Spaces websockets — so a reboot just
|
|
169
|
+
// relaunches into the same dead network and spams "Back online"). Never rejects.
|
|
170
|
+
_probeTelegramReachable() {
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
let settled = false;
|
|
173
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
174
|
+
try {
|
|
175
|
+
const req = https.get(
|
|
176
|
+
{ hostname: "api.telegram.org", port: 443, path: "/", agent: false, timeout: 8000 },
|
|
177
|
+
(res) => { res.resume(); done(true); },
|
|
178
|
+
);
|
|
179
|
+
req.on("timeout", () => { try { req.destroy(); } catch (e) {} done(false); });
|
|
180
|
+
req.on("error", () => done(false));
|
|
181
|
+
} catch (e) { done(false); }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
151
185
|
async _heal() {
|
|
152
186
|
this._healTimer = null;
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// the
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
// A sustained wedge (>60s, and not a 409 streak): probe ONE fresh connection
|
|
188
|
+
// to learn WHY before we keep restarting. This both distinguishes a genuine
|
|
189
|
+
// in-loop poll wedge from a host-network outage AND lets us stop hammering
|
|
190
|
+
// when the network itself is down. On a healthy network wedges clear in
|
|
191
|
+
// seconds and never reach this branch. (Never during a 409 streak: that's a
|
|
192
|
+
// token fight, not a network wedge, and the conflict handler owns the loop.)
|
|
193
|
+
if (this._wedgedSince && !this._conflictSince && Date.now() - this._wedgedSince > 60000) {
|
|
194
|
+
if (!(await this._probeTelegramReachable())) {
|
|
195
|
+
// A fresh connection to Telegram fails too: a host-wide network outage,
|
|
196
|
+
// not a poll wedge (we've watched the Telegram poll, Telegram sends, and
|
|
197
|
+
// the Kazee + Spaces websockets all drop in lockstep). A reboot can't fix
|
|
198
|
+
// a dead network — the fresh process can't reach Telegram either and just
|
|
199
|
+
// spams "Back online". Restarting the poll loop is equally futile and only
|
|
200
|
+
// hammers getUpdates every ~300ms (which can trip a 429 the instant the
|
|
201
|
+
// link flickers back). Stop polling entirely and wait it out on a slow
|
|
202
|
+
// re-probe; resume the moment connectivity returns. Never exits.
|
|
203
|
+
this._enterOutagePause();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Telegram answers a fresh connection, yet our poll loop still hasn't
|
|
207
|
+
// completed a getUpdates. If that's dragged past 10 minutes a restart-in-
|
|
208
|
+
// place plainly isn't clearing it — exit for a clean launchd relaunch. On a
|
|
209
|
+
// healthy network this never fires (the null-restart below clears an in-loop
|
|
210
|
+
// wedge in seconds); it replaces the old hair-trigger ~90s restart storm.
|
|
211
|
+
if (Date.now() - this._wedgedSince > 600000) {
|
|
212
|
+
if (this._anyTurnActive()) {
|
|
213
|
+
// A turn is in flight — never reboot mid-turn: it kills in-progress work
|
|
214
|
+
// and breaks the seamless resume. Defer the exit, fall through to another
|
|
215
|
+
// in-place recovery attempt, and let a later idle heal exit if needed.
|
|
216
|
+
if (!this._deferredExitLogAt || Date.now() - this._deferredExitLogAt > 60000) {
|
|
217
|
+
this._deferredExitLogAt = Date.now();
|
|
218
|
+
console.error(`${this._stamp()} Polling wedged >10min but a turn is in progress — deferring restart until idle.`);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
console.error(`${this._stamp()} Polling wedged >10min and Telegram is reachable — exiting for a clean restart.`);
|
|
222
|
+
try { require("../../core/restart-reason").recordExitReason("Telegram polling wedged >10min while API reachable — self-exit for a clean restart", { code: 1 }); } catch (e) {}
|
|
223
|
+
process.exit(1);
|
|
171
224
|
}
|
|
172
|
-
} else {
|
|
173
|
-
console.error("Polling wedged >10min — exiting for a clean restart.");
|
|
174
|
-
try { require("../../core/restart-reason").recordExitReason("Telegram polling wedged >10min (long-poll unrecoverable) — self-exit for a clean restart", { code: 1 }); } catch (e) {}
|
|
175
|
-
process.exit(1);
|
|
176
225
|
}
|
|
177
226
|
}
|
|
178
227
|
try {
|
|
@@ -197,27 +246,69 @@ class TelegramAdapter {
|
|
|
197
246
|
} catch (e) {
|
|
198
247
|
return; // restart didn't take — the next hiccup reschedules a heal
|
|
199
248
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
249
|
+
this._armHealthyCheck(this._pollStartedAt, "restart completed but no getUpdates within 35s");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Recovery is real only if a getUpdates has actually COMPLETED since the
|
|
253
|
+
// (re)start — not merely "no throw for 35s". A dead one-shot loop throws
|
|
254
|
+
// nothing, so a blind timer would declare it healthy and reset _wedgedSince,
|
|
255
|
+
// hiding the wedge until the 10-min backstop rebooted. Gate the reset on a
|
|
256
|
+
// completed poll; otherwise re-arm a heal.
|
|
257
|
+
_armHealthyCheck(restartedAt, reArmMsg) {
|
|
206
258
|
if (this._healthyTimer) clearTimeout(this._healthyTimer);
|
|
207
259
|
this._healthyTimer = setTimeout(() => {
|
|
208
260
|
this._healthyTimer = null;
|
|
209
261
|
const lastCompletedPoll = this.bot?._polling?._lastUpdate || 0;
|
|
210
262
|
if (lastCompletedPoll >= restartedAt) {
|
|
211
|
-
if (this._wedgedSince) console.log(
|
|
263
|
+
if (this._wedgedSince) console.log(`${this._stamp()} Polling healthy again.`);
|
|
212
264
|
this._wedgedSince = 0;
|
|
213
265
|
this._healBackoff = 0;
|
|
214
266
|
this._deferredExitLogAt = 0;
|
|
215
267
|
} else {
|
|
216
|
-
this._onPollingHiccup(
|
|
268
|
+
this._onPollingHiccup(reArmMsg);
|
|
217
269
|
}
|
|
218
270
|
}, 35000);
|
|
219
271
|
}
|
|
220
272
|
|
|
273
|
+
// Host-network outage recovery: STOP the poll loop entirely (kill the lib's
|
|
274
|
+
// ~300ms getUpdates retry spin) and re-probe Telegram on a slow ~30s cadence,
|
|
275
|
+
// resuming the instant a fresh connection succeeds. Nothing outbound can get
|
|
276
|
+
// through while the host has no network, so spinning only burns CPU and risks a
|
|
277
|
+
// 429 when the link flickers back — this is the "wait between reconnects"
|
|
278
|
+
// backoff. Polling and the socket.io adapters all auto-recover together when
|
|
279
|
+
// connectivity returns. Never exits.
|
|
280
|
+
_enterOutagePause() {
|
|
281
|
+
if (this._pausedForOutage) return;
|
|
282
|
+
this._pausedForOutage = true;
|
|
283
|
+
if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
|
|
284
|
+
console.error(`${this._stamp()} Telegram unreachable — host-network outage; pausing polls, re-probing every 30s (no reboot, no reconnect hammering).`);
|
|
285
|
+
// Stop the fast retry loop now (clean stop: no zombie loop, socket severed).
|
|
286
|
+
Promise.resolve(this._stopPollingClean("host-network outage pause")).catch(() => {});
|
|
287
|
+
const tick = async () => {
|
|
288
|
+
this._outageProbeTimer = null;
|
|
289
|
+
if (!this._pausedForOutage) return;
|
|
290
|
+
if (!(await this._probeTelegramReachable())) {
|
|
291
|
+
if (this._pausedForOutage) this._outageProbeTimer = setTimeout(tick, 30000);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Connectivity is back. Resume on a FRESH poll loop object (the offset
|
|
295
|
+
// survives in bot.options.polling params, so no updates are missed or
|
|
296
|
+
// refetched), then confirm a getUpdates actually completes.
|
|
297
|
+
this._pausedForOutage = false;
|
|
298
|
+
console.log(`${this._stamp()} Telegram reachable again — resuming polling after outage.`);
|
|
299
|
+
try {
|
|
300
|
+
this.bot._polling = null;
|
|
301
|
+
this._pollStartedAt = Date.now();
|
|
302
|
+
await this.bot.startPolling({ restart: false });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
this._onPollingHiccup("resume after outage failed to start");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this._armHealthyCheck(this._pollStartedAt, "resume after outage completed but no getUpdates within 35s");
|
|
308
|
+
};
|
|
309
|
+
this._outageProbeTimer = setTimeout(tick, 30000);
|
|
310
|
+
}
|
|
311
|
+
|
|
221
312
|
// Telegram 409s a getUpdates call while ANOTHER one holds the token: a
|
|
222
313
|
// ghost long-poll left by an uncleanly-killed predecessor (clears within
|
|
223
314
|
// its ≤30s poll timeout), or a genuinely running second poller. We hold
|
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
normalizeDecoderError,
|
|
5
5
|
normalizedUsage,
|
|
6
6
|
providerErrorFlags,
|
|
7
|
+
refusalDetailsFromMessage,
|
|
7
8
|
} = require("./events");
|
|
8
9
|
|
|
9
10
|
function createClaudeParserState() {
|
|
@@ -14,6 +15,7 @@ function createClaudeParserState() {
|
|
|
14
15
|
tools: new Map(),
|
|
15
16
|
seenUsage: new Set(),
|
|
16
17
|
totalCostUsd: 0,
|
|
18
|
+
refusal: null,
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -30,6 +32,27 @@ function claudeErrorMessage(rawEvent) {
|
|
|
30
32
|
return "Claude provider failed";
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function captureRefusal(state, stopReason, stopDetails) {
|
|
36
|
+
if (stopReason !== "refusal") return;
|
|
37
|
+
state.refusal = {
|
|
38
|
+
category: stopDetails?.category ?? null,
|
|
39
|
+
explanation: stopDetails?.explanation ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function claudeFailureEvent(message, state) {
|
|
44
|
+
const flags = providerErrorFlags(message);
|
|
45
|
+
const event = { type: "error", message, ...flags };
|
|
46
|
+
if (flags.refusal || state.refusal) {
|
|
47
|
+
const parsed = refusalDetailsFromMessage(message);
|
|
48
|
+
event.refusal = true;
|
|
49
|
+
event.refusalCategory = state.refusal?.category ?? null;
|
|
50
|
+
event.refusalExplanation = state.refusal?.explanation || parsed.explanation || null;
|
|
51
|
+
event.requestId = parsed.requestId;
|
|
52
|
+
}
|
|
53
|
+
return event;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
34
57
|
if (!rawEvent || typeof rawEvent !== "object") return [];
|
|
35
58
|
const decoderFailure = normalizeDecoderError(rawEvent);
|
|
@@ -41,6 +64,9 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
if (rawEvent.type === "stream_event") {
|
|
67
|
+
if (rawEvent.event?.type === "message_delta") {
|
|
68
|
+
captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
|
|
69
|
+
}
|
|
44
70
|
const delta = rawEvent.event?.type === "content_block_delta" ? rawEvent.event.delta : null;
|
|
45
71
|
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
|
|
46
72
|
state.sawText = true;
|
|
@@ -51,6 +77,7 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
51
77
|
|
|
52
78
|
if (rawEvent.type === "assistant") {
|
|
53
79
|
const events = [];
|
|
80
|
+
captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
|
|
54
81
|
for (const block of rawEvent.message?.content || []) {
|
|
55
82
|
if (block?.type === "text" && typeof block.text === "string" && block.text) {
|
|
56
83
|
state.sawText = true;
|
|
@@ -95,8 +122,7 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
95
122
|
state.terminal = true;
|
|
96
123
|
if (Number.isFinite(rawEvent.total_cost_usd)) state.totalCostUsd = rawEvent.total_cost_usd;
|
|
97
124
|
if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
|
|
98
|
-
|
|
99
|
-
return [{ type: "error", message, ...providerErrorFlags(message) }];
|
|
125
|
+
return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
|
|
100
126
|
}
|
|
101
127
|
const events = [];
|
|
102
128
|
const session = sessionEvent(rawEvent.session_id, state);
|
|
@@ -104,12 +130,19 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
104
130
|
const usage = normalizedUsage(rawEvent.usage, "turn", state.seenUsage);
|
|
105
131
|
if (usage) events.push(usage);
|
|
106
132
|
const hasStructuredOutput = Object.prototype.hasOwnProperty.call(rawEvent, "structured_output");
|
|
133
|
+
const resultText = typeof rawEvent.result === "string" ? rawEvent.result : "";
|
|
134
|
+
if (state.refusal && !hasStructuredOutput && !state.sawText && !resultText.trim()) {
|
|
135
|
+
// A refusal the CLI reports as a "successful" empty turn would otherwise
|
|
136
|
+
// surface as "(no output)" — fail it with the real reason instead.
|
|
137
|
+
events.push(claudeFailureEvent('The model refused this request (stop_reason "refusal").', state));
|
|
138
|
+
return events;
|
|
139
|
+
}
|
|
107
140
|
const structuredText = hasStructuredOutput ? JSON.stringify(rawEvent.structured_output) : "";
|
|
108
141
|
events.push({
|
|
109
142
|
type: "result",
|
|
110
143
|
text: hasStructuredOutput
|
|
111
144
|
? (structuredText === undefined ? "" : structuredText)
|
|
112
|
-
: (state.sawText ? "" :
|
|
145
|
+
: (state.sawText ? "" : resultText),
|
|
113
146
|
sessionId: rawEvent.session_id || state.sessionId || null,
|
|
114
147
|
});
|
|
115
148
|
return events;
|
package/core/providers/events.js
CHANGED
|
@@ -131,9 +131,19 @@ function providerErrorFlags(message) {
|
|
|
131
131
|
return {
|
|
132
132
|
authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
|
|
133
133
|
usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit/.test(text),
|
|
134
|
+
refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// The CLI flattens the API's stop_details into the error text; recover the
|
|
139
|
+
// human-readable pieces so the chat message can lead with the actual reason.
|
|
140
|
+
function refusalDetailsFromMessage(message) {
|
|
141
|
+
const text = String(message || "");
|
|
142
|
+
const requestId = text.match(/\breq_[A-Za-z0-9]+\b/)?.[0] || null;
|
|
143
|
+
const explanation = text.match(/This request triggered[^.]+\./i)?.[0]?.trim() || null;
|
|
144
|
+
return { requestId, explanation };
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
function normalizeDecoderError(rawEvent) {
|
|
138
148
|
if (!rawEvent || rawEvent.type !== "decoder.error") return null;
|
|
139
149
|
// Warning, not error: a single oversized or malformed record is skippable.
|
|
@@ -171,6 +181,7 @@ module.exports = {
|
|
|
171
181
|
normalizeDecoderError,
|
|
172
182
|
normalizedUsage,
|
|
173
183
|
providerErrorFlags,
|
|
184
|
+
refusalDetailsFromMessage,
|
|
174
185
|
selectTerminalText,
|
|
175
186
|
usageFingerprint,
|
|
176
187
|
};
|
package/core/run-context.js
CHANGED
|
@@ -97,9 +97,14 @@ function captureRunContext({
|
|
|
97
97
|
}
|
|
98
98
|
const project = projectSnapshot(state, cwd, opts.project);
|
|
99
99
|
const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
|
|
100
|
-
const
|
|
100
|
+
const requestedSessionId = opts.fresh
|
|
101
101
|
? null
|
|
102
102
|
: (opts.resumeSessionId || activeSessionId || null);
|
|
103
|
+
// A quarantined session's provider-side context tripped a safety refusal;
|
|
104
|
+
// resuming would re-send the flagged context and refuse again. Force the
|
|
105
|
+
// turn fresh instead (explicit resume requests included).
|
|
106
|
+
const quarantinedResume = !!(requestedSessionId && state.quarantinedSessions?.[requestedSessionId]);
|
|
107
|
+
const admittedSessionId = quarantinedResume ? null : requestedSessionId;
|
|
103
108
|
const origin = serializableOrigin(opts.origin, store || {});
|
|
104
109
|
const resolvedChannelId = channelId != null
|
|
105
110
|
? String(channelId)
|
|
@@ -128,9 +133,10 @@ function captureRunContext({
|
|
|
128
133
|
cwd: cwd || project.dir || null,
|
|
129
134
|
replyToMsgId: replyToMsgId == null ? null : String(replyToMsgId),
|
|
130
135
|
userPrompt: prompt,
|
|
131
|
-
fresh: !!opts.fresh,
|
|
132
|
-
continueSession: !!opts.continueSession,
|
|
133
|
-
resumeSessionId: opts.resumeSessionId || null,
|
|
136
|
+
fresh: !!opts.fresh || quarantinedResume,
|
|
137
|
+
continueSession: quarantinedResume ? false : !!opts.continueSession,
|
|
138
|
+
resumeSessionId: quarantinedResume ? null : (opts.resumeSessionId || null),
|
|
139
|
+
quarantinedResume,
|
|
134
140
|
skipAutoCompact: !!opts.skipAutoCompact,
|
|
135
141
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
|
|
136
142
|
maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,
|
package/core/runner.js
CHANGED
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
const {
|
|
15
15
|
currentState, saveState, recordSession,
|
|
16
16
|
resetSessionUsage, acquireRunLock, getActiveProvider, getProjectKey,
|
|
17
|
-
getProviderSession, setProviderSession, clearProviderSession, getUsageForSession, commitProviderCompaction,
|
|
17
|
+
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
|
|
18
18
|
} = require("./state");
|
|
19
19
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
20
20
|
const { buildTurnPrompt } = require("./system-prompt");
|
|
@@ -525,7 +525,11 @@ async function executeProviderInvocation(options = {}) {
|
|
|
525
525
|
else if (closeInfo.signal) failure = stableRunError(`Provider process exited on signal ${closeInfo.signal}`, "PROVIDER_SIGNAL_EXIT", { signal: closeInfo.signal });
|
|
526
526
|
else if (rawErrors.length) {
|
|
527
527
|
const event = rawErrors[0];
|
|
528
|
-
failure = stableRunError(
|
|
528
|
+
failure = stableRunError(
|
|
529
|
+
event.message || "Provider reported an error",
|
|
530
|
+
event.refusal ? "PROVIDER_REFUSAL" : (event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR")),
|
|
531
|
+
event,
|
|
532
|
+
);
|
|
529
533
|
} else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
|
|
530
534
|
else if (!terminalSeen) failure = stableRunError("Provider stream ended without a terminal event", "PROVIDER_MISSING_TERMINAL");
|
|
531
535
|
else if (!cleanupOutcome.ok) failure = cleanupOutcome.error;
|
|
@@ -1394,6 +1398,26 @@ function providerRunDiagnostic(result, runContext) {
|
|
|
1394
1398
|
if (code === "PROVIDER_USAGE_LIMIT") {
|
|
1395
1399
|
return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
|
|
1396
1400
|
}
|
|
1401
|
+
if (code === "PROVIDER_REFUSAL") {
|
|
1402
|
+
const details = result.error?.details || {};
|
|
1403
|
+
const reason = [
|
|
1404
|
+
details.refusalCategory ? `[${details.refusalCategory}]` : null,
|
|
1405
|
+
details.refusalExplanation || null,
|
|
1406
|
+
].filter(Boolean).join(" ");
|
|
1407
|
+
const lines = [
|
|
1408
|
+
`⛔ ${label} declined this turn — the model's safety classifier refused the request (a policy block, not a crash).`,
|
|
1409
|
+
];
|
|
1410
|
+
if (reason) lines.push(`Reason: ${reason}`);
|
|
1411
|
+
if (details.requestId) lines.push(`Request ID: ${details.requestId}`);
|
|
1412
|
+
lines.push(
|
|
1413
|
+
"",
|
|
1414
|
+
"The classifier scores the whole conversation, not just your last message — so retrying in this session usually refuses again. Options:",
|
|
1415
|
+
"• /new — fresh session without the flagged context",
|
|
1416
|
+
"• Rephrase, or drop the material that tripped it",
|
|
1417
|
+
"• /model or /backend — a different model can usually serve the same request",
|
|
1418
|
+
);
|
|
1419
|
+
return lines.join("\n");
|
|
1420
|
+
}
|
|
1397
1421
|
const detail = result.stderr ? `\n\nStderr:\n${result.stderr.slice(-1200)}` : "";
|
|
1398
1422
|
return `${label} run failed: ${result.error?.message || "unknown provider failure"}${detail}`;
|
|
1399
1423
|
}
|
|
@@ -1899,6 +1923,57 @@ function drainQueuedMessages(state) {
|
|
|
1899
1923
|
return Promise.resolve();
|
|
1900
1924
|
}
|
|
1901
1925
|
|
|
1926
|
+
// A refusal on a RESUMED session is almost always the safety classifier
|
|
1927
|
+
// rejecting flagged material in the old context — which every resume re-sends
|
|
1928
|
+
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
1929
|
+
// nothing resumes it again, then replay the same prompt once in a fresh
|
|
1930
|
+
// session. A refusal on already-fresh context gets no retry (the new message
|
|
1931
|
+
// itself is the problem), and the retry run never retries again.
|
|
1932
|
+
function shouldRetryRefusedRun(result, runContext, { capture = false, refusalRetried = false } = {}) {
|
|
1933
|
+
return !capture
|
|
1934
|
+
&& !refusalRetried
|
|
1935
|
+
&& runContext?.purpose === "foreground"
|
|
1936
|
+
&& !!runContext?.sessionId
|
|
1937
|
+
&& !!result
|
|
1938
|
+
&& !result.ok
|
|
1939
|
+
&& result.error?.code === "PROVIDER_REFUSAL";
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function quarantineRefusedRun(result, runContext, state) {
|
|
1943
|
+
const details = result.error?.details || {};
|
|
1944
|
+
const record = { reason: "refusal", requestId: details.requestId || null };
|
|
1945
|
+
try {
|
|
1946
|
+
quarantineProviderSession(state, runContext.provider, runContext.project?.name, runContext.sessionId, record);
|
|
1947
|
+
// The CLI rotates session ids on resume; the rotated id observed this run
|
|
1948
|
+
// carries the same poisoned context, so quarantine it too.
|
|
1949
|
+
if (result.sessionId && result.sessionId !== runContext.sessionId) {
|
|
1950
|
+
quarantineProviderSession(state, runContext.provider, runContext.project?.name, result.sessionId, record);
|
|
1951
|
+
}
|
|
1952
|
+
saveState({ strict: true });
|
|
1953
|
+
return true;
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
console.error("refusal quarantine failed:", redactSensitive(String(error.message || error)));
|
|
1956
|
+
return false;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function refusalRetryContext(runContext, state) {
|
|
1961
|
+
return deepFreeze({
|
|
1962
|
+
...runContext,
|
|
1963
|
+
runId: typeof crypto.randomUUID === "function"
|
|
1964
|
+
? crypto.randomUUID()
|
|
1965
|
+
: `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
1966
|
+
admittedAt: Date.now(),
|
|
1967
|
+
sessionId: null,
|
|
1968
|
+
admittedSessionId: null,
|
|
1969
|
+
previousSessionId: getProviderSession(state, runContext.provider, runContext.project?.name) || null,
|
|
1970
|
+
fresh: true,
|
|
1971
|
+
continueSession: false,
|
|
1972
|
+
resumeSessionId: null,
|
|
1973
|
+
quarantinedResume: true,
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1902
1977
|
async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
|
|
1903
1978
|
const tracksMainRun = ownsAdmissionLock && !capture;
|
|
1904
1979
|
if (tracksMainRun) state.activeRunContext = runContext;
|
|
@@ -1928,6 +2003,16 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
1928
2003
|
stopTyping();
|
|
1929
2004
|
}
|
|
1930
2005
|
|
|
2006
|
+
if (shouldRetryRefusedRun(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
|
|
2007
|
+
&& quarantineRefusedRun(result, runContext, state)) {
|
|
2008
|
+
try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
|
|
2009
|
+
// Re-arm the admission lock across the notice's async gap so a racing
|
|
2010
|
+
// incoming message queues behind the retry instead of interleaving.
|
|
2011
|
+
if (ownsAdmissionLock) state.preparingRun = true;
|
|
2012
|
+
await send("♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one.").catch(() => {});
|
|
2013
|
+
return executeAdmittedRun(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
|
|
2014
|
+
}
|
|
2015
|
+
|
|
1931
2016
|
if (tracksMainRun) {
|
|
1932
2017
|
sideChatCoordinator.noteMainResult(runContext, result);
|
|
1933
2018
|
for (const item of state.messageQueue) {
|
|
@@ -2114,6 +2199,9 @@ module.exports = {
|
|
|
2114
2199
|
runAgent,
|
|
2115
2200
|
runAgentCapture,
|
|
2116
2201
|
runAgentSilent,
|
|
2202
|
+
shouldRetryRefusedRun,
|
|
2203
|
+
quarantineRefusedRun,
|
|
2204
|
+
refusalRetryContext,
|
|
2117
2205
|
shouldStartSideChat,
|
|
2118
2206
|
runSideChatRequest,
|
|
2119
2207
|
enqueueSideChatRequest,
|
package/core/state.js
CHANGED
|
@@ -428,6 +428,50 @@ function clearProviderSession(state, providerId, projectName) {
|
|
|
428
428
|
return true;
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
const MAX_QUARANTINED_SESSIONS = 20;
|
|
432
|
+
|
|
433
|
+
function boundQuarantinedSessions(source) {
|
|
434
|
+
if (!isRecord(source)) return {};
|
|
435
|
+
return Object.fromEntries(
|
|
436
|
+
Object.entries(source)
|
|
437
|
+
.filter(([sessionId, record]) => sessionId && isRecord(record))
|
|
438
|
+
.sort((a, b) => (a[1].at || 0) - (b[1].at || 0))
|
|
439
|
+
.slice(-MAX_QUARANTINED_SESSIONS)
|
|
440
|
+
.map(([sessionId, record]) => [sessionId, { ...record }]),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A quarantined session is one whose provider-side context tripped the model's
|
|
445
|
+
// safety classifier (stop_reason "refusal"). Resuming it re-sends the flagged
|
|
446
|
+
// context and refuses again, so it must never be resumed — the run-context
|
|
447
|
+
// capture forces such turns fresh. Quarantining also drops the active pointer
|
|
448
|
+
// when it still points at the poisoned session.
|
|
449
|
+
function quarantineProviderSession(state, providerId, projectName, sessionId, details = {}) {
|
|
450
|
+
const provider = normalizeProviderId(providerId, null);
|
|
451
|
+
if (!state || !provider || typeof sessionId !== "string" || !sessionId) return false;
|
|
452
|
+
if (!isRecord(state.quarantinedSessions)) state.quarantinedSessions = {};
|
|
453
|
+
state.quarantinedSessions[sessionId] = {
|
|
454
|
+
provider,
|
|
455
|
+
project: projectNameFrom(projectName) || getProjectKey(state, projectName) || null,
|
|
456
|
+
reason: String(details.reason || "refusal"),
|
|
457
|
+
requestId: details.requestId || null,
|
|
458
|
+
at: Number.isFinite(details.at) ? details.at : Date.now(),
|
|
459
|
+
};
|
|
460
|
+
state.quarantinedSessions = boundQuarantinedSessions(state.quarantinedSessions);
|
|
461
|
+
const project = getProjectKey(state, projectName);
|
|
462
|
+
if (project && getProviderSession(state, provider, project) === sessionId) {
|
|
463
|
+
clearProviderSession(state, provider, project);
|
|
464
|
+
}
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function isSessionQuarantined(state, sessionId) {
|
|
469
|
+
return !!(sessionId
|
|
470
|
+
&& typeof sessionId === "string"
|
|
471
|
+
&& isRecord(state?.quarantinedSessions)
|
|
472
|
+
&& isRecord(state.quarantinedSessions[sessionId]));
|
|
473
|
+
}
|
|
474
|
+
|
|
431
475
|
function getProviderSettings(state, providerId) {
|
|
432
476
|
if (!state || typeof state !== "object") throw new TypeError("state is required");
|
|
433
477
|
const provider = normalizeProviderId(providerId || getActiveProvider(state), null);
|
|
@@ -542,6 +586,7 @@ function createUserState(userId) {
|
|
|
542
586
|
streamBuffer: "",
|
|
543
587
|
streamInterval: null,
|
|
544
588
|
activeSessions: normalizeActiveSessions(saved.activeSessions),
|
|
589
|
+
quarantinedSessions: boundQuarantinedSessions(saved.quarantinedSessions),
|
|
545
590
|
providerSettings: normalizeProviderSettings(saved.providerSettings),
|
|
546
591
|
usageBySession: boundUsageBySession(saved.usageBySession),
|
|
547
592
|
messageQueue: [],
|
|
@@ -693,6 +738,7 @@ function serializeUserState(state, existing = {}) {
|
|
|
693
738
|
...existing,
|
|
694
739
|
currentSession: state.currentSession,
|
|
695
740
|
activeSessions: normalizeActiveSessions(state.activeSessions),
|
|
741
|
+
quarantinedSessions: boundQuarantinedSessions(state.quarantinedSessions),
|
|
696
742
|
providerSettings: normalizeProviderSettings(state.providerSettings),
|
|
697
743
|
usageBySession: boundUsageBySession(state.usageBySession),
|
|
698
744
|
settings: serializeCommonSettings(state.settings),
|
|
@@ -1367,6 +1413,8 @@ module.exports = {
|
|
|
1367
1413
|
getProviderSession,
|
|
1368
1414
|
setProviderSession,
|
|
1369
1415
|
clearProviderSession,
|
|
1416
|
+
quarantineProviderSession,
|
|
1417
|
+
isSessionQuarantined,
|
|
1370
1418
|
getProviderSettings,
|
|
1371
1419
|
getActiveUsage,
|
|
1372
1420
|
getUsageForSession,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.44",
|
|
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": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
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-credential-capture.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 && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
|
|
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-refusal-recovery.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-credential-capture.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 && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
|
|
13
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-walker-score.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-score-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-cokept-warming.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-pack-search.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-extractive-injection.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-telegram-conflict-heal-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-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 && 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-spaces-autoenable.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
@@ -252,6 +252,28 @@ function inferSessionId() {
|
|
|
252
252
|
|
|
253
253
|
function claudeEvents(sessionId) {
|
|
254
254
|
const init = { type: "system", subtype: "init", session_id: sessionId };
|
|
255
|
+
if (scenario === "refusal") {
|
|
256
|
+
return [
|
|
257
|
+
init,
|
|
258
|
+
{
|
|
259
|
+
type: "assistant",
|
|
260
|
+
message: {
|
|
261
|
+
role: "assistant",
|
|
262
|
+
content: [],
|
|
263
|
+
stop_reason: "refusal",
|
|
264
|
+
stop_details: { type: "refusal", category: "cyber", explanation: "This request was declined because it could enable cyber harm." },
|
|
265
|
+
},
|
|
266
|
+
session_id: sessionId,
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
type: "result",
|
|
270
|
+
subtype: "error_during_execution",
|
|
271
|
+
is_error: true,
|
|
272
|
+
session_id: sessionId,
|
|
273
|
+
result: "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Request ID: req_fixture123",
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
}
|
|
255
277
|
if (scenario === "auth-failure") {
|
|
256
278
|
return [
|
|
257
279
|
init,
|
|
@@ -332,6 +354,12 @@ function claudeEvents(sessionId) {
|
|
|
332
354
|
|
|
333
355
|
function codexEvents(sessionId) {
|
|
334
356
|
const started = { type: "thread.started", thread_id: sessionId };
|
|
357
|
+
if (scenario === "refusal") {
|
|
358
|
+
return [
|
|
359
|
+
started,
|
|
360
|
+
{ type: "turn.failed", error: { message: "Your request was rejected by the safety classifier under the usage policy." } },
|
|
361
|
+
];
|
|
362
|
+
}
|
|
335
363
|
if (scenario === "auth-failure") {
|
|
336
364
|
return [
|
|
337
365
|
started,
|
|
@@ -491,7 +519,7 @@ async function main() {
|
|
|
491
519
|
setInterval(() => {}, 1000);
|
|
492
520
|
return;
|
|
493
521
|
}
|
|
494
|
-
if (scenario === "auth-failure") {
|
|
522
|
+
if (scenario === "auth-failure" || scenario === "refusal") {
|
|
495
523
|
process.exitCode = 1;
|
|
496
524
|
return;
|
|
497
525
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Preload shim: makes require("node:sqlite") throw, reproducing CI's node:20
|
|
2
|
+
// where the module is absent. Release lesson (v3.0.39): run changed tests with
|
|
3
|
+
// NODE_OPTIONS=--require ./test-fixtures/sim-no-sqlite.js
|
|
4
|
+
// before cutting a version, or node22-vs-node20 graph divergences slip through.
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const Module = require("module");
|
|
8
|
+
const originalLoad = Module._load;
|
|
9
|
+
Module._load = function patchedLoad(request, ...rest) {
|
|
10
|
+
if (request === "node:sqlite" || request === "sqlite") {
|
|
11
|
+
throw new Error("node:sqlite unavailable (sim-no-sqlite shim)");
|
|
12
|
+
}
|
|
13
|
+
return originalLoad.call(this, request, ...rest);
|
|
14
|
+
};
|
package/test-provider-events.js
CHANGED
|
@@ -117,8 +117,78 @@ assert.deepStrictEqual(claudeAuth, [{
|
|
|
117
117
|
message: "Authentication failed",
|
|
118
118
|
authError: true,
|
|
119
119
|
usageLimit: false,
|
|
120
|
+
refusal: false,
|
|
120
121
|
}]);
|
|
121
122
|
|
|
123
|
+
// Refusal surfaced only through the CLI's flattened error text (no structured
|
|
124
|
+
// stop_details): flagged, reason sentence + request id recovered from the text.
|
|
125
|
+
const refusalText = "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback. Try rephrasing the request or attempting a different approach.\n\nRequest ID: req_011CdCrJnhWPXeW3PsPFJEb5";
|
|
126
|
+
const claudeRefusal = normalizeClaudeEvent({
|
|
127
|
+
type: "result",
|
|
128
|
+
is_error: true,
|
|
129
|
+
subtype: "error_during_execution",
|
|
130
|
+
result: refusalText,
|
|
131
|
+
}, createClaudeParserState());
|
|
132
|
+
assert.strictEqual(claudeRefusal.length, 1);
|
|
133
|
+
assert.strictEqual(claudeRefusal[0].type, "error");
|
|
134
|
+
assert.strictEqual(claudeRefusal[0].refusal, true);
|
|
135
|
+
assert.strictEqual(claudeRefusal[0].authError, false);
|
|
136
|
+
assert.strictEqual(claudeRefusal[0].usageLimit, false);
|
|
137
|
+
assert.strictEqual(claudeRefusal[0].requestId, "req_011CdCrJnhWPXeW3PsPFJEb5");
|
|
138
|
+
assert.match(claudeRefusal[0].refusalExplanation, /violative cyber content/);
|
|
139
|
+
|
|
140
|
+
// Structured stop_details from the assistant message wins over text parsing.
|
|
141
|
+
const refusalState = createClaudeParserState();
|
|
142
|
+
normalizeClaudeEvent({
|
|
143
|
+
type: "assistant",
|
|
144
|
+
message: {
|
|
145
|
+
content: [],
|
|
146
|
+
stop_reason: "refusal",
|
|
147
|
+
stop_details: { type: "refusal", category: "cyber", explanation: "This request was declined because it could enable cyber harm." },
|
|
148
|
+
},
|
|
149
|
+
}, refusalState);
|
|
150
|
+
const structuredRefusal = normalizeClaudeEvent({
|
|
151
|
+
type: "result",
|
|
152
|
+
is_error: true,
|
|
153
|
+
subtype: "error_during_execution",
|
|
154
|
+
result: "API Error: request blocked",
|
|
155
|
+
}, refusalState);
|
|
156
|
+
assert.strictEqual(structuredRefusal[0].refusal, true);
|
|
157
|
+
assert.strictEqual(structuredRefusal[0].refusalCategory, "cyber");
|
|
158
|
+
assert.match(structuredRefusal[0].refusalExplanation, /cyber harm/);
|
|
159
|
+
|
|
160
|
+
// A refusal the CLI reports as a "successful" empty turn must fail with the
|
|
161
|
+
// reason instead of settling as an empty "(no output)" result.
|
|
162
|
+
const silentState = createClaudeParserState();
|
|
163
|
+
normalizeClaudeEvent({
|
|
164
|
+
type: "assistant",
|
|
165
|
+
message: { content: [], stop_reason: "refusal", stop_details: { type: "refusal", category: null, explanation: null } },
|
|
166
|
+
}, silentState);
|
|
167
|
+
const silent = normalizeClaudeEvent({
|
|
168
|
+
type: "result",
|
|
169
|
+
subtype: "success",
|
|
170
|
+
is_error: false,
|
|
171
|
+
session_id: "claude-refusal-session",
|
|
172
|
+
result: "",
|
|
173
|
+
}, silentState);
|
|
174
|
+
assert.strictEqual(silent.at(-1).type, "error");
|
|
175
|
+
assert.strictEqual(silent.at(-1).refusal, true);
|
|
176
|
+
|
|
177
|
+
// A refusal stop_reason with answer text already streamed must NOT hijack the
|
|
178
|
+
// successful result path.
|
|
179
|
+
const partialState = createClaudeParserState();
|
|
180
|
+
normalizeClaudeEvent({
|
|
181
|
+
type: "assistant",
|
|
182
|
+
message: { content: [{ type: "text", text: "partial answer" }], stop_reason: "refusal" },
|
|
183
|
+
}, partialState);
|
|
184
|
+
assert.strictEqual(normalizeClaudeEvent({
|
|
185
|
+
type: "result",
|
|
186
|
+
subtype: "success",
|
|
187
|
+
is_error: false,
|
|
188
|
+
session_id: "claude-partial-session",
|
|
189
|
+
result: "",
|
|
190
|
+
}, partialState).at(-1).type, "result");
|
|
191
|
+
|
|
122
192
|
const codexState = createCodexParserState();
|
|
123
193
|
assert.deepStrictEqual(normalizeCodexEvent(samples.codex.session, codexState), [
|
|
124
194
|
{ type: "session", sessionId: "codex-thread-1" },
|
|
@@ -151,6 +221,7 @@ assert.deepStrictEqual(codexAuth, [{
|
|
|
151
221
|
message: "401 Unauthorized",
|
|
152
222
|
authError: true,
|
|
153
223
|
usageLimit: false,
|
|
224
|
+
refusal: false,
|
|
154
225
|
}]);
|
|
155
226
|
|
|
156
227
|
const decoderError = { type: "decoder.error", code: "MALFORMED_JSON", message: "Malformed provider JSONL record" };
|
|
@@ -74,7 +74,32 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
74
74
|
// builds a fresh object (offset survives via the shared params), and the
|
|
75
75
|
// "healthy" reset requires a getUpdates that actually COMPLETED since the restart
|
|
76
76
|
// (else it re-arms a heal) instead of blindly declaring victory after 35s.
|
|
77
|
-
|
|
77
|
+
// 3.0.43 approved by Sumeet 2026-07-18 ("Yeh push"): reachability-gated self-exit
|
|
78
|
+
// + outage-pause backoff, so a host-wide network blip no longer triggers a
|
|
79
|
+
// reboot storm. v3.0.42 healed the in-loop wedge but STILL rebooted after 10min
|
|
80
|
+
// even when the whole host had lost the network (laptop sleep, pod egress
|
|
81
|
+
// starvation) — exiting into a dead network just relaunched into the same
|
|
82
|
+
// outage, spamming "Back online" every ~14min. The 10-min self-exit is now gated
|
|
83
|
+
// on a fresh HEAD probe to api.telegram.org actually SUCCEEDING (proves a genuine
|
|
84
|
+
// in-loop wedge, not a dead host); if the probe fails the adapter enters an
|
|
85
|
+
// outage pause — it STOPS polling (no 300ms lib reconnect spin) and re-probes
|
|
86
|
+
// every 30s, resuming cleanly the moment connectivity returns. Poll-health logs
|
|
87
|
+
// are timestamped. Confined to channels/telegram/adapter.js; new cases in
|
|
88
|
+
// test-telegram-poll-recovery.js cover the pause+resume and the gated exit.
|
|
89
|
+
// 3.0.44 approved by Sumeet 2026-07-20 ("push"): safety-refusal handling +
|
|
90
|
+
// poisoned-session recovery. The API's stop_reason "refusal" scores the WHOLE
|
|
91
|
+
// conversation, so a session whose context tripped the classifier refused every
|
|
92
|
+
// resumed turn (the flagged material was re-sent each time) and the user only
|
|
93
|
+
// saw a raw AUP error dump with no way forward but knowing to type /new.
|
|
94
|
+
// Refusals are now detected structurally (stop_details category/explanation,
|
|
95
|
+
// text-parse fallback on the flattened CLI error) and mapped to PROVIDER_REFUSAL
|
|
96
|
+
// with a clear diagnostic (reason, request ID, /new + rephrase + model options).
|
|
97
|
+
// Recovery: the refused session is QUARANTINED (bounded persisted map in user
|
|
98
|
+
// state; never resumed again — natural turns, explicit /continue, and rotated
|
|
99
|
+
// ids all forced fresh) and the refused prompt is auto-retried exactly ONCE in
|
|
100
|
+
// a fresh session behind a ♻️ notice. A refusal on already-fresh context gets
|
|
101
|
+
// no retry (the new message itself is the problem); capture runs never retry.
|
|
102
|
+
assert.strictEqual(pkg.version, "3.0.44", "release version must remain unchanged without explicit approval");
|
|
78
103
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
79
104
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
80
105
|
}
|
package/test-provider-runner.js
CHANGED
|
@@ -262,6 +262,56 @@ async function main() {
|
|
|
262
262
|
assert.strictEqual(authFailure.error.code, "PROVIDER_UNAUTHENTICATED");
|
|
263
263
|
assert.match(authFailure.diagnostic, /Authentication failed/i);
|
|
264
264
|
|
|
265
|
+
// A safety refusal on a resumed session must surface as PROVIDER_REFUSAL
|
|
266
|
+
// with the structured reason (category/explanation/request id) intact — the
|
|
267
|
+
// inputs the poisoned-session recovery quarantines and retries on.
|
|
268
|
+
const refusalCapture = path.join(root, "refusal-capture.json");
|
|
269
|
+
const refusalProviders = makeProviders(
|
|
270
|
+
{ claude: refusalCapture, codex: captures.codex },
|
|
271
|
+
{ claude: { FAKE_AGENT_SCENARIO: "refusal" } },
|
|
272
|
+
);
|
|
273
|
+
mutable.activeSessions["project-a"].claude = "claude-poisoned-session";
|
|
274
|
+
const refusalService = createRunnerService({
|
|
275
|
+
getState: () => mutable,
|
|
276
|
+
getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
|
|
277
|
+
resolveProvider: (id) => refusalProviders[id],
|
|
278
|
+
buildTurnPrompt: simpleBundle,
|
|
279
|
+
cleanup: async () => {},
|
|
280
|
+
});
|
|
281
|
+
const refused = await refusalService.runAgentCapture("REFUSAL SENTINEL", projectA);
|
|
282
|
+
assert.strictEqual(refused.ok, false);
|
|
283
|
+
assert.strictEqual(refused.admittedSessionId, "claude-poisoned-session", "refusal run resumed the poisoned session");
|
|
284
|
+
assert.strictEqual(refused.error.code, "PROVIDER_REFUSAL");
|
|
285
|
+
assert.strictEqual(refused.error.details.refusal, true);
|
|
286
|
+
assert.strictEqual(refused.error.details.refusalCategory, "cyber");
|
|
287
|
+
assert.match(refused.error.details.refusalExplanation, /cyber harm/);
|
|
288
|
+
assert.strictEqual(refused.error.details.requestId, "req_fixture123");
|
|
289
|
+
assert.match(refused.diagnostic, /Usage Policy/);
|
|
290
|
+
assert.match(refused.diagnostic, /req_fixture123/);
|
|
291
|
+
const refusalArgv = JSON.parse(fs.readFileSync(refusalCapture, "utf8")).argv;
|
|
292
|
+
assert.ok(refusalArgv.includes("claude-poisoned-session"), "refusal run resumed via --resume");
|
|
293
|
+
|
|
294
|
+
// The quarantine guard: once the session is quarantined, the SAME turn shape
|
|
295
|
+
// is admitted fresh — no --resume, healthy success in a new session.
|
|
296
|
+
mutable.quarantinedSessions = {
|
|
297
|
+
"claude-poisoned-session": { provider: "claude", project: "project-a", reason: "refusal", at: Date.now() },
|
|
298
|
+
};
|
|
299
|
+
const recoveredProviders = makeProviders({ claude: refusalCapture, codex: captures.codex });
|
|
300
|
+
const recoveredService = createRunnerService({
|
|
301
|
+
getState: () => mutable,
|
|
302
|
+
getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
|
|
303
|
+
resolveProvider: (id) => recoveredProviders[id],
|
|
304
|
+
buildTurnPrompt: simpleBundle,
|
|
305
|
+
cleanup: async () => {},
|
|
306
|
+
});
|
|
307
|
+
const recovered = await recoveredService.runAgentCapture("RECOVERY SENTINEL", projectA);
|
|
308
|
+
assert.strictEqual(recovered.ok, true);
|
|
309
|
+
assert.strictEqual(recovered.admittedSessionId, null, "quarantined session must not be admitted");
|
|
310
|
+
assert.strictEqual(recovered.sessionId, "claude-fresh-session");
|
|
311
|
+
const recoveredArgv = JSON.parse(fs.readFileSync(refusalCapture, "utf8")).argv;
|
|
312
|
+
assert.ok(!recoveredArgv.includes("--resume"), "quarantined resume is forced fresh");
|
|
313
|
+
assert.ok(!recoveredArgv.includes("claude-poisoned-session"));
|
|
314
|
+
|
|
265
315
|
console.log("provider runner OK");
|
|
266
316
|
}
|
|
267
317
|
|
|
@@ -190,6 +190,7 @@ async function testWedgeExitDefersWhileTurnInFlight() {
|
|
|
190
190
|
adapter._deferredExitLogAt = 0;
|
|
191
191
|
adapter._healBackoff = 0;
|
|
192
192
|
adapter._anyTurnActive = () => true; // a turn is running/queued
|
|
193
|
+
adapter._probeTelegramReachable = async () => true; // reachable ⇒ genuine wedge; defer path
|
|
193
194
|
adapter._agent = { destroy() { calls.push("destroy"); } };
|
|
194
195
|
adapter.bot = {
|
|
195
196
|
async stopPolling() { calls.push("stop"); },
|
|
@@ -217,8 +218,10 @@ async function testWedgeExitDefersWhileTurnInFlight() {
|
|
|
217
218
|
assert.ok(adapter._deferredExitLogAt > 0, "the deferral must be logged (throttle stamp set)");
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
// Once the process is idle (no turn running/queued)
|
|
221
|
-
//
|
|
221
|
+
// Once the process is idle (no turn running/queued) AND Telegram answers a fresh
|
|
222
|
+
// connection — proving this is a real in-loop poll wedge, not a host-network
|
|
223
|
+
// outage — the backstop exits for a clean restart exactly as before. The
|
|
224
|
+
// deferral only postpones, never cancels.
|
|
222
225
|
async function testWedgeExitFiresWhenIdle() {
|
|
223
226
|
let exitCode;
|
|
224
227
|
const adapter = Object.create(TelegramAdapter.prototype);
|
|
@@ -227,6 +230,7 @@ async function testWedgeExitFiresWhenIdle() {
|
|
|
227
230
|
adapter._wedgedSince = Date.now() - 11 * 60 * 1000; // wedged >10min
|
|
228
231
|
adapter._deferredExitLogAt = 0;
|
|
229
232
|
adapter._anyTurnActive = () => false; // idle
|
|
233
|
+
adapter._probeTelegramReachable = async () => true; // Telegram reachable ⇒ genuine poll wedge
|
|
230
234
|
adapter._agent = { destroy() {} };
|
|
231
235
|
adapter.bot = { async stopPolling() {}, async startPolling() {} };
|
|
232
236
|
|
|
@@ -244,6 +248,116 @@ async function testWedgeExitFiresWhenIdle() {
|
|
|
244
248
|
assert.strictEqual(exitCode, 1, "the idle wedge backstop must exit(1) for a clean restart");
|
|
245
249
|
}
|
|
246
250
|
|
|
251
|
+
// THE REBOOT-STORM regression, now resolved by an OUTAGE PAUSE. When the wedge is
|
|
252
|
+
// a host-wide network outage — every outbound connection failing together
|
|
253
|
+
// (Telegram polls + sends AND the Kazee/Spaces websockets) — a reboot can't help:
|
|
254
|
+
// the fresh process can't reach Telegram either and just relaunches into the same
|
|
255
|
+
// dead network, spamming "Back online". So even when idle, the backstop must NOT
|
|
256
|
+
// exit while a fresh connection to Telegram also fails. Instead it STOPS the poll
|
|
257
|
+
// loop entirely (no ~300ms getUpdates hammering that can trip a 429 when the link
|
|
258
|
+
// flickers back) and waits on a slow re-probe. Fails before the fix because the
|
|
259
|
+
// idle backstop exits unconditionally.
|
|
260
|
+
async function testWedgeExitPausesInsteadOfRebootingOnHostOutage() {
|
|
261
|
+
const calls = [];
|
|
262
|
+
let exited = false;
|
|
263
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
264
|
+
adapter._healTimer = null;
|
|
265
|
+
adapter._healthyTimer = null;
|
|
266
|
+
adapter._conflictSince = 0;
|
|
267
|
+
adapter._wedgedSince = Date.now() - 11 * 60 * 1000; // wedged >10min
|
|
268
|
+
adapter._deferredExitLogAt = 0;
|
|
269
|
+
adapter._healBackoff = 0;
|
|
270
|
+
adapter._pausedForOutage = false;
|
|
271
|
+
adapter._outageProbeTimer = null;
|
|
272
|
+
adapter._anyTurnActive = () => false; // idle
|
|
273
|
+
adapter._probeTelegramReachable = async () => false; // host can't reach Telegram at all
|
|
274
|
+
adapter._agent = { destroy() { calls.push("destroy"); } };
|
|
275
|
+
adapter.bot = {
|
|
276
|
+
async stopPolling() { calls.push("stop"); },
|
|
277
|
+
async startPolling() { calls.push("start"); },
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const realExit = process.exit;
|
|
281
|
+
process.exit = () => { exited = true; throw new Error("process.exit must not be called during a host-network outage"); };
|
|
282
|
+
const realSetTimeout = global.setTimeout;
|
|
283
|
+
const realClearTimeout = global.clearTimeout;
|
|
284
|
+
global.setTimeout = (fn, ms) => { if (ms === 500) fn(); return { ms }; };
|
|
285
|
+
global.clearTimeout = () => {};
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await adapter._heal();
|
|
289
|
+
await new Promise((r) => realSetTimeout(r, 5)); // let the fire-and-forget stop settle
|
|
290
|
+
} finally {
|
|
291
|
+
process.exit = realExit;
|
|
292
|
+
global.setTimeout = realSetTimeout;
|
|
293
|
+
global.clearTimeout = realClearTimeout;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
assert.strictEqual(exited, false, "a host-network outage must NOT trigger a reboot (the fresh process can't reach the network either)");
|
|
297
|
+
assert.strictEqual(adapter._pausedForOutage, true, "an unreachable host must pause polling instead of rebooting");
|
|
298
|
+
assert.ok(calls.includes("stop") && calls.includes("destroy"),
|
|
299
|
+
"entering the outage pause must stop the poll loop and sever the socket");
|
|
300
|
+
assert.ok(!calls.includes("start"),
|
|
301
|
+
"while the host is unreachable the bot must NOT keep reconnecting (no ~300ms getUpdates hammering)");
|
|
302
|
+
assert.ok(adapter._outageProbeTimer, "the pause must schedule a slow re-probe to resume when the network returns");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Companion: once the network returns, the outage pause's slow re-probe must
|
|
306
|
+
// RESUME polling on a FRESH loop object (offset preserved via bot.options.polling
|
|
307
|
+
// params) and leave the paused state — the bot recovers on its own, no reboot.
|
|
308
|
+
async function testOutagePauseResumesPollingWhenNetworkReturns() {
|
|
309
|
+
const calls = [];
|
|
310
|
+
let reachable = false;
|
|
311
|
+
let tickFn;
|
|
312
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
313
|
+
adapter._healTimer = null;
|
|
314
|
+
adapter._healthyTimer = null;
|
|
315
|
+
adapter._wedgedSince = Date.now() - 2 * 60 * 1000;
|
|
316
|
+
adapter._healBackoff = 0;
|
|
317
|
+
adapter._deferredExitLogAt = 0;
|
|
318
|
+
adapter._pausedForOutage = false;
|
|
319
|
+
adapter._outageProbeTimer = null;
|
|
320
|
+
adapter._pollStartedAt = 0;
|
|
321
|
+
adapter._onPollingHiccup = () => {};
|
|
322
|
+
adapter._probeTelegramReachable = async () => reachable;
|
|
323
|
+
adapter._agent = { destroy() { calls.push("destroy"); } };
|
|
324
|
+
adapter.bot = {
|
|
325
|
+
_polling: { _abort: true, _lastUpdate: 0 },
|
|
326
|
+
async stopPolling() { calls.push("stop"); },
|
|
327
|
+
async startPolling() {
|
|
328
|
+
calls.push("start");
|
|
329
|
+
if (!this._polling) this._polling = { _abort: false, _lastUpdate: Date.now() };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const realSetTimeout = global.setTimeout;
|
|
334
|
+
const realClearTimeout = global.clearTimeout;
|
|
335
|
+
global.setTimeout = (fn, ms) => {
|
|
336
|
+
if (ms === 500 || ms === 5000) fn(); // stop-clean internals fire inline
|
|
337
|
+
else if (ms === 30000 && !tickFn) tickFn = fn; // capture the outage re-probe tick
|
|
338
|
+
return { ms };
|
|
339
|
+
};
|
|
340
|
+
global.clearTimeout = () => {};
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
adapter._enterOutagePause();
|
|
344
|
+
await new Promise((r) => realSetTimeout(r, 5)); // let the fire-and-forget stop settle
|
|
345
|
+
assert.strictEqual(adapter._pausedForOutage, true, "an unreachable host pauses polling");
|
|
346
|
+
assert.ok(!calls.includes("start"), "no reconnecting while paused (network is down)");
|
|
347
|
+
assert.strictEqual(typeof tickFn, "function", "the pause must schedule a slow re-probe");
|
|
348
|
+
|
|
349
|
+
reachable = true;
|
|
350
|
+
adapter.bot._polling = { _abort: true, _lastUpdate: 0 }; // stale object present before resume
|
|
351
|
+
await tickFn();
|
|
352
|
+
assert.strictEqual(adapter._pausedForOutage, false, "a successful re-probe must leave the pause");
|
|
353
|
+
assert.ok(calls.includes("start"), "a successful re-probe must resume polling");
|
|
354
|
+
assert.strictEqual(adapter.bot._polling._abort, false, "resume must build a FRESH poll loop object (offset preserved)");
|
|
355
|
+
} finally {
|
|
356
|
+
global.setTimeout = realSetTimeout;
|
|
357
|
+
global.clearTimeout = realClearTimeout;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
247
361
|
// THE RESTART STORM regression. A wedge (half-open getUpdates after sleep/wake
|
|
248
362
|
// or a NAT/IPv6 eviction) leaves the lib's REUSED _polling object with `_abort`
|
|
249
363
|
// stuck true: the hung request never settled within our stop cap, so stop()'s
|
|
@@ -266,6 +380,7 @@ async function testHealNullsPoisonedPollingSoLibRebuildsFresh() {
|
|
|
266
380
|
adapter._healBackoff = 0;
|
|
267
381
|
adapter._pollStartedAt = ancient;
|
|
268
382
|
adapter._onPollingHiccup = () => {};
|
|
383
|
+
adapter._probeTelegramReachable = async () => true; // reachable ⇒ normal in-place heal path
|
|
269
384
|
adapter._agent = { destroy() {} };
|
|
270
385
|
adapter.bot = {
|
|
271
386
|
_polling: poisoned,
|
|
@@ -310,6 +425,7 @@ async function testHealthySignalReArmsWhenNoPollCompleted() {
|
|
|
310
425
|
adapter._healBackoff = 2;
|
|
311
426
|
adapter._deferredExitLogAt = 0;
|
|
312
427
|
adapter._pollStartedAt = 0;
|
|
428
|
+
adapter._probeTelegramReachable = async () => true; // reachable ⇒ normal in-place heal path
|
|
313
429
|
adapter._agent = { destroy() {} };
|
|
314
430
|
adapter.bot = {
|
|
315
431
|
_polling: { _abort: true, _lastUpdate: 0 },
|
|
@@ -360,6 +476,7 @@ async function testHealthySignalClearsWedgeOnceAPollCompletes() {
|
|
|
360
476
|
adapter._healBackoff = 3;
|
|
361
477
|
adapter._deferredExitLogAt = 12345;
|
|
362
478
|
adapter._pollStartedAt = 0;
|
|
479
|
+
adapter._probeTelegramReachable = async () => true; // reachable ⇒ normal in-place heal path
|
|
363
480
|
adapter._agent = { destroy() {} };
|
|
364
481
|
adapter.bot = {
|
|
365
482
|
_polling: { _abort: true, _lastUpdate: 0 },
|
|
@@ -401,6 +518,8 @@ async function testHealthySignalClearsWedgeOnceAPollCompletes() {
|
|
|
401
518
|
await test409ConflictSeversGhostSocketAndRestartsIdle();
|
|
402
519
|
await testWedgeExitDefersWhileTurnInFlight();
|
|
403
520
|
await testWedgeExitFiresWhenIdle();
|
|
521
|
+
await testWedgeExitPausesInsteadOfRebootingOnHostOutage();
|
|
522
|
+
await testOutagePauseResumesPollingWhenNetworkReturns();
|
|
404
523
|
await testHealNullsPoisonedPollingSoLibRebuildsFresh();
|
|
405
524
|
await testHealthySignalReArmsWhenNoPollCompleted();
|
|
406
525
|
await testHealthySignalClearsWedgeOnceAPollCompletes();
|