@jskit-ai/assistant-core 0.1.200 → 0.1.202

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 (164) hide show
  1. package/examples/conversation/README.md +8 -1
  2. package/examples/conversation/package.json +5 -5
  3. package/fixtures/responsive-assistant/App.vue +4 -1
  4. package/package.json +99 -36
  5. package/src/client/conversation/AssistantConversationElement.vue +27 -4
  6. package/src/client/conversation/AssistantConversationStatus.vue +24 -0
  7. package/src/client/conversation/AssistantGoalControl.vue +8 -5
  8. package/src/client/conversation/AssistantTranscript.vue +10 -3
  9. package/src/client/conversation/index.js +2 -0
  10. package/src/client/conversation/messageDelivery.js +115 -32
  11. package/src/client/conversation/retainedConversation.js +32 -0
  12. package/src/client/conversation/useAssistantQuestions.js +76 -0
  13. package/src/client/lib/assistantApi.js +72 -2
  14. package/src/server/conversation/agentRun.js +222 -0
  15. package/src/server/conversation/attachments.js +93 -0
  16. package/src/server/conversation/claudeClient.js +221 -0
  17. package/src/server/conversation/claudeHistory.js +256 -0
  18. package/src/server/conversation/claudeProcess.js +327 -0
  19. package/src/server/conversation/claudeTools.js +42 -0
  20. package/src/server/conversation/claudeTurn.js +1082 -0
  21. package/src/server/conversation/codexConfiguration.js +1183 -0
  22. package/src/server/conversation/codexConversations.js +1082 -0
  23. package/src/server/conversation/codexDetachedTurn.js +332 -0
  24. package/src/server/conversation/codexEvents.js +194 -1
  25. package/src/server/conversation/codexGoalCommands.js +124 -0
  26. package/src/server/conversation/codexHelperExecution.js +306 -0
  27. package/src/server/conversation/codexHelperThreadLedger.js +804 -0
  28. package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
  29. package/src/server/conversation/codexHelperThreads.js +638 -0
  30. package/src/server/conversation/codexHistoryAdapter.js +369 -0
  31. package/src/server/conversation/codexMessageCommands.js +663 -0
  32. package/src/server/conversation/codexMessageDelivery.js +211 -0
  33. package/src/server/conversation/codexNativeHistoryExport.js +95 -0
  34. package/src/server/conversation/codexNotificationObserver.js +482 -0
  35. package/src/server/conversation/codexNotificationQueue.js +103 -0
  36. package/src/server/conversation/codexObservationControl.js +174 -0
  37. package/src/server/conversation/codexOutput.js +931 -0
  38. package/src/server/conversation/codexProcess.js +1559 -0
  39. package/src/server/conversation/codexProcessLeader.js +14 -0
  40. package/src/server/conversation/codexProvider.js +2710 -0
  41. package/src/server/conversation/codexProviderOwner.js +1058 -0
  42. package/src/server/conversation/codexProviderSelection.js +218 -0
  43. package/src/server/conversation/codexReasoning.js +314 -0
  44. package/src/server/conversation/codexRenewalCommands.js +290 -0
  45. package/src/server/conversation/codexResultSettlement.js +409 -0
  46. package/src/server/conversation/codexRunJournal.js +658 -0
  47. package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
  48. package/src/server/conversation/codexSessionCleanup.js +96 -0
  49. package/src/server/conversation/codexSessionReconciliation.js +284 -0
  50. package/src/server/conversation/codexThreadReadiness.js +397 -0
  51. package/src/server/conversation/codexThreadStatus.js +354 -0
  52. package/src/server/conversation/codexTurn.js +701 -255
  53. package/src/server/conversation/codexTurnControl.js +317 -0
  54. package/src/server/conversation/codexTurnRecovery.js +990 -0
  55. package/src/server/conversation/codexTurnState.js +549 -0
  56. package/src/server/conversation/commandHook.js +18 -0
  57. package/src/server/conversation/commandWrapper.js +37 -0
  58. package/src/server/conversation/configuration.js +43 -0
  59. package/src/server/conversation/continuity.js +289 -0
  60. package/src/server/conversation/fileStorage.js +55 -0
  61. package/src/server/conversation/hookBridge.js +19 -0
  62. package/src/server/conversation/index.js +13 -0
  63. package/src/server/conversation/localExecution.js +665 -0
  64. package/src/server/conversation/memoryStorage.js +7 -72
  65. package/src/server/conversation/nativeHistoryExport.js +90 -0
  66. package/src/server/conversation/nativeStorage.js +23 -0
  67. package/src/server/conversation/openCodeClient.js +209 -19
  68. package/src/server/conversation/openCodePlugin.js +84 -0
  69. package/src/server/conversation/openCodeProcess.js +476 -0
  70. package/src/server/conversation/openCodeRuntime.js +1578 -0
  71. package/src/server/conversation/openCodeTools.js +60 -0
  72. package/src/server/conversation/openCodeTurn.js +557 -0
  73. package/src/server/conversation/provider.js +30 -0
  74. package/src/server/conversation/providers/api.js +136 -0
  75. package/src/server/conversation/providers/claude.js +63 -0
  76. package/src/server/conversation/providers/claudeDriver.js +425 -0
  77. package/src/server/conversation/providers/codex.js +248 -0
  78. package/src/server/conversation/providers/codexDriver.js +1078 -0
  79. package/src/server/conversation/providers/opencode.js +49 -0
  80. package/src/server/conversation/providers/opencodeDriver.js +650 -0
  81. package/src/server/conversation/providers/scoped.js +312 -0
  82. package/src/server/conversation/runtime.js +2220 -0
  83. package/src/server/conversation/runtimeStateUpgrade.js +125 -0
  84. package/src/server/conversation/storage.js +184 -0
  85. package/src/server/conversation/streams.js +7 -2
  86. package/src/server/conversation/structuredOutput.js +240 -0
  87. package/src/server/conversation/systemPrompt.js +54 -0
  88. package/src/server/conversation/tools.js +84 -0
  89. package/src/server/conversation/transcript.js +5 -4
  90. package/src/server/hosts/claudeHistory.js +5 -0
  91. package/src/server/hosts/claudeProcess.js +11 -0
  92. package/src/server/hosts/claudeTurn.js +4 -0
  93. package/src/server/hosts/codexConfiguration.js +20 -0
  94. package/src/server/hosts/codexEvents.js +13 -0
  95. package/src/server/hosts/codexProcess.js +12 -0
  96. package/src/server/hosts/codexProvider.js +23 -0
  97. package/src/server/hosts/codexTurn.js +20 -0
  98. package/src/server/hosts/nativeHistory.js +3 -0
  99. package/src/server/hosts/openCodeClient.js +3 -0
  100. package/src/server/hosts/openCodeProcess.js +23 -0
  101. package/src/server/hosts/openCodeTurn.js +7 -0
  102. package/src/server/index.js +2 -0
  103. package/src/server/lib/aiConnectionClient.js +13 -6
  104. package/src/server/lib/assistantCompletion.js +190 -0
  105. package/src/server/lib/assistantToolLoop.js +314 -0
  106. package/src/server/lib/serviceToolCatalog.js +25 -10
  107. package/src/shared/assistantResponseText.js +29 -0
  108. package/src/shared/conversation/attachments.js +19 -0
  109. package/src/shared/conversation/index.js +3 -0
  110. package/src/shared/conversation/pagination.js +78 -0
  111. package/src/shared/conversation/patches.js +150 -0
  112. package/src/shared/conversation/questions.js +48 -0
  113. package/src/shared/conversation/streaming.js +7 -4
  114. package/src/shared/index.js +2 -0
  115. package/src/shared/nativeProviders.js +45 -0
  116. package/src/testing/conversationStorageContract.js +31 -1
  117. package/src/testing/nativeCodex.js +11 -0
  118. package/src/testing/nativeOpenCode.js +3 -0
  119. package/test/aiConnectionClient.test.js +2 -0
  120. package/test/apiConversationDriver.test.js +73 -0
  121. package/test/assistantApiSurfaceHeader.test.js +85 -0
  122. package/test/assistantBoundedToolLoop.test.js +285 -0
  123. package/test/assistantScroll.browser.test.js +16 -0
  124. package/test/claudeClient.test.js +189 -0
  125. package/test/claudeConversationSession.test.js +1173 -0
  126. package/test/claudeInstructionResume.test.js +82 -0
  127. package/test/claudeProcess.test.js +204 -0
  128. package/test/claudeTurn.test.js +253 -0
  129. package/test/codexConversationSession.test.js +2122 -0
  130. package/test/codexGoal.test.js +37 -0
  131. package/test/codexHelperDeletion.test.js +163 -0
  132. package/test/codexHelperThreadLedger.test.js +494 -0
  133. package/test/codexHistoryAdapter.test.js +541 -0
  134. package/test/codexInteractiveArguments.test.js +30 -0
  135. package/test/codexIsolation.test.js +756 -0
  136. package/test/codexModelCatalog.test.js +362 -0
  137. package/test/codexNativeHistoryExport.test.js +81 -0
  138. package/test/codexThreadSelection.test.js +136 -0
  139. package/test/codexTurn.test.js +2158 -0
  140. package/test/conversation.test.js +110 -0
  141. package/test/conversationAgentRun.test.js +326 -0
  142. package/test/conversationApiFacilities.test.js +90 -0
  143. package/test/conversationAttachments.test.js +238 -0
  144. package/test/conversationCommandWrapper.test.js +48 -0
  145. package/test/conversationConfiguration.test.js +174 -0
  146. package/test/conversationNativeStorage.test.js +25 -0
  147. package/test/conversationReplacement.test.js +172 -0
  148. package/test/conversationRuntime.test.js +241 -0
  149. package/test/conversationRuntimeStateUpgrade.test.js +144 -0
  150. package/test/conversationSession.test.js +1080 -0
  151. package/test/conversationStreaming.test.js +68 -0
  152. package/test/conversationSystemPrompt.test.js +84 -0
  153. package/test/conversationTools.test.js +416 -0
  154. package/test/fileConversationStorage.test.js +73 -0
  155. package/test/fixtures/codexHelperThreadLedger.js +39 -0
  156. package/test/localConversationExecution.test.js +90 -0
  157. package/test/messageDelivery.test.js +234 -1
  158. package/test/nativeConversationRetirement.test.js +215 -0
  159. package/test/openCodeClient.test.js +515 -0
  160. package/test/openCodeProcess.test.js +177 -0
  161. package/test/openCodeRuntime.test.js +322 -0
  162. package/test/openCodeTools.test.js +287 -0
  163. package/test/opencodeConversationSession.test.js +789 -0
  164. package/test/serviceToolCatalog.test.js +24 -0
