@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.
- package/examples/conversation/package.json +1 -1
- package/package.json +1 -1
- package/src/server/conversation/claudeProcess.js +88 -17
- package/src/server/conversation/claudeTurn.js +37 -5
- package/src/server/conversation/codexProviderOwner.js +62 -5
- package/src/server/conversation/continuity.js +21 -13
- package/src/server/conversation/openCodeRuntime.js +32 -5
- package/src/server/conversation/providers/claudeDriver.js +4 -2
- package/src/server/conversation/providers/codexDriver.js +13 -12
- package/src/server/conversation/providers/opencodeDriver.js +13 -12
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +21 -4
- package/test/assistantScroll.browser.test.js +41 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeProcess.test.js +196 -9
- package/test/claudeTurn.test.js +35 -0
- package/test/codexTurn.test.js +31 -0
- package/test/conversationNativeStorage.test.js +227 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
530
|
-
for (const phase of ["startup", "owned
|
|
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
|
|
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
|
-
|
|
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 {
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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();
|
package/test/codexTurn.test.js
CHANGED
|
@@ -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" };
|