@jskit-ai/assistant-core 0.2.1 → 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.
Files changed (61) 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 +41 -11
  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 +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  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 +272 -10
  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 +34 -14
  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 +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. package/test/openCodeRuntime.test.js +25 -78
@@ -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
+ });