@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,180 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
4
+ import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
5
+ import { createCodexSessionCleanup } from "../src/server/conversation/codexSessionCleanup.js";
6
+
7
+ // A Codex app-server runs only while it is used: reading a conversation's
8
+ // history or status, or stopping it, borrows the process; a running Temporary
9
+ // turn or goal holds it; and closing a project stops every one of its own.
10
+
11
+ async function eventually(check, message) {
12
+ const deadline = Date.now() + 2_000;
13
+ while (!check()) {
14
+ if (Date.now() > deadline) assert.fail(message);
15
+ await new Promise(resolve => setTimeout(resolve, 5));
16
+ }
17
+ }
18
+
19
+ function settle(ms) {
20
+ return new Promise(resolve => setTimeout(resolve, ms));
21
+ }
22
+
23
+ // One native process per runtime directory, as the real app-server has per
24
+ // account and runtime scope. Each provider connection reports its own thread.
25
+ function codexFixture(t, { idleMs = 20 } = {}) {
26
+ const runtimes = new Map();
27
+ const listeners = new Set();
28
+ const runtimeFor = runtimeDir => {
29
+ if (!runtimes.has(runtimeDir)) runtimes.set(runtimeDir, { running: false, starts: 0, stops: 0 });
30
+ return runtimes.get(runtimeDir);
31
+ };
32
+ const owner = createCodexAppServerProviderOwner({ transientIdleMs: idleMs, providerFactory: ({ runtimeDir }) => {
33
+ const runtime = runtimeFor(runtimeDir);
34
+ return {
35
+ observationFailure: null,
36
+ isHelperProvider: () => false,
37
+ isAvailable: () => runtime.running,
38
+ async ensureAvailable() {
39
+ if (!runtime.running) { runtime.running = true; runtime.starts += 1; }
40
+ return { ok: true };
41
+ },
42
+ async stopRuntime() { runtime.running = false; runtime.stops += 1; return { stopped: true }; },
43
+ close() {},
44
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
45
+ async readThreadStatus(threadId) { return { raw: { id: threadId, historyMode: "paginated", status: "idle" } }; },
46
+ async listThreadTurns() { return { data: [{ id: "turn-1", status: "completed", items: [] }] }; },
47
+ async readGoal() { return { goal: null }; },
48
+ async resumeThread() { return {}; },
49
+ async sendTurn() { return { id: "turn-2", status: "inProgress" }; },
50
+ async stopThreadForObservationLoss() {}
51
+ };
52
+ } });
53
+ t.after(() => owner.beginShutdown());
54
+ const sessionKey = sessionId => `codex:project-a:${sessionId}`;
55
+ const connection = (sessionId, keyPrefix = "codex:project-a:") => ({
56
+ sessionId, providerKey: `provider:${sessionId}`, providerOptions: { runtimeDir: `/tmp/jskit-codex-release-${sessionId}` },
57
+ keyFields: key => ({ sessionId: key.slice("provider:".length) }), assertAdmission() {}, closing: false,
58
+ runOwner: { runtimeLifecycle: { rememberManagedSession() {}, managedThreadId: () => "" } },
59
+ async prepare() {
60
+ return { parameters: { runtimeDir: `/tmp/jskit-codex-release-${sessionId}` }, owner: { sessionKey: `${keyPrefix}${sessionId}` },
61
+ resources: null, controls: { available: false, subscribe: () => () => {} }, runInContext: operation => operation() };
62
+ }
63
+ });
64
+ const commands = createCodexConversationCommands({
65
+ async checkpoint() {},
66
+ codexAppServerConversationMessageId: (...parts) => parts.join(":"),
67
+ codexAppServerStreamMessage: () => ({}),
68
+ conversationPreparation: {
69
+ async context() {
70
+ return { context: { session: {} }, helperTurn: false, projectRuntimeRoot: "/runtime", managedIdentity: {},
71
+ providerOptions: {}, project: provider => ({ provider, workdir: "/work", agentSettings: {} }) };
72
+ },
73
+ admissionError: () => null,
74
+ failure(error) { return { ok: false, error: error.message }; },
75
+ response: text => ({ text }),
76
+ execution() {
77
+ return { executionProfile: null, threadPreparation: { settings: () => ({}) }, authorized: { turnSettings: {} },
78
+ readOnly: false, onEvent: null, actorMetadata: {}, projectResult: result => result };
79
+ },
80
+ isolation: { assertCompatibility() {} }
81
+ },
82
+ ensureCodexAppServerProviderForActiveTurn: async () => null,
83
+ errorPrefix: "",
84
+ helperLifecycle: { async assertOrdinaryThreadAvailable() {}, inspectControl: () => undefined },
85
+ namespace: sessionId => sessionKey(sessionId),
86
+ providerSessions: { owner },
87
+ ensureSessionProvider: sessionId => owner.ensureSession(connection(sessionId)),
88
+ borrowSessionProvider: sessionId => owner.borrowSession(connection(sessionId)),
89
+ turnOutcomes: { USER_CANCELLED: "cancelled" }
90
+ });
91
+ const emit = notification => [...listeners].forEach(listener => listener(notification));
92
+ return { owner, commands, connection, emit, runtime: runtimeFor("/tmp/jskit-codex-release-session-1"), runtimeFor };
93
+ }
94
+
95
+ test("reading a Temporary chat's history leaves no Codex app-server running", async t => {
96
+ const f = codexFixture(t);
97
+ const read = await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
98
+ assert.equal(read.ok, true);
99
+ assert.equal(read.status, "completed");
100
+ assert.equal(f.runtime.running, true, "the read itself uses the process");
101
+ await eventually(() => !f.runtime.running, "an unused history read kept Codex running");
102
+ assert.equal(f.runtime.stops, 1);
103
+ assert.deepEqual([...f.owner.providers.keys()], []);
104
+ });
105
+
106
+ test("consecutive Temporary chat reads share one Codex process and the last releases it", async t => {
107
+ const f = codexFixture(t, { idleMs: 40 });
108
+ for (let read = 0; read < 3; read += 1) {
109
+ await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
110
+ }
111
+ assert.equal(f.runtime.starts, 1);
112
+ await eventually(() => !f.runtime.running, "the last read did not release Codex");
113
+ assert.equal(f.runtime.stops, 1);
114
+ });
115
+
116
+ test("stopping an idle Temporary chat leaves no Codex app-server running", async t => {
117
+ const f = codexFixture(t);
118
+ const stopped = await f.commands.stopCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
119
+ assert.equal(stopped.status, "interrupted");
120
+ await eventually(() => !f.runtime.running, "a Stop of idle work kept Codex running");
121
+ assert.deepEqual([...f.owner.providers.keys()], []);
122
+ });
123
+
124
+ test("a running Temporary turn keeps Codex; it stops a short idle after the turn ends", async t => {
125
+ const f = codexFixture(t);
126
+ const started = await f.commands.startCodexAppServerConversationTurn("session-1", {
127
+ conversationId: "chat-1", persistent: true, message: "Explain this file", messageId: "message-1"
128
+ });
129
+ assert.equal(started.status, "inProgress");
130
+ // A history read while the turn runs must not release its process either.
131
+ await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
132
+ f.emit({ method: "turn/started", params: { threadId: "chat-1", turn: { id: "turn-2", status: "inProgress" } } });
133
+ await settle(80);
134
+ assert.equal(f.runtime.running, true, "the running turn's process was stopped");
135
+ assert.equal(f.runtime.stops, 0);
136
+ f.emit({ method: "turn/completed", params: { threadId: "chat-1", turn: { id: "turn-2", status: "completed" } } });
137
+ await eventually(() => !f.runtime.running, "the finished turn kept Codex running");
138
+ assert.equal(f.runtime.stops, 1);
139
+ });
140
+
141
+ test("an active Temporary goal keeps Codex between its turns until the goal ends", async t => {
142
+ const f = codexFixture(t);
143
+ await f.commands.startCodexAppServerConversationTurn("session-1", {
144
+ conversationId: "chat-1", persistent: true, message: "Finish the migration", messageId: "message-1"
145
+ });
146
+ f.emit({ method: "thread/goal/updated", params: { threadId: "chat-1", goal: { status: "active" } } });
147
+ f.emit({ method: "turn/completed", params: { threadId: "chat-1", turn: { id: "turn-2", status: "completed" } } });
148
+ await settle(80);
149
+ assert.equal(f.runtime.running, true, "the goal's process was stopped between its turns");
150
+ f.emit({ method: "thread/goal/updated", params: { threadId: "chat-1", goal: { status: "complete" } } });
151
+ await eventually(() => !f.runtime.running, "the finished goal kept Codex running");
152
+ });
153
+
154
+ test("closing a project stops a Codex app-server its Temporary chat read, and only that project's", async t => {
155
+ const f = codexFixture(t, { idleMs: 60_000 });
156
+ await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
157
+ // Another project's session keeps its own conversation.
158
+ const other = f.runtimeFor("/tmp/jskit-codex-release-session-9");
159
+ await f.owner.ensureSession(f.connection("session-9", "codex:project-b:"));
160
+ assert.equal(f.runtime.running, true);
161
+ assert.equal(other.running, true);
162
+ const cleanup = createCodexSessionCleanup({
163
+ namespace: sessionId => `codex:project-a:${sessionId}`,
164
+ runOwner: {},
165
+ providerSessions: { owner: f.owner, managed: new Map(), keyFields: key => ({ sessionId: key.slice("provider:".length) }) },
166
+ sessionClosures: new Map(),
167
+ helperLifecycle: { async restoreThreads() { return { ok: true, failed: [] }; }, records: () => [] },
168
+ journal: { clearSessionRecoveryTimers() {} },
169
+ conversations: f.commands,
170
+ debugLog() {},
171
+ debugError: error => error?.message
172
+ });
173
+ const closed = await cleanup.closeProject({ projectContextRoot: "/project-a" }, {
174
+ helpers: Promise.resolve({ projectRuntimeRoot: "/runtime", context: {} })
175
+ });
176
+ assert.equal(closed.ok, true);
177
+ assert.equal(f.runtime.running, false, "project close left the Temporary chat's Codex running");
178
+ assert.equal(other.running, true, "another project's Codex was stopped");
179
+ assert.deepEqual([...f.owner.providers.keys()], ["provider:session-9"]);
180
+ });
@@ -491,7 +491,7 @@ test("session cleanup owns its single-flight closures and restricted record with
491
491
  return { exitFailure: null };
492
492
  }
