@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,39 @@
1
+ import {
2
+ CODEX_HELPER_THREAD_LEDGER_SCHEMA_VERSION,
3
+ CODEX_HELPER_THREAD_LIFECYCLES,
4
+ createCodexHelperThreadLedgerOwner
5
+ } from "../../src/server/conversation/codexHelperThreadLedger.js";
6
+
7
+ // This is the original test's resolved profile data, not application policy.
8
+ // Public keeps and tests its original strict codec against its own catalogue.
9
+ function helperProfile() {
10
+ return {
11
+ limits: { maxInputCharacters: 100_000, maxOutputCharacters: 32_000, timeoutMs: 180_000 },
12
+ model: "gpt-5.6-luna",
13
+ policy: { environmentAccess: false, networkAccess: false, repositoryWrite: false, tools: "none" },
14
+ profileId: "helper",
15
+ providerId: "codex",
16
+ request: { allowProviderModelFallback: false, reasoning: true, summary: false },
17
+ revision: "codex-helper-luna-low-v2",
18
+ thinking: "low",
19
+ workloadId: "source_explanation"
20
+ };
21
+ }
22
+
23
+ const {
24
+ codexHelperThreadRecordId,
25
+ createCodexHelperThreadLedger,
26
+ defineCodexHelperThreadRecord
27
+ } = createCodexHelperThreadLedgerOwner({
28
+ executionProfile: value => structuredClone(value),
29
+ errorPrefix: "vibe64_codex_helper_"
30
+ });
31
+
32
+ export {
33
+ CODEX_HELPER_THREAD_LEDGER_SCHEMA_VERSION,
34
+ CODEX_HELPER_THREAD_LIFECYCLES,
35
+ codexHelperThreadRecordId,
36
+ createCodexHelperThreadLedger,
37
+ defineCodexHelperThreadRecord,
38
+ helperProfile
39
+ };
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import { once } from "node:events";
3
+ import { test } from "node:test";
4
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
8
+
9
+ const options = { skip: process.platform === "win32" };
10
+ function host(t) {
11
+ const execution = createLocalConversationExecution();
12
+ t.after(() => execution.close());
13
+ return execution;
14
+ }
15
+ const spec = (code) => ({ command: process.execPath, args: ["-e", code], cwd: process.cwd(), stream: true });
16
+
17
+ test("the supplied execution host carries native protocol bytes and proves owned cleanup", options, async (t) => {
18
+ const execution = host(t);
19
+ const child = await execution.start(spec('process.stdin.on("data", data => process.stdout.write(data));'));
20
+ const response = once(child.stdout, "data");
21
+ child.stdin.write("protocol bytes\n");
22
+ assert.equal(String((await response)[0]), "protocol bytes\n");
23
+ assert.equal(child.running, true);
24
+ assert.deepEqual(await execution.stop(child.id), { scopeEmpty: true });
25
+ assert.equal(child.running, false);
26
+ await child.exited;
27
+ });
28
+
29
+ test("stopping one owned execution cannot stop another and rejects unknown ownership", options, async (t) => {
30
+ const execution = host(t);
31
+ const first = await execution.start(spec('setInterval(() => {}, 1000);'));
32
+ const second = await execution.start(spec('setInterval(() => {}, 1000);'));
33
+ await assert.rejects(execution.stop("another-host"), /does not own/);
34
+ await execution.stop(first.id);
35
+ assert.equal(second.running, true);
36
+ await execution.close();
37
+ assert.equal(second.running, false);
38
+ await assert.rejects(execution.start(spec("")), /closed/);
39
+ });
40
+
41
+ test("native startup failures retain their cause and unsupported limits are explicit", options, async (t) => {
42
+ const execution = host(t);
43
+ await assert.rejects(execution.start({ ...spec(""), command: "/does-not-exist/jskit-native" }),
44
+ (error) => error.cause?.code === "ENOENT" && /Check the executable/.test(error.message));
45
+ await assert.rejects(execution.start({ ...spec(""), limits: { memoryBytes: 1024 } }), /cannot enforce resource limits/);
46
+ });
47
+
48
+ test("shutdown racing with startup releases the child instead of handing out a live process", options, async (t) => {
49
+ const execution = host(t);
50
+ const starting = execution.start(spec('setInterval(() => {}, 1000);'));
51
+ const rejected = assert.rejects(starting, /closed during startup/);
52
+ await execution.close();
53
+ await rejected;
54
+ });
55
+
56
+ test("native command requests use the same execution owner and retain bounded capture and detached logs", options, async (t) => {
57
+ const execution = host(t);
58
+ const directory = await mkdtemp(join(tmpdir(), "jskit-execution-log-"));
59
+ t.after(() => rm(directory, { recursive: true, force: true }));
60
+ const captured = await execution.run({ command: process.execPath,
61
+ args: ["-e", 'process.stdout.write("x".repeat(70000)); process.stderr.write("finished");'],
62
+ cwd: directory, mode: "capture" });
63
+ assert.equal(captured.ok, true);
64
+ assert.equal(Buffer.byteLength(captured.output), 64 * 1024);
65
+ assert.ok(captured.output.endsWith("finished"));
66
+ const logPath = join(directory, "detached.log");
67
+ const child = await execution.run({ command: process.execPath,
68
+ args: ["-e", 'process.stderr.write("ready"); setInterval(() => {}, 1000);'],
69
+ cwd: directory, mode: "detached", logPath });
70
+ assert.equal(child.ok, true);
71
+ assert.ok(Number.isSafeInteger(child.pid) && child.pid > 0);
72
+ const deadline = Date.now() + 3000;
73
+ while (!(await readFile(logPath, "utf8")).includes("ready") && Date.now() < deadline) {
74
+ await new Promise(resolve => setTimeout(resolve, 10));
75
+ }
76
+ assert.equal(await readFile(logPath, "utf8"), "ready");
77
+ assert.deepEqual(await execution.stop(child.execution.id), { scopeEmpty: true });
78
+ assert.deepEqual(await execution.stop(child.execution.id, { allowMissingRecordScopeRecovery: true }),
79
+ { ok: false, scopeEmpty: false });
80
+ });
81
+
82
+ test("native capture timeout drains its execution before returning", options, async (t) => {
83
+ const execution = host(t);
84
+ const result = await execution.run({ command: process.execPath,
85
+ args: ["-e", 'process.stdout.write(String(process.pid)); setInterval(() => {}, 1000);'],
86
+ cwd: process.cwd(), mode: "capture", timeout: 500 });
87
+ assert.equal(result.ok, false);
88
+ assert.equal(result.timedOut, true);
89
+ assert.throws(() => process.kill(Number(result.output), 0), { code: "ESRCH" });
90
+ });
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { createAssistantMessageDelivery } from "../src/client/conversation/messageDelivery.js";
3
+ import { nextTick, ref } from "vue";
4
+ import { createAssistantMessageDelivery, unmatchedOptimisticMessages } from "../src/client/conversation/messageDelivery.js";
5
+ import { normalizeConversationTurn } from "../src/shared/conversation/patches.js";
4
6
 