@@ -0,0 +1,177 @@
1
+ import assert from "node:assert/strict";
2
+ import { access, mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { createOpenCodeServerProcess } from "../src/server/conversation/openCodeProcess.js";
7
+
8
+ async function fixture(t, options = {}) {
9
+ const workdir = await mkdtemp(path.join(os.tmpdir(), "jskit-opencode-process-"));
10
+ t.after(() => rm(workdir, { recursive: true, force: true }));
11
+ const exited = Promise.withResolvers();
12
+ const starts = [], stops = [], requests = [];
13
+ const execution = {
14
+ async start(request) {
15
+ starts.push(request);
16
+ return { id: "owned-process", pid: 123, running: true, exited: exited.promise,
17
+ readLogs: () => ({ stderr: "native startup evidence", stdout: "" }) };
18
+ },
19
+ async stop(id) {
20
+ stops.push(id);
21
+ if (options.stop) return options.stop();
22
+ exited.resolve({ code: 0 });
23
+ return { scopeEmpty: true };
24
+ }
25
+ };
26
+ const config = { execution, workdir, privateRoot: path.join(workdir, "private"), port: 12345,
27
+ env: { OPENCODE_SERVER_PASSWORD: "obsolete-password" },
28
+ async fetchImpl(url, request) {
29
+ requests.push({ url, request });
30
+ assert.equal(request.headers.authorization, `Basic ${Buffer.from(`opencode:${starts[0].env.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`);
31
+ return options.respond ? options.respond(url, request) : new Response(JSON.stringify({ healthy: true, version: "1.18.31" }));
32
+ }
33
+ };
34
+ return { config, starts, stops, requests, exited };
35
+ }
36
+
37
+ test("the shared OpenCode server authenticates selected accounts and drains its host before deleting private state", async t => {
38
+ const f = await fixture(t);
39
+ const native = await createOpenCodeServerProcess({ ...f.config, connections: [
40
+ { providerId: "first", apiKey: "one" }, { providerId: "second", apiKey: "two" }, { providerId: "first", apiKey: "one" }
41
+ ] });
42
+ assert.deepEqual(native.modelProviderIds, ["first", "second"]);
43
+ assert.deepEqual(f.requests.map(({ url }) => url.pathname), ["/global/health", "/auth/first", "/auth/second"]);
44
+ assert.notEqual(native.environment.OPENCODE_SERVER_PASSWORD, "obsolete-password");
45
+ assert.equal(native.executionId, "owned-process");
46
+ assert.deepEqual(native.attachArguments({ conversationId: "ses_one", directory: f.config.workdir }),
47
+ ["attach", "http://127.0.0.1:12345", "--dir", f.config.workdir, "--session", "ses_one", "--pure"]);
48
+ assert.equal((await native.stop()).scopeEmpty, true);
49
+ await native.stop();
50
+ assert.deepEqual(f.stops, ["owned-process"]);
51
+ await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
52
+ });
53
+
54
+ test("version mismatch preserves startup evidence and the owned identity if cleanup is unconfirmed", async t => {
55
+ let confirmed = false;
56
+ let failure;
57
+ const f = await fixture(t, { stop: () => ({ scopeEmpty: confirmed }),
58
+ respond: () => new Response(JSON.stringify({ healthy: true, version: "unsupported" })) });
59
+ await assert.rejects(createOpenCodeServerProcess(f.config), error => {
60
+ assert.equal(error.code, "assistant_opencode_version_mismatch");
61
+ assert.equal(error.executionId, "owned-process");
62
+ assert.equal(error.cleanupFailed, true);
63
+ assert.equal(error.stopProof.scopeEmpty, false);
64
+ assert.equal(error.details.stderr, "native startup evidence");
65
+ failure = error;
66
+ return true;
67
+ });
68
+ await access(f.config.privateRoot);
69
+ assert.deepEqual(f.stops, ["owned-process"]);
70
+ confirmed = true;
71
+ assert.equal((await failure.retryCleanup()).scopeEmpty, true);
72
+ assert.deepEqual(f.stops, ["owned-process", "owned-process"]);
73
+ await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
74
+ });
75
+
76
+ test("cancelled health observation stops the server before startup can return", async t => {
77
+ const waiting = Promise.withResolvers();
78
+ const f = await fixture(t, { respond: (_url, { signal }) => new Promise((_, reject) => {
79
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
80
+ waiting.resolve();
81
+ }) });
82
+ const cancellation = new AbortController();
83
+ const starting = createOpenCodeServerProcess({ ...f.config, signal: cancellation.signal });
84
+ await waiting.promise;
85
+ cancellation.abort(new Error("Start cancelled"));
86
+ await assert.rejects(starting, /Start cancelled/u);
87
+ assert.deepEqual(f.stops, ["owned-process"]);
88
+ await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
89
+ });
90
+
91
+ test("an unexpected native exit reports failure after readiness", async t => {
92
+ const f = await fixture(t);
93
+ const failure = Promise.withResolvers();
94
+ const native = await createOpenCodeServerProcess({ ...f.config, onFailure: failure.resolve });
95
+ f.exited.resolve({ code: 1 });
96
+ assert.match((await failure.promise).message, /process exited/u);
97
+ await native.stop();
98
+ });
99
+
100
+ test("failed host startup retains its execution identity until cleanup is proven", async t => {
101
+ const f = await fixture(t, { stop: () => ({ scopeEmpty: false }) });
102
+ const bindings = [];
103
+ f.config.execution.start = async () => { throw Object.assign(new Error("Host startup failed"), {
104
+ executionId: "partially-started", stopProof: { scopeEmpty: false }
105
+ }); };
106
+ await assert.rejects(createOpenCodeServerProcess({ ...f.config, onStarted: id => bindings.push(id) }), error => {
107
+ assert.equal(error.executionId, "partially-started");
108
+ assert.equal(error.cleanupFailed, true);
109
+ assert.equal(error.stopProof.scopeEmpty, false);
110
+ return true;
111
+ });
112
+ assert.deepEqual(bindings, ["partially-started"]);
113
+ assert.deepEqual(f.stops, ["partially-started"]);
114
+ await access(f.config.privateRoot);
115
+ });
116
+
117
+ test("production readiness requires boolean health and trims the native version", async t => {
118
+ let reads = 0;
119
+ const f = await fixture(t, { respond: () => new Response(JSON.stringify({
120
+ healthy: ++reads === 1 ? "yes" : true, version: " 1.18.31 "
121
+ })) });
122
+ const native = await createOpenCodeServerProcess(f.config);
123
+ assert.equal(reads, 2);
124
+ assert.equal(native.health.healthy, true);
125
+ await native.stop();
126
+ });
127
+
128
+ test("production startup timeout keeps the last health failure and confirms cleanup", async t => {
129
+ const unavailable = new Error("native health unavailable");
130
+ const f = await fixture(t, { respond: () => { throw unavailable; } });
131
+ await assert.rejects(createOpenCodeServerProcess({ ...f.config, readinessTimeoutMs: 20 }), error => {
132
+ assert.equal(error.code, "assistant_opencode_start_timeout");
133
+ assert.equal(error.message, "OpenCode did not become ready before the startup deadline.");
134
+ assert.equal(error.cause, unavailable);
135
+ assert.equal(error.stopProof.exited, true);
136
+ return true;
137
+ });
138
+ assert.deepEqual(f.stops, ["owned-process"]);
139
+ await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
140
+ });
141
+
142
+ test("native authentication does not spend the production readiness deadline", async t => {
143
+ const cancellation = new AbortController();
144
+ const f = await fixture(t, { respond: async (url, { signal }) => {
145
+ if (url.pathname === "/global/health") return new Response(JSON.stringify({ healthy: true, version: "1.18.31" }));
146
+ assert.equal(signal, cancellation.signal);
147
+ await new Promise((resolve, reject) => {
148
+ const timer = setTimeout(resolve, 40);
149
+ signal.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true });
150
+ });
151
+ return new Response("{}");
152
+ } });
153
+ const native = await createOpenCodeServerProcess({ ...f.config, readinessTimeoutMs: 20, signal: cancellation.signal,
154
+ connections: [{ providerId: "provider", apiKey: "key" }] });
155
+ assert.deepEqual(native.modelProviderIds, ["provider"]);
156
+ await native.stop();
157
+ });
158
+
159
+ test("production stop retains failure for retry and keeps the complete confirmed proof", async t => {
160
+ let attempt = 0;
161
+ const f = await fixture(t, { stop: () => {
162
+ attempt++;
163
+ if (attempt === 1) return { scopeEmpty: false };
164
+ if (attempt === 2) throw new Error("stop temporarily unavailable");
165
+ return { scopeEmpty: true, executionId: "owned-process", stopped: true };
166
+ } });
167
+ const native = await createOpenCodeServerProcess(f.config);
168
+ assert.deepEqual(await native.stop(), { scopeEmpty: false, exited: false });
169
+ await access(f.config.privateRoot);
170
+ await assert.rejects(native.stop(), /stop temporarily unavailable/);
171
+ await access(f.config.privateRoot);
172
+ const proof = await native.stop();
173
+ assert.deepEqual(proof, { scopeEmpty: true, executionId: "owned-process", stopped: true, exited: true });
174
+ assert.equal(await native.stop(), proof);
175
+ assert.equal(attempt, 3);
176
+ await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
177
+ });
@@ -0,0 +1,322 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { createOpenCodeSharedRuntime, ensureOpenCodeSession, openCodeServerForDirectory, readOpenCodeEnvironments } from "../src/server/conversation/openCodeRuntime.js";
7
+
8
+ function fixture(options = {}) {
9
+ const runtime = createOpenCodeSharedRuntime();
10
+ const starts = [], stops = [];
11
+ const connection = { modelProviderId: "provider", canonicalUrl: "https://provider.invalid", fingerprint: "account", endpointCode: "api" };
12
+ const start = async selected => {
13
+ const server = {
14
+ client: {
15
+ async health() { return { healthy: true }; },
16
+ async interrupt(id) { return options.interrupt ? options.interrupt(id) : true; },
17
+ async sessionStatus() { return { type: "idle" }; },
18
+ forDirectory(directory) { return { ...this, directory }; }
19
+ },
20
+ async stop() {
21
+ stops.push(server);
22
+ return options.stop ? options.stop(server) : { exited: true };
23
+ }
24
+ };
25
+ starts.push(server);
26
+ return { server, connections: [selected] };
27
+ };
28
+ const acquire = (key, { selected = connection, prepare } = {}) => runtime.acquire(key, async () => {
29
+ await prepare?.();
30
+ const shared = await runtime.ensure(selected, () => start(selected));
31
+ return { key, workdir: `/work/${key}`, abortController: new AbortController(),
32
+ server: openCodeServerForDirectory(shared.server, `/work/${key}`) };
33
+ });
34
+ return { runtime, acquire, connection, starts, stops };
35
+ }
36
+
37
+ test("history projection supplies ordered reasoning facts without native part envelopes", async () => {
38
+ const runtime = createOpenCodeSharedRuntime();
39
+ const message = { id: "reply", type: "assistant", time: { created: 1_000 }, content: [
40
+ { type: "tool", id: "tool", text: "Not reasoning" },
41
+ { type: "reasoning", id: "first", text: " First thought\n", time: { end: 0 }, nativeOnly: true },
42
+ { type: "reasoning", id: "empty", text: " \n " },
43
+ { type: "reasoning", text: "Second thought", time: { end: 2_000 } }
44
+ ] };
45
+ const original = structuredClone(message);
46
+ const calls = [];
47
+ const projection = {
48
+ readError: () => "",
49
+ reasoning: async (parts, options) => { calls.push({ parts, options }); }
50
+ };
51
+ await runtime.writeConversationProjection("conversation", [message], { streaming: true }, projection);
52
+ await runtime.writeConversationProjection("conversation", [message], { streaming: false }, projection);
53
+ const facts = [
54
+ { messageId: "reply", partId: "first", text: " First thought\n", createdAt: 1_000, complete: false },
55
+ { messageId: "reply", partId: undefined, text: "Second thought", createdAt: 1_000, complete: true }
56
+ ];
57
+ assert.deepEqual(calls, [
58
+ { parts: facts, options: { complete: false, flush: false } },
59
+ { parts: facts, options: { complete: true, flush: true } }
60
+ ]);
61
+ assert.deepEqual(message, original, "normalization must not mutate native history");
62
+ });
63
+
64
+ test("native session acquisition stays coalesced through publication and retries a failed registry write", async () => {
65
+ const calls = [];
66
+ const publishing = Promise.withResolvers();
67
+ const release = Promise.withResolvers();
68
+ const selection = { agentId: "assistant", modelId: "model", modelProviderId: "provider", variantId: "" };
69
+ const model = { id: "model", providerID: "provider" };
70
+ const target = { workdir: "/work/project", server: { client: {
71
+ async prepareDirectory() {},
72
+ async createSession(input) {
73
+ calls.push("create");
74
+ assert.deepEqual(input, { agent: "assistant", location: { directory: target.workdir }, model });
75
+ return { id: "native-session" };
76
+ }
77
+ } } };
78
+ let failPublication = true;
79
+ const identity = {
80
+ async write(id) {
81
+ calls.push("write");
82
+ assert.equal(id, "native-session");
83
+ if (failPublication) assert.equal(target.upstream, undefined);
84
+ },
85
+ async publish(id) {
86
+ calls.push("publish");
87
+ assert.equal(target.upstreamSessionId, id);
88
+ assert.deepEqual(target.upstream, { id });
89
+ assert.deepEqual(target.upstreamSelection, selection);
90
+ assert.notEqual(target.upstreamSelection, selection);
91
+ if (failPublication) { publishing.resolve(); await release.promise; }
92
+ }
93
+ };
94
+ const first = ensureOpenCodeSession(target, { selection, model, identity });
95
+ const failedFirst = assert.rejects(first, /registry unavailable/);
96
+ await publishing.promise;
97
+ const pending = target.upstreamStart;
98
+ const joined = ensureOpenCodeSession(target, { selection, model, identity });
99
+ const failedJoined = assert.rejects(joined, /registry unavailable/);
100
+ assert.equal(target.upstreamStart, pending);
101
+ assert.deepEqual(calls, ["create", "write", "publish"]);
102
+ release.reject(new Error("registry unavailable"));
103
+ await Promise.all([failedFirst, failedJoined]);
104
+ assert.equal(target.upstreamStart, undefined);
105
+ failPublication = false;
106
+ assert.equal(await ensureOpenCodeSession(target, { selection, model, identity }), target);
107
+ assert.deepEqual(calls, ["create", "write", "publish", "write", "publish"]);
108
+ });
109
+
110
+ test("retained native selection reads its exact ID before model, agent and identity publication", async () => {
111
+ const calls = [];
112
+ const selection = { agentId: "assistant", modelId: "model", modelProviderId: "provider", variantId: "high" };
113
+ const model = { id: "model", providerID: "provider", variant: "high" };
114
+ const target = { upstreamSessionId: "retained-session", workdir: "/work/project", server: { client: {
115
+ async prepareDirectory() {},
116
+ async readSession(id) { calls.push(["read", id]); return { id }; },
117
+ async switchModel(id, value) { calls.push(["model", id, value]); },
118
+ async switchAgent(id, value) { calls.push(["agent", id, value]); },
119
+ async createSession() { assert.fail("A retained native identity must never create another session."); }
120
+ } } };
121
+ const identity = {
122
+ async write(id) { calls.push(["write", id]); },
123
+ async publish(id) { calls.push(["publish", id]); }
124
+ };
125
+ assert.equal(await ensureOpenCodeSession(target, { selection, model, identity }), target);
126
+ assert.deepEqual(calls, [["read", "retained-session"], ["model", "retained-session", model],
127
+ ["agent", "retained-session", "assistant"], ["write", "retained-session"], ["publish", "retained-session"]]);
128
+ calls.length = 0;
129
+ await ensureOpenCodeSession(target, { selection: { ...selection, modelId: " model " }, model, identity });
130
+ assert.deepEqual(calls, [["write", "retained-session"], ["publish", "retained-session"]]);
131
+ });
132
+
133
+ test("cold directory readiness holds retained identity publication and remains retryable", async () => {
134
+ const entered = Promise.withResolvers();
135
+ const ready = Promise.withResolvers();
136
+ const calls = [];
137
+ let first = true;
138
+ const target = { upstreamSessionId: "retained-session", workdir: "/work/project", server: { client: {
139
+ async prepareDirectory() {
140
+ calls.push("prepare");
141
+ entered.resolve();
142
+ if (first) { first = false; await ready.promise; }
143
+ },
144
+ async readSession(id) { calls.push(["read", id]); return { id }; },
145
+ async switchModel() { calls.push("model"); },
146
+ async switchAgent() { calls.push("agent"); },
147
+ async createSession() { assert.fail("Cold readiness must retain the saved native identity."); }
148
+ } } };
149
+ const options = { selection: { agentId: "assistant" }, model: { id: "model", providerID: "provider" },
150
+ identity: { write: id => calls.push(["write", id]), publish: id => calls.push(["publish", id]) } };
151
+ const acquiring = ensureOpenCodeSession(target, options);
152
+ const failed = assert.rejects(acquiring, /directory preparation failed/);
153
+ await entered.promise;
154
+ assert.deepEqual(calls, ["prepare"]);
155
+ assert.equal(target.upstream, undefined);
156
+ const joined = assert.rejects(ensureOpenCodeSession(target, options), /directory preparation failed/);
157
+ ready.reject(new Error("directory preparation failed"));
158
+ await Promise.all([failed, joined]);
159
+ assert.equal(target.upstreamStart, undefined);
160
+ assert.equal(target.upstreamSessionId, "retained-session");
161
+ assert.equal(await ensureOpenCodeSession(target, options), target);
162
+ assert.deepEqual(calls, ["prepare", "prepare", ["read", "retained-session"], "model", "agent",
163
+ ["write", "retained-session"], ["publish", "retained-session"]]);
164
+ });
165
+
166
+ // Retains the production controller's lazy reuse and last-consumer assertions;
167
+ // the fixture supplies startup directly instead of application session storage.
168
+ test("OpenCode shares one lazy server across open sessions and stops it after the last closes", async () => {
169
+ const f = fixture();
170
+ assert.equal(f.starts.length, 0);
171
+ const first = await f.acquire("session-1");
172
+ const second = await f.acquire("session-2");
173
+ assert.equal(f.starts.length, 1);
174
+ assert.equal(first.server.stop, second.server.stop);
175
+ assert.equal(first.server.client.directory, "/work/session-1");
176
+ assert.equal(second.server.client.directory, "/work/session-2");
177
+ const firstClose = await f.runtime.release(first);
178
+ assert.equal(firstClose.sharedProcessRetained, true);
179
+ assert.equal(f.stops.length, 0);
180
+ const lastClose = await f.runtime.release(second);
181
+ assert.equal(lastClose.exited, true);
182
+ assert.equal(f.stops.length, 1);
183
+ const reopened = await f.acquire("session-1");
184
+ assert.equal(f.starts.length, 2);
185
+ assert.equal((await f.runtime.release(reopened)).exited, true);
186
+ assert.equal(f.stops.length, 2);
187
+ });
188
+
189
+ test("a pending OpenCode session start retains the shared server while another session closes", async () => {
190
+ const f = fixture();
191
+ const first = await f.acquire("session-1");
192
+ const pending = Promise.withResolvers();
193
+ const reached = Promise.withResolvers();
194
+ const starting = f.acquire("session-2", { prepare: () => { reached.resolve(); return pending.promise; } });
195
+ await reached.promise;
196
+ const closed = await f.runtime.release(first);
197
+ assert.equal(closed.sharedProcessRetained, true);
198
+ assert.equal(f.stops.length, 0);
199
+ pending.resolve();
200
+ const second = await starting;
201
+ assert.equal(f.starts.length, 1);
202
+ assert.equal((await f.runtime.release(second)).exited, true);
203
+ assert.equal(f.stops.length, 1);
204
+ });
205
+
206
+ test("OpenCode failed acquisition cleans application state once before stopping an unused server", async () => {
207
+ const f = fixture();
208
+ const ready = Promise.withResolvers();
209
+ const fail = Promise.withResolvers();
210
+ const cleanup = [];
211
+ const starting = f.runtime.acquire("session", async () => {
212
+ await f.runtime.ensure(f.connection, async () => {
213
+ const server = { client: {}, stop: async () => { cleanup.push("server"); return { exited: true }; } };
214
+ return { connections: [f.connection], server };
215
+ });
216
+ ready.resolve();
217
+ await fail.promise;
218
+ }, async () => { cleanup.push("application"); });
219
+ await ready.promise;
220
+ const joined = f.runtime.acquire("session", () => assert.fail("A pending acquisition must be reused"));
221
+ fail.reject(new Error("Preparation failed"));
222
+ await assert.rejects(starting, /Preparation failed/);
223
+ await assert.rejects(joined, /Preparation failed/);
224
+ assert.deepEqual(cleanup, ["application", "server"]);
225
+ assert.equal(f.runtime.processStarts.size, 0);
226
+ assert.equal(f.runtime.current, null);
227
+ });
228
+
229
+ test("OpenCode observation loss leaves cleanup retryable until exit is confirmed", async () => {
230
+ let confirmed = false;
231
+ const f = fixture({ interrupt: () => false, stop: () => ({ exited: confirmed }) });
232
+ const first = await f.acquire("main");
233
+ const second = await f.acquire("colleague");
234
+ const server = f.runtime.current;
235
+ await assert.rejects(f.runtime.stopUnobservedSession(first, "thread"), /exit could not be verified/);
236
+ assert.equal(f.runtime.current, server);
237
+ assert.equal(first.abortController.signal.aborted, false);
238
+ assert.equal(second.abortController.signal.aborted, false);
239
+ confirmed = true;
240
+ await f.runtime.stopUnobservedSession(first, "thread");
241
+ assert.equal(first.abortController.signal.aborted, true);
242
+ assert.equal(second.abortController.signal.aborted, true);
243
+ assert.equal(f.runtime.current, null);
244
+ assert.equal(f.stops.length, 2);
245
+ });
246
+
247
+ test("the shared OpenCode owner retains failed startup cleanup before any replacement can start", async () => {
248
+ const f = fixture();
249
+ let confirmed = false;
250
+ let starts = 0;
251
+ let cleanupCalls = 0;
252
+ const failure = Object.assign(new Error("Startup failed"), { cleanupFailed: true,
253
+ retryCleanup: async () => { cleanupCalls += 1; return { exited: confirmed, scopeEmpty: confirmed }; } });
254
+ await assert.rejects(f.runtime.acquire("first", async () => {
255
+ await f.runtime.ensure(f.connection, async () => { starts += 1; throw failure; });
256
+ }), error => error === failure);
257
+ assert.equal(f.runtime.current, null);
258
+ assert.equal(f.runtime.cleanupPending, true);
259
+ assert.equal(cleanupCalls, 0);
260
+ await assert.rejects(f.runtime.stop(), /exit could not be verified/);
261
+ await assert.rejects(f.runtime.ensure(f.connection, async () => { starts += 1; }), /exit could not be verified/);
262
+ assert.equal(starts, 1);
263
+ assert.equal(cleanupCalls, 2);
264
+ assert.equal(f.runtime.cleanupPending, true);
265
+ confirmed = true;
266
+ const next = await f.acquire("next");
267
+ assert.equal(cleanupCalls, 3);
268
+ assert.equal(f.runtime.cleanupPending, false);
269
+ assert.equal(f.starts.length, 1);
270
+ await f.runtime.release(next);
271
+ });
272
+
273
+ test("OpenCode late observation loss stops the exact old server without aborting its replacement", async () => {
274
+ const f = fixture({ interrupt: () => false });
275
+ const first = await f.acquire("first");
276
+ const observed = { ...first };
277
+ const replacement = await f.acquire("second", { selected: { ...f.connection, fingerprint: "rotated-account" } });
278
+ assert.equal(f.stops[0], f.starts[0]);
279
+ assert.equal(first.server.stop, replacement.server.stop);
280
+ await f.runtime.stopUnobservedSession(observed, "thread");
281
+ assert.deepEqual(f.stops, [f.starts[0], f.starts[0]]);
282
+ assert.equal(first.abortController.signal.aborted, false);
283
+ assert.equal(replacement.abortController.signal.aborted, false);
284
+ assert.equal(f.runtime.current.server, f.starts[1]);
285
+ await f.runtime.release(first);
286
+ await f.runtime.release(replacement);
287
+ });
288
+
289
+ test("OpenCode release waits for application removal before the final server stop", async () => {
290
+ const f = fixture();
291
+ const target = await f.acquire("session");
292
+ const reached = Promise.withResolvers();
293
+ const removed = Promise.withResolvers();
294
+ const closing = f.runtime.release(target, { onRemoved: () => { reached.resolve(); return removed.promise; } });
295
+ await reached.promise;
296
+ assert.equal(f.stops.length, 0);
297
+ removed.resolve();
298
+ assert.equal((await closing).exited, true);
299
+ assert.equal(f.stops.length, 1);
300
+ });
301
+
302
+ test("OpenCode bindings share atomic publication without overwriting another consumer's rows", async t => {
303
+ const root = await mkdtemp(path.join(os.tmpdir(), "jskit-opencode-bindings-"));
304
+ t.after(() => rm(root, { force: true, recursive: true }));
305
+ const registry = path.join(root, "private", "session-environments.json");
306
+ const runtime = createOpenCodeSharedRuntime();
307
+ const main = { upstreamSessionId: "main", workdir: "/work/main", env: { OWNER: "main" } };
308
+ const colleague = { upstreamSessionId: "colleague", workdir: "/work/colleague", systemPrompt: "Current instructions" };
309
+ await Promise.all([
310
+ runtime.writeBindings(registry, "application", [main]),
311
+ runtime.writeBindings(registry, "companion", [colleague])
312
+ ]);
313
+ assert.deepEqual(JSON.parse(await readFile(registry, "utf8")), { sessions: [main, colleague] });
314
+ assert.equal((await stat(registry)).mode & 0o777, 0o600);
315
+ const updated = { ...main, upstreamSessionId: "main-replacement" };
316
+ await runtime.writeBindings(registry, "application", [updated]);
317
+ assert.deepEqual(await readOpenCodeEnvironments(registry), [updated, colleague]);
318
+ await runtime.writeBindings(registry, "companion", []);
319
+ assert.deepEqual(await readOpenCodeEnvironments(registry), [updated]);
320
+ assert.throws(() => runtime.writeBindings(path.join(root, "another.json"), "foreign", []), /one absolute private binding registry/);
321
+ assert.deepEqual(await readOpenCodeEnvironments(""), []);
322
+ });