493
493
  } },
494
- helperLifecycle: { assertRestored() {}, async restoreThreads() {}, assertRetired() {}, async retireAll() {} },
494
+ helperLifecycle: { async restoreThreads() { return { failed: [], ok: true }; }, assertRetired() {}, async retireAll() {} },
495
495
  journal: { clearSessionRecoveryTimers() {} },
496
496
  conversations: { async closeCodexAppServerConversations() {} },
497
497
  debugLog() {}, debugError: error => error
@@ -3201,6 +3201,57 @@ test("native changeover retries retain original application data including its a
3201
3201
  }
3202
3202
  });
3203
3203
 
3204
+ test("native changeover treats a proven not-admitted attempt as not sent", async () => {
3205
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3206
+ const original = { messageId: "lost-request", message: "Prepared prompt", displayMessage: "Original words.",
3207
+ displayAttachments: [], attachmentIds: [], seen: {}, attempted: true, threadId: "thread" };
3208
+ for (const inspectFirst of [true, false]) {
3209
+ let value = { lastEngine: "codex", engines: { codex: { seen: {}, pending: structuredClone(original) } } };
3210
+ let admission = "unknown";
3211
+ const sent = [];
3212
+ const changeover = () => createConversationChangeover({
3213
+ state: { async read() { return structuredClone(value); }, async write(next) { value = structuredClone(next); } },
3214
+ transcript: { async hasMessage() { return false; } },
3215
+ agent: { async inspectMessageAdmission() { return { admission }; },
3216
+ async sendMessage(input) { sent.push(input.message); return { ok: true, delivered: true }; } }
3217
+ });
3218
+ const inspect = () => changeover().send({ engineId: "codex", messages: [], input: { messageId: original.messageId }, inspectOnly: true });
3219
+ assert.equal((await inspect()).code, "conversation_changeover_delivery_unconfirmed", "An unknown receipt is never resent");
3220
+ admission = "not-admitted";
3221
+ if (inspectFirst) {
3222
+ const receipt = await inspect();
3223
+ assert.equal(receipt.notSent, true);
3224
+ assert.equal(receipt.messageId, original.messageId);
3225
+ assert.equal(value.engines.codex.pending.attempted, false);
3226
+ assert.deepEqual(sent, [], "Inspection never sends");
3227
+ }
3228
+ const delivered = await changeover().send({ engineId: "codex", messages: [],
3229
+ input: { messageId: original.messageId, message: "Original words." } });
3230
+ assert.equal(delivered.delivered, true);
3231
+ assert.deepEqual(sent, ["Prepared prompt"], "The proven unsent attempt is sent once, with its original prompt");
3232
+ }
3233
+ });
3234
+
3235
+ test("Cancel discards an unconfirmed native attempt so the next message is neither blocked nor replayed", async () => {
3236
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3237
+ let value = { lastEngine: "codex", engines: { codex: { seen: {}, pending: { messageId: "cancelled-request", message: "Prepared prompt",
3238
+ displayMessage: "Original words.", displayAttachments: [], attachmentIds: [], seen: {}, attempted: true, threadId: "thread" } } } };
3239
+ const sent = [];
3240
+ const changeover = createConversationChangeover({
3241
+ state: { async read() { return structuredClone(value); }, async write(next) { value = structuredClone(next); } },
3242
+ transcript: { async hasMessage() { return false; } },
3243
+ agent: { async inspectMessageAdmission() { return { admission: "unknown" }; },
3244
+ async sendMessage(input) { sent.push(input.messageId); return { ok: true, delivered: true }; } }
3245
+ });
3246
+ assert.equal((await changeover.send({ engineId: "codex", messages: [], input: { messageId: "next", message: "Next." } })).code,
3247
+ "conversation_changeover_delivery_unconfirmed", "An unknown attempt blocks the next message until it is resolved");
3248
+ assert.equal(await changeover.discard({ messageId: "other-request" }), false);
3249
+ assert.equal(await changeover.discard({ messageId: "cancelled-request" }), true);
3250
+ assert.equal(value.engines.codex.pending, undefined);
3251
+ assert.equal((await changeover.send({ engineId: "codex", messages: [], input: { messageId: "next", message: "Next." } })).delivered, true);
3252
+ assert.deepEqual(sent, ["next"], "The cancelled attempt is never replayed");
3253
+ });
3254
+
3204
3255
  test("initial native seed limits preserve generic history, full returning corrections and delivery ownership", async () => {
3205
3256
  const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3206
3257
  const attachment = { attachmentId: "retained-file", name: "retained.txt" };
@@ -1,6 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
3
5
  import { nativeConversationStoragePolicy } from "../src/server/conversation/nativeStorage.js";
6
+ import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
7
+ import { createCodexConversationDriver } from "../src/server/conversation/providers/codexDriver.js";
8
+ import { createOpenCodeSharedRuntime, openCodeServerForDirectory } from "../src/server/conversation/openCodeRuntime.js";
9
+ import { createOpenCodeConversationDriver } from "../src/server/conversation/providers/opencodeDriver.js";
4
10
 
5
11
  test("native storage policy distinguishes locked deletion from host writer exclusion", () => {
6
12
  assert.equal(nativeConversationStoragePolicy("codex").requiresWriterExclusion, false);
@@ -23,3 +29,281 @@ test("native writer recognition handles executables and node entry points withou
23
29
  assert.ok(opencode.matchesProcess({ executable: "/usr/bin/bun", commandLine: "bun\0/tools/opencode\0serve\0" }));
24
30
  assert.equal(opencode.matchesProcess({ executable: "/tools/opencode-helper", commandLine: "node\0/notes/opencode.txt\0" }), false);
25
31
  });
32
+
33
+ async function eventually(check, message) {
34
+ const deadline = Date.now() + 2_000;
35
+ while (!check()) {
36
+ if (Date.now() > deadline) assert.fail(message);
37
+ await new Promise(resolve => setTimeout(resolve, 5));
38
+ }
39
+ }
40
+
41
+ // One shared native process with independent provider connections, as the
42
+ // real Codex app-server has per account and runtime scope.
43
+ function codexStorageFixture(t, { idleMs = 20, listModelsMs = 0 } = {}) {
44
+ // Owned runtimes and sharing are process-wide, so each fixture has its own.
45
+ const runtimeDir = `/tmp/jskit-codex-storage-runtime-${randomUUID()}`;
46
+ const runtime = { running: false, starts: 0, stops: 0, reads: [] };
47
+ const owner = createCodexAppServerProviderOwner({ transientIdleMs: idleMs, providerFactory: () => ({
48
+ isHelperProvider: () => false,
49
+ isAvailable: () => runtime.running,
50
+ async currentRuntimeInfo() { return { runtimeDir }; },
51
+ async listModels() {
52
+ runtime.reads.push("catalogue");
53
+ await new Promise(resolve => setTimeout(resolve, listModelsMs));
54
+ return { running: runtime.running, data: [{ id: "gpt" }] };
55
+ },
56
+ async readGoal() { runtime.reads.push("goal"); return { goal: null }; },
57
+ async readPlanUsage() { runtime.reads.push("usage"); return { status: "available", windows: [] }; },
58
+ async ensureAvailable() {
59
+ if (!runtime.running) { runtime.running = true; runtime.starts += 1; }
60
+ return { ok: true, runtime: { runtimeDir } };
61
+ },
62
+ async listNativeThreadsForCwd(cwd) { return [`thread-in-${path.basename(cwd)}`]; },
63
+ async stopRuntime() { runtime.running = false; runtime.stops += 1; return { stopped: true }; },
64
+ close() {}
65
+ }) });
66
+ t.after(() => owner.beginShutdown());
67
+ const context = sessionId => ({
68
+ sessionId, providerKey: `provider:${sessionId}`, providerOptions: { runtimeDir },
69
+ keyFields: key => ({ sessionId: key }), assertAdmission() {}, closing: false,
70
+ runOwner: { runtimeLifecycle: { rememberManagedSession() {}, managedThreadId: () => "" } },
71
+ async prepare() {
72
+ return { parameters: { runtimeDir }, owner: { sessionKey: sessionId }, resources: null,
73
+ controls: { available: false, subscribe: () => () => {} }, runInContext: operation => operation() };
74
+ }
75
+ });
76
+ const driver = createCodexConversationDriver({ host: {} });
77
+ const list = sessionId => driver.listConversationStorage({
78
+ sessionId, context: {},
79
+ binding: { engineId: "codex", modelProviderId: "openai", workdir: `/archived/${sessionId}` },
80
+ native: { providerOwner: owner, errorPrefix: "", preparation: {
81
+ async storage(id) { return { providerContext: context(id), toolHomeSource: "/home/person" }; }
82
+ } }
83
+ });
84
+ return { owner, runtime, context, list };
85
+ }
86
+
87
+ test("a native storage listing leaves no Codex runtime or provider running", async t => {
88
+ const { owner, runtime, list } = codexStorageFixture(t);
89
+ assert.deepEqual((await list("archived-1")).map(row => row.conversationId), ["thread-in-archived-1"]);
90
+ assert.equal(runtime.running, true, "the listing itself uses the shared process");
91
+ await eventually(() => !runtime.running, "an unused storage listing kept Codex running");
92
+ assert.equal(runtime.stops, 1);
93
+ assert.deepEqual([...owner.providers.keys()], []);
94
+ });
95
+
96
+ test("consecutive native storage reads share one Codex process and stop it after the last", async t => {
97
+ const { owner, runtime, list } = codexStorageFixture(t, { idleMs: 40 });
98
+ await list("archived-1");
99
+ await list("archived-2");
100
+ await list("archived-1");
101
+ assert.equal(runtime.starts, 1);
102
+ await eventually(() => runtime.stops === 1 && owner.providers.get("provider:archived-2") === undefined,
103
+ "the last storage read did not release Codex");
104
+ assert.equal(runtime.running, false);
105
+ assert.deepEqual([...owner.providers.keys()], []);
106
+ });
107
+
108
+ test("native storage reads never stop a Codex process a conversation still uses", async t => {
109
+ const { owner, runtime, context, list } = codexStorageFixture(t);
110
+ const conversation = await owner.ensureSession(context("open-session"));
111
+ await list("archived-1");
112
+ // A conversation that claims a provider a storage read created keeps it too.
113
+ await list("claimed-session");
114
+ await owner.ensureSession(context("claimed-session"));
115
+ await eventually(() => owner.providers.get("provider:archived-1") === undefined, "the storage provider was not released");
116
+ await new Promise(resolve => setTimeout(resolve, 60));
117
+ assert.equal(runtime.running, true);
118
+ assert.equal(runtime.stops, 0);
119
+ assert.equal(owner.providers.get("provider:open-session"), conversation);
120
+ assert.ok(owner.providers.get("provider:claimed-session"));
121
+ });
122
+
123
+ // One shared `opencode serve`, as OpenCode runs per runtime scope.
124
+ function openCodeStorageFixture({ idleMs = 20 } = {}) {
125
+ const owner = createOpenCodeSharedRuntime({ storageIdleMs: idleMs });
126
+ const server = { running: false, starts: 0, stops: 0 };
127
+ const connection = { modelProviderId: "provider", canonicalUrl: "https://provider.invalid", fingerprint: "account", endpointCode: "api" };
128
+ const create = async () => {
129
+ server.running = true;
130
+ server.starts += 1;
131
+ return {
132
+ client: {
133
+ async health() { return { healthy: true }; },
134
+ async listConversationsForDirectory(directory) { return [{ id: `conversation-in-${path.basename(directory)}` }]; },
135
+ forDirectory(directory) { return { ...this, directory }; }
136
+ },
137
+ async stop() {
138
+ server.running = false;
139
+ server.stops += 1;
140
+ return { exited: true };
141
+ }
142
+ };
143
+ };
144
+ const native = { owner, preparation: { storage: () => ({
145
+ process: async () => ({ create, options: {}, connections: [connection] }),
146
+ failure: error => { throw error; }
147
+ }) } };
148
+ const driver = createOpenCodeConversationDriver({ connections: { resolve() {} }, host: { workdir: "/tmp" } });
149
+ const list = archived => driver.listConversationStorage({ native, context: {},
150
+ binding: { engineId: "opencode", modelProviderId: "provider", workdir: `/work/${archived}` } });
151
+ const conversation = key => owner.acquire(key, async () => {
152
+ const shared = await owner.ensure(connection, async () => ({ server: await create(), connections: [connection] }));
153
+ return { key, workdir: `/work/${key}`, abortController: new AbortController(),
154
+ server: openCodeServerForDirectory(shared.server, `/work/${key}`) };
155
+ });
156
+ return { owner, server, list, conversation };
157
+ }
158
+
159
+ test("an OpenCode storage listing leaves no OpenCode server running", async () => {
160
+ const { server, list } = openCodeStorageFixture();
161
+ assert.deepEqual((await list("archived-1")).map(row => row.conversationId), ["conversation-in-archived-1"]);
162
+ assert.equal(server.running, true, "the listing itself uses the shared server");
163
+ await eventually(() => !server.running, "an unused storage listing kept opencode serve running");
164
+ assert.equal(server.stops, 1);
165
+ });
166
+
167
+ test("consecutive OpenCode storage reads share one server and stop it after the last", async () => {
168
+ const { server, list } = openCodeStorageFixture({ idleMs: 40 });
169
+ await list("archived-1");
170
+ await list("archived-2");
171
+ assert.equal(server.starts, 1);
172
+ await eventually(() => server.stops === 1, "the last storage read did not stop opencode serve");
173
+ assert.equal(server.running, false);
174
+ });
175
+
176
+ test("OpenCode storage reads never stop a server a conversation still uses", async () => {
177
+ const { owner, server, list, conversation } = openCodeStorageFixture();
178
+ const open = await conversation("open-session");
179
+ await list("archived-1");
180
+ await new Promise(resolve => setTimeout(resolve, 60));
181
+ assert.equal(server.running, true);
182
+ assert.equal(server.stops, 0);
183
+ await owner.release(open);
184
+ assert.equal(server.running, false);
185
+ assert.equal(server.starts, 1);
186
+ });
187
+
188
+ test("a catalogue read on a storage-started Codex process finishes before its release stops it", async t => {
189
+ const { owner, runtime, list } = codexStorageFixture(t, { listModelsMs: 60 });
190
+ await list("archived-1");
191
+ // The release timer fires during this read; the shared lifecycle holds it back.
192
+ const catalogue = await owner.readModelCatalog({
193
+ prepareProviderOptions: async () => ({ runtimeDir: owner.ownedRuntimes.values().next().value.runtimeDir }),
194
+ providerFactory: () => assert.fail("The running storage process should serve the catalogue.")
195
+ });
196
+ assert.equal(catalogue.running, true, "the release stopped Codex during the catalogue read");
197
+ await eventually(() => !runtime.running, "the storage process was not released after the catalogue read");
198
+ assert.equal(runtime.starts, 1);
199
+ assert.equal(runtime.stops, 1);
200
+ });
201
+
202
+ test("goal and plan-usage reads never use a provider a storage read borrowed", async t => {
203
+ const { owner, runtime, list } = codexStorageFixture(t, { idleMs: 200 });
204
+ await list("archived-1");
205
+ assert.deepEqual(await owner.readSessionGoal("archived-1", "thread-1"), { status: "unavailable", goal: null });
206
+ assert.deepEqual(await owner.readPlanUsage("archived-1"), { status: "unavailable", windows: [] });
207
+ assert.deepEqual(runtime.reads, []);
208
+ });
209
+
210
+ test("real use that reaches a storage-created provider directly keeps it", async t => {
211
+ const { owner, runtime, context, list } = codexStorageFixture(t);
212
+ for (const use of [
213
+ // Helper account checks read the provider without ensureSession.
214
+ id => owner.withLifecycle(() => owner.providerForSession(context(id))),
215
+ id => owner.acquireRuntime({ providerKey: `provider:${id}`, provider: owner.providers.get(`provider:${id}`),
216
+ providerOptions: context(id).providerOptions, operation: async () => {} }),
217
+ id => owner.createProvider({ providerKey: `provider:${id}`, providerOptions: context(id).providerOptions })
218
+ ]) {
219
+ const id = `archived-${randomUUID()}`;
220
+ await list(id);
221
+ const provider = owner.providers.get(`provider:${id}`);
222
+ await use(id);
223
+ await new Promise(resolve => setTimeout(resolve, 60));
224
+ assert.equal(owner.providers.get(`provider:${id}`), provider);
225
+ }
226
+ assert.equal(runtime.running, true);
227
+ assert.equal(runtime.stops, 0);
228
+ });
229
+
230
+ test("a storage read on a managed main thread's key leaves it to that thread", async t => {
231
+ const { owner, runtime, context, list } = codexStorageFixture(t);
232
+ const managed = context("archived-1");
233
+ managed.runOwner.runtimeLifecycle.managedThreadId = () => "main-thread";
234
+ await owner.withTransientSession(managed, async () => {});
235
+ await new Promise(resolve => setTimeout(resolve, 60));
236
+ assert.ok(owner.providers.get("provider:archived-1"));
237
+ assert.equal(runtime.stops, 0);
238
+ await list("archived-2");
239
+ await eventually(() => owner.providers.get("provider:archived-2") === undefined, "the borrowed provider was not released");
240
+ assert.equal(runtime.running, true, "a managed main thread still shares the process");
241
+ });
242
+
243
+ test("a conversation that starts after an OpenCode storage read keeps the server", async () => {
244
+ const { owner, server, list, conversation } = openCodeStorageFixture({ idleMs: 40 });
245
+ await list("archived-1");
246
+ const open = await conversation("open-session");
247
+ await new Promise(resolve => setTimeout(resolve, 80));
248
+ assert.equal(server.running, true, "the storage release stopped a server a conversation uses");
249
+ assert.equal(server.starts, 1);
250
+ await owner.release(open);
251
+ assert.equal(server.running, false);
252
+ });
253
+
254
+ // The inventory keeps only thread ids. Long first prompts in each thread/list
255
+ // preview are not retained, so they cannot make a valid inventory fail.
256
+ function codexThreadListing(pages) {
257
+ const requests = [];
258
+ const client = {
259
+ async request(method, params) {
260
+ assert.equal(method, "thread/list");
261
+ requests.push(params);
262
+ const index = params.cursor ? Number(params.cursor) : 0;
263
+ const archived = params.archived === true;
264
+ const data = archived ? [] : pages[index] || [];
265
+ const nextCursor = !archived && index + 1 < pages.length ? String(index + 1) : null;
266
+ return { data, nextCursor };
267
+ }
268
+ };
269
+ const inventory = { activeClient: async () => client, runRequest: (operation) => operation(), errorPrefix: "vibe64_" };
270
+ return { inventory, requests };
271
+ }
272
+
273
+ function nativeThread(id, { cwd = "/source", previewBytes = 0 } = {}) {
274
+ return { id, cwd, preview: "p".repeat(previewBytes), status: { type: "notLoaded" } };
275
+ }
276
+
277
+ test("Codex native existence checks a home whose thread previews exceed any byte budget", async () => {
278
+ const pages = [
279
+ Array.from({ length: 100 }, (_, index) => nativeThread(`thread-${index}`, { previewBytes: 30 * 1024 })),
280
+ [nativeThread("wanted", { previewBytes: 70 * 1024 })]
281
+ ];
282
+ const { inventory } = codexThreadListing(pages);
283
+ const { CodexAppServerAgentProvider } = await import("../src/server/conversation/codexProvider.js");
284
+ assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "wanted"), true);
285
+ assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "missing"), false);
286
+ });
287
+
288
+ test("Codex native existence searches more threads than an inventory retains", async () => {
289
+ const pages = Array.from({ length: 12 }, (_, page) =>
290
+ Array.from({ length: 100 }, (_, index) => nativeThread(`thread-${page}-${index}`)));
291
+ const { inventory, requests } = codexThreadListing(pages);
292
+ const { CodexAppServerAgentProvider } = await import("../src/server/conversation/codexProvider.js");
293
+ assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "thread-3-7"), true);
294
+ assert.equal(requests.length, 4, "the search stops at the page that lists the thread");
295
+ assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "thread-12-0"), false);
296
+ });
297
+
298
+ test("a refused Codex inventory entry names the check and the thread", async () => {
299
+ const { CodexAppServerAgentProvider } = await import("../src/server/conversation/codexProvider.js");
300
+ const outside = codexThreadListing([[nativeThread("thread-a"), nativeThread("thread-b", { cwd: "/elsewhere" })]]);
301
+ await assert.rejects(CodexAppServerAgentProvider.prototype.listNativeThreadsForCwd.call(outside.inventory, "/source"), (error) => {
302
+ assert.equal(error.code, "vibe64_codex_retirement_inventory_invalid");
303
+ assert.match(error.message, /thread thread-b .*outside the requested directory/u);
304
+ return true;
305
+ });
306
+ const unnamed = codexThreadListing([[{ cwd: "/source" }]]);
307
+ await assert.rejects(CodexAppServerAgentProvider.prototype.listNativeThreadsForCwd.call(unnamed.inventory, "/source"),
308
+ /listed a thread without an id/u);
309
+ });
@@ -9,6 +9,7 @@ import { createCodexConversationAdapter } from "../src/server/conversation/provi
9
9
  import { createOpenCodeConversationAdapter } from "../src/server/conversation/providers/opencode.js";
