@jskit-ai/assistant-core 0.2.0 → 0.2.2

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.
@@ -320,7 +320,7 @@ export async function inspectPersistentConversationAdmission(options) {
320
320
  if (!record.providerConversationId) return { admission: "unknown", turnId: "" };
321
321
  const state = requireSuccess(await operations.readConversation(sessionId,
322
322
  inputFor(record, { messageId: input.messageId }), options.context));
323
- return { admission: state.admitted ? "accepted" : "unknown", turnId: state.runId };
323
+ return { admission: state.admitted ? "accepted" : state.notAdmitted ? "not-admitted" : "unknown", turnId: state.runId };
324
324
  }
325
325
 
326
326
  export async function preparePersistentConversationChangeover(options) {
@@ -353,7 +353,10 @@ export async function inspectPersistentConversationDelivery(options) {
353
353
  const result = await createConversationChangeover({ ...continuity, agent: {
354
354
  inspectMessageAdmission: request => inspectMessageAdmission(sessionId, request, context),
355
355
  sendMessage() { throw new Error("Receipt inspection cannot submit another native prompt."); }
356
- } }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata });
356
+ } }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata, inspectOnly: true });
357
+ if (result?.notSent) return { status: "not-sent", messageId: input.messageId,
358
+ error: "This message was not sent. Review it before sending a new message.",
359
+ messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
357
360
  accepted = result?.delivered === true;
358
361
  } else if (!accepted) {
359
362
  if (row && row.user?.receipt !== false) return { status: "accepted", messageId: input.messageId, turnId: row.turnId, duplicate: true };
@@ -1892,6 +1892,11 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1892
1892
  return { saved: true };
1893
1893
  }
1894
1894
 
1895
+ function notSentReceipt(messageId) {
1896
+ return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
1897
+ messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
1898
+ }
1899
+
1895
1900
  async function recoverDelivery(entry, context, messageId, { observation: suppliedObservation, allowActive = false } = {}) {
1896
1901
  await access(context, entry.id, "inspectDelivery", entry);
1897
1902
  if (entry.active && !allowActive) throw failure("Wait for the current submission before checking delivery.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForSubmission" });
@@ -1920,8 +1925,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1920
1925
  ["codex", "claude", "opencode"].includes(segment.engine) && segment.request.attempted === false && !segment.request.inspectionOnly &&
1921
1926
  isDeepStrictEqual(segment.request, savedRequests[0].request))) {
1922
1927
  await access(context, entry.id, "inspectDelivery", entry);
1923
- return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
1924
- messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
1928
+ return notSentReceipt(messageId);
1925
1929
  }
1926
1930
  const request = existing ? submittedMessage(existing) : metadata.runtime.request;
1927
1931
  if (!request || request.messageId !== messageId || !entry.provider.inspectAdmission) return receipt || { status: "unknown", messageId };
@@ -1930,6 +1934,17 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1930
1934
  const observation = suppliedObservation || await entry.provider.inspectAdmission({ ...request, nativeTurnId: existing?.metadata?.runtime?.nativeTurnId,
1931
1935
  context, configuration: metadata.runtime.configuration });
1932
1936
  await access(context, entry.id, "inspectDelivery", entry);
1937
+ // The engine proved it never admitted this saved request. Record that it
1938
+ // was not sent, as a definite rejection would, so Retry may send it.
1939
+ if (!existing && observation.notAdmitted && pending && !pending.inspectionOnly && !entry.active) {
1940
+ await storage.write(entry.id, async transaction => {
1941
+ const current = await transaction.readMetadata();
1942
+ if (current.runtime.request?.messageId !== messageId) return;
1943
+ current.runtime.request.attempted = false;
1944
+ await transaction.writeMetadata(current);
1945
+ });
1946
+ return notSentReceipt(messageId);
1947
+ }
1933
1948
  if (!observation.accepted) return { ...(receipt || { status: "unknown", messageId }),
1934
1949
  ...(observation.recoveryLimitation ? { recoveryLimitation: observation.recoveryLimitation } : {}) };
