@jskit-ai/assistant-core 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +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 +93 -0
- 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
|
@@ -466,6 +466,99 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
466
466
|
}
|
|
467
467
|
});
|
|
468
468
|
|
|
469
|
+
test("a reader's own upward scrolling leaves the latest message from its first frame", {
|
|
470
|
+
skip: !RUN_BROWSER_TEST,
|
|
471
|
+
timeout: 60_000
|
|
472
|
+
}, async () => {
|
|
473
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
474
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
475
|
+
try {
|
|
476
|
+
for (const width of [390, 1365]) {
|
|
477
|
+
const touch = width < 1000;
|
|
478
|
+
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
|
|
479
|
+
const errors = [];
|
|
480
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
481
|
+
await page.goto(`${vite.baseURL}/?controls=1&history=1`);
|
|
482
|
+
const body = page.locator(".assistant-transcript__body");
|
|
483
|
+
const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
484
|
+
const appendReply = async () => {
|
|
485
|
+
const replies = page.getByText("New reply.", { exact: false });
|
|
486
|
+
const count = await replies.count();
|
|
487
|
+
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
488
|
+
await expect(replies).toHaveCount(count + 1);
|
|
489
|
+
await settleLayout(body);
|
|
490
|
+
};
|
|
491
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
492
|
+
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
493
|
+
await settleLayout(body);
|
|
494
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
495
|
+
await expect.poll(async () => {
|
|
496
|
+
await body.focus();
|
|
497
|
+
return body.evaluate(element => document.activeElement === element);
|
|
498
|
+
}).toBe(true);
|
|
499
|
+
|
|
500
|
+
// A short upward wheel or touch scroll stays within the 48px follow
|
|
501
|
+
// threshold, and a touch fling ends without further input events.
|
|
502
|
+
if (touch) {
|
|
503
|
+
const bounds = await body.boundingBox();
|
|
504
|
+
const cdp = await page.context().newCDPSession(page);
|
|
505
|
+
const [x, y] = [bounds.x + bounds.width / 2, bounds.y + 100];
|
|
506
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
507
|
+
for (const offset of [10, 20, 30]) {
|
|
508
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
|
|
509
|
+
}
|
|
510
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
511
|
+
await cdp.detach();
|
|
512
|
+
} else {
|
|
513
|
+
await body.hover();
|
|
514
|
+
await page.mouse.wheel(0, -20);
|
|
515
|
+
}
|
|
516
|
+
await expect.poll(fromBottom).toBeGreaterThan(1);
|
|
517
|
+
await settleLayout(body);
|
|
518
|
+
await appendReply();
|
|
519
|
+
expect(await fromBottom()).toBeGreaterThan(1);
|
|
520
|
+
|
|
521
|
+
// Returning to the end resumes following new replies.
|
|
522
|
+
await body.press("End");
|
|
523
|
+
await expect.poll(fromBottom).toBeLessThanOrEqual(1);
|
|
524
|
+
await appendReply();
|
|
525
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
526
|
+
|
|
527
|
+
// The first frame of an upward scroll lands inside the follow threshold;
|
|
528
|
+
// the rest of it (a fling or smooth scroll) moves the reader into history
|
|
529
|
+
// without further input events.
|
|
530
|
+
await body.press("ArrowUp");
|
|
531
|
+
await expect.poll(fromBottom).toBeGreaterThan(1);
|
|
532
|
+
await settleLayout(body);
|
|
533
|
+
expect(await fromBottom()).toBeLessThanOrEqual(48);
|
|
534
|
+
await body.evaluate(element => { element.scrollTop = Math.round(element.scrollTop / 2); });
|
|
535
|
+
await settleLayout(body);
|
|
536
|
+
await appendReply();
|
|
537
|
+
expect(await fromBottom()).toBeGreaterThan(1_000);
|
|
538
|
+
await body.press("End");
|
|
539
|
+
await expect.poll(fromBottom).toBeLessThanOrEqual(1);
|
|
540
|
+
await appendReply();
|
|
541
|
+
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
542
|
+
|
|
543
|
+
// The scroll event of the transcript's own follow scroll can arrive after
|
|
544
|
+
// the key press, before the reader has moved; it must not cancel the
|
|
545
|
+
// reader's scroll.
|
|
546
|
+
await body.evaluate(element => {
|
|
547
|
+
element.addEventListener("keydown", () => element.dispatchEvent(new Event("scroll")), { once: true });
|
|
548
|
+
});
|
|
549
|
+
await body.press("Home");
|
|
550
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
|
|
551
|
+
await appendReply();
|
|
552
|
+
expect(await body.evaluate(element => element.scrollTop)).toBe(0);
|
|
553
|
+
assert.deepEqual(errors, []);
|
|
554
|
+
await page.close();
|
|
555
|
+
}
|
|
556
|
+
} finally {
|
|
557
|
+
await browser.close();
|
|
558
|
+
await stopProcess(vite);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
469
562
|
test("retained hidden transcripts preserve readers and follow only at the latest message", {
|
|
470
563
|
skip: !RUN_BROWSER_TEST,
|
|
471
564
|
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
|
+
});
|