@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.
Files changed (59) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +49 -12
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +234 -29
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +13 -1
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +169 -10
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. 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.focus();
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.press("ArrowDown");
437
- await body.press("Home");
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, which would
449
- // cancel a keyboard scroll already in flight; press Home once it settles.
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
- await body.press("Home");
457
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
458
- await expect(requests).toHaveText("History requests: 3");
459
- assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
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
+ });