1935
1950
  if (entry.driver.canonicalTranscript) {
@@ -2046,7 +2061,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2046
2061
  throw new Error("Receipt inspection cannot submit another native prompt.");
2047
2062
  });
2048
2063
  const delivered = await sender.send({ engineId: entry.engine,
2049
- messages: await entry.conversation.transcript.history(), input: { messageId } });
2064
+ messages: await entry.conversation.transcript.history(), input: { messageId }, inspectOnly: true });
2065
+ if (delivered?.notSent) return notSentReceipt(messageId);
2050
2066
  if (!delivered?.delivered) throw deliveryFailure(delivered);
2051
2067
  }
2052
2068
  const row = (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId);
@@ -2057,7 +2073,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2057
2073
  await access(context, entry.id, "inspectDelivery", entry);
2058
2074
  // Native-only acceptance is evidence of delivery, not permission to
2059
2075
  // manufacture an authored row or use a native turn ID as its receipt.
2060
- return observation.accepted ? { status: "accepted", messageId, duplicate: true } : { status: "unknown", messageId };
2076
+ if (observation.accepted) return { status: "accepted", messageId, duplicate: true };
2077
+ return observation.notAdmitted && !entry.active ? notSentReceipt(messageId) : { status: "unknown", messageId };
2061
2078
  }
2062
2079
  const result = await recoverDelivery(entry, context, messageId);
2063
2080
  if (result.status !== "accepted" || entry.engine === "api") return result;
@@ -26,6 +26,31 @@ async function settleReaderAt(body, scrollTop) {
26
26
  }, scrollTop)).toBe(true);
27
27
  }
28
28
 
