@inetafrica/open-claudia 3.5.17 → 3.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -290,7 +290,28 @@ function claudeEvents(sessionId) {
290
290
  ];
291
291
  }
292
292
 
293
+ if (scenario === "usage-limit") {
294
+ // Claude Code 2.1.280 at a plan limit: the rejected rate_limit_event, the
295
+ // synthetic notice (error "rate_limit") and an error result.
296
+ const resetsAt = integerControl("FAKE_AGENT_LIMIT_RESETS_AT", Math.floor(Date.now() / 1000) + 3600, { min: 0, max: 2 ** 31 });
297
+ const text = process.env.FAKE_AGENT_LIMIT_TEXT || "You've hit your session limit · resets 5pm (UTC)";
298
+ return [
299
+ init,
300
+ {
301
+ type: "rate_limit_event",
302
+ rate_limit_info: { status: "rejected", resetsAt, rateLimitType: process.env.FAKE_AGENT_LIMIT_TYPE || "five_hour", isUsingOverage: false },
303
+ session_id: sessionId,
304
+ },
305
+ { type: "assistant", error: "rate_limit", message: { model: "<synthetic>", role: "assistant", content: [{ type: "text", text }] }, session_id: sessionId },
306
+ { type: "result", subtype: "success", is_error: true, session_id: sessionId, result: text },
307
+ ];
308
+ }
309
+
293
310
  const events = [init];
