@jskit-ai/assistant-core 0.2.1 → 0.2.3
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/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +171 -53
- package/src/server/conversation/claudeTurn.js +199 -63
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +272 -10
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +34 -14
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +257 -69
- package/src/server/conversation/providers/claudeDriver.js +7 -5
- package/src/server/conversation/providers/codexDriver.js +23 -16
- package/src/server/conversation/providers/opencodeDriver.js +17 -14
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +93 -6
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +134 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +364 -33
- package/test/claudeTurn.test.js +192 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +52 -1
- package/test/conversationNativeStorage.test.js +284 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- package/test/openCodeRuntime.test.js +25 -78
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os, { tmpdir } from "node:os";
|
|
4
4
|
import path, { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
@@ -29,6 +29,9 @@ test("native command construction preserves tool-free constraints without granti
|
|
|
29
29
|
assert.deepEqual(JSON.parse(coding[coding.indexOf("--mcp-config") + 1]), { mcpServers: { application: { type: "sdk", name: "application" } } });
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// Account queries whose credentials are not the subject read them from nowhere.
|
|
33
|
+
const ABSENT_CLAUDE_CONFIG = join(tmpdir(), "jskit-absent-claude-config");
|
|
34
|
+
|
|
32
35
|
async function fixture(t, body) {
|
|
33
36
|
const directory = await mkdtemp(join(tmpdir(), "jskit-claude-process-"));
|
|
34
37
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
@@ -110,10 +113,10 @@ test("Claude model configuration maps background calls and resets external setti
|
|
|
110
113
|
});
|
|
111
114
|
|
|
112
115
|
test("Claude status reads native JSON and returns only public account details", async () => {
|
|
113
|
-
const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture" }, commandRunner: async (input) => {
|
|
116
|
+
const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture-json" }, commandRunner: async (input) => {
|
|
114
117
|
assert.deepEqual(input.args, ["auth", "status", "--json"]);
|
|
115
118
|
assert.equal(input.timeout, 30_000);
|
|
116
|
-
assert.equal(input.credentialHome.home, "/home/fixture");
|
|
119
|
+
assert.equal(input.credentialHome.home, "/home/fixture-json");
|
|
117
120
|
return { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai", subscriptionType: "max", accessToken: "not-public" }) };
|
|
118
121
|
} });
|
|
119
122
|
assert.equal(status.loggedIn, true);
|
|
@@ -122,8 +125,10 @@ test("Claude status reads native JSON and returns only public account details",
|
|
|
122
125
|
});
|
|
123
126
|
|
|
124
127
|
test("Claude signed-out JSON is normal while failed and malformed status responses stay errors", async () => {
|
|
128
|
+
// Reads of one account share an entry, so each response gets its own account.
|
|
129
|
+
let account = 0;
|
|
125
130
|
const readStatus = (result) => readClaudeCodeAuthStatus({
|
|
126
|
-
credentialHome: { home:
|
|
131
|
+
credentialHome: { home: `/home/fixture-signed-out-${account += 1}` }, commandRunner: async () => result
|
|
127
132
|
});
|
|
128
133
|
const signedOut = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false, authMethod: "none" }) };
|
|
129
134
|
assert.deepEqual(await readStatus(signedOut), {
|
|
@@ -144,7 +149,7 @@ test("Claude signed-out JSON is normal while failed and malformed status respons
|
|
|
144
149
|
}
|
|
145
150
|
});
|
|
146
151
|
|
|
147
|
-
test("Claude status shares
|
|
152
|
+
test("Claude status shares one read per account and invalidates only when its credentials change", async (t) => {
|
|
148
153
|
const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
|
|
149
154
|
t.after(() => rm(home, { recursive: true, force: true }));
|
|
150
155
|
const configRoot = path.join(home, ".claude");
|
|
@@ -163,16 +168,32 @@ test("Claude status shares concurrent reads and invalidates when native account
|
|
|
163
168
|
await readClaudeCodeAuthStatus(input);
|
|
164
169
|
assert.equal(calls, 1);
|
|
165
170
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
await rm(file);
|
|
172
|
-
const before = calls;
|
|
171
|
+
// macOS reuses nothing: file metadata cannot show Keychain changes.
|
|
172
|
+
if (process.platform === "darwin") return;
|
|
173
|
+
// Claude Code rewrites .claude.json during ordinary turns: the account is unchanged.
|
|
174
|
+
for (const file of [path.join(home, ".claude.json"), path.join(configRoot, ".claude.json")]) {
|
|
175
|
+
await writeFile(file, "rewritten-during-a-turn");
|
|
173
176
|
await readClaudeCodeAuthStatus(input);
|
|
174
|
-
assert.equal(calls,
|
|
177
|
+
assert.equal(calls, 1, `${path.basename(path.dirname(file))}/.claude.json does not bust the read`);
|
|
175
178
|
}
|
|
179
|
+
// Another command path and host runner reading the same account share its entry.
|
|
180
|
+
const otherRunner = async () => assert.fail("The same account is read once.");
|
|
181
|
+
await readClaudeCodeAuthStatus({ ...input, command: "/opt/other/bin/claude", commandRunner: otherRunner,
|
|
182
|
+
env: { ...input.env, PATH: "/opt/other/bin", HOME: home } });
|
|
183
|
+
assert.equal(calls, 1);
|
|
184
|
+
// Sign-in writes the credentials and sign-out removes them.
|
|
185
|
+
const credentials = path.join(configRoot, ".credentials.json");
|
|
186
|
+
email = "second@example.test";
|
|
187
|
+
// Deliberately not JSON: the wrapper must never parse native credentials.
|
|
188
|
+
await writeFile(credentials, "native-account-changed");
|
|
189
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).email, email);
|
|
190
|
+
assert.equal(calls, 2);
|
|
191
|
+
await rm(credentials);
|
|
192
|
+
await readClaudeCodeAuthStatus(input);
|
|
193
|
+
assert.equal(calls, 3);
|
|
194
|
+
// A different identity variable is a different account.
|
|
195
|
+
await readClaudeCodeAuthStatus({ ...input, env: { ...input.env, CLAUDE_CODE_OAUTH_TOKEN: "fixture-token" } });
|
|
196
|
+
assert.equal(calls, 4);
|
|
176
197
|
});
|
|
177
198
|
|
|
178
199
|
test("Claude status failures do not poison later reads or share across credential homes", async () => {
|
|
@@ -182,12 +203,79 @@ test("Claude status failures do not poison later reads or share across credentia
|
|
|
182
203
|
return calls === 1 ? { ok: false, error: "Temporary failure" }
|
|
183
204
|
: { ok: true, stdout: JSON.stringify({ loggedIn: false }) };
|
|
184
205
|
};
|
|
185
|
-
const input = { env: {}, credentialHome: { home: "/home/fixture" }, commandRunner };
|
|
206
|
+
const input = { env: {}, credentialHome: { home: "/home/fixture-failure" }, commandRunner };
|
|
186
207
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Temporary failure");
|
|
187
208
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, undefined);
|
|
188
209
|
assert.equal(calls, 2);
|
|
189
|
-
await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/another" } });
|
|
210
|
+
await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/fixture-failure-another" } });
|
|
211
|
+
assert.equal(calls, 3);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a Claude status answer holds until the credentials change, even across its own .claude.json rewrite", {
|
|
215
|
+
skip: process.platform === "darwin"
|
|
216
|
+
}, async (t) => {
|
|
217
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
|
|
218
|
+
t.after(() => rm(home, { recursive: true, force: true }));
|
|
219
|
+
const realNow = Date.now;
|
|
220
|
+
let now = realNow();
|
|
221
|
+
Date.now = () => now;
|
|
222
|
+
t.after(() => { Date.now = realNow; });
|
|
223
|
+
const configRoot = path.join(home, ".claude");
|
|
224
|
+
await mkdir(configRoot);
|
|
225
|
+
const credentials = path.join(configRoot, ".credentials.json");
|
|
226
|
+
await writeFile(credentials, "signed-in");
|
|
227
|
+
const signedIn = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai" }) };
|
|
228
|
+
let calls = 0;
|
|
229
|
+
let result = signedIn;
|
|
230
|
+
const input = { env: { CLAUDE_CONFIG_DIR: configRoot }, credentialHome: { home }, commandRunner: async () => {
|
|
231
|
+
calls += 1;
|
|
232
|
+
// Each native status run rewrites ~/.claude.json a few seconds later.
|
|
233
|
+
await writeFile(path.join(home, ".claude.json"), `status-run-${calls}`);
|
|
234
|
+
return result;
|
|
235
|
+
} };
|
|
236
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
|
|
237
|
+
now += 10 * 60_000;
|
|
238
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
|
|
239
|
+
assert.equal(calls, 1, "the answer is not read again on a timer or after the run's own rewrite");
|
|
240
|
+
// Sign-out removes the credentials: Claude is asked again at once.
|
|
241
|
+
await rm(credentials);
|
|
242
|
+
result = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false }) };
|
|
243
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, false);
|
|
244
|
+
assert.equal(calls, 2);
|
|
245
|
+
// A read the host timed out is reported and not asked again at once.
|
|
246
|
+
await writeFile(credentials, "signed-in-again");
|
|
247
|
+
result = { ok: false, exitCode: 1, timedOut: true, error: "Command timed out." };
|
|
248
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
|
|
249
|
+
now += 29_000;
|
|
250
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
|
|
190
251
|
assert.equal(calls, 3);
|
|
252
|
+
// A read that ran and failed is reported, and asked again next time.
|
|
253
|
+
now += 2_000;
|
|
254
|
+
result = { ok: false, exitCode: 2, error: "Claude Code crashed." };
|
|
255
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Claude Code crashed.");
|
|
256
|
+
result = signedIn;
|
|
257
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
|
|
258
|
+
assert.equal(calls, 5);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("an unfinished Claude status read is reported without probing again at once", {
|
|
262
|
+
skip: process.platform === "darwin"
|
|
263
|
+
}, async (t) => {
|
|
264
|
+
const realNow = Date.now;
|
|
265
|
+
let now = realNow();
|
|
266
|
+
Date.now = () => now;
|
|
267
|
+
t.after(() => { Date.now = realNow; });
|
|
268
|
+
let calls = 0;
|
|
269
|
+
let result = { ok: false, exitCode: 1, retryable: true, error: "Memory-pressure spike." };
|
|
270
|
+
const input = { env: {}, credentialHome: { home: "/home/fixture-busy" }, commandRunner: async () => { calls += 1; return result; } };
|
|
271
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
|
|
272
|
+
now += 29_000;
|
|
273
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
|
|
274
|
+
assert.equal(calls, 1);
|
|
275
|
+
now += 2_000;
|
|
276
|
+
result = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test" }) };
|
|
277
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
|
|
278
|
+
assert.equal(calls, 2);
|
|
191
279
|
});
|
|
192
280
|
|
|
193
281
|
test("Claude plan usage retains real windows and never invents an allowance after reset", () => {
|
|
@@ -469,22 +557,27 @@ test("Claude account queries isolate concurrent identities and deduplicate each
|
|
|
469
557
|
for (const method of ["readCatalogue", "readPlanUsage"]) {
|
|
470
558
|
const pending = new Map(["first", "second"].map(id => [id, Promise.withResolvers()]));
|
|
471
559
|
const calls = [];
|
|
560
|
+
const native = id => ({ command: process.execPath, async stop() { return { scopeEmpty: true }; },
|
|
561
|
+
get initialization() { calls.push(id); return pending.get(id).promise; },
|
|
562
|
+
client: { request() { calls.push(id); return pending.get(id).promise; } } });
|
|
563
|
+
// Plan usage borrows each account's conversation; the model list starts its own Claude.
|
|
564
|
+
const starting = ["first", "second"];
|
|
472
565
|
const queries = createClaudeAccountQueries({
|
|
473
|
-
accountIdentity: async context => context.id,
|
|
474
|
-
processes: () =>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
566
|
+
accountIdentity: async context => context.id, configRoot: ABSENT_CLAUDE_CONFIG,
|
|
567
|
+
processes: () => method === "readPlanUsage" ? starting.map(id => ({ accountIdentity: id, process: native(id) })) : [],
|
|
568
|
+
createProcess() {
|
|
569
|
+
if (method === "readPlanUsage") throw new Error("An owned account process should be reused.");
|
|
570
|
+
return native(starting.shift());
|
|
571
|
+
}
|
|
479
572
|
});
|
|
480
573
|
const first = queries[method]({ id: "first" });
|
|
481
574
|
const duplicate = queries[method]({ id: "first" });
|
|
482
575
|
const second = queries[method]({ id: "second" });
|
|
483
576
|
// Finish in reverse order to expose cross-account sharing and cache races.
|
|
484
577
|
pending.get("second").resolve({ models: [{ value: "second" }], rate_limits_available: true,
|
|
485
|
-
rate_limits: { five_hour: { utilization: 20 } } });
|
|
578
|
+
rate_limits: { five_hour: { utilization: 20 }, limits: [] } });
|
|
486
579
|
pending.get("first").resolve({ models: [{ value: "first" }], rate_limits_available: true,
|
|
487
|
-
rate_limits: { five_hour: { utilization: 80 } } });
|
|
580
|
+
rate_limits: { five_hour: { utilization: 80 }, limits: [] } });
|
|
488
581
|
const [a, same, b] = await Promise.all([first, duplicate, second]);
|
|
489
582
|
assert.deepEqual(calls, ["first", "second"], method);
|
|
490
583
|
assert.deepEqual(same, a);
|
|
@@ -501,15 +594,247 @@ test("Claude account queries isolate concurrent identities and deduplicate each
|
|
|
501
594
|
}
|
|
502
595
|
});
|
|
503
596
|
|
|
504
|
-
test("Claude
|
|
597
|
+
test("Claude plan usage shows only an answer from the usage endpoint as current", async () => {
|
|
598
|
+
let answer;
|
|
599
|
+
const queries = createClaudeAccountQueries({
|
|
600
|
+
accountIdentity: async () => "account",
|
|
601
|
+
processes: () => [{ accountIdentity: "account", process: { client: { request: async () => answer } } }],
|
|
602
|
+
createProcess() { throw new Error("No temporary Claude can start here."); }
|
|
603
|
+
});
|
|
604
|
+
const resets_at = new Date(Date.now() + 86_400_000).toISOString();
|
|
605
|
+
// Shape of Claude Code's answer while its endpoint read fails or is held: the
|
|
606
|
+
// window readings of its own last API response, which can be hours old, without
|
|
607
|
+
// the endpoint's `limits` list and without any age.
|
|
608
|
+
const seeded = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at } } };
|
|
609
|
+
answer = seeded;
|
|
610
|
+
assert.deepEqual(await queries.readPlanUsage({}), { status: "unavailable", windows: [] });
|
|
611
|
+
queries.invalidateUsage();
|
|
612
|
+
answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 23, resets_at }, limits: [] } };
|
|
613
|
+
const verified = await queries.readPlanUsage({});
|
|
614
|
+
assert.equal(verified.status, "available");
|
|
615
|
+
assert.equal(verified.windows[0].remainingPercent, 77);
|
|
616
|
+
assert.ok(verified.currentForMs > 0 && verified.currentForMs <= 420_000);
|
|
617
|
+
queries.invalidateUsage();
|
|
618
|
+
answer = seeded;
|
|
619
|
+
const kept = await queries.readPlanUsage({});
|
|
620
|
+
assert.equal(kept.windows[0].remainingPercent, 77, "an unverified answer replaced the endpoint reading");
|
|
621
|
+
assert.equal(kept.checkedAt, verified.checkedAt);
|
|
622
|
+
await queries.invalidate();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("Claude plan usage asks a temporary Claude when a live process cannot prove its answer", async (t) => {
|
|
626
|
+
const realNow = Date.now;
|
|
627
|
+
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
628
|
+
Date.now = () => now;
|
|
629
|
+
t.after(() => { Date.now = realNow; });
|
|
630
|
+
const resets_at = "2026-11-04T05:00:00Z";
|
|
631
|
+
const starts = [];
|
|
632
|
+
let stops = 0;
|
|
633
|
+
let liveReads = 0;
|
|
634
|
+
let startRefused = false;
|
|
635
|
+
const queries = createClaudeAccountQueries({
|
|
636
|
+
accountIdentity: async () => "account",
|
|
637
|
+
// An idle conversation whose own endpoint read is held answers from old headers.
|
|
638
|
+
processes: () => [{ accountIdentity: "account", process: { client: { request: async () => {
|
|
639
|
+
liveReads++;
|
|
640
|
+
return { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at } } };
|
|
641
|
+
} } } }],
|
|
642
|
+
createProcess(input) {
|
|
643
|
+
if (startRefused) throw new Error("Memory is low.");
|
|
644
|
+
starts.push(input.purpose);
|
|
645
|
+
return { client: { request: async () => ({ rate_limits_available: true,
|
|
646
|
+
rate_limits: { seven_day: { utilization: 23, resets_at }, limits: [] } }) },
|
|
647
|
+
async stop() { stops++; return { scopeEmpty: true }; } };
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
const first = await queries.readPlanUsage({});
|
|
651
|
+
assert.equal(first.status, "available");
|
|
652
|
+
assert.equal(first.windows[0].remainingPercent, 77);
|
|
653
|
+
assert.deepEqual(starts, ["usage"]);
|
|
654
|
+
for (let poll = 1; poll <= 4; poll++) {
|
|
655
|
+
now += 60_000;
|
|
656
|
+
assert.equal((await queries.readPlanUsage({})).windows[0].remainingPercent, 77);
|
|
657
|
+
}
|
|
658
|
+
assert.equal(liveReads, 5);
|
|
659
|
+
assert.deepEqual(starts, ["usage"], "a fallback started Claude inside the idle interval");
|
|
660
|
+
now += 61_000;
|
|
661
|
+
await queries.readPlanUsage({});
|
|
662
|
+
assert.deepEqual(starts, ["usage", "usage"]);
|
|
663
|
+
assert.equal(stops, starts.length, "a temporary usage process outlived its read");
|
|
664
|
+
// A refused fallback keeps the last verified reading rather than failing the read.
|
|
665
|
+
now += 301_000;
|
|
666
|
+
startRefused = true;
|
|
667
|
+
const kept = await queries.readPlanUsage({});
|
|
668
|
+
assert.equal(kept.windows[0].remainingPercent, 77);
|
|
669
|
+
await queries.invalidate();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("Claude plan usage starts one temporary Claude per idle interval and marks an old reading stale", async (t) => {
|
|
673
|
+
const realNow = Date.now;
|
|
674
|
+
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
675
|
+
Date.now = () => now;
|
|
676
|
+
t.after(() => { Date.now = realNow; });
|
|
677
|
+
const starts = [];
|
|
678
|
+
let stops = 0;
|
|
679
|
+
let answer = { rate_limits_available: true, rate_limits: {
|
|
680
|
+
seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
|
|
681
|
+
const queries = createClaudeAccountQueries({
|
|
682
|
+
accountIdentity: async () => "account",
|
|
683
|
+
createProcess(input) {
|
|
684
|
+
starts.push(input.purpose);
|
|
685
|
+
return { client: { request: async () => answer }, async stop() { stops++; return { scopeEmpty: true }; } };
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
const first = await queries.readPlanUsage({});
|
|
689
|
+
assert.equal(first.status, "available");
|
|
690
|
+
assert.equal(first.windows[0].remainingPercent, 77);
|
|
691
|
+
// Pages polling every minute share the account's reading.
|
|
692
|
+
for (let poll = 1; poll <= 4; poll++) {
|
|
693
|
+
now += 60_000;
|
|
694
|
+
assert.equal((await queries.readPlanUsage({})).status, "available");
|
|
695
|
+
}
|
|
696
|
+
assert.deepEqual(starts, ["usage"]);
|
|
697
|
+
now += 61_000;
|
|
698
|
+
answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at: "2026-11-04T05:00:00Z" } } };
|
|
699
|
+
const unverified = await queries.readPlanUsage({});
|
|
700
|
+
assert.deepEqual(starts, ["usage", "usage"]);
|
|
701
|
+
assert.equal(unverified.windows[0].remainingPercent, 77);
|
|
702
|
+
now += 120_000;
|
|
703
|
+
const stale = await queries.readPlanUsage({});
|
|
704
|
+
assert.equal(stale.status, "stale");
|
|
705
|
+
assert.equal(stale.windows[0].remainingPercent, 77);
|
|
706
|
+
assert.equal(stale.checkedAt, first.checkedAt);
|
|
707
|
+
assert.equal("currentForMs" in stale, false);
|
|
708
|
+
assert.deepEqual(starts, ["usage", "usage"], "a poll inside the idle interval started Claude");
|
|
709
|
+
assert.equal(stops, starts.length, "a temporary usage process outlived its read");
|
|
710
|
+
await queries.invalidate();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("Claude plan usage starts one temporary Claude per account for every owner polling it", async (t) => {
|
|
714
|
+
const realNow = Date.now;
|
|
715
|
+
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
716
|
+
Date.now = () => now;
|
|
717
|
+
t.after(() => { Date.now = realNow; });
|
|
718
|
+
const starts = [];
|
|
719
|
+
let stops = 0;
|
|
720
|
+
// Three account-query owners in one server process, none with a live conversation.
|
|
721
|
+
const owners = ["first", "second", "third"].map(owner => createClaudeAccountQueries({
|
|
722
|
+
accountIdentity: async context => context.account,
|
|
723
|
+
createProcess(input) {
|
|
724
|
+
starts.push([owner, input.purpose]);
|
|
725
|
+
return { client: { request: async () => ({ rate_limits_available: true, rate_limits: {
|
|
726
|
+
seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } }) },
|
|
727
|
+
async stop() { stops++; return { scopeEmpty: true }; } };
|
|
728
|
+
}
|
|
729
|
+
}));
|
|
730
|
+
t.after(() => Promise.all(owners.map(queries => queries.invalidate())));
|
|
731
|
+
const account = { account: "plan-usage-shared-across-owners" };
|
|
732
|
+
// Requests that arrive while the account is read share that one read.
|
|
733
|
+
const concurrent = await Promise.all(owners.map(queries => queries.readPlanUsage(account)));
|
|
734
|
+
assert.deepEqual(starts, [["first", "usage"]]);
|
|
735
|
+
for (const reading of concurrent) assert.deepEqual(reading, concurrent[0]);
|
|
736
|
+
assert.equal(concurrent[0].windows[0].remainingPercent, 77);
|
|
737
|
+
// An hour of every owner polling once a minute.
|
|
738
|
+
for (let minute = 1; minute < 60; minute++) {
|
|
739
|
+
now += 60_000;
|
|
740
|
+
for (const queries of owners) assert.equal((await queries.readPlanUsage(account)).status, "available");
|
|
741
|
+
}
|
|
742
|
+
assert.equal(starts.length, 12, "the account started more than one temporary Claude per idle interval");
|
|
743
|
+
assert.equal(stops, starts.length, "a temporary usage process outlived its read");
|
|
744
|
+
// Another account is read on its own.
|
|
745
|
+
await owners[1].readPlanUsage({ account: "plan-usage-another-account" });
|
|
746
|
+
assert.equal(starts.length, 13);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("Claude plan usage reuses another owner's live reading instead of starting a temporary Claude", async (t) => {
|
|
750
|
+
const realNow = Date.now;
|
|
751
|
+
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
752
|
+
Date.now = () => now;
|
|
753
|
+
t.after(() => { Date.now = realNow; });
|
|
754
|
+
const account = "plan-usage-live-in-another-owner";
|
|
755
|
+
const answer = { rate_limits_available: true, rate_limits: {
|
|
756
|
+
seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
|
|
757
|
+
let liveReads = 0;
|
|
758
|
+
const starts = [];
|
|
759
|
+
const createProcess = () => {
|
|
760
|
+
starts.push("usage");
|
|
761
|
+
return { client: { request: async () => answer }, async stop() { return { scopeEmpty: true }; } };
|
|
762
|
+
};
|
|
763
|
+
const withConversation = createClaudeAccountQueries({ accountIdentity: async () => account, createProcess,
|
|
764
|
+
processes: () => [{ accountIdentity: account, process: { client: { request: async () => { liveReads++; return answer; } } } }] });
|
|
765
|
+
const idle = createClaudeAccountQueries({ accountIdentity: async () => account, createProcess });
|
|
766
|
+
t.after(() => Promise.all([withConversation.invalidate(), idle.invalidate()]));
|
|
767
|
+
const live = await withConversation.readPlanUsage({});
|
|
768
|
+
assert.equal(liveReads, 1);
|
|
769
|
+
// Only the idle owner's page stays open: the account's verified reading serves it
|
|
770
|
+
// until that reading is an idle interval old.
|
|
771
|
+
for (let minute = 1; minute <= 4; minute++) {
|
|
772
|
+
now += 60_000;
|
|
773
|
+
const reading = await idle.readPlanUsage({});
|
|
774
|
+
assert.equal(reading.checkedAt, live.checkedAt);
|
|
775
|
+
assert.equal(reading.status, "available");
|
|
776
|
+
}
|
|
777
|
+
assert.deepEqual(starts, [], "a temporary Claude started while the account had a recent verified reading");
|
|
778
|
+
now += 60_000;
|
|
779
|
+
await idle.readPlanUsage({});
|
|
780
|
+
assert.deepEqual(starts, ["usage"]);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("Claude model list starts a Claude only after its account, credentials or Claude Code changed", async (t) => {
|
|
784
|
+
const root = await mkdtemp(join(tmpdir(), "jskit-claude-catalogue-"));
|
|
785
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
786
|
+
const configRoot = join(root, ".claude");
|
|
787
|
+
await mkdir(configRoot);
|
|
788
|
+
// Sign-in, a token refresh and an update each replace the file at a stable path.
|
|
789
|
+
const replace = async (file, contents) => {
|
|
790
|
+
await writeFile(`${file}.next`, contents);
|
|
791
|
+
await rename(`${file}.next`, file);
|
|
792
|
+
};
|
|
793
|
+
const credentials = join(configRoot, ".credentials.json");
|
|
794
|
+
const command = join(root, "claude");
|
|
795
|
+
await replace(credentials, "signed in");
|
|
796
|
+
await replace(command, "2.1.1");
|
|
797
|
+
const realNow = Date.now;
|
|
798
|
+
let now = realNow();
|
|
799
|
+
Date.now = () => now;
|
|
800
|
+
t.after(() => { Date.now = realNow; });
|
|
801
|
+
let account = "account";
|
|
802
|
+
const starts = [];
|
|
803
|
+
const queries = createClaudeAccountQueries({ accountIdentity: async () => account, configRoot,
|
|
804
|
+
// A conversation that started before every change below.
|
|
805
|
+
processes: () => [{ accountIdentity: account, process: { command, initialization: { models: [{ value: "live" }] } } }],
|
|
806
|
+
createProcess({ purpose }) {
|
|
807
|
+
starts.push(purpose);
|
|
808
|
+
return { command, initialization: { models: [{ value: `read-${starts.length}` }] },
|
|
809
|
+
async stop() { return { scopeEmpty: true }; } };
|
|
810
|
+
} });
|
|
811
|
+
const model = async () => (await queries.readCatalogue({})).models[0].value;
|
|
812
|
+
// Reconnects, tab returns and closed sessions ask about four times an hour.
|
|
813
|
+
const askForADay = async (expected) => {
|
|
814
|
+
for (let read = 0; read < 96; read++, now += 15 * 60_000) assert.equal(await model(), expected);
|
|
815
|
+
};
|
|
816
|
+
await askForADay("read-1");
|
|
817
|
+
assert.deepEqual(starts, ["catalogue"]);
|
|
818
|
+
await replace(credentials, "refreshed");
|
|
819
|
+
await askForADay("read-2");
|
|
820
|
+
await replace(command, "2.1.2");
|
|
821
|
+
await askForADay("read-3");
|
|
822
|
+
account = "other account";
|
|
823
|
+
await askForADay("read-4");
|
|
824
|
+
assert.deepEqual(starts, ["catalogue", "catalogue", "catalogue", "catalogue"]);
|
|
825
|
+
await queries.invalidate();
|
|
826
|
+
assert.equal(await model(), "read-5");
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("Claude account queries label temporary startup and reuse each account's recent reading", async () => {
|
|
505
830
|
const starts = [];
|
|
506
831
|
let stops = 0;
|
|
507
832
|
const queries = createClaudeAccountQueries({
|
|
508
|
-
accountIdentity: async () => "private-account-identity",
|
|
833
|
+
accountIdentity: async () => "private-account-identity", configRoot: ABSENT_CLAUDE_CONFIG,
|
|
509
834
|
createProcess(input) {
|
|
510
835
|
starts.push(input);
|
|
511
836
|
return {
|
|
512
|
-
initialization: { models: [] },
|
|
837
|
+
initialization: { models: [] }, command: process.execPath,
|
|
513
838
|
client: { request: async () => ({ rate_limits_available: false }) },
|
|
514
839
|
async stop() { stops++; return { scopeEmpty: true }; }
|
|
515
840
|
};
|
|
@@ -526,31 +851,33 @@ test("Claude account queries label temporary startup without changing cached pro
|
|
|
526
851
|
await queries.invalidate();
|
|
527
852
|
});
|
|
528
853
|
|
|
529
|
-
test("account invalidation cancels held startup and
|
|
530
|
-
for (const phase of ["startup", "owned
|
|
854
|
+
test("account invalidation cancels held startup and reads without stopping a borrowed conversation", async () => {
|
|
855
|
+
for (const phase of ["catalogue startup", "owned catalogue", "borrowed usage"]) {
|
|
531
856
|
const started = Promise.withResolvers();
|
|
532
857
|
let stops = 0;
|
|
533
858
|
const native = { client: { request() { started.resolve(); return new Promise(() => {}); } },
|
|
859
|
+
get initialization() { started.resolve(); return new Promise(() => {}); },
|
|
534
860
|
async stop() { stops++; return { scopeEmpty: true }; } };
|
|
535
861
|
const queries = createClaudeAccountQueries({ accountIdentity: async () => "account",
|
|
536
862
|
processes: () => phase === "borrowed usage" ? [{ accountIdentity: "account", process: native }] : [],
|
|
537
863
|
createProcess({ signal }) {
|
|
538
|
-
if (phase !== "startup") return native;
|
|
864
|
+
if (phase !== "catalogue startup") return native;
|
|
539
865
|
started.resolve();
|
|
540
866
|
return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
|
|
541
867
|
}
|
|
542
868
|
});
|
|
543
|
-
const pending = queries.readPlanUsage({});
|
|
869
|
+
const pending = phase === "borrowed usage" ? queries.readPlanUsage({}) : queries.readCatalogue({});
|
|
544
870
|
const rejected = assert.rejects(pending, /cancelled for reconnection/);
|
|
545
871
|
await started.promise;
|
|
546
872
|
await queries.invalidate();
|
|
547
873
|
await rejected;
|
|
548
|
-
assert.equal(stops, phase === "owned
|
|
874
|
+
assert.equal(stops, phase === "owned catalogue" ? 1 : 0, phase);
|
|
549
875
|
}
|
|
550
876
|
});
|
|
551
877
|
|
|
552
878
|
for (const method of ["readCatalogue", "readPlanUsage"]) {
|
|
553
|
-
|
|
879
|
+
// Plan usage borrows a conversation's process, so it has no cleanup to retain.
|
|
880
|
+
for (const phase of method === "readPlanUsage" ? ["initial identity", "final identity"] : ["initial identity", "final identity", "retained cleanup"]) {
|
|
554
881
|
test(`${method} invalidation fences ${phase} before launching or caching a stale query`, { timeout: 2_000 }, async () => {
|
|
555
882
|
const entered = Promise.withResolvers();
|
|
556
883
|
const release = Promise.withResolvers();
|
|
@@ -566,10 +893,14 @@ for (const method of ["readCatalogue", "readPlanUsage"]) {
|
|
|
566
893
|
}
|
|
567
894
|
return "account";
|
|
568
895
|
},
|
|
896
|
+
// Usage reads count as native reads of the borrowed conversation process.
|
|
897
|
+
processes: () => method === "readPlanUsage" ? [{ accountIdentity: "account", process: { client: {
|
|
898
|
+
request: async () => { starts++; return { rate_limits_available: true, rate_limits: { limits: [] } }; } } } }] : [],
|
|
899
|
+
configRoot: ABSENT_CLAUDE_CONFIG,
|
|
569
900
|
createProcess() {
|
|
570
901
|
starts++;
|
|
571
902
|
return {
|
|
572
|
-
initialization: { models: [{ value: `model-${starts}` }] },
|
|
903
|
+
initialization: { models: [{ value: `model-${starts}` }] }, command: process.execPath,
|
|
573
904
|
client: { request: async () => ({ rate_limits_available: true, rate_limits: {} }) },
|
|
574
905
|
async stop() {
|
|
575
906
|
stops++;
|