29
+ // Prepended history starts at those estimates and materializes on a later frame,
30
+ // when scroll anchoring moves scrollTop. Read positions only once that settles.
31
+ async function settleLayout(body) {
32
+ await expect.poll(() => body.evaluate(async element => {
33
+ const [top, height] = [element.scrollTop, element.scrollHeight];
34
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
35
+ return element.scrollTop === top && element.scrollHeight === height;
36
+ })).toBe(true);
37
+ }
38
+
39
+ // A gesture's scroll ends after its last input event; a touch drag ends with
40
+ // its fling. A scrollend still queued from earlier scrolling must not count, or
41
+ // the reader is measured while the fling keeps moving it.
42
+ async function scrollGesture(body, lastEventType, perform) {
43
+ await body.evaluate((element, type) => {
44
+ element.gestureScrollEnded = new Promise(resolve => {
45
+ element.addEventListener(type, () => {
46
+ element.addEventListener("scrollend", () => resolve(), { once: true });
47
+ }, { once: true, passive: true });
48
+ });
49
+ }, lastEventType);
50
+ await perform();
51
+ await body.evaluate(element => element.gestureScrollEnded);
52
+ }
53
+
29
54
  const VIEWPORTS = Object.freeze([
30
55
  Object.freeze({ name: "phone", width: 390, height: 844 }),
31
56
  Object.freeze({ name: "compact", width: 800, height: 900 }),
@@ -266,10 +291,12 @@ test("conversation scrolling preserves history anchors and resets expansion on s
266
291
  await page.getByRole("button", { name: "Read more", exact: true }).click();
267
292
  await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
268
293
  const original = page.getByText("Conversation line 1:", { exact: false }).first();
294
+ await settleLayout(body);
269
295
  const originalTop = (await original.boundingBox()).y;
270
296
  // Measure history anchoring without Playwright first scrolling to the button.
271
297
  await page.getByRole("button", { name: "Load older messages", exact: true }).evaluate(element => element.click());
272
- await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
298
+ await settleLayout(body);
299
+ expect(Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
273
300
  const before = await body.evaluate((element) => element.scrollTop);
274
301
  await page.getByRole("button", { name: "Append reply", exact: true }).click();
275
302
  await expect(page.getByText("New reply.", { exact: false })).toBeAttached();
@@ -370,20 +397,18 @@ test("upward scrolling loads older history once and preserves retry and selectio
370
397
  const cdp = await page.context().newCDPSession(page);
371
398
  const x = bounds.x + bounds.width / 2;
372
399
  const y = bounds.y + 40;
373
- const stopped = body.evaluate(element => new Promise(resolve => {
374
- element.addEventListener("scrollend", () => resolve(), { once: true });
375
- }));
376
- await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
377
- for (let offset = 20; offset <= 380; offset += 20) {
378
- await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
379
- await page.evaluate(() => new Promise(requestAnimationFrame));
380
- }
381
- await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
382
- await stopped;
400
+ await scrollGesture(body, "touchend", async () => {
401
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
402
+ for (let offset = 20; offset <= 380; offset += 20) {
403
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
404
+ await page.evaluate(() => new Promise(requestAnimationFrame));
405
+ }
406
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
407
+ });
383
408
  await cdp.detach();
384
409
  } else {
385
410
  await body.hover();
386
- await page.mouse.wheel(0, -380);
411
+ await scrollGesture(body, "wheel", () => page.mouse.wheel(0, -380));
387
412
  }
388
413
  const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
389
414
  await expect(pending).toBeDisabled();
@@ -394,10 +419,12 @@ test("upward scrolling loads older history once and preserves retry and selectio
394
419
  element.dispatchEvent(new Event("scroll"));
395
420
  });
396
421
  const original = page.getByText("Conversation line 1:", { exact: false }).first();
422
+ await settleLayout(body);
397
423
  const originalTop = (await original.boundingBox()).y;
398
424
  await external("Complete history load");
399
425
  await expect(pending).toHaveCount(0);
400
- await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
426
+ await settleLayout(body);
427
+ expect(Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
401
428
  await expect(requests).toHaveText("History requests: 1");
402
429
 
403
430
  // Keyboard navigation also requests older history.
@@ -421,11 +448,7 @@ test("upward scrolling loads older history once and preserves retry and selectio
421
448
  // Removing the history control re-anchors the scroller, which would
422
449
  // cancel a keyboard scroll already in flight; press Home once it settles.
423
450
  await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
424
- await expect.poll(() => body.evaluate(async element => {
425
- const height = element.scrollHeight;
426
- await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
427
- return element.scrollHeight === height;
428
- })).toBe(true);
451
+ await settleLayout(body);
429
452
  await expect.poll(async () => {
430
453
  await body.focus();
431
454
  return body.evaluate(element => document.activeElement === element);
@@ -265,7 +265,7 @@ async function fixture(t, options = {}) {
265
265
  await writeFile(account, "owner@example.test");
266
266
  await writeFile(command, `#!${process.execPath}
267
267
  import { createInterface } from "node:readline";
268
- import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
268
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
269
269
  import path from "node:path";
270
270
  const args = process.argv.slice(2);
271
271
  const structuredResponse = ${JSON.stringify(options.structuredResponse || null)};
@@ -308,6 +308,12 @@ async function fixture(t, options = {}) {
308
308
  if (frame.request.subtype === "interrupt") emit({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
309
309
  return;
310
310
  }
311
+ // A crash after reading the prompt but before recording it in history.
312
+ const dropped = path.join(process.env.CLAUDE_CONFIG_DIR, "dropped-once");
313
+ if (JSON.stringify(frame.message.content).includes("drop once") && !existsSync(dropped)) {
314
+ writeFileSync(dropped, "");
315
+ process.exit(1);
316
+ }
311
317
  record(frame);
312
318
  const text = typeof frame.message.content === "string" ? frame.message.content
313
319
  : frame.message.content.filter(part => part.type === "text").map(part => part.text).join(" ");
@@ -783,6 +789,25 @@ test("uncertain native delivery is reconciled from history without a second infe
783
789
  assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
784
790
  });
785
791
 
792
+ test("a Claude prompt missing from history after its process ended is not sent, and Retry sends it", async t => {
793
+ const f = await fixture(t);
794
+ const dropped = { messageId: "dropped-before-history", text: "drop once" };
795
+ await assert.rejects(f.conversation.send(dropped), error => error.delivery === "uncertain");
796
+ assert.equal((await f.conversation.wait()).status, "unconfirmed");
797
+ const receipt = await f.conversation.inspectDelivery({ messageId: dropped.messageId });
798
+ assert.equal(receipt.status, "not-sent");
799
+ assert.equal(receipt.messageCode, "assistant.conversation.messageNotSent");
800
+ // The proof is saved: a reload no longer restores an unconfirmed message.
801
+ assert.equal((await f.conversation.read()).status, "ready");
802
+ assert.equal((await f.conversation.inspectDelivery({ messageId: dropped.messageId })).status, "not-sent");
803
+ const sent = await f.conversation.send(dropped);
804
+ assert.equal(sent.messageId, dropped.messageId);
805
+ const finished = await f.conversation.wait();
806
+ assert.equal(finished.conversationLog.length, 1);
807
+ assert.equal(finished.conversationLog[0].assistant.text, "Answer: drop once");
808
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
809
+ });
810
+
786
811
  test("inspecting an older accepted Claude message leaves a different pending receipt untouched", async t => {
787
812
  const f = await fixture(t);
788
813
  const first = await f.conversation.send(input);
@@ -190,6 +190,72 @@ test("Claude status failures do not poison later reads or share across credentia
190
190
  assert.equal(calls, 3);
191
191
  });
192
192
 
193
+ test("a Claude status read that cannot finish keeps Claude's recent answer for unchanged account files", {
194
+ skip: process.platform === "darwin"
195
+ }, async (t) => {
196
+ const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
197
+ t.after(() => rm(home, { recursive: true, force: true }));
198
+ const realNow = Date.now;
199
+ let now = realNow();
200
+ Date.now = () => now;
201
+ t.after(() => { Date.now = realNow; });
202
+ const signedIn = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai" }) };
203
+ const signedInStatus = { loggedIn: true, email: "owner@example.test", authMethod: "claude.ai", subscriptionType: "" };
204
+ const timedOut = { ok: false, exitCode: 1, timedOut: true, error: "Command timed out." };
205
+ let calls = 0;
206
+ let result = signedIn;
207
+ const input = { env: { CLAUDE_CONFIG_DIR: path.join(home, ".claude") }, credentialHome: { home },
208
+ commandRunner: async () => { calls += 1; return result; } };
209
+ assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
210
+ // An overloaded host times the next read out, or refuses to start it for now.
211
+ for (const unfinished of [timedOut, { ok: false, exitCode: 1, retryable: true, error: "Memory-pressure spike." }]) {
212
+ now += 31_000;
213
+ result = unfinished;
214
+ assert.deepEqual(await readClaudeCodeAuthStatus(input), signedInStatus);
215
+ }
216
+ // That answer is reused for the normal window, so the busy host is not probed again at once.
217
+ const probes = calls;
218
+ now += 29_000;
219
+ assert.deepEqual(await readClaudeCodeAuthStatus(input), signedInStatus);
220
+ assert.equal(calls, probes);
221
+ // A read that ran and failed is still reported.
222
+ now += 2_000;
223
+ result = { ok: false, exitCode: 2, error: "Claude Code crashed." };
224
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Claude Code crashed.");
225
+ // Changed account files need Claude's own new answer.
226
+ await mkdir(path.join(home, ".claude"));
227
+ await writeFile(path.join(home, ".claude", ".credentials.json"), "native-account-changed");
228
+ result = timedOut;
229
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
230
+ // The recent answer expires: an old one is not kept forever.
231
+ now += 31_000;
232
+ result = signedIn;
233
+ assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
234
+ now += 5 * 60_000 + 1;
235
+ result = timedOut;
236
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
237
+ });
238
+
239
+ test("an unfinished Claude status read with no recent answer is reported without probing again at once", {
240
+ skip: process.platform === "darwin"
241
+ }, async (t) => {
242
+ const realNow = Date.now;
243
+ let now = realNow();
244
+ Date.now = () => now;
245
+ t.after(() => { Date.now = realNow; });
246
+ let calls = 0;
247
+ let result = { ok: false, exitCode: 1, retryable: true, error: "Memory-pressure spike." };
248
+ const input = { env: {}, credentialHome: { home: "/home/fixture-busy" }, commandRunner: async () => { calls += 1; return result; } };
249
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
250
+ now += 29_000;
251
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
252
+ assert.equal(calls, 1);
253
+ now += 2_000;
254
+ result = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test" }) };
255
+ assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
256
+ assert.equal(calls, 2);
257
+ });
258
+
193
259
  test("Claude plan usage retains real windows and never invents an allowance after reset", () => {
194
260
  assert.deepEqual(claudePlanUsage({ rate_limits_available: false }).windows, []);
195
261
  const usage = claudePlanUsage({ rate_limits_available: true, rate_limits: {
@@ -482,9 +548,9 @@ test("Claude account queries isolate concurrent identities and deduplicate each
482
548
  const second = queries[method]({ id: "second" });
483
549
  // Finish in reverse order to expose cross-account sharing and cache races.
484
550
  pending.get("second").resolve({ models: [{ value: "second" }], rate_limits_available: true,
485
- rate_limits: { five_hour: { utilization: 20 } } });
551
+ rate_limits: { five_hour: { utilization: 20 }, limits: [] } });
486
552
  pending.get("first").resolve({ models: [{ value: "first" }], rate_limits_available: true,
487
- rate_limits: { five_hour: { utilization: 80 } } });
553
+ rate_limits: { five_hour: { utilization: 80 }, limits: [] } });
488
554
  const [a, same, b] = await Promise.all([first, duplicate, second]);
489
555
  assert.deepEqual(calls, ["first", "second"], method);
490
556
  assert.deepEqual(same, a);
@@ -501,7 +567,123 @@ test("Claude account queries isolate concurrent identities and deduplicate each
501
567
  }
502
568
  });
503
569
 
504
- test("Claude account queries label temporary startup without changing cached process reuse", async () => {
570
+ test("Claude plan usage shows only an answer from the usage endpoint as current", async () => {
571
+ let answer;
572
+ const queries = createClaudeAccountQueries({
573
+ accountIdentity: async () => "account",
574
+ processes: () => [{ accountIdentity: "account", process: { client: { request: async () => answer } } }],
575
+ createProcess() { throw new Error("No temporary Claude can start here."); }
576
+ });
577
+ const resets_at = new Date(Date.now() + 86_400_000).toISOString();
578
+ // Shape of Claude Code's answer while its endpoint read fails or is held: the
579
+ // window readings of its own last API response, which can be hours old, without
580
+ // the endpoint's `limits` list and without any age.
581
+ const seeded = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at } } };
582
+ answer = seeded;
583
+ assert.deepEqual(await queries.readPlanUsage({}), { status: "unavailable", windows: [] });
584
+ queries.invalidateUsage();
585
+ answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 23, resets_at }, limits: [] } };
586
+ const verified = await queries.readPlanUsage({});
587
+ assert.equal(verified.status, "available");
588
+ assert.equal(verified.windows[0].remainingPercent, 77);
589
+ assert.ok(verified.currentForMs > 0 && verified.currentForMs <= 420_000);
590
+ queries.invalidateUsage();
591
+ answer = seeded;
592
+ const kept = await queries.readPlanUsage({});
593
+ assert.equal(kept.windows[0].remainingPercent, 77, "an unverified answer replaced the endpoint reading");
594
+ assert.equal(kept.checkedAt, verified.checkedAt);
595
+ await queries.invalidate();
596
+ });
597
+
598
+ test("Claude plan usage asks a temporary Claude when a live process cannot prove its answer", async (t) => {
599
+ const realNow = Date.now;
600
+ let now = Date.parse("2026-10-29T03:00:00Z");
601
+ Date.now = () => now;
602
+ t.after(() => { Date.now = realNow; });
603
+ const resets_at = "2026-11-04T05:00:00Z";
604
+ const starts = [];
605
+ let stops = 0;
606
+ let liveReads = 0;
607
+ let startRefused = false;
608
+ const queries = createClaudeAccountQueries({
609
+ accountIdentity: async () => "account",
610
+ // An idle conversation whose own endpoint read is held answers from old headers.
611
+ processes: () => [{ accountIdentity: "account", process: { client: { request: async () => {
612
+ liveReads++;
613
+ return { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at } } };
614
+ } } } }],
615
+ createProcess(input) {
616
+ if (startRefused) throw new Error("Memory is low.");
617
+ starts.push(input.purpose);
618
+ return { client: { request: async () => ({ rate_limits_available: true,
619
+ rate_limits: { seven_day: { utilization: 23, resets_at }, limits: [] } }) },
620
+ async stop() { stops++; return { scopeEmpty: true }; } };
621
+ }
622
+ });
623
+ const first = await queries.readPlanUsage({});
624
+ assert.equal(first.status, "available");
625
+ assert.equal(first.windows[0].remainingPercent, 77);
626
+ assert.deepEqual(starts, ["usage"]);
627
+ for (let poll = 1; poll <= 4; poll++) {
628
+ now += 60_000;
629
+ assert.equal((await queries.readPlanUsage({})).windows[0].remainingPercent, 77);
630
+ }
631
+ assert.equal(liveReads, 5);
632
+ assert.deepEqual(starts, ["usage"], "a fallback started Claude inside the idle interval");
633
+ now += 61_000;
634
+ await queries.readPlanUsage({});
635
+ assert.deepEqual(starts, ["usage", "usage"]);
636
+ assert.equal(stops, starts.length, "a temporary usage process outlived its read");
637
+ // A refused fallback keeps the last verified reading rather than failing the read.
638
+ now += 301_000;
639
+ startRefused = true;
640
+ const kept = await queries.readPlanUsage({});
641
+ assert.equal(kept.windows[0].remainingPercent, 77);
642
+ await queries.invalidate();
643
+ });
644
+
645
+ test("Claude plan usage starts one temporary Claude per idle interval and marks an old reading stale", async (t) => {
646
+ const realNow = Date.now;
647
+ let now = Date.parse("2026-10-29T03:00:00Z");
648
+ Date.now = () => now;
649
+ t.after(() => { Date.now = realNow; });
650
+ const starts = [];
651
+ let stops = 0;
652
+ let answer = { rate_limits_available: true, rate_limits: {
653
+ seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
654
+ const queries = createClaudeAccountQueries({
655
+ accountIdentity: async () => "account",
656
+ createProcess(input) {
657
+ starts.push(input.purpose);
658
+ return { client: { request: async () => answer }, async stop() { stops++; return { scopeEmpty: true }; } };
659
+ }
660
+ });
661
+ const first = await queries.readPlanUsage({});
662
+ assert.equal(first.status, "available");
663
+ assert.equal(first.windows[0].remainingPercent, 77);
664
+ // Pages polling every minute share the account's reading.
665
+ for (let poll = 1; poll <= 4; poll++) {
666
+ now += 60_000;
667
+ assert.equal((await queries.readPlanUsage({})).status, "available");
668
+ }
669
+ assert.deepEqual(starts, ["usage"]);
670
+ now += 61_000;
671
+ answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at: "2026-11-04T05:00:00Z" } } };
672
+ const unverified = await queries.readPlanUsage({});
673
+ assert.deepEqual(starts, ["usage", "usage"]);
674
+ assert.equal(unverified.windows[0].remainingPercent, 77);
675
+ now += 120_000;
676
+ const stale = await queries.readPlanUsage({});
677
+ assert.equal(stale.status, "stale");
678
+ assert.equal(stale.windows[0].remainingPercent, 77);
679
+ assert.equal(stale.checkedAt, first.checkedAt);
680
+ assert.equal("currentForMs" in stale, false);
681
+ assert.deepEqual(starts, ["usage", "usage"], "a poll inside the idle interval started Claude");
682
+ assert.equal(stops, starts.length, "a temporary usage process outlived its read");
683
+ await queries.invalidate();
684
+ });
685
+
686
+ test("Claude account queries label temporary startup and reuse each account's recent reading", async () => {
505
687
  const starts = [];
506
688
  let stops = 0;
507
689
  const queries = createClaudeAccountQueries({
@@ -526,31 +708,33 @@ test("Claude account queries label temporary startup without changing cached pro
526
708
  await queries.invalidate();
527
709
  });
528
710
 
529
- test("account invalidation cancels held startup and usage without stopping a borrowed conversation", async () => {
530
- for (const phase of ["startup", "owned usage", "borrowed usage"]) {
711
+ test("account invalidation cancels held startup and reads without stopping a borrowed conversation", async () => {
712
+ for (const phase of ["catalogue startup", "owned catalogue", "borrowed usage"]) {
531
713
  const started = Promise.withResolvers();
532
714
  let stops = 0;
533
715
  const native = { client: { request() { started.resolve(); return new Promise(() => {}); } },
716
+ get initialization() { started.resolve(); return new Promise(() => {}); },
534
717
  async stop() { stops++; return { scopeEmpty: true }; } };
535
718
  const queries = createClaudeAccountQueries({ accountIdentity: async () => "account",
536
719
  processes: () => phase === "borrowed usage" ? [{ accountIdentity: "account", process: native }] : [],
537
720
  createProcess({ signal }) {
538
- if (phase !== "startup") return native;
721
+ if (phase !== "catalogue startup") return native;
539
722
  started.resolve();
540
723
  return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
541
724
  }
542
725
  });
543
- const pending = queries.readPlanUsage({});
726
+ const pending = phase === "borrowed usage" ? queries.readPlanUsage({}) : queries.readCatalogue({});
544
727
  const rejected = assert.rejects(pending, /cancelled for reconnection/);
545
728
  await started.promise;
546
729
  await queries.invalidate();
547
730
  await rejected;
548
- assert.equal(stops, phase === "owned usage" ? 1 : 0, phase);
731
+ assert.equal(stops, phase === "owned catalogue" ? 1 : 0, phase);
549
732
  }
550
733
  });
551
734
 
552
735
  for (const method of ["readCatalogue", "readPlanUsage"]) {
553
- for (const phase of ["initial identity", "final identity", "retained cleanup"]) {
736
+ // Plan usage borrows a conversation's process, so it has no cleanup to retain.
737
+ for (const phase of method === "readPlanUsage" ? ["initial identity", "final identity"] : ["initial identity", "final identity", "retained cleanup"]) {
554
738
  test(`${method} invalidation fences ${phase} before launching or caching a stale query`, { timeout: 2_000 }, async () => {
555
739
  const entered = Promise.withResolvers();
556
740
  const release = Promise.withResolvers();
@@ -566,6 +750,9 @@ for (const method of ["readCatalogue", "readPlanUsage"]) {
566
750
  }
567
751
  return "account";
568
752
  },
753
+ // Usage reads count as native reads of the borrowed conversation process.
754
+ processes: () => method === "readPlanUsage" ? [{ accountIdentity: "account", process: { client: {
755
+ request: async () => { starts++; return { rate_limits_available: true, rate_limits: { limits: [] } }; } } } }] : [],
569
756
  createProcess() {
570
757
  starts++;
571
758
  return {
@@ -894,6 +894,24 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
894
894
  async reopen() { entry = await owner.open(context, { mainId: conversationId }); return entry; } };
895
895
  }
896
896
 
897
+ test("Claude proves a prompt was not admitted only once no process can still record it", async t => {
898
+ const f = await publishedClaudeFixture(t);
899
+ const { inspectPersistentConversationAdmission } = await import("../src/server/conversation/providers/scoped.js");
900
+ // Temporary chats inspect the same proof through their native read.
901
+ const persistent = () => inspectPersistentConversationAdmission({ sessionId: "session", context: {},
902
+ record: { providerConversationId: f.conversationId }, input: { messageId: "held" }, inputFor: (_record, input) => input,
903
+ operations: { readConversation: (_sessionId, input) => f.owner.read(f.entry, input) } });
904
+ await f.send("held");
905
+ // The live process received the prompt but has not recorded it yet.
906
+ assert.equal((await f.owner.inspectAdmission(f.entry, { messageId: "held" })).admission, "unknown");
907
+ assert.equal((await f.owner.read(f.entry, { messageId: "held" })).notAdmitted, false);
908
+ assert.equal((await persistent()).admission, "unknown");
909
+ await f.owner.stopConversation(f.entry, "Stopped.");
910
+ assert.equal((await f.owner.inspectAdmission(f.entry, { messageId: "held" })).admission, "not-admitted");
911
+ assert.equal((await f.owner.read(f.entry, { messageId: "held" })).notAdmitted, true);
912
+ assert.equal((await persistent()).admission, "not-admitted");
913
+ });
914
+
897
915
  test("Claude status retains active instructions and applies changed guidance once idle", async t => {
898
916
  const f = await publishedClaudeFixture(t);
899
917
  await f.send();
@@ -916,6 +934,23 @@ test("Claude active status still rejects account replacement", async t => {
916
934
  assert.equal(f.entry.process, null);
917
935
  });
918
936
 
937
+ test("Claude account change stops running processes without reopening saved conversations", async t => {
938
+ const f = await publishedClaudeFixture(t);
939
+ f.context.sessionId = "session-a";
940
+ await f.send();
941
+ const closedTerminals = [];
942
+ // A saved conversation can belong to an archived session whose context no longer resolves.
943
+ const application = {
944
+ readContext: async () => { throw new Error("A saved conversation was reopened"); },
945
+ terminals: { close: async sessionId => { closedTerminals.push(sessionId); return { ok: true, closed: 0 }; } }
946
+ };
947
+ const result = await f.owner.invalidateRuntimes({ provider: "claude", reason: "claude-auth-change" }, application,
948
+ { queries: { invalidate: async () => {} }, failure: error => { throw error; } });
949
+ assert.equal(result.ok, true);
950
+ assert.equal(f.entry.process, null, "the running process stopped");
951
+ assert.deepEqual(closedTerminals, ["session-a"], "the session's Claude terminals closed");
952
+ });
953
+
919
954
  test("Claude continuation supersedes an older completion checkpoint", { timeout: 10_000 }, async t => {
920
955
  const f = await publishedClaudeFixture(t);
921
956
  await f.send();
@@ -3201,6 +3201,37 @@ test("native changeover retries retain original application data including its a
3201
3201
  }
3202
3202
  });
3203
3203
 
3204
+ test("native changeover treats a proven not-admitted attempt as not sent", async () => {
3205
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3206
+ const original = { messageId: "lost-request", message: "Prepared prompt", displayMessage: "Original words.",
3207
+ displayAttachments: [], attachmentIds: [], seen: {}, attempted: true, threadId: "thread" };
3208
+ for (const inspectFirst of [true, false]) {
3209
+ let value = { lastEngine: "codex", engines: { codex: { seen: {}, pending: structuredClone(original) } } };
3210
+ let admission = "unknown";
3211
+ const sent = [];
3212
+ const changeover = () => createConversationChangeover({
3213
+ state: { async read() { return structuredClone(value); }, async write(next) { value = structuredClone(next); } },
3214
+ transcript: { async hasMessage() { return false; } },
3215
+ agent: { async inspectMessageAdmission() { return { admission }; },
3216
+ async sendMessage(input) { sent.push(input.message); return { ok: true, delivered: true }; } }
3217
+ });
3218
+ const inspect = () => changeover().send({ engineId: "codex", messages: [], input: { messageId: original.messageId }, inspectOnly: true });
3219
+ assert.equal((await inspect()).code, "conversation_changeover_delivery_unconfirmed", "An unknown receipt is never resent");
3220
+ admission = "not-admitted";
3221
+ if (inspectFirst) {
3222
+ const receipt = await inspect();
3223
+ assert.equal(receipt.notSent, true);
3224
+ assert.equal(receipt.messageId, original.messageId);
3225
+ assert.equal(value.engines.codex.pending.attempted, false);
3226
+ assert.deepEqual(sent, [], "Inspection never sends");
3227
+ }
3228
+ const delivered = await changeover().send({ engineId: "codex", messages: [],
3229
+ input: { messageId: original.messageId, message: "Original words." } });
3230
+ assert.equal(delivered.delivered, true);
3231
+ assert.deepEqual(sent, ["Prepared prompt"], "The proven unsent attempt is sent once, with its original prompt");
3232
+ }
3233
+ });
3234
+
3204
3235
  test("initial native seed limits preserve generic history, full returning corrections and delivery ownership", async () => {
3205
3236
  const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3206
3237
  const attachment = { attachmentId: "retained-file", name: "retained.txt" };