@jskit-ai/assistant-core 0.2.6 → 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 +18 -12
  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
@@ -0,0 +1,187 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { conversationAttachmentIds } from "./attachments.js";
3
+ import { failure } from "./conversationRefusals.js";
4
+ import { pendingSuccessor, submittedMessage } from "./conversationReads.js";
5
+ import { serial } from "./conversationEvents.js";
6
+
7
+ /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
8
+
9
+ /**
10
+ * Reading and changing an open conversation's native goal.
11
+ * @param {object} dependencies
12
+ * @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
13
+ * @param {import("./conversationEntries.js").ConversationEntries} dependencies.entries
14
+ * @param {import("./conversationReads.js").ConversationReads} dependencies.reads
15
+ * @param {import("./conversationSending.js").ConversationSending} dependencies.sending
16
+ * @param {import("./conversationStopping.js").ConversationStopping} dependencies.stopping
17
+ * @param {import("../types.js").ServiceToolCatalog | null} dependencies.catalog
18
+ */
19
+ function createConversationGoals({ events, entries, reads, sending, stopping, catalog }) {
20
+ /**
21
+ * @param {ConversationEntry} entry
22
+ * @param {object} context
23
+ * @param {string} representation
24
+ */
25
+ async function readGoal(entry, context, representation) {
26
+ await events.access(context, entry.id, "readGoal", entry);
27
+ if (!reads.capabilities(entry).goals) return null;
28
+ const configuration = representation === "native" ? undefined : (await entries.readState(entry)).configuration;
29
+ const result = await entry.provider.readGoal({ context, configuration });
30
+ if (entry.conversation && representation !== "native" && result.nativeResult.status === "unavailable") {
31
+ throw failure("The native goal is unavailable. Try reading its status again.", "conversation_goal_unavailable", 503);
32
+ }
33
+ entry.goal = entry.conversation ? result.goal : result;
34
+ return representation === "native" ? result.nativeResult : structuredClone(entry.goal);
35
+ }
36
+
37
+ /**
38
+ * @param {ConversationEntry} entry
39
+ * @param {object} context
40
+ * @param {object} input
41
+ * @param {string} representation
42
+ * @param {boolean} [retryFinishedTurn] Whether a goal that Claude's completion refused may be admitted once more.
43
+ */
44
+ async function updateGoal(entry, context, input, representation, retryFinishedTurn = true) {
45
+ await events.access(context, entry.id, "goal", entry);
46
+ context?.signal?.throwIfAborted();
47
+ if (!reads.capabilities(entry).goals) throw failure("This connection does not support native goals.", "conversation_goals_unsupported", 400, { messageCode: "assistant.conversation.goalsUnsupported" });
48
+ if (representation === "native") {
49
+ const { operation } = await serial(entry, async () => {
50
+ await events.access(context, entry.id, "goal", entry);
51
+ // The original goal owner has its own admission. Its native message
52
+ // acknowledgement must not hold the common queue ahead of Stop.
53
+ return { operation: entry.provider.updateGoal({ context, input, representation }) };
54
+ });
55
+ const result = await operation;
56
+ if (result.nativeResult.ok !== false) {
57
+ entry.goal = result.goal;
58
+ await events.emit(entry, { type: "goal", goal: entry.goal });
59
+ }
60
+ return result.nativeResult;
61
+ }
62
+ if (!input || !["set", "resume", "pause", "cancel"].includes(input.action) ||
63
+ (typeof input.expectedSegmentId !== "string" && !(entry.conversation && input.expectedSegmentId === null)) ||
64
+ input.expectedGoalId != null && (typeof input.expectedGoalId !== "string" || !input.expectedGoalId)) {
65
+ throw failure("A goal action and current conversation segment are required.", "conversation_invalid_goal", 400, { messageCode: "assistant.conversation.goalActionRequired" });
66
+ }
67
+ const starting = input.action === "set" || input.action === "resume";
68
+ const goal = { action: input.action, expectedSegmentId: input.expectedSegmentId, expectedGoalId: input.expectedGoalId || null };
69
+ if (starting) {
70
+ if (input.action === "set") {
71
+ if (input.tokenBudget != null && !entry.driver.capabilities.goalBudgets) throw failure("This connection does not support goal token budgets.", "conversation_goal_token_budgets_unsupported", 400, { messageCode: "assistant.conversation.goalTokenBudgetsUnsupported" });
72
+ if (typeof input.objective !== "string" || !input.objective.trim() || input.objective.length > 4000 ||
73
+ input.tokenBudget != null && (!Number.isSafeInteger(input.tokenBudget) || input.tokenBudget < 1)) {
74
+ throw failure("A bounded objective and optional positive token budget are required.", "conversation_invalid_goal", 400, { messageCode: "assistant.conversation.goalObjectiveInvalid" });
75
+ }
76
+ goal.objective = input.objective.trim();
77
+ if (input.tokenBudget != null) goal.tokenBudget = input.tokenBudget;
78
+ } else if (!goal.expectedGoalId) throw failure("Select the goal to resume.", "conversation_invalid_goal", 400, { messageCode: "assistant.conversation.goalToResumeRequired" });
79
+ }
80
+ if (!starting && !input.expectedGoalId) throw failure("Select the goal to stop.", "conversation_invalid_goal", 400, { messageCode: "assistant.conversation.goalToStopRequired" });
81
+ const delivery = entry.driver.capabilities.goalCommands?.[input.action]?.delivery;
82
+ if (!["message", "control"].includes(delivery)) throw failure("This connection does not support that goal command.", "conversation_goal_command_unsupported", 400, { messageCode: "assistant.conversation.goalCommandUnsupported" });
83
+ const messageCommand = delivery === "message";
84
+ if (messageCommand && (typeof input.messageId !== "string" || !/^[\w-]{1,128}$/u.test(input.messageId))) {
85
+ throw failure("A stable messageId is required for this goal command.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.goalMessageIdRequired" });
86
+ }
87
+ if (conversationAttachmentIds(input.attachmentIds).length || input.attachments?.length) {
88
+ throw failure("This engine's native goal commands do not accept attachments.", "conversation_goal_attachments_unsupported", 400, { messageCode: "assistant.conversation.goalAttachmentsUnsupported" });
89
+ }
90
+ const result = await serial(entry, async () => {
91
+ await events.access(context, entry.id, "goal", entry);
92
+ if (entry.conversation) await entries.readState(entry);
93
+ if (entry.segmentId !== input.expectedSegmentId) throw failure("The conversation changed before this goal command was sent.", "conversation_goal_changed", 409, { messageCode: "assistant.conversation.goalConversationChanged" });
94
+ if (entry.conversation && input.expectedSegmentId === null && input.action !== "set") {
95
+ throw failure("This command requires an existing native conversation.", "conversation_goal_changed", 409, { messageCode: "assistant.conversation.goalNeedsNativeConversation" });
96
+ }
97
+ if (entry.storageFailure) throw entry.storageFailure;
98
+ if (entry.executionFailure) throw entry.executionFailure;
99
+ if (messageCommand) {
100
+ const history = await reads.readTranscript(entry);
101
+ const existing = history.find(turn => submittedMessage(turn)?.messageId === input.messageId);
102
+ if (existing) {
103
+ if (!entry.conversation && !isDeepStrictEqual(submittedMessage(existing).goal, goal)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
104
+ return { receipt: { status: "accepted", messageId: input.messageId, turnId: existing.turnId, origin: "user", duplicate: true } };
105
+ }
106
+ }
107
+ if (entry.active && !entry.active.request.accepted) throw failure("Wait for the goal command to be admitted.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForGoalCommand" });
108
+ const metadata = { runtime: await entries.readState(entry) };
109
+ if (metadata.runtime.request && (entry.engine === "api" || metadata.runtime.request.attempted || metadata.runtime.request.inspectionOnly)) {
110
+ throw failure("Check the uncertain command's delivery before changing the goal.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.goalCommandDeliveryUncertain" });
111
+ }
112
+ if (entry.driver.canonicalTranscript && metadata.runtime.binding?.goalRequest) {
113
+ throw failure("A goal command from an earlier runtime has unresolved delivery. Inspect its saved state before changing the goal.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.earlierGoalCommandUnresolved" });
114
+ }
115
+ if (starting && (metadata.runtime.replacement || !entry.conversation && pendingSuccessor(metadata.runtime) || metadata.runtime.lastEngine !== entry.engine)) {
116
+ throw failure("Send a message to catch this AI up before starting or resuming its goal.", "conversation_briefing_before_goal", 409, { messageCode: "assistant.conversation.briefingBeforeGoal" });
117
+ }
118
+ // Definitions keep native thread discovery stable. A control has no
119
+ // admitted request, application-tool actor or executor of its own.
120
+ const tools = catalog && entry.driver.toolDiscovery
121
+ ? { schemas: catalog.resolveToolSet(context, { discoveryOnly: true }).tools.map(catalog.toOpenAiToolSchema) } : undefined;
122
+ if (entry.conversation) {
123
+ let receipt;
124
+ // Preserve the original goal owner's acknowledgement wait outside the
125
+ // invocation queue so the same Stop command can still interrupt it.
126
+ const operation = entry.provider.updateGoal({ context, configuration: metadata.runtime.configuration,
127
+ input: { ...input, ...goal }, representation, tools, interrupt: () => stopping.stop(entry, context),
128
+ async send({ message }, { threadId }) {
129
+ const admission = await serial(entry, async () => {
130
+ const state = await entries.readState(entry);
131
+ const selectedSegment = `${entry.engine}:${threadId}`;
132
+ if (!threadId || state.segmentId !== selectedSegment ||
133
+ input.expectedSegmentId !== null && input.expectedSegmentId !== selectedSegment) {
134
+ throw failure("The conversation changed before this goal command was sent.", "conversation_goal_changed", 409, { messageCode: "assistant.conversation.goalConversationChanged" });
135
+ }
136
+ return sending.admitSend(entry, context, { messageId: input.messageId, text: message }, "user",
137
+ { ...goal, expectedSegmentId: selectedSegment });
138
+ });
139
+ const accepted = await (admission.receipt || admission.promise);
140
+ if (accepted.error) throw accepted.error;
141
+ receipt = accepted.receipt;
142
+ }
143
+ }).then(async updated => {
144
+ if (updated.nativeResult.ok === false) throw Object.assign(new Error(updated.nativeResult.error), updated.nativeResult);
145
+ entry.goal = updated.goal;
146
+ await events.emit(entry, { type: "goal", goal: entry.goal });
147
+ return receipt ? { receipt } : { goal: structuredClone(entry.goal) };
148
+ });
149
+ return { operation };
150
+ }
151
+ let delivery;
152
+ const updated = await entry.provider.updateGoal({ context, configuration: metadata.runtime.configuration, input: { ...input, ...goal }, tools,
153
+ interrupt: () => stopping.stop(entry, context),
154
+ async send({ message }) {
155
+ delivery = await sending.admitSend(entry, context, { messageId: input.messageId, text: message }, "user", goal);
156
+ }
157
+ });
158
+ // Release the queue once Send is reserved, just as an ordinary message
159
+ // does. Stop remains available while native acknowledgement is pending.
160
+ if (delivery) return delivery;
161
+ entry.goal = updated;
162
+ await events.emit(entry, { type: "goal", goal: entry.goal });
163
+ return { goal: structuredClone(entry.goal) };
164
+ });
165
+ try {
166
+ const completed = result.operation ? await result.operation : result;
167
+ return await (completed.receipt || completed.promise || completed.goal);
168
+ } catch (error) {
169
+ if (!retryFinishedTurn || !entry.conversation || !starting || !messageCommand ||
170
+ error.code !== "conversation_steer_turn_finished" || error.delivery !== "not-sent") throw error;
171
+ // Completion can retire Claude's generation during asynchronous native
172
+ // preparation. Its exact pre-write rejection permits one re-admission of
173
+ // this same goal/message ID, after the original cleanup and save finish.
174
+ await entry.active?.done;
175
+ return updateGoal(entry, context, input, representation, false);
176
+ }
177
+ }
178
+
179
+ return Object.freeze({
180
+ readGoal,
181
+ updateGoal
182
+ });
183
+ }
184
+
185
+ /** @typedef {ReturnType<typeof createConversationGoals>} ConversationGoals */
186
+
187
+ export { createConversationGoals };
@@ -0,0 +1,237 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { normalizeConversationConfiguration } from "./configuration.js";
3
+ import { failure } from "./conversationRefusals.js";
4
+ import { serial } from "./conversationEvents.js";
5
+
6
+ /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
7
+ /** @typedef {import("./conversationEntries.js").HostConversation} HostConversation */
8
+
9
+ /**
10
+ * Checks the conversation a host supplies before the runtime binds it: a logical conversation
11
+ * brings its command, read and stream owners; a scoped one its native representation, identity
12
+ * and operations; a bound native one its identity, store, preparation and native owners.
13
+ * @param {HostConversation | null} conversation
14
+ * @param {{ id: string, configuration: object | undefined, representation: string }} request
15
+ */
16
+ function assertHostConversation(conversation, { id, configuration, representation }) {
17
+ // readable-check: allow method-probe - input validation: the conversation a host supplies is checked before the runtime binds it
18
+ const offers = (owner, names) => names.every(name => typeof owner?.[name] === "function");
19
+ const scoped = conversation?.native?.scoped;
20
+ const commands = conversation?.commands;
21
+ const owner = conversation?.native?.owner;
22
+ const preparation = conversation?.native?.preparation;
23
+ const invalidClaudeOwner = conversation?.engine === "claude" && (!owner || typeof owner !== "object" || !offers(owner, ["acquire"]) ||
24
+ !offers(preparation, ["cleanup"]));
25
+ const invalidOpenCodeOwner = conversation?.engine === "opencode" && (!owner || typeof owner !== "object" ||
26
+ !offers(preparation, ["cleanup", ...(scoped ? ["turn", "existing"] : ["message", "inspection", "interruption"])]) ||
27
+ !scoped && !offers(conversation.native, ["acquire"]));
28
+ if (commands && (representation !== "canonical" || configuration !== undefined ||
29
+ !offers(commands, ["send", "cancel", "select", "inspectDelivery"]) || !offers(conversation, ["read", "readStream"]))) {
30
+ throw new TypeError("A logical conversation requires its complete original command, read and stream owners.");
31
+ }
32
+ if (scoped && (representation !== "native" || configuration !== undefined || !["codex", "claude", "opencode"].includes(conversation.engine) ||
33
+ typeof scoped.conversationId !== "string" || !scoped.conversationId ||
34
+ (conversation.engine === "claude" ? invalidClaudeOwner : conversation.engine === "opencode" ? invalidOpenCodeOwner
35
+ : !offers(preparation, ["cleanup"]) ||
36
+ !offers(conversation.native.runOwner, ["startConversationTurn", "readConversation", "waitForConversationTurn", "stopConversation", "deleteConversation"])))) {
37
+ throw new TypeError("An original scoped conversation requires its native representation, identity and existing operations.");
38
+ }
39
+ if (conversation && (conversation.sessionId !== id || typeof conversation.namespace !== "string" || !conversation.namespace ||
40
+ !["codex", "claude", "opencode"].includes(conversation.engine) || !scoped && !commands && (
41
+ (conversation.engine === "codex" ? !conversation.native?.runOwner || !conversation.native?.providerOwner
42
+ : conversation.engine === "claude" ? invalidClaudeOwner
43
+ : invalidOpenCodeOwner) ||
44
+ !offers(conversation, ["prepareInput", "read"])))) {
45
+ throw new TypeError("A bound native conversation requires its original identity, store, preparation and native owners.");
46
+ }
47
+ if (conversation?.maximumHistoryBytes !== undefined && !offers(conversation, ["maximumHistoryBytes"])) {
48
+ throw new TypeError("A bound native history budget requires a server-owned resolver.");
49
+ }
50
+ if (!["canonical", "native"].includes(representation) || representation === "native" && !conversation) {
51
+ throw new TypeError("Native result representation requires an original bound conversation.");
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Opening a conversation handle: the host's conversation is checked, its entry is created or
57
+ * reused, and the handle's operations are bound to that entry and caller context.
58
+ * @param {object} dependencies
59
+ * @param {import("./conversationEntries.js").ConversationEntries} dependencies.entries
60
+ * @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
61
+ * @param {import("../types.js").ConversationHost | undefined} dependencies.defaultHost
62
+ * @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
63
+ * @param {import("./conversationReads.js").ConversationReads} dependencies.reads
64
+ * @param {import("./conversationSending.js").ConversationSending} dependencies.sending
65
+ * @param {import("./conversationGoals.js").ConversationGoals} dependencies.goals
66
+ * @param {import("./conversationDelivery.js").ConversationDelivery} dependencies.delivery
67
+ * @param {import("./conversationStopping.js").ConversationStopping} dependencies.stopping
68
+ * @param {import("./conversationReplacement.js").ConversationReplacement} dependencies.replacement
69
+ * @param {import("./completedResponses.js").CompletedResponses} dependencies.completedResponses
70
+ * @param {boolean} dependencies.completedEnvelope
71
+ * @param {boolean} dependencies.persistCommentary
72
+ * @param {string | undefined} dependencies.defaultIntegrationId
73
+ */
74
+ function createConversationHandles({ entries, storage, defaultHost, events, reads, sending, goals, delivery, stopping, replacement,
75
+ completedResponses, completedEnvelope, persistCommentary, defaultIntegrationId }) {
76
+ /**
77
+ * What a handle reads: a scoped conversation's own result, a bound conversation's native result,
78
+ * a logical conversation's snapshot from its own read, or the snapshot of its saved state.
79
+ * @param {ConversationEntry} entry
80
+ * @param {string} representation
81
+ * @param {object} context
82
+ * @param {object} [query]
83
+ */
84
+ async function readSnapshot(entry, representation, context, query) {
85
+ if (entry.conversation?.native?.scoped) return entry.provider.readNative({ input: query, context });
86
+ if (representation === "native") return reads.readNativeResult(entry, context);
87
+ if (entry.conversation?.commands) {
88
+ const logical = await entry.conversation.read(context, query);
89
+ entries.followEngine(entry, logical.engine);
90
+ return reads.logicalSnapshot(entry, logical);
91
+ }
92
+ return reads.snapshot(entry, await entries.readState(entry, context), query);
93
+ }
94
+
95
+ async function open({ id, context, configuration, engine, host, representation = "canonical" }) {
96
+ if (typeof id !== "string" || !id.trim()) throw new TypeError("A conversation id is required.");
97
+ await events.access(context, id, "open");
98
+ const selectedHost = host || defaultHost;
99
+ const conversation = selectedHost?.conversation ? await selectedHost.conversation({ id, context }) : null;
100
+ if (completedEnvelope && (conversation || representation !== "canonical")) {
101
+ throw failure("Completed envelopes require runtime-owned canonical storage.", "conversation_completed_envelopes_need_storage", 400, { messageCode: "assistant.conversation.completedEnvelopesNeedStorage" });
102
+ }
103
+ if (conversation && !persistCommentary) {
104
+ throw new TypeError("Transient commentary requires a runtime-owned transcript; supplied conversations retain their host's policy.");
105
+ }
106
+ assertHostConversation(conversation, { id, configuration, representation });
107
+ const entry = entries.acquire({ id, conversation, host: selectedHost, configuration, engine });
108
+ await entry.initializing;
109
+ if (entry.disposed) throw failure("This conversation handle is disposed.", "conversation_handle_disposed", 409, { messageCode: "assistant.conversation.handleDisposed" });
110
+ if (host && !isDeepStrictEqual(host, entry.host)) throw failure("This open conversation already belongs to another host scope. Dispose its handle before reopening it on the original host.", "conversation_host_scope_mismatch", 409, { messageCode: "assistant.conversation.hostScopeMismatch" });
111
+ if (engine && engine !== entry.engine) throw failure("Use replace() to change this conversation's engine.", "conversation_use_replace_for_engine", 409, { messageCode: "assistant.conversation.useReplaceForEngine" });
112
+ if (configuration) {
113
+ const state = await entries.readState(entry);
114
+ const supplied = entry.conversation ? configuration
115
+ : normalizeConversationConfiguration(configuration, { engine: entry.engine, defaultIntegrationId });
116
+ if (!isDeepStrictEqual(supplied, state.configuration)) {
117
+ throw failure("Use configure() to change a saved conversation's settings.", "conversation_configuration_mismatch");
118
+ }
119
+ }
120
+ return Object.freeze({
121
+ id,
122
+ get capabilities() { return reads.capabilities(entry); },
123
+ replace: input => serial(entry, () => replacement.replace(entry, context, input, false, representation)),
124
+ select: input => serial(entry, () => replacement.replace(entry, context, input, true, representation)),
125
+ async read(query) {
126
+ await events.access(context, id, "read", entry);
127
+ const result = await readSnapshot(entry, representation, context, query);
128
+ if (entry.conversation?.commands) await events.access(context, id, "read", entry);
129
+ return result;
130
+ },
131
+ send: input => sending.send(entry, context, input, "user", undefined, representation),
132
+ wake: (input, options) => sending.send(entry, context, input, "application", undefined, representation, options),
133
+ ...(completedEnvelope ? { prepareCompletedResponse: (input, options) =>
134
+ serial(entry, () => completedResponses.prepareCompletedResponse(entry, context, input, options)) } : {}),
135
+ readGoal: () => serial(entry, () => goals.readGoal(entry, context, representation)),
136
+ updateGoal: input => goals.updateGoal(entry, context, input, representation),
137
+ inspectDelivery({ messageId, threadId }) {
138
+ if (typeof messageId !== "string" || !messageId) throw new TypeError("A message id is required.");
139
+ return serial(entry, async () => {
140
+ if (representation !== "native") return delivery.inspectDelivery(entry, context, messageId);
141
+ await events.access(context, id, "inspectDelivery", entry);
142
+ return entry.provider.inspectAdmission({ messageId, threadId, context, representation });
143
+ });
144
+ },
145
+ async wait(input) {
146
+ await events.access(context, id, "read", entry);
147
+ if (entry.conversation?.commands) throw failure("This conversation uses its original read and subscription operations.", "conversation_original_read_only", 400, { messageCode: "assistant.conversation.originalReadOnly" });
148
+ if (entry.conversation?.native?.scoped) return entry.provider.waitNative({ input, context });
149
+ await entry.pending;
150
+ await entry.active?.done;
151
+ await events.access(context, id, "read", entry);
152
+ return reads.snapshot(entry, await entries.readState(entry));
153
+ },
154
+ // Cancel of one unsent or unconfirmed message always records it; an
155
+ // application conversation supplies its own operation for that.
156
+ async cancelDelivery({ messageId }) {
157
+ if (typeof messageId !== "string" || !messageId) throw new TypeError("A message id is required.");
158
+ await events.access(context, id, "cancel", entry);
159
+ if (entry.conversation?.commands) {
160
+ // readable-check: allow method-probe - optional operation: cancelling one message is optional in a logical conversation's commands
161
+ if (typeof entry.conversation.commands.cancelDelivery !== "function") {
162
+ throw failure("This conversation cannot cancel one message.", "conversation_message_cancel_unsupported", 400,
163
+ { messageCode: "assistant.conversation.messageCancelUnsupported" });
164
+ }
165
+ return entry.conversation.commands.cancelDelivery({ messageId }, context);
166
+ }
167
+ return stopping.cancelDelivery(entry, context, messageId, representation);
168
+ },
169
+ async cancel(input) {
170
+ await events.access(context, id, "cancel", entry);
171
+ if (entry.conversation?.commands) return entry.conversation.commands.cancel(input, context);
172
+ await entry.pending;
173
+ await events.access(context, id, "cancel", entry);
174
+ return stopping.stop(entry, context, input, representation);
175
+ },
176
+ async retrySave() {
177
+ await events.access(context, id, "retrySave", entry);
178
+ return serial(entry, async () => {
179
+ await events.access(context, id, "retrySave", entry);
180
+ return stopping.retrySave(entry);
181
+ });
182
+ },
183
+ async configure(patch) {
184
+ await events.access(context, id, "configure", entry);
185
+ if (entry.conversation?.commands) throw failure("Use the conversation's authorized selection operation.", "conversation_authorized_selection_required", 400, { messageCode: "assistant.conversation.authorizedSelectionRequired" });
186
+ return serial(entry, async () => {
187
+ await events.access(context, id, "configure", entry);
188
+ if (entry.active) throw failure("Stop the current turn before changing its settings.", "conversation_busy", 409, { messageCode: "assistant.conversation.stopBeforeSettings" });
189
+ if (entry.storageFailure) throw entry.storageFailure;
190
+ if (entry.executionFailure) throw entry.executionFailure;
191
+ if (entry.conversation) {
192
+ if (!entry.conversation.selection?.write) throw failure("This host requires its authorized assistant selection operation.", "conversation_host_selection_required", 409, { messageCode: "assistant.conversation.hostSelectionRequired" });
193
+ return entry.conversation.selection.write({ configuration: patch }, context);
194
+ }
195
+ return storage.write(id, async transaction => {
196
+ const metadata = await transaction.readMetadata();
197
+ if (metadata.runtime.replacement) throw failure("Finish the pending native replacement before changing settings.", "conversation_replacement_pending_before_settings", 409, { messageCode: "assistant.conversation.replacementPendingBeforeSettings" });
198
+ if (metadata.runtime.request && (entry.engine === "api" || metadata.runtime.request.attempted || metadata.runtime.request.inspectionOnly)) {
199
+ throw failure("Resolve uncertain delivery before changing settings.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.deliveryUncertainBeforeSettings" });
200
+ }
201
+ const next = normalizeConversationConfiguration({ ...metadata.runtime.configuration, ...patch }, {
202
+ engine: metadata.runtime.engine, defaultIntegrationId
203
+ });
204
+ entry.driver.validateConfiguration(next);
205
+ metadata.runtime.configuration = next;
206
+ await transaction.writeMetadata(metadata);
207
+ return structuredClone(next);
208
+ });
209
+ });
210
+ },
211
+ async subscribe(listener) {
212
+ await events.access(context, id, "subscribe", entry);
213
+ if (entry.conversation?.native?.scoped) throw failure("This scoped conversation retains its original event delivery.", "conversation_event_delivery_retained", 409, { messageCode: "assistant.conversation.eventDeliveryRetained" });
214
+ if (typeof listener !== "function") throw new TypeError("A conversation listener is required.");
215
+ const subscription = { context, listener };
216
+ entry.listeners.add(subscription);
217
+ return () => entry.listeners.delete(subscription);
218
+ },
219
+ async dispose(options) {
220
+ await events.access(context, id, "dispose", entry);
221
+ return serial(entry, async () => {
222
+ await events.access(context, id, "dispose", entry);
223
+ const result = await stopping.disposeEntry(entry, context, options, representation);
224
+ if (result?.ok === false || representation === "native") return result;
225
+ });
226
+ }
227
+ });
228
+ }
229
+
230
+ return Object.freeze({
231
+ open
232
+ });
233
+ }
234
+
235
+ /** @typedef {ReturnType<typeof createConversationHandles>} ConversationHandles */
236
+
237
+ export { createConversationHandles };
@@ -0,0 +1,206 @@
1
+ import { codexAppServerTurnStateFromAgentRun } from "./codexTurn.js";
2
+ import { failure, stateErrorFields } from "./conversationRefusals.js";
3
+
4
+ /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
5
+ /** @typedef {import("./conversationEntries.js").ConversationState} ConversationState */
6
+
7
+ /** The authored message of a saved turn: a user's message, or the application's. */
8
+ const submittedMessage = turn => turn.user || turn.system;
9
+
10
+ /** Every runtime segment a saved state retains: its predecessors and itself, once each. */
11
+ const retainedSegments = state => [...new Map([...state.predecessors, state].map(segment => [segment.segmentId, segment])).values()];
12
+
13
+ // readable-check: allow method-probe - optional operation: a host's conversation transcript may offer paged history
14
+ const offersPages = entry => typeof entry.conversation.transcript.readConversationLogPage === "function";
15
+
16
+ /** The predecessor whose replacement still waits for its successor's first accepted message. */
17
+ const pendingSuccessor = state => state.engine !== "api" && state.predecessors.findLast(segment => segment.successorId === state.segmentId &&
18
+ segment.segmentId !== state.segmentId && segment.replacement?.operation === "replace" && !segment.acceptedAt);
19
+
20
+ /**
21
+ * What an open conversation reads: its capabilities, its transcript, and the snapshot a reader
22
+ * sees. Nothing here changes the conversation.
23
+ * @param {object} dependencies
24
+ * @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
25
+ * @param {import("../types.js").ConversationTranscript | null} dependencies.transcript
26
+ * @param {import("../types.js").ConversationStreams} dependencies.streams
27
+ * @param {boolean} dependencies.attachmentsOffered Whether the application resolves message attachments.
28
+ * @param {boolean} dependencies.completedEnvelope
29
+ */
30
+ function createConversationReads({ storage, transcript, streams, attachmentsOffered, completedEnvelope }) {
31
+ /** @param {ConversationEntry} entry */
32
+ function capabilities(entry) {
33
+ const value = { ...entry.driver.capabilities, ...entry.provider?.capabilities,
34
+ attachments: Boolean((attachmentsOffered || entry.conversation?.prepareInput || entry.conversation?.commands) && entry.driver.capabilities.attachments) };
35
+ if (entry.conversation?.commands) {
36
+ delete value.goalCommands;
37
+ Object.assign(value, { goals: false, configuration: false, wake: false, replacement: false });
38
+ }
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * The saved turns; `afterTurnId` reads only the turns a stored summary doesn't cover.
44
+ * @param {ConversationEntry} entry
45
+ * @param {string} [afterTurnId]
46
+ */
47
+ const readTranscript = (entry, afterTurnId = "") => entry.conversation
48
+ ? entry.conversation.transcript.readConversationLog() : transcript.readConversationLog(entry.id, { afterTurnId });
49
+
50
+ /**
51
+ * The stored compaction summary, when it belongs to this history. A bound conversation's host
52
+ * owns its transcript, so it has none.
53
+ * @param {ConversationEntry} entry
54
+ * @param {(turn: object) => boolean} inHistory
55
+ */
56
+ async function readSummary(entry, inHistory) {
57
+ if (entry.conversation) return null;
58
+ return storage.read(entry.id, async transaction => {
59
+ const summary = await transaction.readSummary();
60
+ const covered = summary && await transaction.readTurn(summary.throughTurnId);
61
+ return covered && inHistory(covered) ? summary : null;
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Identity lookups avoid reading the whole log when the transcript supports them. A bound host
67
+ * may supply findSubmittedTurn(messageId) and readTurn(turnId).
68
+ * @param {ConversationEntry} entry
69
+ * @param {string} messageId
70
+ */
71
+ async function findSubmittedTurn(entry, messageId) {
72
+ const find = entry.conversation ? entry.conversation.transcript.findSubmittedTurn : transcript.findSubmittedTurn &&
73
+ (id => transcript.findSubmittedTurn(entry.id, id));
74
+ if (typeof find === "function") return (await find(messageId)) || null;
75
+ return (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId) || null;
76
+ }
77
+
78
+ /**
79
+ * @param {ConversationEntry} entry
80
+ * @param {string} turnId
81
+ */
82
+ async function readBoundTurn(entry, turnId) {
83
+ // readable-check: allow method-probe - optional operation: a host's conversation transcript may offer direct turn reads
84
+ if (typeof entry.conversation.transcript.readTurn === "function") return (await entry.conversation.transcript.readTurn(turnId)) || null;
85
+ return (await readTranscript(entry)).find(turn => turn.turnId === turnId) || null;
86
+ }
87
+
88
+ /** @param {ConversationEntry} entry */
89
+ async function lastTranscriptTurn(entry) {
90
+ const page = entry.conversation && !offersPages(entry) ? null
91
+ : entry.conversation ? await entry.conversation.transcript.readConversationLogPage({ limit: 1 })
92
+ : await transcript.readConversationLogPage(entry.id, { limit: 1 });
93
+ return page?.conversationLog.at(-1) || (await readTranscript(entry)).at(-1);
94
+ }
95
+
96
+ /**
97
+ * @param {ConversationEntry} entry
98
+ * @param {object} query
99
+ */
100
+ function readTranscriptPage(entry, query) {
101
+ if (!entry.conversation) return transcript.readConversationLogPage(entry.id, { ...query, presentation: completedEnvelope });
102
+ if (!offersPages(entry)) {
103
+ throw failure("This conversation does not supply paged history.", "conversation_paged_history_unsupported", 400, { messageCode: "assistant.conversation.pagedHistoryUnsupported" });
104
+ }
105
+ return entry.conversation.transcript.readConversationLogPage(query);
106
+ }
107
+
108
+ /**
109
+ * A bound conversation's own result, in its native representation.
110
+ * @param {ConversationEntry} entry
111
+ * @param {object} context
112
+ */
113
+ async function readNativeResult(entry, context) {
114
+ const value = entry.driver.readState
115
+ ? await entry.driver.readState({ conversation: entry.conversation, context, representation: "native" })
116
+ : await entry.conversation.read(context, "native");
117
+ return value.nativeResult;
118
+ }
119
+
120
+ /**
121
+ * What a reader sees of a logical conversation, from the result of its own read.
122
+ * @param {ConversationEntry} entry
123
+ * @param {object} logical
124
+ */
125
+ async function logicalSnapshot(entry, logical) {
126
+ const streaming = await entry.conversation.readStream();
127
+ const error = logical.error || "";
128
+ return {
129
+ id: entry.id, engine: entry.engine, capabilities: capabilities(entry), configuration: logical.configuration,
130
+ segmentId: logical.threadId ? `${logical.engine}:${logical.threadId}` : null,
131
+ replacement: null,
132
+ status: logical.status,
133
+ phase: logical.phase || "",
134
+ error,
135
+ ...stateErrorFields(error),
136
+ goal: entry.goal || null,
137
+ pendingRequest: logical.pendingRequest || null,
138
+ ...(logical.presentation ? { presentation: logical.presentation } : {}),
139
+ conversationLog: logical.conversationLog.filter(turn => !turn.metadata?.runtime?.supersededBy),
140
+ ...(logical.pagination ? { pagination: logical.pagination } : {}),
141
+ streaming
142
+ };
143
+ }
144
+
145
+ /**
146
+ * What a reader sees of a conversation from its saved state: a page of its transcript, or all
147
+ * of it, with the current turn's status, phase, error and live stream.
148
+ * @param {ConversationEntry} entry
149
+ * @param {ConversationState} state
150
+ * @param {object} [query]
151
+ */
152
+ async function snapshot(entry, state, query) {
153
+ const page = query ? await readTranscriptPage(entry, query) : null;
154
+ const conversationLog = page ? page.conversationLog
155
+ : completedEnvelope ? await transcript.readConversationLog(entry.id, { presentation: true }) : await readTranscript(entry);
156
+ // Browsing an older page does not change the current turn's control/error state.
157
+ const latest = completedEnvelope || query?.beforeTurnId
158
+ ? entry.conversation ? (await readTranscriptPage(entry, { limit: 1 })).conversationLog
159
+ : (await transcript.readConversationLogPage(entry.id, { limit: 1 })).conversationLog : conversationLog;
160
+ const last = latest.at(-1)?.metadata?.runtime;
161
+ const savedRequest = state.request;
162
+ const nativeTurn = state.nativeTurn || (entry.driver.canonicalTranscript && state.binding?.codexAppServerRun
163
+ ? codexAppServerTurnStateFromAgentRun(state.binding.codexAppServerRun) : null);
164
+ const request = savedRequest && (entry.engine === "api" || savedRequest.attempted || savedRequest.inspectionOnly ||
165
+ entry.active?.requests.has(savedRequest.messageId)) ? savedRequest : null;
166
+ const streaming = entry.conversation ? await entry.conversation.readStream() : streams.read(entry.id);
167
+ const error = entry.error || nativeTurn?.error || request?.error || last?.error || "";
168
+ const pendingRequest = request && !(completedEnvelope && request.origin === "application" && request.completedEnvelope === true) ? Object.fromEntries([
169
+ "messageId", "text", "origin", "data", "attachments", "at", "goal", "steering", "error"
170
+ ].filter(name => Object.hasOwn(request, name)).map(name => [name, request[name]])) : null;
171
+ if (pendingRequest?.error) Object.assign(pendingRequest, stateErrorFields(pendingRequest.error));
172
+ return {
173
+ id: entry.id, engine: entry.engine, capabilities: capabilities(entry), configuration: state.configuration,
174
+ segmentId: state.segmentId,
175
+ replacement: state.replacement ? { operationId: state.replacement.request?.operationId || state.replacement.operationId,
176
+ reason: state.replacement.reason, segmentId: state.replacement.segmentId } : null,
177
+ status: entry.storageFailure || entry.executionFailure ? "unavailable" : entry.active || nativeTurn?.active ? "working" : state.replacement ? "replacement-pending"
178
+ : state.request && (entry.engine === "api" || state.request.attempted || state.request.inspectionOnly) ? "unconfirmed" : "ready",
179
+ phase: entry.phase || (nativeTurn?.active && nativeTurn.status !== "observation_lost" ? ["compacting", "background"].includes(nativeTurn.phase) ? nativeTurn.phase : "working" : ""),
180
+ error,
181
+ ...stateErrorFields(error),
182
+ goal: entry.goal || null,
183
+ pendingRequest,
184
+ conversationLog: conversationLog.filter(turn => !turn.metadata?.runtime?.supersededBy),
185
+ ...(page ? { pagination: page.pagination } : {}),
186
+ streaming: completedEnvelope && !entry.conversation ? { ...streaming,
187
+ messages: streaming.messages.filter(message => !(message.origin === "application" && message.completedEnvelope === true)) } : streaming
188
+ };
189
+ }
190
+
191
+ return Object.freeze({
192
+ capabilities,
193
+ readTranscript,
194
+ readSummary,
195
+ findSubmittedTurn,
196
+ readBoundTurn,
197
+ lastTranscriptTurn,
198
+ readNativeResult,
199
+ logicalSnapshot,
200
+ snapshot
201
+ });
202
+ }
203
+
204
+ /** @typedef {ReturnType<typeof createConversationReads>} ConversationReads */
205
+
206
+ export { createConversationReads, pendingSuccessor, retainedSegments, submittedMessage };