311
+ // Plan use as Claude Code reports it on a fresh run (JSON rate_limit_info).
312
+ if (process.env.FAKE_AGENT_RATE_LIMIT_INFO) {
313
+ events.push({ type: "rate_limit_event", rate_limit_info: JSON.parse(process.env.FAKE_AGENT_RATE_LIMIT_INFO), session_id: sessionId });
314
+ }
294
315
  if (process.env.FAKE_AGENT_THINKING) {
295
316
  events.push({
296
317
  type: "assistant",
@@ -589,7 +610,7 @@ async function main() {
589
610
  setInterval(() => {}, 1000);
590
611
  return;
591
612
  }
592
- if (scenario === "auth-failure" || scenario === "refusal") {
613
+ if (scenario === "auth-failure" || scenario === "refusal" || scenario === "usage-limit") {
593
614
  process.exitCode = 1;
594
615
  return;
595
616
  }
@@ -97,9 +97,11 @@ async function immutableSuccessProbe() {
97
97
  assert.strictEqual(stateApi.acquireRunLock(state), false, "foreground admission stays closed between compaction stages");
98
98
  alphaLane().isCompacting = false;
99
99
  const contexts = [];
100
- const service = serviceWith(async (_prompt, _cwd, options) => {
100
+ const prompts = [];
101
+ const service = serviceWith(async (prompt, _cwd, options) => {
101
102
  assert.strictEqual(options.persist, false, "compaction provider stages have no ordinary capture persistence");
102
103
  contexts.push(options.runContext);
104
+ prompts.push(String(prompt));
103
105
  assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude", "pointer stays old during both provider stages");
104
106
  if (contexts.length === 1) {
105
107
  state.currentSession = { name: "beta", dir: betaDir };
@@ -125,6 +127,11 @@ async function immutableSuccessProbe() {
125
127
  assert.strictEqual(contexts[1].timeoutMs, contexts[0].timeoutMs);
126
128
  assert.strictEqual(contexts[0].purpose, "compact-summary");
127
129
  assert.strictEqual(contexts[0].recallQuery, null, "summary stage carries no recall query");
130
+ // Claude runs the summary stage with --max-turns 1: a tool call (a transcript
131
+ // search, a status check) uses up the one turn and ends it with no brief, so
132
+ // the compaction fails and its cost is wasted (4 of 23 failed on 7-8 Oct).
133
+ assert.strictEqual(contexts[0].maxTurns, 1);
134
+ assert.ok(prompts[0].includes("Do NOT use any tools"), "the one-turn summary stage forbids tool calls");
128
135
  assert.strictEqual(contexts[1].purpose, "compact-seed");
129
136
  assert.ok(String(contexts[1].recallQuery || "").includes("A sufficiently detailed condensed continuation"),
130
137
  "seed recall query carries the condensed brief, not seed-prompt boilerplate");
@@ -156,6 +163,63 @@ async function immutableSuccessProbe() {
156
163
  assert.strictEqual(alphaLane().isCompacting, false);
157
164
  }
158
165
 
166
+ // A scheduled job's own conversation (a branch beside the thread's live
167
+ // session) is compacted in place of the live one: the summary reads the
168
+ // branch, the lineage lands in the history and the index, and the thread's
169
+ // live pointer stays where it is. Only the job follows the new session.
170
+ async function branchCompactionProbe() {
171
+ resetFixture("claude");
172
+ state.usageBySession["alpha\0claude\0job-branch"] = { ...stateApi.freshUsage(), turns: 17, liveContextTokens: 894000 };
173
+ const contexts = [];
174
+ const recorded = [];
175
+ const service = serviceWith(async (_prompt, _cwd, options) => {
176
+ contexts.push(options.runContext);
177
+ return result({ sessionId: contexts.length === 1 ? "job-branch" : "job-branch-new" });
178
+ }, {
179
+ commitBranchCompaction: stateApi.commitBranchCompaction,
180
+ recordCompaction: (entry) => recorded.push(entry),
181
+ });
182
+ const compacted = await service.compact(alphaDir, {
183
+ operationId: "compact-branch", sessionId: "job-branch", provider: "claude", compactReason: "cold-resume",
184
+ });
185
+ assert.strictEqual(compacted.compacted, true, JSON.stringify(compacted));
186
+ assert.strictEqual(compacted.branch, true);
187
+ assert.strictEqual(compacted.oldSessionId, "job-branch");
188
+ assert.strictEqual(contexts[0].sessionId, "job-branch", "the summary stage reads the job's conversation");
189
+ assert.strictEqual(contexts[0].resumeSessionId, "job-branch");
190
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude", "the thread's live pointer stays");
191
+ const record = stateApi.getProjectSessions(state.userId, "alpha", "claude").find((entry) => entry.id === "job-branch-new");
192
+ assert.deepStrictEqual(record.lineage, {
193
+ type: "compaction",
194
+ operationId: "compact-branch",
195
+ previousSessionId: "job-branch",
196
+ newSessionId: "job-branch-new",
197
+ branch: true,
198
+ });
199
+ assert.strictEqual(state.completedCompactions["compact-branch"].previousSessionId, "job-branch");
200
+ assert.strictEqual(state.completedCompactions["compact-branch"].newSessionId, "job-branch-new");
201
+ const usage = stateApi.getUsageForSession(state, "alpha", "claude", "job-branch-new");
202
+ assert.strictEqual(usage.turns, 0, "the seed is not billed as a turn");
203
+ assert.strictEqual(usage.compactionCheckpoint.previousSessionId, "job-branch");
204
+ assert.ok(usage.lastCompactedAt > 0, "the new session carries the compaction spacing");
205
+ assert.strictEqual(recorded[0].branch, true);
206
+ assert.strictEqual(recorded[0].contextTokensBefore, 894000, "the context it replaced is the branch's, not the thread's");
207
+ assert.strictEqual(runner.recordCompactionUsage({ ...recorded[0] }, { appendUsageRecord: (entry) => entry }).scope, "job", "the ledger marks a job conversation");
208
+ assert.ok(!("scope" in runner.recordCompactionUsage({ compactReason: "threshold", provider: "claude" }, { appendUsageRecord: (entry) => entry })), "and only that");
209
+ assert.strictEqual(alphaLane().isCompacting, false);
210
+
211
+ const again = stateApi.commitBranchCompaction(state, {
212
+ operationId: "compact-branch", canonicalUserId: state.userId, project: "alpha", provider: "claude",
213
+ previousSessionId: "job-branch", newSessionId: "job-branch-new",
214
+ });
215
+ assert.strictEqual(again.duplicate, true, "a repeated commit is a no-op");
216
+ assert.throws(() => stateApi.commitBranchCompaction(state, {
217
+ operationId: "compact-became-live", canonicalUserId: state.userId, project: "alpha", provider: "claude",
218
+ previousSessionId: "old-claude", newSessionId: "other-new",
219
+ }), /became the live session/, "a branch that is now the live session is the thread's to compact");
220
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "old-claude");
221
+ }
222
+
159
223
  async function failureProbe() {
160
224
  resetFixture("claude");
161
225
  alphaLane().runningProcess = { pid: 123 };
@@ -492,6 +556,7 @@ function restartRecoveryProbe() {
492
556
  assert.strictEqual(typeof runner.createCompactionService, "function");
493
557
  assert.strictEqual(typeof stateApi.commitProviderCompaction, "function");
494
558
  await immutableSuccessProbe();
559
+ await branchCompactionProbe();
495
560
  await failureProbe();
496
561
  await codexRestrictionProbe();
497
562
  await deferredWatchAcrossCompactionProbe();
@@ -25,6 +25,7 @@ assert.deepStrictEqual([...NORMALIZED_EVENT_TYPES].sort(), [
25
25
  "tool_start",
26
26
  "usage",
27
27
  "warning",
28
+ "rate_limit",
28
29
  ].sort());
29
30
 
30
31
  const claudeState = createClaudeParserState();
@@ -189,6 +190,90 @@ assert.strictEqual(normalizeClaudeEvent({
189
190
  result: "",
190
191
  }, partialState).at(-1).type, "result");
191
192
 
193
+ // Plan limits. Claude Code 2.1.280 reports each window's use on every fresh
194
+ // run (shape captured live 7 Oct 2026) …
195
+ const rateLimitState = createClaudeParserState();
196
+ assert.deepStrictEqual(normalizeClaudeEvent({
197
+ type: "rate_limit_event",
198
+ rate_limit_info: {
199
+ status: "allowed_warning",
200
+ resetsAt: 1791939600,
201
+ rateLimitType: "seven_day",
202
+ utilization: 0.26,
203
+ isUsingOverage: false,
204
+ unifiedWindows: {
205
+ five_hour: { utilization: 0.57, resetsAt: 1791382800 },
206
+ seven_day: { utilization: 0.26, resetsAt: 1791939600 },
207
+ },
208
+ },
209
+ uuid: "u-1",
210
+ session_id: "s-1",
211
+ }, rateLimitState), [{
212
+ type: "rate_limit",
213
+ provider: "claude",
214
+ status: "allowed_warning",
215
+ limitType: "seven_day",
216
+ resetsAt: 1791939600000,
217
+ windows: [
218
+ { id: "five_hour", usedPercent: 57, resetsAt: 1791382800000 },
219
+ { id: "seven_day", usedPercent: 26, resetsAt: 1791939600000 },
220
+ ],
221
+ }]);
222
+ assert.strictEqual(rateLimitState.limitHit, null, "a warning is not a limit hit");
223
+
224
+ // … and a limit hit as status "rejected", a synthetic assistant notice
225
+ // (error "rate_limit") and an error result. The notice is never answer text,
226
+ // and the failure carries the reset time.
227
+ const limitText = "You've hit your session limit · resets 4:10pm (Africa/Nairobi)";
228
+ const limitState = createClaudeParserState();
229
+ assert.strictEqual(normalizeClaudeEvent({
230
+ type: "rate_limit_event",
231
+ rate_limit_info: { status: "rejected", resetsAt: 1791205800, rateLimitType: "five_hour", isUsingOverage: false },
232
+ }, limitState)[0].status, "rejected");
233
+ assert.deepStrictEqual(normalizeClaudeEvent({
234
+ type: "assistant",
235
+ error: "rate_limit",
236
+ message: { model: "<synthetic>", role: "assistant", content: [{ type: "text", text: limitText }] },
237
+ }, limitState), [], "the limit notice must not stream as a reply");
238
+ const limitFailure = normalizeClaudeEvent({ type: "result", subtype: "success", is_error: true, result: limitText }, limitState);
239
+ assert.deepStrictEqual(limitFailure, [{
240
+ type: "error",
241
+ message: limitText,
242
+ authError: false,
243
+ usageLimit: true,
244
+ refusal: false,
245
+ limitResetsAt: 1791205800000,
246
+ limitType: "five_hour",
247
+ }]);
248
+
249
+ // The same notice closed as a "success" (no answer text) still fails.
250
+ const quietLimit = createClaudeParserState();
251
+ normalizeClaudeEvent({
252
+ type: "assistant",
253
+ message: { model: "<synthetic>", content: [{ type: "text", text: "You've hit your weekly limit · resets Oct 9, 5am (UTC)" }] },
254
+ }, quietLimit);
255
+ const quietFailure = normalizeClaudeEvent({ type: "result", subtype: "success", is_error: false, result: "You've hit your weekly limit · resets Oct 9, 5am (UTC)" }, quietLimit);
256
+ assert.strictEqual(quietFailure.at(-1).type, "error");
257
+ assert.strictEqual(quietFailure.at(-1).usageLimit, true);
258
+
259
+ // A rejection Claude Code recovered from (it answered) stays a success.
260
+ const recovered = createClaudeParserState();
261
+ normalizeClaudeEvent({ type: "rate_limit_event", rate_limit_info: { status: "rejected", rateLimitType: "seven_day_opus", resetsAt: 1791939600 } }, recovered);
262
+ normalizeClaudeEvent({ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } }, recovered);
263
+ assert.strictEqual(normalizeClaudeEvent({ type: "result", subtype: "success", is_error: false, result: "Here is the answer." }, recovered).at(-1).type, "result");
264
+
265
+ // Every wording of a plan limit counts as one, for both providers.
266
+ for (const text of [
267
+ "You've hit your limit · resets 1pm (Africa/Nairobi)",
268
+ "You've hit your session limit · resets 11:30am (Africa/Nairobi)",
269
+ "You've hit your weekly limit · resets Oct 9, 5am (UTC)",
270
+ "You’ve hit your Opus limit · resets Oct 9, 5am (UTC)",
271
+ "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 5:28 AM.",
272
+ ]) {
273
+ assert.strictEqual(normalizeClaudeEvent({ type: "result", is_error: true, result: text }, createClaudeParserState())[0].usageLimit, true, text);
274
+ assert.strictEqual(normalizeCodexEvent({ type: "turn.failed", error: { message: text } }, createCodexParserState())[0].usageLimit, true, text);
275
+ }
276
+
192
277
  const codexState = createCodexParserState();
193
278
  assert.deepStrictEqual(normalizeCodexEvent(samples.codex.session, codexState), [
194
279
  { type: "session", sessionId: "codex-thread-1" },
@@ -578,7 +578,20 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
578
578
  // dream fixes, task archive, interrupted turns resume), the 7 Oct security
579
579
  // fixes, the 6 Oct dashboard and provider fixes, and the rule-catcher fix.
580
580
  // Canary on the owner's Mac bot (one thread on the token diet), then the fleet.
581
- assert.strictEqual(pkg.version, "3.5.17", "release version must remain unchanged without explicit approval");
581
+ // 3.5.18 approved by Sumeet 2026-10-08 09:25 EAT ("Yeah, go ahead and then
582
+ // maybe by the end of the day if everything is stable on this bot, then we
583
+ // roll it out to the whole fleet"), answering the 08:19 EAT plan for "Let's
584
+ // roll it out to the fleet if we already seeing savings?": the token diet on
585
+ // by default (fleet pods set no TOKEN_DIET_* env), the compaction summary
586
+ // step without tools, the same cap for scheduled jobs' own conversations,
587
+ // and usage-limit pick-ups with 80/95% warnings. This Mac bot first, then the
588
+ // fleet once it is stable here.
589
+ // 3.5.19 approved by Sumeet 2026-10-08 13:08 EAT ("I'd rather just push out
590
+ // another release now rather than waiting for another day or so"): the memory
591
+ // dedup judge and the classic recall filter reply in an object (the API
592
+ // refused their bare-array formats on every Claude call), and the utility
593
+ // agent refuses any reply format without an object root. This Mac bot first.
594
+ assert.strictEqual(pkg.version, "3.5.19", "release version must remain unchanged without explicit approval");
582
595
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
583
596
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
584
597
  }
@@ -20,7 +20,7 @@ const { createClaudeProvider } = require("./core/providers/claude");
20
20
  const { createCodexProvider } = require("./core/providers/codex");
21
21
  const { createUtilityPolicy } = require("./core/providers/utility-policy");
22
22
  const { createUtilityAgentService } = require("./core/utility-agent");
23
- const { createRecallFilter } = require("./core/recall-filter");
23
+ const { createRecallFilter, FILTER_OUTPUT_SCHEMA } = require("./core/recall-filter");
24
24
  const discoverer = require("./core/recall/discoverer");
25
25
  const metrics = require("./core/recall/metrics");
26
26
  const warmWalker = require("./core/recall/warm-walker");
@@ -89,15 +89,25 @@ async function main() {
89
89
  const capture = path.join(root, `${kind}-filter.json`);
90
90
  const h = harness(kind, {
91
91
  FAKE_AGENT_CAPTURE: capture,
92
- FAKE_AGENT_TEXT: '["pack:keep"]',
92
+ FAKE_AGENT_TEXT: '{"relevant":["pack:keep"]}',
93
93
  });
94
94
  const filter = createRecallFilter({ spawnUtilityAgent: h.utility.spawnUtilityAgent });
95
95
  const kept = await filter.judgeRelevance("keep this project", [
96
96
  { id: "pack:keep", name: "Keep", description: "relevant" },
97
97
  { id: "pack:drop", name: "Drop", description: "unrelated" },
98
98
  ], { provider: kind, context: "same project" });
99
- assert.deepStrictEqual([...kept], ["pack:keep"]);
100
99
  const record = JSON.parse(fs.readFileSync(capture, "utf8"));
100
+ if (kind === "claude") {
101
+ // Claude Code turns the schema into a tool whose input_schema must be an
102
+ // object; the bare array the filter's schema was got a 400 on every call.
103
+ assert.ok(record.argv.includes("--json-schema"), "Claude Code is handed the filter's schema");
104
+ assert.strictEqual(JSON.parse(record.argv[record.argv.indexOf("--json-schema") + 1]).type, "object",
105
+ "Claude Code gets an object-root schema: the API refuses any other tool input_schema");
106
+ } else {
107
+ assert.ok(record.argv.includes("--output-schema"), "the filter's schema passes strict mode, so Codex enforces it");
108
+ }
109
+ assert.ok(kept, `${kind}: the relevance judgement is read (not failed open)`);
110
+ assert.deepStrictEqual([...kept], ["pack:keep"]);
101
111
  assert.strictEqual(record.stdin.present, true);
102
112
  assert.ok(!record.argv.some((arg) => /keep this project/.test(arg)), "recall candidates stay out of argv");
103
113
  if (kind === "claude") {
@@ -108,6 +118,29 @@ async function main() {
108
118
  }
109
119
  }
110
120
 
121
+ // The filter's schema has an object root and the reply is {"relevant": [...]};
122
+ // the prompts ask for that object, and a bare array is still read.
123
+ assert.strictEqual(FILTER_OUTPUT_SCHEMA.type, "object", "the recall filter schema's top-level type is object");
124
+ assert.deepStrictEqual(FILTER_OUTPUT_SCHEMA.required, ["relevant"]);
125
+ assert.strictEqual(FILTER_OUTPUT_SCHEMA.properties.relevant.type, "array");
126
+ for (const [shape, reply] of [
127
+ ["wrapped JSON", { json: { relevant: ["pack:a"] } }],
128
+ ["wrapped text", { text: 'Kept: {"relevant": ["pack:a"]}' }],
129
+ ["bare array JSON", { json: ["pack:a"] }],
130
+ ["bare array text", { text: '["pack:a"]' }],
131
+ ]) {
132
+ let asked = null;
133
+ const kept = await createRecallFilter({
134
+ spawnUtilityAgent: async (prompt, request) => { asked = { prompt, request }; return reply; },
135
+ }).judgeRelevance("alpha question", [{ id: "pack:a", name: "A" }, { id: "pack:b", name: "B" }]);
136
+ assert.ok(kept, `${shape}: the ids are read`);
137
+ assert.deepStrictEqual([...kept], ["pack:a"], `${shape}: only the kept candidate`);
138
+ assert.strictEqual(asked.request.outputSchema, FILTER_OUTPUT_SCHEMA);
139
+ assert.match(asked.request.systemPrompt, /\{"relevant"/, "the system prompt asks for the object");
140
+ assert.match(asked.prompt, /\{"relevant": \["pack:a"\]\}/, "the task prompt asks for the object");
141
+ assert.doesNotMatch(`${asked.request.systemPrompt}\n${asked.prompt}`, /JSON array/, "nothing asks for a bare array any more");
142
+ }
143
+
111
144
  const failedFilter = createRecallFilter({
112
145
  spawnUtilityAgent: async () => { throw new Error("fixture unavailable"); },
113
146
  });
@@ -120,7 +153,7 @@ async function main() {
120
153
  const legacyClaudeCapture = path.join(root, "legacy-claude-filter.json");
121
154
  const legacyClaude = harness("claude", {
122
155
  FAKE_AGENT_CAPTURE: legacyClaudeCapture,
123
- FAKE_AGENT_TEXT: '["pack:a"]',
156
+ FAKE_AGENT_TEXT: '{"relevant":["pack:a"]}',
124
157
  }, { RECALL_FILTER_MODEL: "legacy-claude-filter" });
125
158
  await createRecallFilter({ spawnUtilityAgent: legacyClaude.utility.spawnUtilityAgent })
126
159
  .judgeRelevance("query", [{ id: "pack:a", name: "A" }], { provider: "claude" });
@@ -135,7 +168,7 @@ async function main() {
135
168
  const codexExactCapture = path.join(root, "exact-codex-filter.json");
136
169
  const codexExact = harness("codex", {
137
170
  FAKE_AGENT_CAPTURE: codexExactCapture,
138
- FAKE_AGENT_TEXT: '["pack:a"]',
171
+ FAKE_AGENT_TEXT: '{"relevant":["pack:a"]}',
139
172
  }, {
140
173
  RECALL_FILTER_MODEL: "legacy-must-not-cross",
141
174
  RECALL_FILTER_MODEL_CODEX: "codex-filter-exact",
@@ -442,6 +442,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
442
442
  return next && typeof next.then === "function" ? next : next;
443
443
  },
444
444
  providerStatus: options.providerStatus || (() => ({ availability: { state: "available" }, auth: { state: "authenticated" } })),
445
+ getProjectSessions: options.getProjectSessions || (() => []),
445
446
  fallbackProviders: options.fallbackProviders || [],
446
447
  retryDelayMs: 1000,
447
448
  busyDelayMs: 250,
@@ -615,6 +616,73 @@ async function detachedHistoricalBranchProbe(createSchedulerService) {
615
616
  assert.strictEqual(h.admissions[0].opts.attachSession, false, "historical scheduled work must not replace an unrelated live branch");
616
617
  }
617
618
 
619
+ // The completed-compactions index keeps only the last 20, under a day of
620
+ // token-diet compactions, so the conversation's session history is walked too
621
+ // (7 Oct: the PBX thread's wakeup and watch lost their entry and resumed a
622
+ // 790k session compacted that morning). A recurring job then remembers the
623
+ // session it ran on, so it never depends on old lineage records again.
624
+ async function historyLineageProbe(createSchedulerService) {
625
+ // Newest first, as getProjectSessions returns them.
626
+ const history = [
627
+ { id: "claude-third", provider: "claude", lineage: { type: "compaction", previousSessionId: "claude-second", newSessionId: "claude-third" } },
628
+ { id: "claude-second", provider: "claude", lineage: { type: "compaction", previousSessionId: "claude-before-compact", newSessionId: "claude-second" } },
629
+ { id: "claude-before-compact", provider: "claude" },
630
+ ];
631
+ const lookups = [];
632
+ const cron = (id, extra = {}) => baseJob({ id, kind: "cron", schedule: "0 * * * *", fireAt: undefined, occurrenceId: `occ-${id}`, ...extra });
633
+ const pruned = schedulerHarness(createSchedulerService, [cron("pruned")], {
634
+ state: {
635
+ userId: "fixture-user",
636
+ currentSession: { name: "other", dir: "/workspace/other" },
637
+ settings: { backend: "claude" },
638
+ activeSessions: { alpha: { claude: "claude-third" } },
639
+ completedCompactions: {},
640
+ },
641
+ getProjectSessions: (userId, project, provider) => { lookups.push([userId, project, provider]); return history; },
642
+ outcomes: [{ ...succeededRun(), sessionId: "claude-third" }],
643
+ });
644
+ await pruned.service.fireJob("pruned");
645
+ assert.deepStrictEqual(lookups[0], ["fixture-user", "alpha", "claude"], "the job's own conversation history, under its provider");
646
+ assert.strictEqual(pruned.admissions[0].opts.resumeSessionId, "claude-third", "a pruned index entry no longer strands the job on its old session");
647
+ assert.strictEqual(pruned.admissions[0].opts.attachSession, true, "it reached the thread's live session");
648
+ assert.strictEqual(pruned.jobsStore.get("pruned").sessionId, "claude-third", "a recurring job remembers the session it ran on");
649
+
650
+ // A job on its own branch whose run moved to a compacted session (cold
651
+ // resume) keeps that session; the thread's live session is not its own.
652
+ const branch = schedulerHarness(createSchedulerService, [cron("branch")], {
653
+ state: {
654
+ userId: "fixture-user",
655
+ currentSession: { name: "other", dir: "/workspace/other" },
656
+ settings: { backend: "claude" },
657
+ activeSessions: { alpha: { claude: "unrelated-live-session" } },
658
+ completedCompactions: {},
659
+ },
660
+ outcomes: [{ ...succeededRun(), sessionId: "claude-branch-compacted" }],
661
+ });
662
+ await branch.service.fireJob("branch");
663
+ assert.strictEqual(branch.admissions[0].opts.resumeSessionId, "claude-before-compact");
664
+ assert.strictEqual(branch.admissions[0].opts.attachSession, false);
665
+ assert.strictEqual(branch.jobsStore.get("branch").sessionId, "claude-branch-compacted", "the job follows its compacted conversation");
666
+
667
+ // A run that did not resume the job's conversation (provider switched:
668
+ // fresh, on another provider) leaves the saved session alone; so does a
669
+ // one-shot, which is removed anyway.
670
+ const switched = schedulerHarness(createSchedulerService, [cron("switched")], {
671
+ state: {
672
+ userId: "fixture-user",
673
+ currentSession: { name: "other", dir: "/workspace/other" },
674
+ settings: { backend: "codex" },
675
+ providerSettings: { codex: { model: "codex-current" } },
676
+ activeSessions: { alpha: { claude: "claude-before-compact", codex: "codex-live" } },
677
+ completedCompactions: {},
678
+ },
679
+ outcomes: [{ ...succeededRun(), sessionId: "codex-fresh-thread" }],
680
+ });
681
+ await switched.service.fireJob("switched");
682
+ assert.strictEqual(switched.admissions[0].opts.fresh, true);
683
+ assert.strictEqual(switched.jobsStore.get("switched").sessionId, "claude-before-compact", "a fresh run on another provider is not the job's conversation");
684
+ }
685
+
618
686
  async function busyRestartAndMissedProbe(createSchedulerService) {
619
687
  const busyState = {
620
688
  userId: "fixture-user",
@@ -1633,6 +1701,7 @@ async function main() {
1633
1701
  await duplicateFireProbe(schedulerApi.createSchedulerService);
1634
1702
  await failureAndRetryProbe(schedulerApi.createSchedulerService);
1635
1703
  await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
1704
+ await historyLineageProbe(schedulerApi.createSchedulerService);
1636
1705
  await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
1637
1706
  await threadLaneProbe(schedulerApi.createSchedulerService);
1638
1707
  await durablePendingProbe(schedulerApi.createSchedulerService, jobsApi);
@@ -4,6 +4,10 @@ const os = require("os");
4
4
  const path = require("path");
5
5
  const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-usage-accounting-"));
6
6
  process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
7
+ // These cases pin the classic thresholds (AUTO_COMPACT_TOKENS, Codex's
8
+ // 80%-of-window rule). The token diet, on by default since 3.5.18, has its own
9
+ // test (test-token-diet.js).
10
+ process.env.TOKEN_DIET_CONVERSATIONS = "off";
7
11
  const {
8
12
  usageParts,
9
13
  normalizeCodexUsage,
@@ -386,6 +386,33 @@ async function main() {
386
386
  cwd: root,
387
387
  }), "UTILITY_SCHEMA_INVALID");
388
388
 
389
+ // A schema whose root is not an object never reaches a provider. Claude Code
390
+ // turns it into a tool whose input_schema must be an object (OpenAI strict
391
+ // mode wants one too): the dedup adjudicator's array root got "400 ...
392
+ // input_schema.type: Input should be 'object'" at the API on every call and
393
+ // showed only as a fail-open warning. It fails here, before any spawn.
394
+ const arrayRootCapture = path.join(root, "array-root-capture.json");
395
+ const arrayRootService = createUtilityAgentService({
396
+ registry: registry(providers({ claude: { FAKE_AGENT_CAPTURE: arrayRootCapture, FAKE_AGENT_TEXT: "[]" } })),
397
+ env: { UTILITY_PROVIDER: "claude" },
398
+ });
399
+ for (const outputSchema of [
400
+ { type: "array", items: { type: "string" } },
401
+ { items: { type: "object" } },
402
+ [{ type: "object" }],
403
+ ]) {
404
+ await assert.rejects(arrayRootService.spawnUtilityAgent("judge", {
405
+ purpose: "dedup", tier: "low", systemPrompt: "judge", readOnly: true, outputSchema, cwd: root,
406
+ }), (error) => {
407
+ assert.ok(error instanceof UtilityAgentError);
408
+ assert.strictEqual(error.code, "INVALID_UTILITY_SCHEMA");
409
+ assert.match(error.message, /top-level type "object"/);
410
+ return true;
411
+ }, `${JSON.stringify(outputSchema)} is refused`);
412
+ }
413
+ assert.strictEqual(fs.existsSync(arrayRootCapture), false, "no provider ran for a schema it would refuse");
414
+ assert.deepStrictEqual(arrayRootService.stats(), { activeGlobal: 0, activeByPurpose: {} }, "the refused calls released their slots");
415
+
389
416
  const truncService = createUtilityAgentService({
390
417
  registry: registry(providers({ claude: { FAKE_AGENT_TEXT: "0123456789abcdefghijklmnopqrstuvwxyz" } })),
391
418
  env: { UTILITY_PROVIDER: "claude" },