10
10
  import { readPersistentConversation } from "../src/server/conversation/providers/scoped.js";
11
11
  import { createConversationTranscript, createMemoryConversationStorage } from "../src/server/conversation/index.js";
12
+ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
12
13
 
13
14
  test("scoped reads retain each request's output when an accepted steer interrupts the earlier reply", async () => {
14
15
  const transcript = createConversationTranscript({ storage: createMemoryConversationStorage() });
@@ -707,3 +708,68 @@ test("a user Pause during the final goal read prevents automatic settings recove
707
708
  assert.equal(f.requests.some(r => r.method === "thread/goal/set" && r.input.status === "active"), false);
708
709
  f.runtime.close();
709
710
  });
711
+
712
+ const logicalConversation = namespace => ({
713
+ sessionId: "session", namespace, engine: "claude",
714
+ read: async () => ({ engine: "claude", conversationLog: [], status: "ready" }),
715
+ readStream: async () => ({ messages: [] }),
716
+ commands: { send: async () => ({ status: "accepted" }), cancel: async () => ({ ok: true }),
717
+ select: async () => ({ ok: true }), inspectDelivery: async () => ({ status: "unknown" }) }
718
+ });
719
+
720
+ test("a deleted logical conversation retires its open entry without opening another", async () => {
721
+ const opened = [];
722
+ const runtime = createConversationRuntime({ authorize: () => true,
723
+ host: { conversation: ({ context }) => { opened.push(context.chat); return logicalConversation(`temporary-${context.chat}`); } } });
724
+ try {
725
+ const handle = await runtime.open({ id: "session", context: { chat: "one" } });
726
+ await handle.subscribe(() => {});
727
+ assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: true });
728
+ await assert.rejects(handle.read(), { code: "conversation_closed" });
729
+ await assert.rejects(handle.subscribe(() => {}), { code: "conversation_closed" });
730
+ assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: false },
731
+ "an absent entry is already retired; nothing is opened to retire it");
732
+ assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-two", sessionId: "session" }), { retired: false });
733
+ assert.deepEqual(opened, ["one"]);
734
+ const reopened = await runtime.open({ id: "session", context: { chat: "one" } });
735
+ assert.equal((await reopened.read()).status, "ready", "a later open starts a fresh entry");
736
+ } finally {
737
+ await runtime.close();
738
+ }
739
+ });
740
+
741
+ test("retiring a logical conversation keeps the live stream of the conversation that shares its id", { timeout: 15_000 }, async () => {
742
+ const source = new TransformStream();
743
+ const writer = source.writable.getWriter();
744
+ const frame = (content, finish = false) => new TextEncoder().encode(`data: ${JSON.stringify({ choices: [{ index: 0,
745
+ delta: content ? { content } : {}, ...(finish ? { finish_reason: "stop" } : {}) }] })}\n\n`);
746
+ const streamed = Promise.withResolvers();
747
+ const context = { applicationId: "test-app", subjectId: "owner" };
748
+ const connections = createAiConnectionResolver({ authorize: actor => actor, configuration: { schemaVersion: 1, registrations: {}, integrations: {
749
+ assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
750
+ } } });
751
+ const runtime = createConversationRuntime({ storage: createMemoryConversationStorage(), connections, authorize: () => true,
752
+ fetch: () => new Response(source.readable, { headers: { "content-type": "text/event-stream" } }),
753
+ host: { conversation: ({ context }) => context.chat ? logicalConversation(`temporary-${context.chat}`) : null } });
754
+ let finished = false;
755
+ try {
756
+ const main = await runtime.open({ id: "session", context, configuration: { systemPrompt: "Give concise answers.", integrationId: "assistant" } });
757
+ const chat = await runtime.open({ id: "session", context: { ...context, chat: "one" } });
758
+ await chat.subscribe(() => {});
759
+ await main.subscribe(event => { if (event.type === "message") streamed.resolve(); });
760
+ assert.equal((await main.send({ messageId: "main-message", text: "Hello" })).status, "accepted");
761
+ await writer.write(frame("Main words"));
762
+ await streamed.promise;
763
+ assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: true });
764
+ assert.deepEqual((await main.read()).streaming.messages.map(({ text }) => text), ["Main words"],
765
+ "a logical conversation owns no runtime stream, so retiring it leaves the same-id conversation's live reply");
766
+ await writer.write(frame(" and more"));
767
+ await writer.write(frame("", true));
768
+ await writer.close();
769
+ finished = true;
770
+ assert.equal((await main.wait()).conversationLog[0].assistant.text, "Main words and more");
771
+ } finally {
772
+ if (!finished) await writer.abort(new Error("The test ended before the reply.")).catch(() => {});
773
+ await runtime.close();
774
+ }
775
+ });
@@ -735,7 +735,7 @@ test("renewal proof cleanup preserves OpenCode failure, consumption and retry th
735
735
  assert.deepEqual(calls, ["proof-id"]);
736
736
  });
