@inetafrica/open-claudia 3.0.43 → 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 CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## v3.0.43 — Telegram stops rebooting into a dead network: outages now pause-and-wait instead of restart-storming
4
11
 
5
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.
@@ -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
- const message = claudeErrorMessage(rawEvent);
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 ? "" : (typeof rawEvent.result === "string" ? rawEvent.result : "")),
145
+ : (state.sawText ? "" : resultText),
113
146
  sessionId: rawEvent.session_id || state.sessionId || null,
114
147
  });
115
148
  return events;
@@ -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
  };
@@ -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 admittedSessionId = opts.fresh
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(event.message || "Provider reported an error", event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR"), event);
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.43",
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
+ };
@@ -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" };
@@ -86,7 +86,20 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
86
86
  // every 30s, resuming cleanly the moment connectivity returns. Poll-health logs
87
87
  // are timestamped. Confined to channels/telegram/adapter.js; new cases in
88
88
  // test-telegram-poll-recovery.js cover the pause+resume and the gated exit.
89
- assert.strictEqual(pkg.version, "3.0.43", "release version must remain unchanged without explicit approval");
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");
90
103
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
91
104
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
92
105
  }
@@ -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