5
7
  test("sending immediately renders the message and reconciles only its authoritative receipt", async () => {
6
8
  const request = Promise.withResolvers();
@@ -146,3 +148,234 @@ test("reset discards queued delivery without waiting for the old transport", asy
146
148
  assert.deepEqual(await next, { ok: true });
147
149
  assert.equal(delivery.state.sending, false);
148
150
  });
151
+
152
+ test("a canonical receipt settles the retained send before its HTTP response", async () => {
153
+ const request = Promise.withResolvers();
154
+ const receipts = ref([]);
155
+ const delivery = createAssistantMessageDelivery();
156
+ const sending = delivery.send({ message: "Hello" }, {
157
+ messageId: "one", deliver: () => request.promise, receiptTurns: receipts, uncertainOnError: true
158
+ });
159
+ receipts.value = [{ user: { messageId: "one", text: "Hello" } }];
160
+ await nextTick();
161
+ assert.deepEqual(await sending, { ok: true });
162
+ assert.equal(delivery.find("one").status, "accepted");
163
+ request.reject(new Error("The old HTTP connection closed."));
164
+ await nextTick();
165
+ assert.equal(delivery.find("one").error, "");
166
+ delivery.reconcile(receipts.value);
167
+ assert.deepEqual(delivery.turns(receipts.value), receipts.value);
168
+ });
169
+
170
+ test("provisional authored rows remain visible without settling delivery or restored retries", async () => {
171
+ const request = Promise.withResolvers();
172
+ const receipts = ref([]);
173
+ const payload = { message: "Original", displayAttachments: [{ attachmentId: "original" }] };
174
+ let acknowledgements = 0;
175
+ let submissions = 0;
176
+ let settled = false;
177
+ const delivery = createAssistantMessageDelivery();
178
+ const sending = delivery.send(payload, {
179
+ messageId: "one", receiptTurns: receipts,
180
+ deliver() { submissions += 1; return request.promise; },
181
+ onAccepted() { acknowledgements += 1; }
182
+ });
183
+ void sending.then(() => { settled = true; }, () => { settled = true; });
184
+ const turn = normalizeConversationTurn({ turnId: "saved", user: {
185
+ role: "user", messageId: "one", text: "Original", receipt: false, attachments: payload.displayAttachments
186
+ } });
187
+ receipts.value = [turn];
188
+ delivery.reconcile(receipts.value);
189
+ await nextTick();
190
+ assert.equal(settled, false);
191
+ assert.equal(acknowledgements, 0);
192
+ assert.equal(delivery.turns(receipts.value).length, 1);
193
+ assert.equal(delivery.turns(receipts.value)[0].optimistic.status, "pending");
194
+ assert.equal(delivery.turns(receipts.value)[0].user.receipt, false);
195
+ const rejected = assert.rejects(sending, /Native history unavailable/);
196
+ request.reject(Object.assign(new Error("Native history unavailable"), { status: "uncertain" }));
197
+ await rejected;
198
+ delivery.reconcile(receipts.value);
199
+ assert.equal(delivery.find("one").status, "uncertain");
200
+ assert.equal(delivery.turns(receipts.value)[0].system.delivery.messageId, "one");
201
+ assert.equal(delivery.resend("one"), false);
202
+ assert.equal(await delivery.send(payload, { messageId: "one" }), false);
203
+ assert.equal(submissions, 1);
204
+ assert.deepEqual(delivery.find("one").payload, payload);
205
+ assert.equal(unmatchedOptimisticMessages(receipts.value, delivery.state.messages, { receiptsOnly: true }).length, 1);
206
+ const restored = createAssistantMessageDelivery();
207
+ assert.equal(restored.restoreUncertain({ messageId: "one", text: "Original", attachments: payload.displayAttachments }, receipts.value), true);
208
+ assert.equal(restored.turns(receipts.value).length, 1);
209
+ assert.equal(restored.turns(receipts.value)[0].optimistic.status, "uncertain");
210
+ const admitted = { ...turn, user: { ...turn.user } };
211
+ delete admitted.user.receipt;
212
+ delivery.reconcile([admitted]);
213
+ delivery.reconcile([admitted]);
214
+ assert.equal(acknowledgements, 1);
215
+ assert.equal(delivery.state.messages.length, 0);
216
+ assert.equal(submissions, 1);
217
+ });
218
+
219
+ test("an exact HTTP admission can retire its still provisional displayed row", async () => {
220
+ let acknowledgements = 0;
221
+ const delivery = createAssistantMessageDelivery({ deliver: async () => ({ ok: true, delivered: true }) });
222
+ await delivery.send({ message: "Accepted" }, { messageId: "one", onAccepted() { acknowledgements += 1; } });
223
+ const provisional = { turnId: "saved", user: { messageId: "one", text: "Accepted", receipt: false } };
224
+ delivery.reconcile([provisional]);
225
+ assert.deepEqual(delivery.turns([provisional]), [provisional]);
226
+ assert.equal(delivery.state.messages.length, 0);
227
+ assert.equal(acknowledgements, 1);
228
+ });
229
+
230
+ test("a late canonical receipt acknowledges captured attachments once after an uncertain HTTP response", async () => {
231
+ let ready = ["original"];
232
+ let acknowledgements = 0;
233
+ const delivery = createAssistantMessageDelivery({ deliver: async () => { throw new Error("Receipt lost."); } });
234
+ const payload = { message: "", displayAttachments: [{ attachmentId: "original" }] };
235
+ await assert.rejects(delivery.send(payload, {
236
+ messageId: "one", uncertainOnError: true,
237
+ onAccepted() { acknowledgements += 1; ready = ready.filter(id => id !== "original"); }
238
+ }), /Receipt lost/);
239
+ ready.push("newer");
240
+ assert.equal(acknowledgements, 0);
241
+ assert.equal(Object.values(delivery.find("one")).some(value => typeof value === "function"), false);
242
+ assert.deepEqual(delivery.find("one").payload, payload);
243
+ delivery.reconcile([{ user: { messageId: "another", text: "" } }]);
244
+ assert.equal(acknowledgements, 0);
245
+ const receipt = { user: { messageId: "one", text: "", attachments: payload.displayAttachments } };
246
+ delivery.reconcile([receipt]);
247
+ delivery.accept("one");
248
+ delivery.reconcile([receipt]);
249
+ assert.deepEqual(ready, ["newer"]);
250
+ assert.equal(acknowledgements, 1);
251
+ assert.deepEqual(delivery.state.messages, []);
252
+ });
253
+
254
+ test("a canonical receipt acknowledges attachments before HTTP completes and late failure cannot repeat it", async () => {
255
+ const request = Promise.withResolvers();
256
+ const receipts = ref([]);
257
+ const delivery = createAssistantMessageDelivery();
258
+ let acknowledgements = 0;
259
+ const sending = delivery.send({ message: "See the file", displayAttachments: [{ attachmentId: "original" }] }, {
260
+ messageId: "one", deliver: () => request.promise, receiptTurns: receipts, uncertainOnError: true,
261
+ onAccepted() { acknowledgements += 1; }
262
+ });
263
+ receipts.value = [{ user: { messageId: "one", text: "See the file" } }];
264
+ delivery.reconcile(receipts.value);
265
+ assert.equal(acknowledgements, 1);
266
+ assert.deepEqual(await sending, { ok: true });
267
+ request.reject(new Error("Late HTTP failure."));
268
+ await nextTick();
269
+ delivery.accept("one");
270
+ assert.equal(acknowledgements, 1);
271
+ assert.equal(delivery.state.sending, false);
272
+ });
273
+
274
+ test("retry preserves the original attachment owner and inspection can acknowledge an uncertain entry", async () => {
275
+ const sent = [];
276
+ const owners = [];
277
+ const delivery = createAssistantMessageDelivery({ deliver: async payload => {
278
+ sent.push(payload);
279
+ return sent.length === 1 ? { ok: false, error: "Rejected before admission." } : { ok: true };
280
+ } });
281
+ const payload = { message: "Retry this file", displayAttachments: [{ attachmentId: "original" }] };
282
+ await delivery.send(payload, { messageId: "one", onAccepted: () => owners.push("original") });
283
+ await delivery.send(payload, { messageId: "one", onAccepted: () => owners.push("newer") });
284
+ assert.deepEqual(sent[0], sent[1]);
285
+ assert.deepEqual(owners, ["original"]);
286
+ delivery.accept("one");
287
+ assert.deepEqual(owners, ["original"]);
288
+
289
+ await assert.rejects(delivery.send({ message: "Check receipt" }, {
290
+ messageId: "uncertain", uncertainOnError: true, deliver: async () => { throw new Error("Offline"); },
291
+ onAccepted: () => owners.push("inspected")
292
+ }), /Offline/);
293
+ delivery.accept("uncertain");
294
+ delivery.accept("uncertain");
295
+ assert.deepEqual(owners, ["original", "inspected"]);
296
+ });
297
+
298
+ test("unconfirmed delivery preserves its authored payload for inspection without replay or edit controls", async () => {
299
+ const sent = [];
300
+ const delivery = createAssistantMessageDelivery({ deliver: async payload => {
301
+ sent.push(payload);
302
+ throw new Error("Connection closed.");
303
+ } });
304
+ const payload = { message: "Hello", displayAttachments: [{ attachmentId: "file", name: "Original" }] };
305
+ await assert.rejects(delivery.send(payload, { messageId: "one", uncertainOnError: true }), /Connection closed/);
306
+ payload.displayAttachments[0].name = "Changed";
307
+ const [turn] = delivery.turns();
308
+ assert.equal(turn.optimistic.status, "uncertain");
309
+ assert.equal(turn.user.attachments[0].name, "Original");
310
+ assert.deepEqual(turn.system.delivery, { messageId: "one", error: "Connection closed.", checking: false });
311
+ assert.equal(delivery.cancel("one"), false);
312
+ assert.equal(delivery.edit("one", "New draft"), null);
313
+ assert.equal(delivery.resend("one"), false);
314
+ assert.equal(await delivery.send({ message: "Hello" }, { messageId: "one" }), false);
315
+ assert.equal(sent.length, 1);
316
+ delivery.accept("one");
317
+ assert.equal(delivery.find("one").status, "accepted");
318
+ assert.equal(delivery.find("one").error, "");
319
+ assert.equal(delivery.restoreUncertain({ messageId: "one", text: "Hello", error: "Stale error" }), false);
320
+ assert.equal(delivery.turns()[0].system, undefined);
321
+ });
322
+
323
+ test("reconnect restores one uncertain request and ends its HTTP wait without dispatching again", async () => {
324
+ const request = Promise.withResolvers();
325
+ const receipts = ref([]);
326
+ let sends = 0;
327
+ const delivery = createAssistantMessageDelivery();
328
+ const sending = delivery.send({ message: "Hello" }, {
329
+ messageId: "one", receiptTurns: receipts, uncertainOnError: true,
330
+ deliver: () => { sends += 1; return request.promise; }
331
+ });
332
+ const pendingRequest = { messageId: "one", text: "Hello", error: "Delivery is unconfirmed." };
333
+ assert.equal(delivery.restoreUncertain(pendingRequest), true);
334
+ assert.deepEqual(await sending, { ok: false, status: "uncertain", error: pendingRequest.error });
335
+ delivery.restoreUncertain(pendingRequest);
336
+ assert.equal(delivery.state.messages.length, 1);
337
+ assert.equal(delivery.state.sending, false);
338
+ assert.equal(sends, 1);
339
+ request.reject(new Error("Late transport failure"));
340
+ await nextTick();
341
+ assert.equal(delivery.find("one").error, pendingRequest.error);
342
+ const receipt = { user: { messageId: "one", text: "Hello" } };
343
+ delivery.reconcile([receipt]);
344
+ assert.equal(delivery.restoreUncertain(pendingRequest, [receipt]), false);
345
+ assert.deepEqual(delivery.turns([receipt]), [receipt]);
346
+ });
347
+
348
+ test("an uncertain predecessor leaves queued guidance unsent and safely retryable", async () => {
349
+ const request = Promise.withResolvers();
350
+ const sent = [];
351
+ const delivery = createAssistantMessageDelivery({ deliver: payload => {
352
+ sent.push(payload.messageId);
353
+ return request.promise;
354
+ } });
355
+ const first = delivery.send({ message: "First" }, { messageId: "one", uncertainOnError: true });
356
+ const second = delivery.send({ message: "Second" }, { messageId: "two", uncertainOnError: true, queue: true });
357
+ const rejected = assert.rejects(first, /Connection lost/);
358
+ request.reject(new Error("Connection lost"));
359
+ await rejected;
360
+ assert.equal(await second, false);
361
+ assert.deepEqual(sent, ["one"]);
362
+ assert.equal(delivery.find("one").status, "uncertain");
363
+ assert.equal(delivery.find("two").status, "failed");
364
+ assert.match(delivery.find("two").error, /Check the previous message/);
365
+ });
366
+
367
+ test("restored application requests retain their origin and attachment-only user messages remain empty", async () => {
368
+ const delivery = createAssistantMessageDelivery();
369
+ delivery.restoreUncertain({ messageId: "wake", origin: "application", text: "Continue the goal.", at: "2026-01-01T00:00:00Z" });
370
+ const [restored] = delivery.turns();
371
+ assert.equal(restored.user, undefined);
372
+ assert.equal(restored.system.messageId, "wake");
373
+ assert.match(restored.system.text, /Continue the goal/);
374
+ delivery.reconcile([{ system: { messageId: "wake", text: "Continue the goal." } }]);
375
+ assert.equal(delivery.state.messages.length, 0);
376
+ await delivery.send({ message: "", displayAttachments: [{ attachmentId: "image" }] }, {
377
+ messageId: "image-only", deliver: async () => ({ status: "accepted" })
378
+ });
379
+ assert.equal(delivery.turns()[0].user.text, "");
380
+ assert.deepEqual(delivery.turns()[0].user.attachments, [{ attachmentId: "image" }]);
381
+ });
@@ -0,0 +1,215 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { retireNativeConversation } from "../src/server/conversation/nativeHistoryExport.js";
7
+ import { listClaudeConversationStorage, retireClaudeConversationHistory } from "../src/server/conversation/claudeHistory.js";
8
+ import { createConversationRuntime } from "../src/server/conversation/runtime.js";
9
+ import { createClaudeConversationOwner } from "../src/server/conversation/claudeTurn.js";
10
+
11
+ const binding = { engineId: "codex", conversationId: "parent", workdir: "/saved/source" };
12
+ const proof = async () => ({ preserved: true, exclusive: true });
13
+
14
+ test("native retirement requires every complete export and rechecks exact content even when metadata is unchanged", async () => {
15
+ let revision = "a".repeat(64);
16
+ let deleted = false;
17
+ const input = { binding, inspect: async () => [{ conversationId: "parent" }],
18
+ remove: async () => { deleted = true; }, exportConversation: async (_id, emit) => { await emit({ type: "item" }); return { revision }; } };
19
+ await assert.rejects(retireNativeConversation({ ...input, beforeDelete: proof }), /complete native export/);
20
+ await assert.rejects(retireNativeConversation({ ...input, beforeDelete: async ({ exportConversation }) => {
21
+ await assert.rejects(exportConversation("foreign", async () => {}), /outside/);
22
+ await exportConversation("parent", async () => {});
23
+ revision = "b".repeat(64);
24
+ return proof();
25
+ } }), /changed during preservation/);
26
+ assert.equal(deleted, false);
27
+ });
28
+
29
+ test("native retirement preserves the complete scope before removal and verifies absence", async () => {
30
+ let rows = [{ conversationId: "parent", modified: 1 }, { conversationId: "child", modified: 2 }];
31
+ const calls = [];
32
+ const input = { binding, inspect: async () => { calls.push("inspect"); return rows; },
33
+ beforeDelete: async (inventory) => {
34
+ calls.push("preserve");
35
+ assert.deepEqual(inventory.conversations, rows);
36
+ inventory.conversations.length = 0; // The host cannot change deletion's snapshot.
37
+ return proof();
38
+ }, remove: async (scope) => { calls.push("delete"); assert.equal(scope.length, 2); rows = []; } };
39
+ assert.deepEqual(await retireNativeConversation(input), { ok: true, alreadyAbsent: false, conversationIds: ["parent", "child"] });
40
+ assert.deepEqual(calls, ["inspect", "preserve", "inspect", "delete", "inspect"]);
41
+ calls.length = 0;
42
+ assert.equal((await retireNativeConversation(input)).alreadyAbsent, true);
43
+ assert.deepEqual(calls, ["inspect"]);
44
+ });
45
+
46
+ test("native retirement refuses missing proofs, changing history and unconfirmed deletion", async () => {
47
+ let deleted = 0;
48
+ const input = { binding, inspect: async () => [{ conversationId: "parent", modified: 1 }], remove: async () => deleted++ };
49
+ await assert.rejects(retireNativeConversation(input), /callback/);
50
+ for (const result of [undefined, {}, { preserved: true }, { exclusive: true }]) {
51
+ await assert.rejects(retireNativeConversation({ ...input, beforeDelete: async () => result }), /did not confirm/);
52
+ }
53
+ let revision = 0;
54
+ await assert.rejects(retireNativeConversation({ ...input, beforeDelete: proof,
55
+ inspect: async () => [{ conversationId: "parent", modified: ++revision }] }), /changed/);
56
+ assert.equal(deleted, 0);
57
+ await assert.rejects(retireNativeConversation({ ...input, beforeDelete: proof }), /not confirmed/);
58
+ assert.equal(deleted, 1);
59
+ });
60
+
61
+ async function claudeFixture(t) {
62
+ const root = await mkdtemp(path.join(os.tmpdir(), "vibe64-native-retirement-"));
63
+ t.after(() => rm(root, { recursive: true, force: true }));
64
+ const configRoot = path.join(root, "claude");
65
+ const workdir = path.join(root, "removed-source");
66
+ const id = "12345678-1234-4234-8234-123456789abc";
67
+ const project = path.join(configRoot, "projects", workdir.replace(/[^a-zA-Z0-9]/gu, "-"));
68
+ const write = async (name) => { await mkdir(path.dirname(name), { recursive: true }); await writeFile(name, "preserved native bytes"); };
69
+ const targets = [path.join(project, `${id}.jsonl`), path.join(project, id, "subagents/agent.jsonl"),
70
+ path.join(configRoot, "file-history", id, "checkpoint"), path.join(configRoot, "uploads", id, "image.png")];
71
+ for (const name of targets) await write(name);
72
+ await writeFile(targets[0], [
73
+ { type: "user", uuid: "question", timestamp: "2026-09-25T00:00:00Z", message: { content: [
74
+ { type: "text", text: "Keep the question" }, { type: "image", source: { data: "attachment payload" } }
75
+ ] } },
76
+ { type: "assistant", uuid: "answer", parentUuid: "question", message: { model: "claude-model", content: [{ type: "text", text: "Keep the answer" }] } }
77
+ ].map(JSON.stringify).join("\n") + "\n");
78
+ await writeFile(targets[1], JSON.stringify({ type: "assistant", uuid: "child-answer", isSidechain: true, parent_tool_use_id: "call",
79
+ message: { content: [{ type: "text", text: "Keep the child answer" }] } }) + "\n");
80
+ const originals = new Map(await Promise.all(targets.map(async (file) => [file, await readFile(file, "utf8")])));
81
+ const protectedFiles = [path.join(configRoot, "settings.json"), path.join(project, "memory/MEMORY.md"), path.join(project, "another.jsonl")];
82
+ for (const name of protectedFiles) await write(name);
83
+ return { root, project, configRoot, targets, protectedFiles, originals, binding: { engineId: "claude", conversationId: id, workdir },
84
+ requireIdle: async () => {}, beforeDelete: proof };
85
+ }
86
+
87
+ test("Claude retirement works after source removal, preserves shared files and is idempotent", async (t) => {
88
+ const f = await claudeFixture(t);
89
+ let preserved = 0;
90
+ f.beforeDelete = async ({ conversations, exportConversation }) => {
91
+ assert.equal(conversations.length, 1);
92
+ assert.equal(conversations[0].files.filter((file) => !file.directory).length, 4);
93
+ preserved++;
94
+ for (const target of f.targets) assert.equal(await readFile(target, "utf8"), f.originals.get(target));
95
+ const entries = [];
96
+ await exportConversation(f.binding.conversationId, async (record) => entries.push(...record.text));
97
+ assert.deepEqual(entries.map((entry) => entry.text).sort(), ["Keep the answer", "Keep the child answer", "Keep the question"]);
98
+ assert.equal(new Set(entries.map((entry) => entry.branchId)).size, 2);
99
+ assert.equal(entries.find((entry) => entry.messageId === "question").createdAt, "2026-09-25T00:00:00Z");
100
+ assert.equal(entries.find((entry) => entry.messageId === "answer").modelId, "claude-model");
101
+ return proof();
102
+ };
103
+ assert.equal((await retireClaudeConversationHistory(f)).ok, true);
104
+ for (const target of f.targets) await assert.rejects(stat(target), { code: "ENOENT" });
105
+ for (const file of f.protectedFiles) assert.equal(await readFile(file, "utf8"), "preserved native bytes");
106
+ assert.equal((await retireClaudeConversationHistory(f)).alreadyAbsent, true);
107
+ assert.equal(preserved, 1);
108
+ });
109
+
110
+ test("Claude inventory discovers additional native roots without silently claiming ownership", async (t) => {
111
+ const f = await claudeFixture(t);
112
+ const other = "23456789-1234-4234-8234-123456789abc";
113
+ await writeFile(path.join(f.project, `${other}.jsonl`), "native fork");
114
+ assert.deepEqual((await listClaudeConversationStorage(f)).map((row) => row.conversationId), [f.binding.conversationId, other]);
115
+ assert.equal(await readFile(path.join(f.project, `${other}.jsonl`), "utf8"), "native fork");
116
+ });
117
+
118
+ test("Claude retirement rejects writers, symlinks and changed files without deleting history", async (t) => {
119
+ const f = await claudeFixture(t);
120
+ await assert.rejects(retireClaudeConversationHistory({ ...f, requireIdle: async () => { throw new Error("busy"); } }), /busy/);
121
+ const link = path.join(f.project, f.binding.conversationId, "outside");
122
+ await symlink(f.protectedFiles[0], link);
123
+ await assert.rejects(retireClaudeConversationHistory(f), /unsafe paths/);
124
+ await rm(link);
125
+ await assert.rejects(retireClaudeConversationHistory({ ...f, beforeDelete: async ({ exportConversation }) => {
126
+ await exportConversation(f.binding.conversationId, async () => {});
127
+ await writeFile(f.targets[0], f.originals.get(f.targets[0]).replace("Keep the answer", "changed after inventory")); return proof();
128
+ } }), /changed/);
129
+ assert.match(await readFile(f.targets[0], "utf8"), /changed after inventory/);
130
+ });
131
+
132
+
133
+ test("configured Claude retirement preserves saved, native and terminal gates before asynchronous history preservation", { timeout: 10000 }, async t => {
134
+ const f = await claudeFixture(t);
135
+ const context = { key: "retained-parent", sessionId: "retained-parent", workdir: f.binding.workdir };
136
+ const owner = createClaudeConversationOwner({
137
+ configRoot: f.configRoot, preparation: {},
138
+ process: { create() { assert.fail("Historical retirement must not start a native process."); } },
139
+ store: {
140
+ select() { assert.fail("Restoring the saved native entry must not select Main."); },
141
+ read() { return JSON.stringify({ turnId: "saved-turn", state: "active" }); },
142
+ async save() {}
143
+ }
144
+ });
145
+ const reads = [];
146
+ let saved = { executionId: "saved-managed-execution" };
147
+ let terminalRunning = false;
148
+ let preservations = 0;
149
+ const entered = Promise.withResolvers();
150
+ const release = Promise.withResolvers();
151
+ const releaseOnAbort = () => release.resolve();
152
+ t.signal.addEventListener("abort", releaseOnAbort, { once: true });
153
+ const beforeDelete = async ({ exportConversation }) => {
154
+ preservations++;
155
+ await exportConversation(f.binding.conversationId, async () => {});
156
+ entered.resolve();
157
+ await release.promise;
158
+ return proof();
159
+ };
160
+ const application = {
161
+ get saved() { reads.push("saved"); return saved; },
162
+ get terminalRunning() { reads.push("terminal"); return terminalRunning; }
163
+ };
164
+ const runtime = createConversationRuntime({
165
+ authorize() { assert.fail("Saved-history cleanup must not authorize inference."); },
166
+ storage: {
167
+ read() { assert.fail("Retirement must not open Main."); },
168
+ write() { assert.fail("Retirement must not allocate a binding."); }
169
+ },
170
+ host: { conversation(request) {
171
+ assert.deepEqual(request, { id: context.sessionId, context, input: f.binding, operation: "retireConversationHistory" });
172
+ return { sessionId: request.id, engine: "claude", binding: f.binding, context,
173
+ native: { owner, preparation: { retirement(binding, current) {
174
+ assert.equal(binding, f.binding);
175
+ assert.equal(current, context);
176
+ return { configRoot: f.configRoot, binding, beforeDelete, application };
177
+ } } } };
178
+ } }
179
+ });
180
+ t.after(async () => { release.resolve(); t.signal.removeEventListener("abort", releaseOnAbort); await runtime.close(); owner.forget(context.key); });
181
+ const retire = () => runtime.retireNativeConversationHistory({ id: context.sessionId, context, binding: f.binding });
182
+ const blocked = /Stop the saved Claude process and its terminal before retiring native history/;
183
+
184
+ await assert.rejects(retire(), blocked);
185
+ assert.deepEqual(reads, ["saved"], "Saved execution blocks without querying the PTY.");
186
+ saved = {};
187
+ await owner.open(context, { conversationId: f.binding.conversationId, create: true });
188
+ reads.length = 0;
189
+ await assert.rejects(retire(), blocked);
190
+ assert.deepEqual(reads, ["saved"], "The restored native active turn blocks before the PTY read.");
191
+ owner.forget(context.key);
192
+ terminalRunning = true;
193
+ reads.length = 0;
194
+ await assert.rejects(retire(), blocked);
195
+ assert.deepEqual(reads, ["saved", "terminal"]);
196
+ assert.equal(preservations, 0);
197
+ for (const target of f.targets) assert.equal(await readFile(target, "utf8"), f.originals.get(target));
198
+
199
+ terminalRunning = false;
200
+ let settled = false;
201
+ const retiring = retire().finally(() => { settled = true; });
202
+ try {
203
+ await Promise.race([entered.promise, retiring.then(() => assert.fail("Retirement must await preservation."))]);
204
+ assert.equal(settled, false);
205
+ for (const target of f.targets) assert.equal(await readFile(target, "utf8"), f.originals.get(target));
206
+ release.resolve();
207
+ assert.deepEqual(await retiring, { ok: true, alreadyAbsent: false, conversationIds: [f.binding.conversationId] });
208
+ } finally {
209
+ release.resolve();
210
+ await retiring.catch(() => {});
211
+ }
212
+ assert.equal(preservations, 1);
213
+ for (const target of f.targets) await assert.rejects(stat(target), { code: "ENOENT" });
214
+ for (const file of f.protectedFiles) assert.equal(await readFile(file, "utf8"), "preserved native bytes");
215
+ });