@jskit-ai/assistant-core 0.2.2 → 0.2.4
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 +49 -12
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +105 -58
- package/src/server/conversation/claudeTurn.js +179 -59
- 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 +234 -29
- 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 +13 -1
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +225 -64
- package/src/server/conversation/providers/claudeDriver.js +3 -3
- package/src/server/conversation/providers/codexDriver.js +10 -4
- package/src/server/conversation/providers/opencodeDriver.js +4 -2
- package/src/server/conversation/runtime.js +72 -2
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +169 -10
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +195 -51
- package/test/claudeTurn.test.js +174 -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 +21 -1
- package/test/conversationNativeStorage.test.js +57 -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
|
@@ -51,6 +51,30 @@ async function scrollGesture(body, lastEventType, perform) {
|
|
|
51
51
|
await body.evaluate(element => element.gestureScrollEnded);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Chromium animates a keyboard scroll towards a target fixed at the key press
|
|
55
|
+
// while content-visibility rows keep changing the transcript's height, so the
|
|
56
|
+
// animation can stop short of the end or not move at all. Keep the real key
|
|
57
|
+
// event for the transcript's own handling, replace only the browser's scroll
|
|
58
|
+
// with a move to `target` ("end", "top" or a pixel offset), and wait until the
|
|
59
|
+
// transcript has handled that scroll.
|
|
60
|
+
async function pressScrollKey(body, key, target) {
|
|
61
|
+
await body.evaluate((element, [scrollKey, scrollTarget]) => {
|
|
62
|
+
element.keyScrolled = new Promise(resolve => {
|
|
63
|
+
element.addEventListener("keydown", function move(event) {
|
|
64
|
+
if (event.key !== scrollKey) return;
|
|
65
|
+
element.removeEventListener("keydown", move);
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
element.addEventListener("scroll", () => resolve(), { once: true });
|
|
68
|
+
element.scrollTop = scrollTarget === "end"
|
|
69
|
+
? element.scrollHeight
|
|
70
|
+
: scrollTarget === "top" ? 0 : element.scrollTop + scrollTarget;
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}, [key, target]);
|
|
74
|
+
await body.press(key);
|
|
75
|
+
await body.evaluate(element => element.keyScrolled);
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
const VIEWPORTS = Object.freeze([
|
|
55
79
|
Object.freeze({ name: "phone", width: 390, height: 844 }),
|
|
56
80
|
Object.freeze({ name: "compact", width: 800, height: 900 }),
|
|
@@ -428,13 +452,12 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
428
452
|
await expect(requests).toHaveText("History requests: 1");
|
|
429
453
|
|
|
430
454
|
// Keyboard navigation also requests older history.
|
|
431
|
-
await body
|
|
432
|
-
await body.press("Home");
|
|
455
|
+
await pressScrollKey(body, "Home", "top");
|
|
433
456
|
await expect(requests).toHaveText("History requests: 2");
|
|
434
457
|
await external("Fail history load");
|
|
435
458
|
await expect(page.getByText("History unavailable", { exact: true })).toBeVisible();
|
|
436
|
-
await body
|
|
437
|
-
await body
|
|
459
|
+
await pressScrollKey(body, "ArrowDown", 40);
|
|
460
|
+
await pressScrollKey(body, "Home", "top");
|
|
438
461
|
await expect(requests).toHaveText("History requests: 2");
|
|
439
462
|
await page.getByRole("button", { name: "Load older messages", exact: true }).click();
|
|
440
463
|
await expect(requests).toHaveText("History requests: 3");
|
|
@@ -445,18 +468,103 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
445
468
|
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
446
469
|
await expect(requests).toHaveText("History requests: 3");
|
|
447
470
|
await external("Exhaust history");
|
|
448
|
-
// Removing the history control re-anchors the scroller
|
|
449
|
-
//
|
|
471
|
+
// Removing the history control re-anchors the scroller; press Home once
|
|
472
|
+
// it settles.
|
|
450
473
|
await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
|
|
451
474
|
await settleLayout(body);
|
|
475
|
+
await pressScrollKey(body, "Home", "top");
|
|
476
|
+
expect(await body.evaluate(element => element.scrollTop)).toBe(0);
|
|
477
|
+
await expect(requests).toHaveText("History requests: 3");
|
|
478
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
479
|
+
assert.deepEqual(errors, []);
|
|
480
|
+
await page.close();
|
|
481
|
+
}
|
|
482
|
+
} finally {
|
|
483
|
+
await browser.close();
|
|
484
|
+
await stopProcess(vite);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("a reader's own upward scrolling leaves the latest message from its first frame", {
|
|
489
|
+
skip: !RUN_BROWSER_TEST,
|
|
490
|
+
timeout: 60_000
|
|
491
|
+
}, async () => {
|
|
492
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
493
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
494
|
+
try {
|
|
495
|
+
for (const width of [390, 1365]) {
|
|
496
|
+
const touch = width < 1000;
|
|
497
|
+
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
|
|
498
|
+
const errors = [];
|
|
499
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
500
|
+
await page.goto(`${vite.baseURL}/?controls=1&history=1`);
|
|
501
|
+
const body = page.locator(".assistant-transcript__body");
|
|
502
|
+
const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
503
|
+
const appendReply = async () => {
|
|
504
|
+
const replies = page.getByText("New reply.", { exact: false });
|
|
505
|
+
const count = await replies.count();
|
|
506
|
+
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
507
|
+
await expect(replies).toHaveCount(count + 1);
|
|
508
|
+
await settleLayout(body);
|
|
509
|
+
};
|
|
510
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
511
|
+
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
512
|
+
await settleLayout(body);
|
|
513
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
452
514
|
await expect.poll(async () => {
|
|
453
515
|
await body.focus();
|
|
454
516
|
return body.evaluate(element => document.activeElement === element);
|
|
455
517
|
}).toBe(true);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
518
|
+
|
|
519
|
+
// A short upward wheel or touch scroll stays within the 48px follow
|
|
520
|
+
// threshold, and a touch fling ends without further input events.
|
|
521
|
+
if (touch) {
|
|
522
|
+
const bounds = await body.boundingBox();
|
|
523
|
+
const cdp = await page.context().newCDPSession(page);
|
|
524
|
+
const [x, y] = [bounds.x + bounds.width / 2, bounds.y + 100];
|
|
525
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
526
|
+
for (const offset of [10, 20, 30]) {
|
|
527
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
|
|
528
|
+
}
|
|
529
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
530
|
+
await cdp.detach();
|
|
531
|
+
} else {
|
|
532
|
+
await body.hover();
|
|
533
|
+
await page.mouse.wheel(0, -20);
|
|
534
|
+
}
|
|
535
|
+
await expect.poll(fromBottom).toBeGreaterThan(1);
|
|
536
|
+
await settleLayout(body);
|
|
537
|
+
await appendReply();
|
|
538
|
+
expect(await fromBottom()).toBeGreaterThan(1);
|
|
539
|
+
|
|
540
|
+
// Returning to the end resumes following new replies.
|
|
541
|
+
await pressScrollKey(body, "End", "end");
|
|
542
|
+
await appendReply();
|
|
543
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
544
|
+
|
|
545
|
+
// The first frame of an upward keyboard scroll lands inside the follow
|
|
546
|
+
// threshold; the rest of it (a fling or smooth scroll) moves the reader
|
|
547
|
+
// into history without further input events.
|
|
548
|
+
await pressScrollKey(body, "ArrowUp", -10);
|
|
549
|
+
const firstFrame = await fromBottom();
|
|
550
|
+
assert.ok(firstFrame > 1 && firstFrame <= 48, `first frame ${firstFrame}px from the end`);
|
|
551
|
+
await body.evaluate(element => { element.scrollTop = Math.round(element.scrollTop / 2); });
|
|
552
|
+
await settleLayout(body);
|
|
553
|
+
await appendReply();
|
|
554
|
+
expect(await fromBottom()).toBeGreaterThan(1_000);
|
|
555
|
+
await pressScrollKey(body, "End", "end");
|
|
556
|
+
await appendReply();
|
|
557
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
558
|
+
|
|
559
|
+
// The scroll event of the transcript's own follow scroll can arrive after
|
|
560
|
+
// the key press, before the reader has moved; it must not cancel the
|
|
561
|
+
// reader's scroll.
|
|
562
|
+
await body.evaluate(element => {
|
|
563
|
+
element.addEventListener("keydown", () => element.dispatchEvent(new Event("scroll")), { once: true });
|
|
564
|
+
});
|
|
565
|
+
await pressScrollKey(body, "Home", "top");
|
|
566
|
+
await appendReply();
|
|
567
|
+
expect(await body.evaluate(element => element.scrollTop)).toBe(0);
|
|
460
568
|
assert.deepEqual(errors, []);
|
|
461
569
|
await page.close();
|
|
462
570
|
}
|
|
@@ -466,6 +574,57 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
466
574
|
}
|
|
467
575
|
});
|
|
468
576
|
|
|
577
|
+
test("a reader moved away from the end without a scroll direction stays there", {
|
|
578
|
+
skip: !RUN_BROWSER_TEST,
|
|
579
|
+
timeout: 60_000
|
|
580
|
+
}, async () => {
|
|
581
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
582
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
583
|
+
try {
|
|
584
|
+
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
|
585
|
+
const errors = [];
|
|
586
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
587
|
+
await page.goto(`${vite.baseURL}/?controls=1&history=1`);
|
|
588
|
+
const body = page.locator(".assistant-transcript__body");
|
|
589
|
+
const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
590
|
+
// Move the reader and wait until the transcript has handled the scroll.
|
|
591
|
+
const moveReader = target => body.evaluate((element, by) => new Promise(resolve => {
|
|
592
|
+
element.addEventListener("scroll", () => resolve(), { once: true });
|
|
593
|
+
element.scrollTop = by === "end" ? element.scrollHeight : element.scrollTop + by;
|
|
594
|
+
}), target);
|
|
595
|
+
const appendReply = async () => {
|
|
596
|
+
const replies = page.getByText("New reply.", { exact: false });
|
|
597
|
+
const count = await replies.count();
|
|
598
|
+
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
599
|
+
await expect(replies).toHaveCount(count + 1);
|
|
600
|
+
await settleLayout(body);
|
|
601
|
+
};
|
|
602
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
603
|
+
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
604
|
+
await settleLayout(body);
|
|
605
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
606
|
+
|
|
607
|
+
// A scrollbar drag starts on the scroller itself and carries no scroll
|
|
608
|
+
// direction; its frames, the first inside the follow threshold, then move
|
|
609
|
+
// the reader without further input. Headless Chromium hides scrollbars, so
|
|
610
|
+
// the drag's pointerdown is dispatched directly.
|
|
611
|
+
await body.evaluate(element => element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
|
|
612
|
+
await moveReader(-10);
|
|
613
|
+
await moveReader(-300);
|
|
614
|
+
await appendReply();
|
|
615
|
+
expect(await fromBottom()).toBeGreaterThan(300);
|
|
616
|
+
|
|
617
|
+
// Returning to the end resumes following new replies.
|
|
618
|
+
await moveReader("end");
|
|
619
|
+
await appendReply();
|
|
620
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
621
|
+
assert.deepEqual(errors, []);
|
|
622
|
+
} finally {
|
|
623
|
+
await browser.close();
|
|
624
|
+
await stopProcess(vite);
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
469
628
|
test("retained hidden transcripts preserve readers and follow only at the latest message", {
|
|
470
629
|
skip: !RUN_BROWSER_TEST,
|
|
471
630
|
timeout: 90_000
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createClaudeConversationOwner } from "../src/server/conversation/claudeTurn.js";
|
|
8
|
+
import { createClaudeConversationDriver } from "../src/server/conversation/providers/claudeDriver.js";
|
|
9
|
+
|
|
10
|
+
const MAIN_IDS = {
|
|
11
|
+
a: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
12
|
+
b: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
13
|
+
c: "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
|
14
|
+
};
|
|
15
|
+
const TEMPORARY_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
|
16
|
+
const IDLE_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
|
17
|
+
|
|
18
|
+
// A host with Vibe64's storage rules: every save writes the session record, an
|
|
19
|
+
// archived session refuses writes, saved conversations are re-read from storage
|
|
20
|
+
// and a session whose workspace is gone cannot prepare a conversation context.
|
|
21
|
+
async function lifecycleFixture(t) {
|
|
22
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "claude-lifecycle-"));
|
|
23
|
+
const sessions = new Map();
|
|
24
|
+
const calls = { saves: [], reads: 0, stops: [] };
|
|
25
|
+
const processes = [];
|
|
26
|
+
const session = sessionId => {
|
|
27
|
+
if (!sessions.has(sessionId)) {
|
|
28
|
+
mkdirSync(path.join(root, sessionId));
|
|
29
|
+
sessions.set(sessionId, { records: new Map(), archived: false, workspace: true });
|
|
30
|
+
}
|
|
31
|
+
return sessions.get(sessionId);
|
|
32
|
+
};
|
|
33
|
+
const contextFor = sessionId => ({ key: `${root}\0${sessionId}`, sessionId, workdir: path.join(root, sessionId), projectRoot: root });
|
|
34
|
+
const store = {
|
|
35
|
+
select() {},
|
|
36
|
+
read: (ctx, id) => session(ctx.sessionId).records.get(id),
|
|
37
|
+
async save(entry) {
|
|
38
|
+
const { sessionId } = entry.context;
|
|
39
|
+
calls.saves.push(sessionId);
|
|
40
|
+
if (session(sessionId).archived) {
|
|
41
|
+
throw Object.assign(new Error(`Vibe64 session is already archived: ${sessionId}`), { code: "vibe64_session_archived" });
|
|
42
|
+
}
|
|
43
|
+
session(sessionId).records.set(entry.id, JSON.stringify({ executionId: entry.process?.executionId || entry.executionId || "",
|
|
44
|
+
state: entry.turn?.state || "ready", turnId: entry.turn?.id || "", main: entry.main }));
|
|
45
|
+
},
|
|
46
|
+
async hasMessage() { return false; },
|
|
47
|
+
readSessionConversations(ctx) {
|
|
48
|
+
calls.reads++;
|
|
49
|
+
return { context: ctx, ids: [...session(ctx.sessionId).records.keys()] };
|
|
50
|
+
},
|
|
51
|
+
projectContextRoot: entry => entry.context.projectRoot,
|
|
52
|
+
modelProviderId: () => "anthropic"
|
|
53
|
+
};
|
|
54
|
+
let owner;
|
|
55
|
+
const create = () => createClaudeConversationOwner({ configRoot: root, store,
|
|
56
|
+
preparation: {
|
|
57
|
+
async context(context) {
|
|
58
|
+
if (!session(context.sessionId).workspace) throw new Error("Claude requires a prepared session workspace.");
|
|
59
|
+
return contextFor(context.sessionId);
|
|
60
|
+
},
|
|
61
|
+
entry: (ctx, _context, conversationId, { create }) => ({
|
|
62
|
+
options: { conversationId, mainId: MAIN_IDS[ctx.sessionId], create }, check() {} }),
|
|
63
|
+
check() {}, account: () => ({ identity: "account-a", providerId: "anthropic" }),
|
|
64
|
+
async configuration() { return { systemPrompt: "Host instructions", settings: {}, model: "sonnet" }; },
|
|
65
|
+
async message(_entry, _input, { message }) { return message; }
|
|
66
|
+
},
|
|
67
|
+
process: {
|
|
68
|
+
configure() { return {}; },
|
|
69
|
+
async stopExecution(executionId) { calls.stops.push(executionId); return { exited: true, scopeEmpty: true }; },
|
|
70
|
+
async create(options) {
|
|
71
|
+
const native = { executionId: `execution-${processes.length}`, options, proof: { exited: true, scopeEmpty: true },
|
|
72
|
+
async stop() {
|
|
73
|
+
calls.stops.push(native.executionId);
|
|
74
|
+
await native.beforeStop?.();
|
|
75
|
+
return native.proof;
|
|
76
|
+
},
|
|
77
|
+
client: { async request() { return {}; },
|
|
78
|
+
async send(_message, input) { await options.onEvent({ type: "user", uuid: input.messageId, session_id: options.sessionId }); },
|
|
79
|
+
async interrupt() {} } };
|
|
80
|
+
processes.push(native);
|
|
81
|
+
await options.onStarted(native.executionId, () => native.stop());
|
|
82
|
+
return native;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
// As in Vibe64, the native turn's own checkpoints write the session record.
|
|
86
|
+
async onEvent(entry, event) { if (event.type === "save") await store.save(entry); }
|
|
87
|
+
});
|
|
88
|
+
owner = create();
|
|
89
|
+
const terminals = [];
|
|
90
|
+
// The host's original cleanup reader still needs the session's workspace.
|
|
91
|
+
const application = {
|
|
92
|
+
async readContext(context) {
|
|
93
|
+
if (!session(context.sessionId).workspace) throw new Error("Claude requires a prepared session workspace.");
|
|
94
|
+
return contextFor(context.sessionId);
|
|
95
|
+
},
|
|
96
|
+
terminals: { async close(sessionId) { terminals.push(sessionId); return { ok: true, closed: 0 }; } }
|
|
97
|
+
};
|
|
98
|
+
const account = { queries: { invalidate: async () => {} }, failure: error => { throw error; } };
|
|
99
|
+
async function running(sessionId) {
|
|
100
|
+
const entry = await owner.acquire({ sessionId });
|
|
101
|
+
await owner.send(entry, { message: "Work", messageId: `${sessionId}-work` });
|
|
102
|
+
assert.ok(entry.process && entry.turn.active);
|
|
103
|
+
return entry;
|
|
104
|
+
}
|
|
105
|
+
async function idle(sessionId) {
|
|
106
|
+
return owner.acquire({ sessionId });
|
|
107
|
+
}
|
|
108
|
+
// A session closed without forgetting (changeover, renewal) and then archived.
|
|
109
|
+
function archive(sessionId) {
|
|
110
|
+
Object.assign(session(sessionId), { archived: true, workspace: false });
|
|
111
|
+
}
|
|
112
|
+
t.after(async () => {
|
|
113
|
+
for (const entry of owner.entries.values()) if (entry.process) await entry.nativeTurn.stopProcess().catch(() => {});
|
|
114
|
+
await rm(root, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
return { root, calls, processes, session, contextFor, application, account, terminals, running, idle, archive,
|
|
117
|
+
get owner() { return owner; }, restart() { owner = create(); return owner; } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test("Claude shutdown stops every session's work although a stale archived session refuses writes", async t => {
|
|
121
|
+
const f = await lifecycleFixture(t);
|
|
122
|
+
await f.idle("a");
|
|
123
|
+
f.archive("a");
|
|
124
|
+
const b = await f.running("b");
|
|
125
|
+
const c = await f.running("c");
|
|
126
|
+
const before = f.calls.saves.length;
|
|
127
|
+
const result = await f.owner.invalidateRuntimes({ provider: "claude", reason: "server-shutdown" }, f.application, f.account);
|
|
128
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
129
|
+
assert.equal(b.process, null);
|
|
130
|
+
assert.equal(c.process, null);
|
|
131
|
+
assert.ok(f.calls.stops.includes("execution-0") && f.calls.stops.includes("execution-1"));
|
|
132
|
+
assert.deepEqual(f.calls.saves.slice(before).filter(id => id === "a"), [], "an idle chat has nothing to stop or save");
|
|
133
|
+
assert.deepEqual(f.terminals.sort(), ["a", "b", "c"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a Claude session whose stop fails is reported by ID while every other session still stops", async t => {
|
|
137
|
+
const f = await lifecycleFixture(t);
|
|
138
|
+
const a = await f.running("a");
|
|
139
|
+
a.process.proof = { exited: false, scopeEmpty: false };
|
|
140
|
+
const b = await f.running("b");
|
|
141
|
+
const result = await f.owner.invalidateRuntimes({ provider: "claude", reason: "server-shutdown" }, f.application, f.account);
|
|
142
|
+
assert.equal(result.ok, false);
|
|
143
|
+
assert.deepEqual(result.failed.map(failure => failure.sessionId), ["a"]);
|
|
144
|
+
assert.match(result.error, /Claude session a:/);
|
|
145
|
+
assert.equal(b.process, null, "the healthy session's process stopped");
|
|
146
|
+
a.process.proof = { exited: true, scopeEmpty: true };
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a Claude provider connection change stops the matching live work past a stale archived chat", async t => {
|
|
150
|
+
const f = await lifecycleFixture(t);
|
|
151
|
+
await f.idle("a");
|
|
152
|
+
f.archive("a");
|
|
153
|
+
const b = await f.running("b");
|
|
154
|
+
const result = await f.owner.invalidateRuntimes({ provider: "claude", modelProviderId: "anthropic", reason: "provider-key-change" },
|
|
155
|
+
f.application, f.account);
|
|
156
|
+
assert.deepEqual(result, { ok: true, closed: 1, failed: [] });
|
|
157
|
+
assert.equal(b.process, null);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a Claude provider connection change reports a conversation whose stop fails while the others still stop", async t => {
|
|
161
|
+
const f = await lifecycleFixture(t);
|
|
162
|
+
const a = await f.running("a");
|
|
163
|
+
a.process.proof = { exited: false, scopeEmpty: false };
|
|
164
|
+
const b = await f.running("b");
|
|
165
|
+
const result = await f.owner.invalidateRuntimes({ provider: "claude", modelProviderId: "anthropic", reason: "provider-key-change" },
|
|
166
|
+
f.application, f.account);
|
|
167
|
+
assert.equal(result.ok, false);
|
|
168
|
+
assert.equal(result.closed, 1);
|
|
169
|
+
assert.deepEqual(result.failed.map(({ sessionId, conversationId }) => [sessionId, conversationId]), [["a", MAIN_IDS.a]]);
|
|
170
|
+
assert.equal(b.process, null, "the healthy conversation's process stopped");
|
|
171
|
+
a.process.proof = { exited: true, scopeEmpty: true };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a failed Claude account query cleanup at shutdown still stops the sessions' processes", async t => {
|
|
175
|
+
const f = await lifecycleFixture(t);
|
|
176
|
+
const b = await f.running("b");
|
|
177
|
+
const queries = { invalidate: async () => { throw Object.assign(new Error("Account query cleanup failed."), { code: "claude_account_query_failed" }); } };
|
|
178
|
+
await assert.rejects(f.owner.invalidateRuntimes({ provider: "claude", reason: "server-shutdown" }, f.application,
|
|
179
|
+
{ ...f.account, queries }), /Account query cleanup failed/);
|
|
180
|
+
assert.equal(b.process, null, "the session's process stopped anyway");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("Claude shutdown stops sessions together and never rewrites an idle chat", { timeout: 5_000 }, async t => {
|
|
184
|
+
const f = await lifecycleFixture(t);
|
|
185
|
+
const running = [await f.running("a"), await f.running("b"), await f.running("c")];
|
|
186
|
+
// Each stop finishes only once every session's stop has started.
|
|
187
|
+
const started = [];
|
|
188
|
+
const all = Promise.withResolvers();
|
|
189
|
+
for (const entry of running) entry.process.beforeStop = async () => {
|
|
190
|
+
started.push(entry.context.sessionId);
|
|
191
|
+
if (started.length === running.length) all.resolve();
|
|
192
|
+
await all.promise;
|
|
193
|
+
};
|
|
194
|
+
let timer;
|
|
195
|
+
const result = await Promise.race([
|
|
196
|
+
f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account),
|
|
197
|
+
new Promise((_, reject) => { timer = setTimeout(() => { all.resolve(); reject(new Error("Sessions stopped one at a time")); }, 1_000); })
|
|
198
|
+
]).finally(() => clearTimeout(timer));
|
|
199
|
+
assert.equal(result.ok, true);
|
|
200
|
+
assert.deepEqual(running.map(entry => entry.process), [null, null, null]);
|
|
201
|
+
const before = f.calls.saves.length;
|
|
202
|
+
// Shutdown invalidates more than once; the chats are idle now.
|
|
203
|
+
assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
|
|
204
|
+
assert.equal(f.calls.saves.length, before, "idle chats were not written again");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("Claude project close releases its sessions from memory without reopening saved conversations", async t => {
|
|
208
|
+
const f = await lifecycleFixture(t);
|
|
209
|
+
await f.idle("a");
|
|
210
|
+
f.session("a").records.set(IDLE_ID, JSON.stringify({ state: "completed", main: false }));
|
|
211
|
+
f.archive("a");
|
|
212
|
+
const b = await f.running("b");
|
|
213
|
+
const saves = f.calls.saves.length;
|
|
214
|
+
const result = await f.owner.closeProject({ projectContextRoot: f.root }, f.application);
|
|
215
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
216
|
+
assert.equal(b.process, null);
|
|
217
|
+
assert.equal(f.calls.reads, 0, "no saved conversation was re-read");
|
|
218
|
+
assert.deepEqual(f.calls.saves.slice(saves).filter(id => id === "a"), []);
|
|
219
|
+
assert.equal(f.owner.entries.size, 0, "the closed project's conversations are forgotten");
|
|
220
|
+
assert.equal(b.disposed, true, "a forgotten entry can never start a process again");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("releasing an archived Claude renewal predecessor neither reopens nor rewrites it, also after a restart", async t => {
|
|
224
|
+
const f = await lifecycleFixture(t);
|
|
225
|
+
const driver = createClaudeConversationDriver({ host: { workdir: f.root, env: { HOME: f.root } } });
|
|
226
|
+
await f.idle("a");
|
|
227
|
+
f.archive("a");
|
|
228
|
+
const release = () => driver.releaseRenewalPredecessorProcessExitProof({ native: { owner: f.owner, application: f.application },
|
|
229
|
+
context: { sessionId: "a", renewalCleanup: { kind: "predecessor" } } });
|
|
230
|
+
// The renewal transaction's own context still resolves its archived predecessor.
|
|
231
|
+
f.session("a").workspace = true;
|
|
232
|
+
const saves = f.calls.saves.length;
|
|
233
|
+
const released = await release();
|
|
234
|
+
assert.equal(released.ok, true);
|
|
235
|
+
assert.equal(released.processExitProof.scopeEmpty, true);
|
|
236
|
+
assert.equal(f.calls.saves.length, saves, "the archived record was not written");
|
|
237
|
+
assert.equal(f.calls.reads, 0, "its saved conversations were not re-read");
|
|
238
|
+
assert.equal(f.owner.entries.size, 0, "the released predecessor is forgotten");
|
|
239
|
+
f.restart();
|
|
240
|
+
assert.equal((await release()).ok, true, "a fresh server releases it too");
|
|
241
|
+
assert.equal(f.calls.reads, 0);
|
|
242
|
+
assert.equal(f.owner.entries.size, 0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("Claude temporary activity is the work this server runs, never a reopened saved chat", async t => {
|
|
246
|
+
const f = await lifecycleFixture(t);
|
|
247
|
+
const driver = createClaudeConversationDriver({ host: { workdir: f.root, env: { HOME: f.root } } });
|
|
248
|
+
const records = f.session("a").records;
|
|
249
|
+
records.set(TEMPORARY_ID, JSON.stringify({ executionId: "execution-before-restart", state: "active", turnId: "turn", main: false }));
|
|
250
|
+
records.set(IDLE_ID, "{");
|
|
251
|
+
const inspect = () => driver.inspectTemporaryActivity({ native: { owner: f.owner }, context: f.contextFor("a") });
|
|
252
|
+
assert.equal(await inspect(), false, "a turn saved active by an earlier server ended with it");
|
|
253
|
+
assert.equal(f.owner.entries.size, 0);
|
|
254
|
+
assert.equal(f.calls.reads, 0);
|
|
255
|
+
assert.deepEqual(f.calls.saves, []);
|
|
256
|
+
records.delete(IDLE_ID);
|
|
257
|
+
records.set(TEMPORARY_ID, JSON.stringify({ state: "completed", main: false }));
|
|
258
|
+
const temporary = await f.owner.acquire({ sessionId: "a" }, TEMPORARY_ID);
|
|
259
|
+
await f.owner.send(temporary, { message: "Look this up", messageId: "temporary-work" });
|
|
260
|
+
assert.equal(await inspect(), true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("closing a Claude session reopens only the saved custody an earlier server left", async t => {
|
|
264
|
+
const f = await lifecycleFixture(t);
|
|
265
|
+
const records = f.session("a").records;
|
|
266
|
+
records.set(MAIN_IDS.a, JSON.stringify({ state: "completed", turnId: "done", main: true }));
|
|
267
|
+
records.set(IDLE_ID, JSON.stringify({ state: "completed", main: false }));
|
|
268
|
+
records.set(TEMPORARY_ID, JSON.stringify({ executionId: "execution-before-restart", state: "active", turnId: "turn", main: false }));
|
|
269
|
+
const closed = await f.owner.closeSession({ sessionId: "a" }, f.application);
|
|
270
|
+
assert.equal(closed.ok, true);
|
|
271
|
+
assert.deepEqual(f.calls.stops, ["execution-before-restart"], "the saved execution's exit was proven");
|
|
272
|
+
assert.equal(JSON.parse(records.get(TEMPORARY_ID)).executionId, "");
|
|
273
|
+
assert.equal(JSON.parse(records.get(TEMPORARY_ID)).state, "interrupted");
|
|
274
|
+
assert.deepEqual([...f.owner.entries.values()].map(entry => entry.id), [TEMPORARY_ID], "idle saved chats stayed closed");
|
|
275
|
+
assert.deepEqual([...new Set(f.calls.saves)], ["a"]);
|
|
276
|
+
records.set(IDLE_ID, "{");
|
|
277
|
+
f.restart();
|
|
278
|
+
records.set(TEMPORARY_ID, JSON.stringify({ executionId: "execution-again", state: "active", main: false }));
|
|
279
|
+
await assert.rejects(f.owner.closeSession({ sessionId: "a" }, f.application),
|
|
280
|
+
new RegExp(`saved Claude conversation ${IDLE_ID} is unreadable`));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("after shutdown invalidation a Claude session close reopens nothing", async t => {
|
|
284
|
+
const f = await lifecycleFixture(t);
|
|
285
|
+
await f.idle("a");
|
|
286
|
+
f.session("a").records.set(TEMPORARY_ID, JSON.stringify({ executionId: "execution-before-restart", state: "active", main: false }));
|
|
287
|
+
assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
|
|
288
|
+
const saves = f.calls.saves.length;
|
|
289
|
+
// The runtime's own close() disposes each retained Main handle through this close.
|
|
290
|
+
assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
|
|
291
|
+
assert.equal(f.calls.reads, 0, "no session was re-read");
|
|
292
|
+
assert.equal(f.calls.saves.length, saves, "no record was written");
|
|
293
|
+
});
|