@inetafrica/open-claudia 3.5.17 → 3.5.18
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/.env.example +10 -0
- package/CHANGELOG.md +18 -0
- package/README.md +3 -0
- package/bot.js +7 -0
- package/core/config.js +9 -6
- package/core/handlers.js +56 -7
- package/core/limit-pickup.js +313 -0
- package/core/providers/claude-events.js +70 -0
- package/core/providers/events.js +4 -1
- package/core/router.js +10 -0
- package/core/run-context.js +3 -0
- package/core/runner.js +186 -13
- package/core/scheduler.js +33 -2
- package/core/state.js +91 -0
- package/core/turn-journal.js +2 -0
- package/core/usage-limits.js +321 -0
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +22 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-events.js +85 -0
- package/test-provider-language.js +9 -1
- package/test-provider-scheduler.js +69 -0
- package/test-usage-accounting.js +4 -0
package/test-provider-events.js
CHANGED
|
@@ -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,15 @@ 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
|
-
|
|
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
|
+
assert.strictEqual(pkg.version, "3.5.18", "release version must remain unchanged without explicit approval");
|
|
582
590
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
583
591
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
584
592
|
}
|
|
@@ -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);
|
package/test-usage-accounting.js
CHANGED
|
@@ -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,
|