@jskit-ai/assistant-core 0.2.7 → 0.2.8

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 (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +13 -10
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -71,7 +71,7 @@ test("renewal preserves logical history and receipts while subsequent inference
71
71
  const retry = await f.conversation.replace(operation);
72
72
  assert.equal(retry.duplicate, true);
73
73
  assert.equal(retry.segmentId, result.segmentId);
74
- await assert.rejects(f.conversation.replace({ ...operation, briefing: "Different" }), { code: "conversation_replacement_conflict" });
74
+ await assert.rejects(f.conversation.replace({ ...operation, briefing: "Different" }), { code: "conversation_replacement_operation_conflict" });
75
75
  });
76
76
 
77
77
  test("completed historical branch markers retain history filtering and admission receipts", async t => {
@@ -105,8 +105,8 @@ test("unfinished historical Undo operations require offline inspection without c
105
105
  saved.runtime.replacement = { request: { ...operation, operation: "replace" }, reason: "renewal", engine: "api",
106
106
  configuration, segmentId: "historical-successor", seen: {}, ...patch };
107
107
  await f.storage.write("one", transaction => transaction.writeMetadata(saved));
108
- await assert.rejects(f.conversation.replace(operation), { code: "conversation_rewind_inspection_required" });
109
- await assert.rejects(f.runtime().open({ id: "one" }), { code: "conversation_rewind_inspection_required" });
108
+ await assert.rejects(f.conversation.replace(operation), { code: "conversation_rewind_inspection_before_replace" });
109
+ await assert.rejects(f.runtime().open({ id: "one" }), { code: "conversation_rewind_inspection_before_open" });
110
110
  assert.deepEqual(await f.storage.read("one", transaction => transaction.readMetadata()), saved);
111
111
  assert.equal((await f.conversation.read()).conversationLog.length, 1);
112
112
  assert.equal(f.requests.length, 1);
@@ -120,8 +120,8 @@ test("an interrupted replacement blocks sending and resumes exactly once after a
120
120
  f.state.failCommit = true;
121
121
  await assert.rejects(f.conversation.replace(operation), /Storage offline/);
122
122
  assert.equal((await f.conversation.read()).status, "replacement-pending");
123
- await assert.rejects(f.conversation.send({ text: "Too early", messageId: "early" }), { code: "conversation_replacement_pending" });
124
- await assert.rejects(f.conversation.configure({ systemPrompt: "Too early" }), { code: "conversation_replacement_pending" });
123
+ await assert.rejects(f.conversation.send({ text: "Too early", messageId: "early" }), { code: "conversation_replacement_pending_before_send" });
124
+ await assert.rejects(f.conversation.configure({ systemPrompt: "Too early" }), { code: "conversation_replacement_pending_before_settings" });
125
125
  await f.first.close();
126
126
  f.state.failCommit = false;
127
127
  const resumed = await f.runtime().open({ id: "one" });
@@ -137,11 +137,11 @@ test("an interrupted replacement blocks sending and resumes exactly once after a
137
137
  test("invalid, stale and forbidden replacement requests do not alter the conversation", async t => {
138
138
  const f = await fixture(t);
139
139
  const operation = await f.replacement();
140
- await assert.rejects(f.conversation.replace({ ...operation, expectedSegmentId: "stale" }), { code: "conversation_replacement_conflict" });
141
- await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind" }), { code: "conversation_invalid_replacement" });
142
- await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind", throughTurnId: "missing" }), { code: "conversation_invalid_replacement" });
143
- await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind", throughTurnId: null }), { code: "conversation_invalid_replacement" });
144
- await assert.rejects(f.conversation.replace({ ...operation, throughTurnId: null }), { code: "conversation_invalid_replacement" });
140
+ await assert.rejects(f.conversation.replace({ ...operation, expectedSegmentId: "stale" }), { code: "conversation_predecessor_changed" });
141
+ await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind" }), { code: "conversation_replacement_request_invalid" });
142
+ await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind", throughTurnId: "missing" }), { code: "conversation_replacement_request_invalid" });
143
+ await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind", throughTurnId: null }), { code: "conversation_replacement_request_invalid" });
144
+ await assert.rejects(f.conversation.replace({ ...operation, throughTurnId: null }), { code: "conversation_replacement_request_invalid" });
145
145
  await assert.rejects(f.conversation.replace({ ...operation, engine: "missing" }), /does not yet support/);
146
146
  f.state.allowed = false;
147
147
  await assert.rejects(f.conversation.replace(operation), { code: "conversation_forbidden" });
@@ -167,7 +167,7 @@ test("host scope belongs to a conversation and cannot be changed through another
167
167
  const host = { workdir: "/application/one", env: { HOME: "/accounts/one" } };
168
168
  const one = await f.first.open({ id: "scoped-one", host, configuration });
169
169
  const two = await f.first.open({ id: "scoped-two", host: { workdir: "/application/two" }, configuration });
170
- await assert.rejects(f.first.open({ id: "scoped-one", host: { workdir: "/application/other" } }), { code: "conversation_host_mismatch" });
170
+ await assert.rejects(f.first.open({ id: "scoped-one", host: { workdir: "/application/other" } }), { code: "conversation_host_scope_mismatch" });
171
171
  await one.send({ messageId: "one", text: "One" });
172
172
  await two.send({ messageId: "two", text: "Two" });
173
173
  assert.equal((await one.wait()).conversationLog.length, 1);
@@ -196,7 +196,7 @@ test("interrupted OpenCode integration selection retries the original fresh bind
196
196
  assert.notEqual(pending.runtime.replacement.binding.directory, metadata.runtime.binding.directory);
197
197
  assert.equal((await f.conversation.read()).status, "replacement-pending");
198
198
  await assert.rejects(f.conversation.send({ messageId: "premature", text: "Too early" }),
199
- { code: "conversation_replacement_pending" });
199
+ { code: "conversation_replacement_pending_before_send" });
200
200
  await f.first.close();
201
201
  f.state.failCommit = false;
202
202
  const reopened = await f.runtime().open({ id: "one" });
@@ -230,7 +230,7 @@ test("interrupted fresh native selection retains its consumer policy and success
230
230
  assert.notEqual(pending.runtime.replacement.binding.directory, original.runtime.binding.directory);
231
231
  assert.equal((await f.conversation.read()).status, "replacement-pending");
232
232
  await assert.rejects(f.conversation.send({ messageId: "too-early", text: "Wait for the change" }),
233
- { code: "conversation_replacement_pending" });
233
+ { code: "conversation_replacement_pending_before_send" });
234
234
  await f.first.close();
235
235
  f.state.failCommit = false;
236
236
  const reopened = await f.runtime().open({ id: "one" });
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { createConversationHookBridge, createConversationRuntime } from "../src/server/conversation/index.js";
7
- import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
7
+ import { createClaudeConversationTurn } from "../src/server/conversation/claudeNativeTurn.js";
8
8
  import { createCodexConversationAdapter } from "../src/server/conversation/providers/codex.js";
9
9
  import { createOpenCodeConversationAdapter } from "../src/server/conversation/providers/opencode.js";
10
10
  import { readPersistentConversation } from "../src/server/conversation/providers/scoped.js";
@@ -97,7 +97,7 @@ test("saved persistent history projects receipts and pages without native observ
97
97
  assert.equal(result.page.conversationLog[0].user.receipt, false);
98
98
  assert.deepEqual(calls.map(([kind]) => kind), ["log", "page"]);
99
99
  await runtime.close();
100
- await assert.rejects(runtime.projectStoredPersistentConversation(options), { code: "conversation_closed" });
100
+ await assert.rejects(runtime.projectStoredPersistentConversation(options), { code: "conversation_runtime_closed" });
101
101
  });
102
102
 
103
103
  test("a standalone Claude consumer reuses, updates and replaces processes without changing native history", async () => {
@@ -197,7 +197,11 @@ function codexHost({ readInstructions } = {}) {
197
197
  verifyEnvironment: async () => {},
198
198
  publishGoal: (threadId, goal) => requests.push({ method: "host.goal", input: { threadId, goal } }),
199
199
  recordInterruption: (threadId, turnId) => requests.push({ method: "host.recordInterruption", input: { threadId, turnId } }),
200
- prepareResume: async (id, params) => { requests.push({ method: "host.prepareResume", input: { id } }); return params; }
200
+ prepareResume: async (id, params) => { requests.push({ method: "host.prepareResume", input: { id } }); return params; },
201
+ prepareParameters: () => undefined,
202
+ beforeResume: async () => {},
203
+ onRecoveryFailure: () => {},
204
+ log: () => {}
201
205
  });
202
206
  return { runtime, state, requests, client };
203
207
  }
@@ -725,8 +729,8 @@ test("a deleted logical conversation retires its open entry without opening anot
725
729
  const handle = await runtime.open({ id: "session", context: { chat: "one" } });
726
730
  await handle.subscribe(() => {});
727
731
  assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: true });
728
- await assert.rejects(handle.read(), { code: "conversation_closed" });
729
- await assert.rejects(handle.subscribe(() => {}), { code: "conversation_closed" });
732
+ await assert.rejects(handle.read(), { code: "conversation_handle_closed" });
733
+ await assert.rejects(handle.subscribe(() => {}), { code: "conversation_handle_closed" });
730
734
  assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: false },
731
735
  "an absent entry is already retired; nothing is opened to retire it");
732
736
  assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-two", sessionId: "session" }), { retired: false });
@@ -29,7 +29,7 @@ async function fixture(t, options = {}) {
29
29
  authorize: ({ context: actor, conversationId, operation }) => state.allowed && actor === context && ["one", "two"].includes(conversationId) &&
30
30
  (options.authorize?.({ operation, context: actor, conversationId }) ?? true),
31
31
  fetch: (url, init) => { requests.push(JSON.parse(init.body)); return options.fetch?.(url, init) || response(); },
32
- limits: options.limits
32
+ limits: options.limits, ...(options.attachments ? { attachments: options.attachments } : {})
33
33
  });
34
34
  t.after(() => runtime.close());
35
35
  const conversation = await runtime.open({ id: "one", context, configuration: options.resume ? undefined : configuration });
@@ -71,7 +71,7 @@ test("API runtime persists one admission for concurrent identical sends and stre
71
71
  assert.equal(events.find(event => event.type === "accepted").messageId, input.messageId);
72
72
  assert.equal(events.at(-1).type, "settled");
73
73
  unsubscribe();
74
- await assert.rejects(f.conversation.send({ ...input, text: "Changed content" }), { code: "conversation_message_conflict" });
74
+ await assert.rejects(f.conversation.send({ ...input, text: "Changed content" }), { code: "conversation_message_id_content_conflict" });
75
75
  assert.equal(f.requests.length, 1);
76
76
  });
77
77
 
@@ -82,7 +82,8 @@ test("saved history and current instructions use the existing authorized model c
82
82
  await f.conversation.configure({ systemPrompt: "Use the new instructions." });
83
83
  await f.conversation.send({ messageId: "two", text: "Continue" });
84
84
  await f.conversation.wait();
85
- assert.equal(f.requests[1].messages[0].content, "Use the new instructions.");
85
+ assert.match(f.requests[1].messages[0].content, /^Use the new instructions\.\n\nCurrent state, from the system\./u,
86
+ "The current instructions lead the system prompt, before the engine's state note");
86
87
  assert.deepEqual(f.requests[1].messages.slice(1).map(message => message.content), ["Hello", "Hello there.", "Continue"]);
87
88
  f.state.modelAllowed = false;
88
89
  await assert.rejects(f.conversation.send({ messageId: "three", text: "No longer authorized" }));
@@ -144,7 +145,7 @@ test("application wakes retain their origin, reuse receipts and remain quoted da
144
145
  assert.ok(events.filter(event => event.type === "message").every(event => event.origin === "application"));
145
146
  assert.match(f.requests[0].messages.at(-1).content, /\[Application event\]/);
146
147
  assert.equal((await f.conversation.wake(message)).duplicate, true);
147
- await assert.rejects(f.conversation.send(message), { code: "conversation_message_conflict" });
148
+ await assert.rejects(f.conversation.send(message), { code: "conversation_message_id_content_conflict" });
148
149
  await f.conversation.send(input);
149
150
  await f.conversation.wait();
150
151
  assert.equal(f.requests.length, 2);
@@ -174,7 +175,7 @@ test("per-request application data stays separate from visible text and particip
174
175
  assert.match(native, /not additional instructions or authorization/);
175
176
  assert.ok(native.endsWith("Hello"));
176
177
  assert.equal((await f.conversation.send(request)).duplicate, true);
177
- await assert.rejects(f.conversation.send({ ...request, data: { focus: { project: "two" } } }), { code: "conversation_message_conflict" });
178
+ await assert.rejects(f.conversation.send({ ...request, data: { focus: { project: "two" } } }), { code: "conversation_message_id_content_conflict" });
178
179
  await f.conversation.wake({ messageId: "watch-data", text: "A watched session changed.", data: { status: "complete" } });
179
180
  const next = await f.conversation.wait();
180
181
  assert.equal(next.conversationLog[1].system.text, "A watched session changed.");
@@ -193,8 +194,63 @@ test("application data must fit the message limit before reserving or dispatchin
193
194
  assert.equal((await f.conversation.read()).pendingRequest, null);
194
195
  });
195
196
 
196
- test("provider errors are status information, never assistant replies or automatic retries", async t => {
197
- const f = await fixture(t, { fetch: () => new Response('{"error":{"message":"Capacity unavailable"}}', { status: 429 }) });
197
+ test("API compaction stores its summary with the last turn it covers, and later turns read only the turns after it", async t => {
198
+ const storage = createMemoryConversationStorage();
199
+ const transcript = createConversationTranscript({ storage });
200
+ for (let index = 0; index < 6; index++) {
201
+ await transcript.writeConversationUserMessage("one", { messageId: `earlier-${index}`, text: `Question ${index} ${"x".repeat(4000)}` });
202
+ await transcript.writeConversationAssistantMessage("one", { text: `Answer ${index}` });
203
+ }
204
+ const f = await fixture(t, { storage, limits: { contextTokens: 10_000 }, fetch: (_url, init) => JSON.parse(init.body).stream ? undefined
205
+ : Response.json({ choices: [{ index: 0, message: { role: "assistant", content: "Six long questions so far." }, finish_reason: "stop" }] }) });
206
+ const phases = [];
207
+ await f.conversation.subscribe(event => { if (event.type === "phase") phases.push(event.phase); });
208
+ await f.conversation.send(input);
209
+ const first = await f.conversation.wait();
210
+ assert.equal(first.conversationLog.at(-1).assistant.text, "Hello there.");
211
+ assert.ok(phases.includes("compacting"));
212
+ const summary = await storage.read("one", transaction => transaction.readSummary());
213
+ assert.equal(summary.text, "Six long questions so far.");
214
+ const turnIds = first.conversationLog.map(turn => turn.turnId);
215
+ const after = turnIds.slice(turnIds.indexOf(summary.throughTurnId) + 1);
216
+ assert.ok(after.length >= 2 && after.length < turnIds.length);
217
+ const seen = (await storage.read("one", transaction => transaction.readMetadata())).runtime.seen;
218
+ await f.conversation.send({ messageId: "two", text: "Continue" });
219
+ const second = await f.conversation.wait();
220
+ const last = f.requests.at(-1);
221
+ assert.match(last.messages[0].content, /Summary of the earlier part of this conversation[^]*Six long questions so far\./u);
222
+ assert.deepEqual(last.messages.filter(message => message.role === "user").map(message => message.content),
223
+ [...second.conversationLog.filter(turn => after.includes(turn.turnId)).map(turn => turn.user.text), "Continue"]);
224
+ assert.deepEqual(await storage.read("one", transaction => transaction.readSummary()), summary, "A short remainder is not compacted again");
225
+ const later = (await storage.read("one", transaction => transaction.readMetadata())).runtime.seen;
226
+ assert.ok(Object.keys(seen).every(id => Object.hasOwn(later, id)), "Versions saved before the summary still stand");
227
+ });
228
+
229
+ test("API history sends files again only within the newest 20 messages; older turns name theirs", async t => {
230
+ const resolved = [];
231
+ const attachments = { async resolve({ attachmentIds }) {
232
+ resolved.push([...attachmentIds]);
233
+ return { attachments: attachmentIds.map(attachmentId => ({ attachmentId, fileName: `${attachmentId}.txt`, size: 4 })),
234
+ content: attachmentIds.map(attachmentId => ({ type: "text", text: `Bytes of ${attachmentId}` })) };
235
+ } };
236
+ const f = await fixture(t, { attachments });
237
+ const send = async message => { await f.conversation.send(message); await f.conversation.wait(); };
238
+ await send({ messageId: "old", text: "Old file", attachmentIds: ["old"] });
239
+ for (let index = 0; index < 10; index++) await send({ messageId: `m${index}`, text: `Message ${index}` });
240
+ await send({ messageId: "recent", text: "Recent file", attachmentIds: ["recent"] });
241
+ resolved.length = 0;
242
+ await send({ messageId: "last", text: "Last" });
243
+ assert.deepEqual(resolved, [["recent"]], "Only the recent turn's file is read again");
244
+ const messages = f.requests.at(-1).messages;
245
+ assert.deepEqual(messages.find(message => Array.isArray(message.content) && message.content[0].text === "Old file").content,
246
+ [{ type: "text", text: "Old file" }, { type: "text", text: "(Files attached to this message, no longer shown: old.txt.)" }]);
247
+ assert.deepEqual(messages.find(message => Array.isArray(message.content) && message.content[0].text === "Recent file").content,
248
+ [{ type: "text", text: "Recent file" }, { type: "text", text: "Bytes of recent" }]);
249
+ });
250
+
251
+ test("provider errors before anything was done are status information after the engine's retries, never assistant replies", async t => {
252
+ const f = await fixture(t, { fetch: () => new Response('{"error":{"message":"Capacity unavailable"}}',
253
+ { status: 429, headers: { "content-type": "application/json", "retry-after-ms": "0" } }) });
198
254
  const receipt = await f.conversation.send(input);
199
255
  assert.equal(receipt.status, "accepted");
200
256
  const result = await f.conversation.wait();
@@ -202,7 +258,7 @@ test("provider errors are status information, never assistant replies or automat
202
258
  assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
203
259
  assert.match(result.error, /Capacity unavailable/);
204
260
  assert.equal((await f.conversation.send(input)).duplicate, true);
205
- assert.equal(f.requests.length, 1);
261
+ assert.equal(f.requests.length, 7, "The engine tries a failed call again six times, then the turn fails");
206
262
  });
207
263
 
208
264
  test("cancellation aborts inference and leaves another conversation usable", async t => {
@@ -297,7 +353,7 @@ test("native creation authorizes its parent scope and uses only the configured o
297
353
  assert.equal(acquisitions, 1);
298
354
 
299
355
  await runtime.close();
300
- await assert.rejects(runtime.createNativeConversation(request), { code: "conversation_closed" });
356
+ await assert.rejects(runtime.createNativeConversation(request), { code: "conversation_handle_closed" });
301
357
  assert.equal(accesses.length, 2);
302
358
  assert.equal(hostCalls, 1);
303
359
  assert.equal(acquisitions, 1);
@@ -358,9 +414,9 @@ test("native detached execution separates admitted context from host-approved or
358
414
  await entered.promise;
359
415
  await runtime.close();
360
416
  preparing.resolve();
361
- await assert.rejects(pending, { code: "conversation_closed" });
417
+ await assert.rejects(pending, { code: "conversation_runtime_closed" });
362
418
  assert.deepEqual(calls.slice(6), ["authorize", "host"]);
363
- await assert.rejects(runtime.runNativeDetachedConversation(request), { code: "conversation_closed" });
419
+ await assert.rejects(runtime.runNativeDetachedConversation(request), { code: "conversation_handle_closed" });
364
420
  assert.equal(calls.length, 8);
365
421
  });
366
422
 
@@ -492,8 +548,8 @@ test("native readiness authorizes its configured owner and rejects closure durin
492
548
  await preparation.entered.promise;
493
549
  await runtime.close();
494
550
  preparation.finished.resolve();
495
- await assert.rejects(pending, { code: "conversation_closed" });
496
- await assert.rejects(runtime.ensureNativeConversation(request), { code: "conversation_closed" });
551
+ await assert.rejects(pending, { code: "conversation_runtime_closed" });
552
+ await assert.rejects(runtime.ensureNativeConversation(request), { code: "conversation_handle_closed" });
497
553
  assert.deepEqual([hostCalls, acquisitions, accounts, saves], [2, 1, 1, 1]);
498
554
  assert.equal(owner.entries.size, 1, "Readiness did not create a retained runtime handle to dispose.");
499
555
  });
@@ -651,7 +707,7 @@ test("native invalidation preserves synchronous fences and failures without open
651
707
  });
652
708
 
653
709
  test("renewal proof cleanup preserves OpenCode failure, consumption and retry through the configured owner", async t => {
654
- const owner = createOpenCodeSharedRuntime();
710
+ const owner = createOpenCodeSharedRuntime({});
655
711
  const calls = [];
656
712
  const id = "renewal-session";
657
713
  const requestContext = { sessionId: id };
@@ -910,9 +966,9 @@ test("renewal authorizes the existing session without opening a conversation or
910
966
  assert.equal(await runtime[method](request), result);
911
967
  assert.deepEqual(calls.slice(3), ["authorize", "host", "prepare", "acquire", "native"]);
912
968
  closeDuringPreparation = true;
913
- await assert.rejects(runtime[method](request), { code: "conversation_closed" });
969
+ await assert.rejects(runtime[method](request), { code: "conversation_runtime_closed" });
914
970
  assert.deepEqual(calls.slice(8), ["authorize", "host"]);
915
- await assert.rejects(runtime[method](request), { code: "conversation_closed" });
971
+ await assert.rejects(runtime[method](request), { code: "conversation_handle_closed" });
916
972
  assert.deepEqual(calls.slice(8), ["authorize", "host"]);
917
973
  });
918
974
  });
@@ -1124,7 +1180,7 @@ test("disposed handles cannot alter the next owner of the same conversation", as
1124
1180
  const next = await f.runtime.open({ id: "one", context });
1125
1181
  for (const run of [() => f.conversation.read(), () => f.conversation.send(input), () => f.conversation.dispose(),
1126
1182
  () => f.conversation.configure({ systemPrompt: "Stale" }), () => f.conversation.subscribe(() => {})]) {
1127
- await assert.rejects(run(), { code: "conversation_closed" });
1183
+ await assert.rejects(run(), { code: "conversation_handle_closed" });
1128
1184
  }
1129
1185
  await next.send(input);
1130
1186
  assert.equal((await next.wait()).conversationLog.length, 1);
@@ -1162,11 +1218,11 @@ test("a saved running turn is marked interrupted and its accepted message is not
1162
1218
 
1163
1219
  test("limits and unsupported operations fail explicitly without silently changing the request", async t => {
1164
1220
  const f = await fixture(t, { limits: { maxOutputCharacters: 3 } });
1165
- await assert.rejects(f.conversation.send({ ...input, attachments: [{ id: "one" }] }), { code: "conversation_unsupported" });
1166
- await assert.rejects(f.conversation.send({ ...input, steer: true }), { code: "conversation_unsupported" });
1221
+ await assert.rejects(f.conversation.send({ ...input, attachments: [{ id: "one" }] }), { code: "conversation_attachment_ids_only" });
1222
+ await assert.rejects(f.conversation.send({ ...input, steer: true }), { code: "conversation_steering_unsupported" });
1167
1223
  assert.equal(await f.conversation.readGoal(), null);
1168
1224
  await assert.rejects(f.conversation.updateGoal({ action: "set", messageId: "goal", objective: "Keep working",
1169
- expectedSegmentId: (await f.conversation.read()).segmentId }), { code: "conversation_unsupported" });
1225
+ expectedSegmentId: (await f.conversation.read()).segmentId }), { code: "conversation_goals_unsupported" });
1170
1226
  await f.conversation.send(input);
1171
1227
  const result = await f.conversation.wait();
1172
1228
  assert.equal(result.conversationLog[0].assistant, null);
@@ -1276,7 +1332,7 @@ test("native settings authorize their scope and reject replaced or closed host p
1276
1332
  await gate.entered.promise;
1277
1333
  await runtime.close();
1278
1334
  gate.finished.resolve();
1279
- await assert.rejects(pending, { code: "conversation_closed" });
1280
- await assert.rejects(runtime.updateNativeConversationSettings(request), { code: "conversation_closed" });
1335
+ await assert.rejects(pending, { code: "conversation_runtime_closed" });
1336
+ await assert.rejects(runtime.updateNativeConversationSettings(request), { code: "conversation_handle_closed" });
1281
1337
  assert.deepEqual([acquisitions, updates], [1, 1]);
1282
1338
  });
@@ -5,6 +5,7 @@ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
5
  import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
6
6
  import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
7
7
  import { createConversationTools } from "../src/server/conversation/tools.js";
8
+ import { checkTexts } from "@jskit-ai/agent/server";
8
9
 
9
10
  const configuration = { systemPrompt: "Use the available application operations when needed.", integrationId: "assistant" };
10
11
  const context = { actor: { id: "owner" }, surface: "app", applicationId: "test", subjectId: "owner" };
@@ -19,7 +20,7 @@ function actionCatalog(execute) {
19
20
  const actions = createActionCatalogue();
20
21
  actions.register({ contributorId: "test.tools", domain: "test", actions: [{
21
22
  id: "numbers.add", version: 1, kind: "command", channels: ["automation"], surfaces: ["app"],
22
- permission: { require: "authenticated" }, idempotency: "none",
23
+ permission: { require: "authenticated" },
23
24
  input: { schema: createSchema({ left: { type: "number", required: true }, right: { type: "number", required: true } }), mode: "replace" },
24
25
  output: { schema: createSchema({ total: { type: "number", required: true } }), mode: "replace" }, execute
25
26
  }] });
@@ -73,6 +74,8 @@ test("API tools use authorized action contracts, save results and resume the sam
73
74
 
74
75
  // Carried from assistant-runtime's original split-tag lifecycle case. Only the
75
76
  // action name/arguments and confirmed API framing adapt to the common fixture.
77
+ // A call written out as text never runs: the reply carries on once, told so, and
78
+ // the model then calls the action itself.
76
79
  test("split internal tags and tool arguments never become common answer deltas", async t => {
77
80
  const f = await fixture(t, { response: (_body, round, name) => round === 1 ? new Response([
78
81
  "<th", "ink>private reasoning", "</thi", "nk>", "<|DSML|function_",
@@ -80,31 +83,35 @@ test("split internal tags and tool arguments never become common answer deltas",
80
83
  "</|DSML|invoke></|DSML|function_calls>"
81
84
  ].map(content => frame({ content })).join("") + frame({}, "stop"), {
82
85
  headers: { "content-type": "text/event-stream" }
83
- }) : response({ text: "The search is complete." }) });
86
+ }) : round === 2 ? response({ name }) : response({ text: "The search is complete." }) });
84
87
  await f.conversation.send(message);
85
88
  const result = await f.conversation.wait();
86
89
  assert.deepEqual(f.observed.executions, [{ left: 2, right: 3 }]);
90
+ assert.equal(f.observed.requests[1].messages.at(-1).content, checkTexts("the app").markup);
87
91
  assert.deepEqual(f.observed.events.filter(event => event.type === "message" && event.status !== "complete")
88
92
  .map(event => event.text), ["The search is complete."]);
89
93
  assert.equal(result.conversationLog[0].assistant.text, "The search is complete.");
90
94
  });
91
95
 
92
- test("text-encoded calls in separate responses retain separate durable receipts", async t => {
96
+ test("text-encoded calls never run or reach the person, even when written again after the check", async t => {
93
97
  const f = await fixture(t, { response: (_body, round, name) => round < 3 ? new Response(frame({ content:
94
98
  `<function_calls><invoke name="${name}">${round === 1 ? call.arguments : '{"left":4,"right":5}'}</invoke></function_calls>`
95
99
  }) + frame({}, "stop"), { headers: { "content-type": "text/event-stream" } }) : response() });
96
100
  await f.conversation.send(message);
97
101
  const result = await f.conversation.wait();
98
- assert.deepEqual(f.observed.executions, [{ left: 2, right: 3 }, { left: 4, right: 5 }]);
99
- assert.equal(result.error, "");
100
- assert.equal(new Set(result.conversationLog[0].metadata.applicationTools.map(call => call.id)).size, 2);
102
+ assert.deepEqual(f.observed.executions, []);
103
+ assert.equal(f.observed.requests.length, 2, "The check sends the reply back once");
104
+ assert.equal(f.observed.requests[1].messages.at(-1).content, checkTexts("the app").markup);
105
+ assert.deepEqual(result.conversationLog[0].metadata.applicationTools, []);
106
+ assert.equal(f.observed.events.some(event => event.type === "message" && /invoke|function_calls/u.test(event.text)), false);
101
107
  });
102
108
 
103
109
  test("text-encoded calls still require confirmed completion and raw-output limits", async t => {
104
110
  for (const oversized of [false, true]) {
105
111
  const f = await fixture(t, { limits: oversized ? { maxOutputCharacters: 100 } : {},
106
112
  response: (_body, _round, name) => new Response(frame({ content:
107
- (oversized ? `<think>${"private".repeat(50)}</think>` : "") +
113
+ // Inline <think> reasoning is reasoning, bounded by tokens; the limit counts reply text.
114
+ (oversized ? "Working on it. ".repeat(10) : "") +
108
115
  `<function_calls><invoke name="${name}">${call.arguments}</invoke></function_calls>`
109
116
  }) + (oversized ? frame({}, "stop") : ""), { headers: { "content-type": "text/event-stream" } }) });
110
117
  await f.conversation.send(message);
@@ -186,18 +193,13 @@ test("the runtime applies application tool policy to schemas and an unsolicited
186
193
  assert.equal(result.conversationLog[0].metadata.runtime.status, "complete");
187
194
  assert.equal(result.error, "");
188
195
  assert.equal(result.conversationLog[0].assistant.text, "The total is five.");
189
- const refusal = { ok: false, error: { code: "assistant_tool_unknown", message: "Unknown tool." } };
190
- const recorded = result.conversationLog[0].metadata.applicationTools[0];
191
- assert.equal(result.conversationLog[0].metadata.applicationTools.length, 1);
192
- assert.equal(recorded.id, call.id);
193
- assert.equal(recorded.name, f.name);
194
- assert.equal(recorded.arguments, call.arguments);
195
- assert.equal(recorded.status, "complete");
196
- assert.deepEqual(recorded.result, refusal);
196
+ // The engine answers a call to a tool it was not given as a tool error; no
197
+ // application receipt is made, since nothing could run.
198
+ assert.deepEqual(result.conversationLog[0].metadata.applicationTools, []);
197
199
  const feedback = f.observed.requests[1].messages.filter(message => message.role === "tool");
198
200
  assert.equal(feedback.length, 1);
199
201
  assert.equal(feedback[0].tool_call_id, call.id);
200
- assert.deepEqual(JSON.parse(feedback[0].content), { error: refusal.error });
202
+ assert.match(feedback[0].content, new RegExp(`unavailable tool '${f.name}'`, "u"));
201
203
  });
202
204
 
203
205
  test("incomplete model tool streams cannot execute application actions", async t => {
@@ -211,7 +213,7 @@ test("incomplete model tool streams cannot execute application actions", async t
211
213
  });
212
214
 
213
215
  test("tool limits bound continuation even when the model keeps requesting actions", async t => {
214
- const f = await fixture(t, { limits: { maxToolCalls: 2 }, response: (_body, round, name) => response({ name, id: `call_${round}` }) });
216
+ const f = await fixture(t, { limits: { maxToolCalls: 2, maxSteps: 5 }, response: (_body, round, name) => response({ name, id: `call_${round}` }) });
215
217
  await f.conversation.send(message);
216
218
  const result = await f.conversation.wait();
217
219
  assert.equal(f.observed.executions.length, 2);
@@ -219,6 +221,25 @@ test("tool limits bound continuation even when the model keeps requesting action
219
221
  assert.match(result.error, /tool-call limit/);
220
222
  });
221
223
 
224
+ test("by default the step limit stays within the action-call limit, so the model gives its own account", async t => {
225
+ const account = "I added the numbers twice and stopped there.";
226
+ const answer = content => Response.json({ id: "once", object: "chat.completion", created: 0, model: "exact-model",
227
+ choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }] });
228
+ const f = await fixture(t, { limits: { maxToolCalls: 2 }, response: (body, round, name) => {
229
+ if (body.stream) return response({ name, id: `call_${round}` });
230
+ return /^You check an assistant's reply/u.test(body.messages[0].content) ? answer('{"unsupported": []}') : answer(account);
231
+ } });
232
+ await f.conversation.send(message);
233
+ const result = await f.conversation.wait();
234
+ assert.equal(f.observed.executions.length, 2);
235
+ assert.deepEqual(f.observed.requests.map(body => body.stream === true), [true, true, false, false],
236
+ "Two steps, then the engine's account of where it got to and its check of that account's claims");
237
+ assert.match(f.observed.requests[2].messages[0].content, /stopped early because I reached the most steps/u);
238
+ assert.equal(result.error, "");
239
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "complete");
240
+ assert.equal(result.conversationLog[0].assistant.text, account);
241
+ });
242
+
222
243
  test("cancel waits for an admitted application action to settle and saves its result", async t => {
223
244
  const entered = Promise.withResolvers(), complete = Promise.withResolvers();
224
245
  const f = await fixture(t, { execute: () => { entered.resolve(); return complete.promise; } });
@@ -732,7 +753,7 @@ test("bound admission current guard fences a held host effect after steering or
732
753
  await f.conversation.send({ messageId: "guard-steer", text: "A new instruction.", steer: true });
733
754
  await f.currentCommand(1);
734
755
  boundary.resolve();
735
- await assert.rejects(held, { code: "conversation_tool_request_retired" });
756
+ await assert.rejects(held, { code: "conversation_application_tool_message_stale" });
736
757
  await f.tools().execute({ id: "guard-contract", name: "assistant_action_contract",
737
758
  arguments: JSON.stringify({ actionId: "numbers.add", version: 1 }) });
738
759
  const current = f.observed.mappings.at(-1).admission;
@@ -740,7 +761,7 @@ test("bound admission current guard fences a held host effect after steering or
740
761
  assert.equal(current.messageId, "guard-steer");
741
762
  f.finish.resolve();
742
763
  await f.conversation.wait();
743
- assert.throws(() => current.assertCurrent(), { code: "conversation_tool_request_retired" });
764
+ assert.throws(() => current.assertCurrent(), { code: "conversation_application_tool_message_stale" });
744
765
  assert.equal(f.observed.executions.length, 1, "Inspecting current authority must not dispatch or repeat work");
745
766
  });
746
767
 
@@ -826,7 +847,7 @@ test("bound admission completion guard retains only its latest successful native
826
847
  const original = f.observed.mappings[0].admission;
827
848
  assert.equal(typeof original.assertCompletionCurrent, "function");
828
849
  assert.equal(JSON.parse(JSON.stringify(original)).assertCompletionCurrent, undefined);
829
- assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_tool_request_retired" },
850
+ assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" },
830
851
  "A working native turn cannot authorize completion");
831
852
  if (boundary === "stopped") {
832
853
  await f.conversation.cancel();
@@ -839,16 +860,16 @@ test("bound admission completion guard retains only its latest successful native
839
860
  }
840
861
  f.finish.resolve();
841
862
  await f.conversation.wait();
842
- assert.throws(() => original.assertCurrent(), { code: "conversation_tool_request_retired" },
863
+ assert.throws(() => original.assertCurrent(), { code: "conversation_application_tool_message_stale" },
843
864
  "Native settlement never restores live tool authority");
844
865
  if (boundary === "steered") {
845
- assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
866
+ assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
846
867
  return;
847
868
  }
848
869
  original.assertCompletionCurrent();
849
870
  if (boundary === "closed") {
850
871
  await f.runtime.close();
851
- assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
872
+ assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
852
873
  } else if (boundary === "rejected-successor") {
853
874
  f.binding.prepareInput = async () => { throw new Error("The newer input was rejected before native dispatch."); };
854
875
  await assert.rejects(f.conversation.send({ messageId: "unaccepted-successor", text: "Never admitted." }),
@@ -865,7 +886,7 @@ test("bound admission completion guard retains only its latest successful native
865
886
  const receipt = await f.conversation.send({ messageId: "recovered-successor", text: "An original recovered native receipt." });
866
887
  assert.equal(receipt.recovered, true);
867
888
  await f.conversation.wait();
868
- assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_tool_request_retired" },
889
+ assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" },
869
890
  "Verified recovered admission also retires the previous completion receipt");
870
891
  } else if (boundary.startsWith("successor-")) {
871
892
  const gate = Promise.withResolvers();
@@ -878,11 +899,11 @@ test("bound admission completion guard retains only its latest successful native
878
899
  await f.conversation.send({ messageId: "accepted-successor", text: "A genuinely accepted later input." });
879
900
  await f.currentCommand(1);
880
901
  if (boundary === "successor-active") {
881
- assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
902
+ assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
882
903
  gate.resolve();
883
904
  }
884
905
  await f.conversation.wait();
885
- assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_tool_request_retired" },
906
+ assert.throws(() => original.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" },
886
907
  "A later accepted receipt stays current after its active slot clears");
887
908
  } finally { gate.resolve(); }
888
909
  }
@@ -19,13 +19,19 @@ async function fixture(t) {
19
19
  test("file conversations satisfy the shared storage contract and survive a new process", async (t) => {
20
20
  const f = await fixture(t);
21
21
  const { scope, transcript } = await verifyConversationStorageContract(f.storage, { runtime: true });
22
- const expected = await transcript.readConversationLog(scope);
22
+ const log = await transcript.readConversationLog(scope);
23
+ const expected = { log, summary: await f.storage.read(scope, transaction => transaction.readSummary()),
24
+ after: await transcript.readConversationLog(scope, { afterTurnId: log[0].turnId }) };
25
+ assert.ok(expected.summary);
26
+ assert.deepEqual(expected.after, log.slice(1));
23
27
  const { stdout } = await promisify(execFile)(process.execPath, ["--input-type=module", "-e", `
24
28
  import { createFileConversationStorage } from ${JSON.stringify(new URL("../src/server/conversation/fileStorage.js", import.meta.url).href)};
25
29
  import { createConversationTranscript } from ${JSON.stringify(new URL("../src/server/conversation/transcript.js", import.meta.url).href)};
26
30
  const storage = createFileConversationStorage({ directory: process.argv[1] });
27
31
  const transcript = createConversationTranscript({ storage });
28
- process.stdout.write(JSON.stringify(await transcript.readConversationLog(process.argv[2])));
32
+ const log = await transcript.readConversationLog(process.argv[2]);
33
+ process.stdout.write(JSON.stringify({ log, summary: await storage.read(process.argv[2], transaction => transaction.readSummary()),
34
+ after: await transcript.readConversationLog(process.argv[2], { afterTurnId: log[0].turnId }) }));
29
35
  `, f.directory, scope]);
30
36
  assert.deepEqual(JSON.parse(stdout), expected);
31
37
  assert.equal((await readdir(f.directory)).some((name) => name.endsWith(".tmp")), false);
@@ -54,7 +60,8 @@ test("damaged or unsupported stored data fails explicitly and is never overwritt
54
60
  await f.transcript.writeConversationUserMessage("one", { text: "Saved" });
55
61
  const [filename] = await readdir(f.directory);
56
62
  const path = join(f.directory, filename);
57
- for (const damaged of ["{", "null", '{"version":2}', '{"version":1,"scope":"other","turns":[]}']) {
63
+ for (const damaged of ["{", "null", '{"version":2}', '{"version":1,"scope":"other","turns":[]}',
64
+ '{"version":1,"scope":"one","turns":[],"summary":{"text":"Earlier","throughTurnId":"000009"}}']) {
58
65
  await writeFile(path, damaged);
59
66
  await assert.rejects(f.transcript.readConversationLog("one"), /Conversation storage/);
60
67
  await assert.rejects(f.transcript.writeConversationUserMessage("one", { text: "Do not replace" }), /Conversation storage/);
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  CODEX_HELPER_THREAD_LEDGER_SCHEMA_VERSION,
3
- CODEX_HELPER_THREAD_LIFECYCLES,
4
- createCodexHelperThreadLedgerOwner
5
- } from "../../src/server/conversation/codexHelperThreadLedger.js";
3
+ CODEX_HELPER_THREAD_LIFECYCLES
4
+ } from "../../src/server/conversation/codexHelperThreadRecord.js";
5
+ import { createCodexHelperThreadLedgerOwner } from "../../src/server/conversation/codexHelperThreadLedger.js";
6
6
 
7
7
  // This is the original test's resolved profile data, not application policy.
8
8
  // Public keeps and tests its original strict codec against its own catalogue.