@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,362 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { prepareCodexModelCatalog } from "../src/server/conversation/codexConfiguration.js";
8
+ import { createCodexAppServerModelCatalogCache, codexAppServerProviderConnectionGeneration } from "../src/server/conversation/codexProvider.js";
9
+ import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
10
+
11
+ async function fixture(t, source) {
12
+ const runtimeDir = await mkdtemp(path.join(os.tmpdir(), "codex-catalog-unit-"));
13
+ t.after(() => rm(runtimeDir, { recursive: true, force: true }));
14
+ const command = path.join(runtimeDir, "codex");
15
+ await writeFile(command, `#!${process.execPath}\n${source}\n`);
16
+ await chmod(command, 0o700);
17
+ return { runtimeDir, command };
18
+ }
19
+
20
+ for (const [name, source] of [
21
+ ["CLI failure", 'process.stderr.write("secret-provider-key"); process.exit(1);'],
22
+ ["invalid JSON", 'process.stdout.write("secret-provider-key");'],
23
+ ["oversized output", 'process.stdout.write("x".repeat(17 * 1024 * 1024));'],
24
+ ["empty catalogue", 'process.stdout.write(JSON.stringify({ models: [] }));'],
25
+ ["missing slug", 'process.stdout.write(JSON.stringify({ models: [{}] }));'],
26
+ ["duplicate slugs", 'process.stdout.write(JSON.stringify({ models: [{slug:"gpt"}, {slug:"gpt"}] }));']
27
+ ]) test(`${name} blocks startup without overwriting the last catalogue or exposing CLI output`, async (t) => {
28
+ const f = await fixture(t, source);
29
+ const target = path.join(f.runtimeDir, "models.json");
30
+ await writeFile(target, "previous catalogue");
31
+ await assert.rejects(prepareCodexModelCatalog(f), (error) => {
32
+ assert.match(error.message, /could not load its model catalogue/);
33
+ assert.doesNotMatch(String(error), /secret-provider-key/);
34
+ return true;
35
+ });
36
+ assert.equal(await readFile(target, "utf8"), "previous catalogue");
37
+ await assert.rejects(stat(`${target}.tmp`), { code: "ENOENT" });
38
+ });
39
+
40
+ test("stopping startup cancels the exporter and publishes no catalogue", async (t) => {
41
+ const f = await fixture(t, "setInterval(() => {}, 1000);");
42
+ const controller = new AbortController();
43
+ const pending = prepareCodexModelCatalog({ ...f, signal: controller.signal });
44
+ controller.abort();
45
+ await assert.rejects(pending, /could not load its model catalogue/);
46
+ await assert.rejects(stat(path.join(f.runtimeDir, "models.json")), { code: "ENOENT" });
47
+ });
48
+
49
+ function liveCatalogFixture() {
50
+ const calls = [];
51
+ const captures = { connectionGeneration: 1, failModelLists: 0 };
52
+ const provider = {
53
+ currentConnectionGeneration() { return captures.connectionGeneration; },
54
+ async listModels(params, options) {
55
+ calls.push(["models", params]);
56
+ captures.signal = options.signal;
57
+ if (captures.failModelLists > 0) {
58
+ captures.failModelLists -= 1;
59
+ throw Object.assign(new Error("model catalog temporarily unavailable"), { code: "rate_limited" });
60
+ }
61
+ return { data: [{ model: "gpt-5.6-luna", supportedReasoningEfforts: [{ reasoningEffort: "low" }] }] };
62
+ }
63
+ };
64
+ const readCatalog = createCodexAppServerModelCatalogCache();
65
+ return { calls, captures, provider, readCatalog };
66
+ }
67
+
68
+ // These two cases carry the original public lifecycle assertions. Their public
69
+ // counterparts retain host acquisition, restoration and application integration.
70
+ test("helper model discovery uses one live provider catalog per connection generation", async () => {
71
+ const { calls, provider, readCatalog } = liveCatalogFixture();
72
+ const first = await readCatalog(provider);
73
+ const second = await readCatalog(provider);
74
+
75
+ assert.equal(first, second);
76
+ assert.deepEqual(first, {
77
+ data: [{
78
+ hidden: false,
79
+ model: "gpt-5.6-luna",
80
+ supportedReasoningEfforts: [{
81
+ reasoningEffort: "low"
82
+ }]
83
+ }]
84
+ });
85
+ assert.deepEqual(calls.filter(([operation]) => operation === "models"), [[
86
+ "models",
87
+ {
88
+ includeHidden: false,
89
+ limit: 100
90
+ }
91
+ ]]);
92
+ });
93
+
94
+ test("helper model discovery does not cache failures and invalidates on reconnect", async () => {
95
+ const { calls, captures, provider, readCatalog } = liveCatalogFixture();
96
+ captures.failModelLists = 1;
97
+ await assert.rejects(
98
+ readCatalog(provider),
99
+ (error) => error.code === "rate_limited"
100
+ );
101
+ await readCatalog(provider);
102
+ captures.connectionGeneration = 2;
103
+ await readCatalog(provider);
104
+
105
+ assert.equal(calls.filter(([operation]) => operation === "models").length, 3);
106
+ });
107
+
108
+ test("live model snapshots freeze normalized native facts and expire after the original TTL", async t => {
109
+ const { provider, readCatalog } = liveCatalogFixture();
110
+ let now = 1000;
111
+ t.mock.method(Date, "now", () => now);
112
+ provider.listModels = async () => ({ data: [{ hidden: true, model: " model ",
113
+ supportedReasoningEfforts: [{ reasoningEffort: " high " }, { reasoningEffort: false }] }] });
114
+ const first = await readCatalog(provider);
115
+ assert.deepEqual(first, { data: [{ hidden: true, model: "model",
116
+ supportedReasoningEfforts: [{ reasoningEffort: "high" }, { reasoningEffort: "" }] }] });
117
+ for (const value of [first, first.data, first.data[0], first.data[0].supportedReasoningEfforts,
118
+ ...first.data[0].supportedReasoningEfforts]) assert.equal(Object.isFrozen(value), true);
119
+ now += 29_999;
120
+ assert.equal(await readCatalog(provider), first);
121
+ now += 1;
122
+ assert.notEqual(await readCatalog(provider), first);
123
+ });
124
+
125
+ test("concurrent live catalog readers share the original pending request and its signal", async () => {
126
+ const { provider, readCatalog } = liveCatalogFixture();
127
+ const response = Promise.withResolvers();
128
+ const firstSignal = new AbortController().signal;
129
+ const signals = [];
130
+ provider.listModels = (_params, { signal }) => { signals.push(signal); return response.promise; };
131
+ const first = readCatalog(provider, { signal: firstSignal });
132
+ const second = readCatalog(provider, { signal: new AbortController().signal });
133
+ response.resolve({ data: [] });
134
+ assert.equal(await first, await second);
135
+ assert.deepEqual(signals, [firstSignal]);
136
+ });
137
+
138
+ test("a stale pending result cannot erase a newer generation's pending catalog", async () => {
139
+ const { captures, provider, readCatalog } = liveCatalogFixture();
140
+ const responses = [];
141
+ provider.listModels = () => { const response = Promise.withResolvers(); responses.push(response); return response.promise; };
142
+ const first = readCatalog(provider);
143
+ const rejected = assert.rejects(first, {
144
+ code: "codex_model_catalog_stale",
145
+ message: "Codex reconnected while resolving the helper model catalog."
146
+ });
147
+ captures.connectionGeneration = 2;
148
+ const second = readCatalog(provider);
149
+ responses[0].resolve({ data: [] });
150
+ await rejected;
151
+ const third = readCatalog(provider);
152
+ assert.equal(responses.length, 2);
153
+ responses[1].resolve({ data: [] });
154
+ assert.equal(await second, await third);
155
+ assert.equal(await readCatalog(provider), await second);
156
+ });
157
+
158
+ test("invalid and unavailable native catalogs retain the host's error prefix without caching failure", async () => {
159
+ const readCatalog = createCodexAppServerModelCatalogCache({ errorPrefix: "vibe64_" });
160
+ await assert.rejects(readCatalog({}), { code: "vibe64_codex_model_catalog_unavailable" });
161
+ const { provider } = liveCatalogFixture();
162
+ const originalList = provider.listModels;
163
+ provider.listModels = async () => ({ malformed: [] });
164
+ await assert.rejects(readCatalog(provider), {
165
+ code: "vibe64_codex_model_catalog_invalid",
166
+ message: "Codex did not return a usable live model catalog."
167
+ });
168
+ provider.listModels = originalList;
169
+ assert.equal((await readCatalog(provider)).data[0].model, "gpt-5.6-luna");
170
+ });
171
+
172
+ test("native connection generation keeps the production method priority and falsy normalization", () => {
173
+ assert.equal(codexAppServerProviderConnectionGeneration({ currentConnectionGeneration: () => " first ", connectionGeneration: "second" }), "first");
174
+ assert.equal(codexAppServerProviderConnectionGeneration({ connectionGeneration: () => 2 }), "2");
175
+ assert.equal(codexAppServerProviderConnectionGeneration({ connectionGeneration: " direct " }), "direct");
176
+ for (const value of [0, false, null, undefined]) {
177
+ assert.equal(codexAppServerProviderConnectionGeneration({ connectionGeneration: value }), "");
178
+ }
179
+ });
180
+
181
+ function chatCatalogFixture(t, { resources = null } = {}) {
182
+ const runtimeDir = path.join(os.tmpdir(), `jskit-codex-chat-catalog-${randomUUID()}`);
183
+ const owner = createCodexAppServerProviderOwner({ runtimeRoot: runtimeDir });
184
+ const providerOptions = { runtimeDir };
185
+ const captures = {
186
+ providerOptions: [], stopRuntimes: 0, closes: 0, failModelLists: 0,
187
+ runtimeReused: false, stopRuntimeResult: { stopped: true },
188
+ runtimeInfo: { runtimeDir, authStateSignature: "v1:original", accountIdentitySignature: `sha256:${"a".repeat(64)}` },
189
+ operations: [], prepareCount: 0
190
+ };
191
+ const providerFactory = options => {
192
+ captures.providerOptions.push(options);
193
+ return {
194
+ async currentRuntimeInfo() { return { ...captures.runtimeInfo }; },
195
+ async ensureRuntime() {
196
+ captures.operations.push("ensure");
197
+ captures.onEnsureRuntime?.();
198
+ await captures.ensureRuntimeWait;
199
+ return { runtimeDir, reused: captures.runtimeReused };
200
+ },
201
+ async listModels(_params, { signal } = {}) {
202
+ captures.operations.push("models");
203
+ signal?.throwIfAborted();
204
+ if (captures.failModelLists > 0) {
205
+ captures.failModelLists -= 1;
206
+ throw new Error("model catalog temporarily unavailable");
207
+ }
208
+ return { data: [{ model: "gpt-5.6-luna" }] };
209
+ },
210
+ async stopRuntime() {
211
+ captures.operations.push("stop");
212
+ captures.stopRuntimes += 1;
213
+ return captures.stopRuntimeResult;
214
+ },
215
+ close() { captures.operations.push("close"); captures.closes += 1; }
216
+ };
217
+ };
218
+ const readCatalog = (options = {}) => owner.readModelCatalog({
219
+ async prepareProviderOptions() { captures.prepareCount += 1; return providerOptions; },
220
+ providerFactory,
221
+ resources,
222
+ ...options
223
+ });
224
+ async function retainProvider() {
225
+ const provider = owner.createProvider({ providerKey: "resident", providerOptions,
226
+ create: () => providerFactory(providerOptions) });
227
+ await owner.acquireRuntime({ provider, providerKey: "resident", providerOptions,
228
+ operation: () => provider.ensureRuntime() });
229
+ }
230
+ t.after(async () => {
231
+ captures.stopRuntimeResult = { stopped: true };
232
+ await owner.invalidateRuntimes({ includeOwned: true, stopOwnedRuntimes: true });
233
+ owner.beginShutdown();
234
+ });
235
+ return { owner, captures, readCatalog, retainProvider };
236
+ }
237
+
238
+ // Carried from the original public chat catalog tests. Only the application
239
+ // controller fixture becomes the existing native provider owner.
240
+ test("chat model discovery reuses a resident provider without closing it", async t => {
241
+ const { captures, readCatalog, retainProvider } = chatCatalogFixture(t);
242
+ await retainProvider();
243
+ const providerCount = captures.providerOptions.length;
244
+ const catalog = await readCatalog();
245
+ assert.equal(catalog.data[0].model, "gpt-5.6-luna");
246
+ assert.equal(captures.providerOptions.length, providerCount);
247
+ assert.equal(captures.stopRuntimes, 0);
248
+ assert.equal(captures.closes, 0);
249
+ });
250
+
251
+ test("chat model discovery stops its temporary service on success and failure", async t => {
252
+ const { captures, owner, readCatalog } = chatCatalogFixture(t);
253
+ assert.equal((await readCatalog()).data[0].model, "gpt-5.6-luna");
254
+ assert.equal(captures.stopRuntimes, 1);
255
+ await owner.invalidateRuntimes({ includeOwned: true, reason: "logout", requireVerifiedExit: true, stopOwnedRuntimes: true });
256
+ captures.failModelLists = 1;
257
+ await assert.rejects(readCatalog(), /model catalog temporarily unavailable/u);
258
+ assert.equal(captures.stopRuntimes, 2);
259
+ captures.stopRuntimeResult = { stopped: false };
260
+ await assert.rejects(readCatalog(), /process exit could not be verified/u);
261
+ captures.stopRuntimeResult = { stopped: true };
262
+ });
263
+
264
+ test("chat model catalog expires and never caches unverified runtime cleanup", async t => {
265
+ const { captures, owner, readCatalog } = chatCatalogFixture(t);
266
+ const now = Date.now();
267
+ await readCatalog();
268
+ t.mock.method(Date, "now", () => now + 31_000);
269
+ captures.stopRuntimeResult = { stopped: false };
270
+ await assert.rejects(readCatalog(), /process exit could not be verified/u);
271
+ captures.stopRuntimeResult = { stopped: true };
272
+ await owner.invalidateRuntimes({ includeOwned: true, reason: "auth-session-status", requireVerifiedExit: true, stopOwnedRuntimes: true });
273
+ const stopped = captures.stopRuntimes;
274
+ await readCatalog();
275
+ assert.equal(captures.stopRuntimes, stopped + 1);
276
+ });
277
+
278
+ test("chat model discovery leaves an already running shared process alive", async t => {
279
+ const { captures, readCatalog } = chatCatalogFixture(t);
280
+ captures.runtimeReused = true;
281
+ await readCatalog();
282
+ assert.equal(captures.stopRuntimes, 0);
283
+ assert.equal(captures.closes, 1);
284
+ });
285
+
286
+ test("repeated chat model discovery uses one short-lived runtime per auth generation", async t => {
287
+ const { captures, readCatalog } = chatCatalogFixture(t);
288
+ const catalogs = await Promise.all([readCatalog(), readCatalog()]);
289
+ assert.deepEqual(catalogs[0], catalogs[1]);
290
+ assert.equal(captures.stopRuntimes, 1);
291
+ await readCatalog();
292
+ assert.equal(captures.stopRuntimes, 1);
293
+
294
+ captures.runtimeInfo.authStateSignature = "v1:changed-login";
295
+ captures.runtimeInfo.accountIdentitySignature = `sha256:${"d".repeat(64)}`;
296
+ await readCatalog();
297
+ assert.equal(captures.stopRuntimes, 2, "a new login must read its own live catalog");
298
+ });
299
+
300
+ test("catalog cleanup retains host retirement and reconciliation before observer close", async t => {
301
+ let captures;
302
+ const f = chatCatalogFixture(t, { resources: {
303
+ async retire() { captures.operations.push("retire"); },
304
+ async reconcileRetirement({ retired, stopped }) {
305
+ captures.operations.push("reconcile");
306
+ assert.equal(retired.status, "fulfilled");
307
+ assert.equal(stopped.status, "fulfilled");
308
+ return { retired, pendingThreadCleanup: [] };
309
+ }
310
+ } });
311
+ captures = f.captures;
312
+ await f.readCatalog();
313
+ assert.deepEqual(captures.operations, ["ensure", "models", "retire", "stop", "reconcile", "close"]);
314
+ });
315
+
316
+ test("failed resource retirement prevents catalog caching after native stop and observer close", async t => {
317
+ let retirementFails = true;
318
+ const { captures, readCatalog } = chatCatalogFixture(t, { resources: {
319
+ async retire() { if (retirementFails) throw new Error("Helper retirement failed."); }
320
+ } });
321
+ await assert.rejects(readCatalog(), /Helper retirement failed/u);
322
+ assert.equal(captures.stopRuntimes, 1);
323
+ assert.equal(captures.closes, 1);
324
+ retirementFails = false;
325
+ await readCatalog();
326
+ assert.equal(captures.stopRuntimes, 2);
327
+ });
328
+
329
+ test("catalog caller cancellation while queued runs before host preparation or provider creation", async t => {
330
+ const { owner, captures, readCatalog } = chatCatalogFixture(t);
331
+ const queued = Promise.withResolvers();
332
+ const queue = owner.withLifecycle(() => queued.promise);
333
+ const controller = new AbortController();
334
+ const pending = readCatalog({ signal: controller.signal });
335
+ const cancellation = new Error("caller cancelled model discovery");
336
+ controller.abort(cancellation);
337
+ const rejected = assert.rejects(pending, error => error === cancellation);
338
+ queued.resolve();
339
+ await queue;
340
+ await rejected;
341
+ assert.equal(captures.prepareCount, 0);
342
+ assert.equal(captures.providerOptions.length, 0);
343
+ });
344
+
345
+ test("catalog cancellation during shared acquisition still awaits startup and cleans the owned runtime", async t => {
346
+ const { captures, readCatalog } = chatCatalogFixture(t);
347
+ const started = Promise.withResolvers();
348
+ const acquired = Promise.withResolvers();
349
+ captures.onEnsureRuntime = started.resolve;
350
+ captures.ensureRuntimeWait = acquired.promise;
351
+ const controller = new AbortController();
352
+ const pending = readCatalog({ signal: controller.signal });
353
+ await started.promise;
354
+ const cancellation = new Error("caller cancelled model discovery");
355
+ controller.abort(cancellation);
356
+ const rejected = assert.rejects(pending, error => error === cancellation);
357
+ assert.equal(captures.stopRuntimes, 0);
358
+ acquired.resolve();
359
+ await rejected;
360
+ assert.equal(captures.stopRuntimes, 1);
361
+ assert.equal(captures.closes, 1);
362
+ });
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { exportCodexNativeHistory } from "../src/server/conversation/codexNativeHistoryExport.js";
4
+
5
+ function codexExportClient() {
6
+ const calls = [];
7
+ const thread = { id: "parent", historyMode: "paginated", status: { type: "notLoaded" }, updatedAt: 1 };
8
+ const goal = { objective: "Finish the project", status: "paused" };
9
+ return { calls, thread, goal, request: async (method, params) => {
10
+ calls.push([method, params]);
11
+ assert.equal(params.threadId, "parent");
12
+ if (method === "thread/read") return { thread: structuredClone(thread) };
13
+ if (method === "thread/goal/get") return { goal: structuredClone(goal) };
14
+ if (method === "thread/turns/list") {
15
+ assert.equal(params.itemsView, "notLoaded");
16
+ return { data: [{ id: "turn", status: "completed", itemsView: "notLoaded", items: [] }] };
17
+ }
18
+ assert.equal(method, "thread/items/list");
19
+ return params.cursor ? { data: [{ turnId: "turn", item: { id: "answer", type: "agentMessage", text: "Saved answer" } }] }
20
+ : { data: [{ turnId: "turn", item: { id: "prompt", type: "userMessage", content: [
21
+ { type: "text", text: "Saved question" }, { type: "image", url: "data:image/png;base64,payload" }
22
+ ] } }], nextCursor: "next" };
23
+ } };
24
+ }
25
+
26
+ test("Codex modern export pages items independently and preserves goal and readable text without attachments", async () => {
27
+ const client = codexExportClient();
28
+ const records = [];
29
+ const result = await exportCodexNativeHistory(client, "parent", async (record) => { records.push(record); });
30
+ assert.equal(result.turnCount, 1);
31
+ assert.equal(result.itemCount, 2);
32
+ assert.match(result.revision, /^[a-f0-9]{64}$/u);
33
+ assert.deepEqual(records.flatMap((record) => record.text).map(({ role, text }) => ({ role, text })), [
34
+ { role: "goal", text: "Finish the project" }, { role: "user", text: "Saved question" }, { role: "assistant", text: "Saved answer" }
35
+ ]);
36
+ assert.equal(records[3].text[0].messageId, "prompt");
37
+ assert.equal(records[3].text[0].branchId, "parent");
38
+ assert.equal(records[3].item.content[1].url, "data:image/png;base64,payload");
39
+ assert.equal((await exportCodexNativeHistory(client, "parent", async () => {})).revision, result.revision);
40
+ client.goal.objective = "Changed goal";
41
+ assert.notEqual((await exportCodexNativeHistory(client, "parent", async () => {})).revision, result.revision);
42
+ });
43
+
44
+ test("Codex export rejects unsupported modes, active goals, oversized exports, repeating cursors and cancellation", async () => {
45
+ const client = codexExportClient();
46
+ client.thread.historyMode = "legacy";
47
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => {}), /requires paginated/);
48
+ client.thread.historyMode = "paginated";
49
+ client.goal.status = "active";
50
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => {}), /Pause/);
51
+ client.goal.status = "paused";
52
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => {}, { maxBytes: 1 }), /byte limit/);
53
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => {}, { maxPages: 1 }), /page limit/);
54
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => {}, { signal: AbortSignal.abort() }), { name: "AbortError" });
55
+ const request = client.request;
56
+ client.request = (method, params) => method === "thread/items/list" ? { data: [], nextCursor: "repeat" } : request(method, params);
57
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => {}), /cursor/);
58
+ });
59
+
60
+ test("Codex export awaits preservation and rejects changed metadata or a failed sink", async () => {
61
+ const client = codexExportClient();
62
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async () => { throw new Error("disk full"); }), /disk full/);
63
+ assert.deepEqual(client.calls.map(([method]) => method), ["thread/read", "thread/goal/get"]);
64
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async (record) => {
65
+ if (record.type === "item") client.thread.updatedAt++;
66
+ }), /changed during export/);
67
+ });
68
+
69
+ test("Codex native export revisions survive unloading while retaining exact conversation content", async () => {
70
+ const client = codexExportClient();
71
+ Object.assign(client.thread, { status: { type: "idle" }, canAcceptDirectInput: true,
72
+ environments: [{ environmentId: "local", cwd: "/saved/source" }] });
73
+ const first = await exportCodexNativeHistory(client, "parent", async () => {});
74
+ Object.assign(client.thread, { status: { type: "notLoaded" }, canAcceptDirectInput: null, environments: null });
75
+ const unloaded = await exportCodexNativeHistory(client, "parent", async () => {});
76
+ assert.equal(unloaded.revision, first.revision);
77
+ await assert.rejects(exportCodexNativeHistory(client, "parent", async (record) => {
78
+ if (record.type === "item") client.thread.status = { type: "active" };
79
+ }), /changed during export/u);
80
+ });
81
+
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ensureCodexAppServerThread } from "../src/server/conversation/codexProvider.js";
4
+
5
+ test("retained Codex selection awaits observation before resume and identity publication before return", async () => {
6
+ const calls = [];
7
+ const stages = [];
8
+ const observation = Promise.withResolvers();
9
+ const observing = Promise.withResolvers();
10
+ const publication = Promise.withResolvers();
11
+ const publishing = Promise.withResolvers();
12
+ const runtime = { id: "shared-runtime" };
13
+ const settings = { model: "selected-model" };
14
+ let paused = true;
15
+ let finished = false;
16
+ const selected = ensureCodexAppServerThread({
17
+ workdir: " /repo/worktree ",
18
+ provider: {
19
+ async ensureAvailable() { calls.push(["available"]); return { runtime }; },
20
+ async ensureRuntime() { assert.fail("An available runtime must be reused"); },
21
+ async readGoal(id) { calls.push(["goal", id]); return { goal: { status: "active" } }; },
22
+ async setGoalStatus(id, status) { calls.push(["pause", id, status]); },
23
+ async resumeThread(id, input) { calls.push(["resume", id]); assert.equal(input, settings); return { id }; },
24
+ async startThread() { assert.fail("A retained thread must never be replaced"); }
25
+ },
26
+ async settings(workdir) {
27
+ calls.push(["prepare", workdir]);
28
+ return { threadSettings: settings };
29
+ },
30
+ async observeThread(id) {
31
+ calls.push(["observe", id]);
32
+ observing.resolve();
33
+ await observation.promise;
34
+ },
35
+ identity: {
36
+ read: () => "original-codex",
37
+ get pauseGoal() { return paused; },
38
+ async clearPause() { calls.push(["clear"]); paused = false; },
39
+ async write(value) {
40
+ calls.push(["write", value.threadId]);
41
+ assert.deepEqual(value, { appServerRuntime: runtime, threadId: "original-codex", workdir: "/repo/worktree" });
42
+ publishing.resolve();
43
+ await publication.promise;
44
+ }
45
+ },
46
+ onStage({ stage }) { stages.push(stage); }
47
+ }).then(value => { finished = true; return value; });
48
+ await observing.promise;
49
+ assert.deepEqual(calls, [["available"], ["prepare", "/repo/worktree"], ["goal", "original-codex"],
50
+ ["pause", "original-codex", "paused"], ["observe", "original-codex"]]);
51
+ observation.resolve();
52
+ await publishing.promise;
53
+ assert.equal(finished, false);
54
+ assert.deepEqual(calls.slice(-3), [["resume", "original-codex"], ["clear"], ["write", "original-codex"]]);
55
+ publication.resolve();
56
+ assert.deepEqual(await selected, { appServerRuntime: runtime, thread: { id: "original-codex" }, threadId: "original-codex" });
57
+ assert.deepEqual(stages, ["runtime", "resume", "identity-metadata"]);
58
+ });
59
+
60
+ test("selection failures preserve the original error and never fall back to a new thread", async () => {
61
+ for (const failed of ["prepare", "goal", "pause", "observe", "resume", "clear", "write"]) {
62
+ const calls = [];
63
+ const failure = new Error(failed);
64
+ const step = name => {
65
+ calls.push(name);
66
+ if (name === failed) throw failure;
67
+ };
68
+ await assert.rejects(ensureCodexAppServerThread({
69
+ provider: {
70
+ async ensureRuntime() { step("runtime"); return {}; },
71
+ async readGoal() { step("goal"); return { goal: { status: "active" } }; },
72
+ async setGoalStatus() { step("pause"); },
73
+ async resumeThread() { step("resume"); return { id: "retained" }; },
74
+ async startThread() { assert.fail("Selection failure must not replace history"); }
75
+ },
76
+ async settings() { step("prepare"); return { threadSettings: {} }; },
77
+ async observeThread() { step("observe"); },
78
+ identity: {
79
+ read: () => "retained",
80
+ pauseGoal: true,
81
+ async clearPause() { step("clear"); },
82
+ async write() { step("write"); }
83
+ }
84
+ }), error => error === failure);
85
+ const order = ["runtime", "prepare", "goal", "pause", "observe", "resume", "clear", "write"];
86
+ assert.deepEqual(calls, order.slice(0, order.indexOf(failed) + 1));
87
+ }
88
+ });
89
+
90
+ test("new selection starts once and rejects a missing native ID before publishing identity", async () => {
91
+ for (const id of ["started", ""]) {
92
+ const calls = [];
93
+ const settings = { model: "selected-model", sessionStartSource: "startup" };
94
+ const selecting = ensureCodexAppServerThread({
95
+ provider: {
96
+ async ensureRuntime() { calls.push("runtime"); return {}; },
97
+ async startThread(input) { calls.push("start"); assert.equal(input, settings); return { id }; },
98
+ async resumeThread() { assert.fail("There is no saved thread to resume"); },
99
+ async readGoal() { assert.fail("There is no saved thread to pause"); }
100
+ },
101
+ async settings() { return { threadStartSettings: settings }; },
102
+ observeThread() { assert.fail("The original new-thread path observes after selection"); },
103
+ identity: { read: () => "", async write(value) { calls.push("write"); assert.equal(value.threadId, id); } }
104
+ });
105
+ if (id) {
106
+ assert.equal((await selecting).threadId, id);
107
+ assert.deepEqual(calls, ["runtime", "start", "write"]);
108
+ } else {
109
+ await assert.rejects(selecting, /Codex app-server did not return a thread id\./u);
110
+ assert.deepEqual(calls, ["runtime", "start"]);
111
+ }
112
+ }
113
+ });
114
+
115
+ test("selection reads the saved pause marker again after resume and retains the fallback native ID", async () => {
116
+ let pauseGoal = true;
117
+ let written;
118
+ const selected = await ensureCodexAppServerThread({
119
+ provider: {
120
+ async ensureRuntime() { return {}; },
121
+ async readGoal() { return { goal: { status: "paused" } }; },
122
+ async setGoalStatus() { assert.fail("An already paused goal stays paused"); },
123
+ async resumeThread() { pauseGoal = false; return {}; }
124
+ },
125
+ async settings() { return { threadSettings: {} }; },
126
+ observeThread() {},
127
+ identity: {
128
+ read: () => "retained",
129
+ get pauseGoal() { return pauseGoal; },
130
+ async clearPause() { assert.fail("The marker changed during native resume"); },
131
+ async write(value) { written = value.threadId; }
132
+ }
133
+ });
134
+ assert.equal(selected.threadId, "retained");
135
+ assert.equal(written, "retained");
136
+ });