737
737
 
738
- test("Claude renewal proof routes use the original close owner and preserve retry after shutdown", async t => {
738
+ test("Claude renewal proof routes release only retained work and preserve retry after shutdown", async t => {
739
739
  const nativeContext = { sessionId: "renewal-one", key: "renewal-scope" };
740
740
  const calls = [];
741
741
  const failure = new Error("Terminal cleanup is unconfirmed.");
@@ -744,11 +744,7 @@ test("Claude renewal proof routes use the original close owner and preserve retr
744
744
  configRoot: "/unused-native-config",
745
745
  preparation: { context() { assert.fail("No saved native conversations exist."); } },
746
746
  process: { create() { assert.fail("Proof release must not start a process."); } },
747
- store: { readSessionConversations(current) {
748
- assert.equal(current, nativeContext);
749
- calls.push("restore");
750
- return { context: current, ids: [] };
751
- } }
747
+ store: { readSessionConversations() { assert.fail("Proof release must not reopen saved conversations."); } }
752
748
  });
753
749
  const application = {
754
750
  readContext(current) { assert.equal(current, nativeContext); calls.push("context"); return current; },
@@ -777,7 +773,7 @@ test("Claude renewal proof routes use the original close owner and preserve retr
777
773
  ok: true, closed: 1, processExitProof: { exited: true, scopeEmpty: true }, processExitProofs: []
778
774
  });
779
775
  }
780
- assert.deepEqual(calls, Array(3).fill(["context", "restore", "terminal"]).flat());
776
+ assert.deepEqual(calls, Array(3).fill(["context", "terminal"]).flat());
781
777
  assert.equal(owner.closingSessions.size, 0);
782
778
  assert.equal(owner.entries.size, 0);
783
779
  });
@@ -929,7 +925,7 @@ test("native activity uses the configured inventory without opening Main, includ
929
925
  const acquire = () => assert.fail("The native activity owner controls receipt restoration.");
930
926
  const inspect = (...args) => {
931
927
  calls.push("inspect");
932
- if (engine === "claude") assert.deepEqual(args, [preparedContext, { acquire }]);
928
+ if (engine === "claude") assert.deepEqual(args, [preparedContext]);
933
929
  else assert.deepEqual(args, [id]);
934
930
  return true;
935
931
  };