@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.
- package/examples/conversation/package.json +5 -5
- package/package.json +18 -7
- package/src/client/browser.js +21 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
- package/src/client/conversation/AssistantComposerActions.vue +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +14 -52
- package/src/client/conversation/AssistantGoalControl.vue +13 -10
- package/src/client/conversation/AssistantPromptInput.vue +6 -91
- package/src/client/conversation/AssistantTranscript.css +505 -0
- package/src/client/conversation/AssistantTranscript.vue +24 -1197
- package/src/client/conversation/attachmentFiles.js +158 -0
- package/src/client/conversation/attachmentQueueItems.js +121 -0
- package/src/client/conversation/attachmentUploadRows.js +265 -0
- package/src/client/conversation/conversationDisplay.js +54 -0
- package/src/client/conversation/messageDelivery.js +13 -9
- package/src/client/conversation/retainedConversation.js +14 -3
- package/src/client/conversation/submitText.js +13 -2
- package/src/client/conversation/transcriptEntries.js +225 -0
- package/src/client/conversation/useAssistantAttachments.js +86 -388
- package/src/client/conversation/useAssistantQuestions.js +16 -3
- package/src/client/conversation/useAssistantSuggestions.js +16 -2
- package/src/client/conversation/useScrollToBottom.js +17 -19
- package/src/client/conversation/useTextareaAutoGrow.js +115 -0
- package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
- package/src/client/lib/assistantApi.js +192 -161
- package/src/client/types.js +110 -0
- package/src/server/conversation/attachments.js +21 -6
- package/src/server/conversation/boundedToolLoop.js +1 -0
- package/src/server/conversation/claudeClient.js +71 -20
- package/src/server/conversation/claudeCommandRun.js +191 -0
- package/src/server/conversation/claudeHistory.js +7 -0
- package/src/server/conversation/claudeNativeTurn.js +708 -0
- package/src/server/conversation/claudeProcess.js +186 -112
- package/src/server/conversation/claudeProcessStart.js +214 -0
- package/src/server/conversation/claudePromptDelivery.js +183 -0
- package/src/server/conversation/claudeSessionCleanup.js +248 -0
- package/src/server/conversation/claudeTools.js +15 -3
- package/src/server/conversation/claudeTurn.js +212 -1388
- package/src/server/conversation/codexAccountReader.js +168 -0
- package/src/server/conversation/codexAuthPreflight.js +5 -4
- package/src/server/conversation/codexClient.js +18 -6
- package/src/server/conversation/codexConfiguration.js +8 -844
- package/src/server/conversation/codexConversationStops.js +377 -0
- package/src/server/conversation/codexConversationStore.js +119 -85
- package/src/server/conversation/codexConversationTurns.js +378 -0
- package/src/server/conversation/codexConversations.js +134 -866
- package/src/server/conversation/codexDetachedConversation.js +203 -0
- package/src/server/conversation/codexDetachedTurn.js +145 -104
- package/src/server/conversation/codexEvents.js +5 -200
- package/src/server/conversation/codexGoalCommands.js +14 -4
- package/src/server/conversation/codexHelperExecution.js +57 -57
- package/src/server/conversation/codexHelperThreadLedger.js +30 -288
- package/src/server/conversation/codexHelperThreadRecord.js +304 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
- package/src/server/conversation/codexHelperThreads.js +94 -93
- package/src/server/conversation/codexHistoryAdapter.js +13 -8
- package/src/server/conversation/codexIsolation.js +672 -0
- package/src/server/conversation/codexLiveItems.js +379 -0
- package/src/server/conversation/codexMessageCommands.js +68 -286
- package/src/server/conversation/codexMessageDelivery.js +46 -37
- package/src/server/conversation/codexModelCatalog.js +86 -7
- package/src/server/conversation/codexNotificationObserver.js +81 -88
- package/src/server/conversation/codexNotificationQueue.js +21 -10
- package/src/server/conversation/codexObservationControl.js +105 -89
- package/src/server/conversation/codexOutput.js +84 -419
- package/src/server/conversation/codexProcess.js +29 -1053
- package/src/server/conversation/codexProcessLeader.js +1 -0
- package/src/server/conversation/codexPromptDispatch.js +241 -0
- package/src/server/conversation/codexProvider.js +147 -1807
- package/src/server/conversation/codexProviderAuth.js +348 -0
- package/src/server/conversation/codexProviderInvalidation.js +371 -0
- package/src/server/conversation/codexProviderInventories.js +437 -0
- package/src/server/conversation/codexProviderOwner.js +149 -957
- package/src/server/conversation/codexProviderRelease.js +465 -0
- package/src/server/conversation/codexProviderSelection.js +26 -15
- package/src/server/conversation/codexProviderThreads.js +698 -0
- package/src/server/conversation/codexProviderTurns.js +453 -0
- package/src/server/conversation/codexReasoning.js +32 -23
- package/src/server/conversation/codexRenewalCommands.js +18 -6
- package/src/server/conversation/codexRenewalThreads.js +7 -22
- package/src/server/conversation/codexResultSettlement.js +70 -88
- package/src/server/conversation/codexRunJournal.js +91 -97
- package/src/server/conversation/codexRuntime.js +426 -0
- package/src/server/conversation/codexRuntimeInventory.js +113 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
- package/src/server/conversation/codexRuntimeStart.js +431 -0
- package/src/server/conversation/codexRuntimeStop.js +324 -0
- package/src/server/conversation/codexSessionCleanup.js +25 -10
- package/src/server/conversation/codexSessionReconciliation.js +21 -7
- package/src/server/conversation/codexThreadReadiness.js +49 -52
- package/src/server/conversation/codexThreadSnapshot.js +210 -0
- package/src/server/conversation/codexThreadStatus.js +48 -62
- package/src/server/conversation/codexTurn.js +171 -55
- package/src/server/conversation/codexTurnActivity.js +210 -0
- package/src/server/conversation/codexTurnControl.js +44 -48
- package/src/server/conversation/codexTurnEndings.js +610 -0
- package/src/server/conversation/codexTurnRecovery.js +97 -848
- package/src/server/conversation/codexTurnState.js +138 -111
- package/src/server/conversation/completedResponses.js +227 -0
- package/src/server/conversation/configuration.js +2 -2
- package/src/server/conversation/continuity.js +43 -6
- package/src/server/conversation/conversationDelivery.js +394 -0
- package/src/server/conversation/conversationEntries.js +365 -0
- package/src/server/conversation/conversationEvents.js +192 -0
- package/src/server/conversation/conversationGoals.js +187 -0
- package/src/server/conversation/conversationHandles.js +237 -0
- package/src/server/conversation/conversationReads.js +206 -0
- package/src/server/conversation/conversationRefusals.js +58 -0
- package/src/server/conversation/conversationReplacement.js +254 -0
- package/src/server/conversation/conversationSending.js +273 -0
- package/src/server/conversation/conversationStopping.js +121 -0
- package/src/server/conversation/conversationTurnDelivery.js +413 -0
- package/src/server/conversation/conversationTurns.js +511 -0
- package/src/server/conversation/deliveryTiming.js +25 -3
- package/src/server/conversation/fileStorage.js +11 -6
- package/src/server/conversation/hookBridge.js +14 -3
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/localExecution.js +82 -625
- package/src/server/conversation/nativeCommandCallbacks.js +10 -0
- package/src/server/conversation/nativeConversationLifecycle.js +379 -0
- package/src/server/conversation/nativeHistoryExport.js +27 -18
- package/src/server/conversation/normalize.js +9 -0
- package/src/server/conversation/openCodeClient.js +295 -366
- package/src/server/conversation/openCodeNativeHistory.js +141 -0
- package/src/server/conversation/openCodeNativeSession.js +79 -0
- package/src/server/conversation/openCodeProcess.js +109 -53
- package/src/server/conversation/openCodeProcessOwner.js +565 -0
- package/src/server/conversation/openCodeRuntime.js +111 -1814
- package/src/server/conversation/openCodeSessionTurns.js +547 -0
- package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
- package/src/server/conversation/openCodeTools.js +28 -14
- package/src/server/conversation/openCodeTurn.js +80 -74
- package/src/server/conversation/openCodeTurnMonitor.js +388 -0
- package/src/server/conversation/processIdentity.js +582 -0
- package/src/server/conversation/provider.js +21 -6
- package/src/server/conversation/providers/api.js +268 -107
- package/src/server/conversation/providers/claude.js +29 -6
- package/src/server/conversation/providers/claudeDriver.js +325 -250
- package/src/server/conversation/providers/codex.js +69 -33
- package/src/server/conversation/providers/codexDriver.js +503 -1081
- package/src/server/conversation/providers/codexDriverConnection.js +331 -0
- package/src/server/conversation/providers/codexDriverTurns.js +464 -0
- package/src/server/conversation/providers/opencode.js +30 -4
- package/src/server/conversation/providers/opencodeDriver.js +321 -659
- package/src/server/conversation/providers/opencodeStandalone.js +475 -0
- package/src/server/conversation/providers/scoped.js +64 -19
- package/src/server/conversation/runtime.js +171 -2863
- package/src/server/conversation/storage.js +91 -50
- package/src/server/conversation/streams.js +15 -2
- package/src/server/conversation/structuredOutput.js +7 -11
- package/src/server/conversation/systemPrompt.js +13 -1
- package/src/server/conversation/tools.js +47 -15
- package/src/server/conversation/transcript.js +49 -20
- package/src/server/hosts/claudeTurn.js +2 -4
- package/src/server/hosts/codexConfiguration.js +6 -8
- package/src/server/hosts/codexEvents.js +4 -2
- package/src/server/hosts/codexProcess.js +1 -1
- package/src/server/hosts/codexProvider.js +11 -9
- package/src/server/hosts/codexTurn.js +3 -3
- package/src/server/hosts/openCodeClient.js +4 -2
- package/src/server/index.js +0 -2
- package/src/server/lib/aiConnectionClient.js +35 -25
- package/src/server/lib/ndjson.js +1 -0
- package/src/server/lib/providers/anthropicClient.js +15 -6
- package/src/server/lib/providers/common.js +10 -6
- package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
- package/src/server/lib/serviceToolCatalog.js +29 -283
- package/src/server/lib/serviceToolEntries.js +270 -0
- package/src/server/types.js +78 -0
- package/src/shared/assistantResource.js +0 -23
- package/src/shared/conversation/answerChoiceSugar.js +2 -14
- package/src/shared/conversation/messageInputSugar.js +18 -0
- package/src/shared/conversation/numberedQuestionSugar.js +6 -16
- package/src/shared/conversation/pagination.js +1 -1
- package/src/shared/conversation/questions.js +1 -0
- package/src/shared/conversation/streaming.js +1 -0
- package/src/shared/index.js +0 -1
- package/src/testing/conversationStorageContract.js +20 -0
- package/test/aiConnectionClient.test.js +42 -1
- package/test/apiConversationDriver.test.js +217 -38
- package/test/assistantBoundedToolLoop.test.js +1 -1
- package/test/claudeConversationSession.test.js +15 -11
- package/test/claudeInstructionResume.test.js +1 -1
- package/test/claudeProcess.test.js +9 -9
- package/test/claudeTurn.test.js +21 -8
- package/test/codexAccountChange.test.js +5 -3
- package/test/codexConversationSession.test.js +22 -21
- package/test/codexEvents.test.js +3 -1
- package/test/codexHelperDeletion.test.js +3 -7
- package/test/codexIsolation.test.js +6 -6
- package/test/codexModelCatalog.test.js +6 -7
- package/test/codexProcess.test.js +3 -3
- package/test/codexThreadSelection.test.js +4 -1
- package/test/codexTurn.test.js +29 -20
- package/test/conversationApiFacilities.test.js +11 -9
- package/test/conversationAttachments.test.js +3 -3
- package/test/conversationConfiguration.test.js +4 -4
- package/test/conversationNativeStorage.test.js +11 -10
- package/test/conversationReplacement.test.js +13 -13
- package/test/conversationRuntime.test.js +9 -5
- package/test/conversationSession.test.js +78 -22
- package/test/conversationTools.test.js +47 -26
- package/test/fileConversationStorage.test.js +10 -3
- package/test/fixtures/codexHelperThreadLedger.js +3 -3
- package/test/frameworkActions.test.js +33 -18
- package/test/localConversationExecution.test.js +4 -4
- package/test/localExecution.test.js +1 -1
- package/test/openCodeConnectionChange.test.js +3 -2
- package/test/openCodeRuntime.test.js +8 -7
- package/test/openCodeTools.test.js +1 -1
- package/test/opencodeConversationSession.test.js +4 -3
- package/test/serviceToolCatalog.test.js +3 -8
- package/src/server/lib/assistantCompletion.js +0 -190
- package/src/server/lib/assistantToolLoop.js +0 -259
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The refusals the conversation runtime gives its callers, and the error texts its saved state keeps.
|
|
2
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A refusal for the caller. code is its own value, or one a client branches on
|
|
6
|
+
* shared with other sentences; messageCode names this sentence so a client can
|
|
7
|
+
* show it in the user's language.
|
|
8
|
+
*/
|
|
9
|
+
function failure(message, code, statusCode = 409, { messageCode, messageParams } = {}) {
|
|
10
|
+
return new AppError(statusCode, message, { code, ...(messageCode ? { messageCode, messageParams } : {}) });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A native delivery result keeps its own sentence and messageCode when it has one. */
|
|
14
|
+
function deliveryFailure(delivered) {
|
|
15
|
+
const code = delivered?.code || "conversation_delivery_uncertain";
|
|
16
|
+
return delivered?.error
|
|
17
|
+
? failure(delivered.error, code, 409, delivered)
|
|
18
|
+
: failure("Native delivery could not be confirmed.", code, 409, { messageCode: "assistant.conversation.deliveryUnconfirmed" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Error texts that conversation state keeps, including saved records written by
|
|
22
|
+
// earlier releases and the API engine's turn failures. A snapshot or event names
|
|
23
|
+
// them so a client can show them in the user's language; any other error text is
|
|
24
|
+
// shown as sent.
|
|
25
|
+
const STATE_ERROR_MESSAGES = Object.freeze([
|
|
26
|
+
{ message: "This conversation exceeds the configured API history limit. Start another conversation or increase the limit.",
|
|
27
|
+
messageCode: "assistant.conversation.apiHistoryLimit" },
|
|
28
|
+
{ message: "The model reached its output token limit before completing the answer. Increase maxOutputTokens in the conversation limits.",
|
|
29
|
+
messageCode: "assistant.conversation.outputTokenLimit" },
|
|
30
|
+
{ message: "The model stream ended without confirming its application tool calls.", messageCode: "assistant.conversation.toolCallsUnconfirmed" },
|
|
31
|
+
{ message: "The model stream ended without confirming a completed answer.", messageCode: "assistant.conversation.answerUnconfirmed" },
|
|
32
|
+
{ message: "Application tool progress text exceeds the configured API limit.", messageCode: "assistant.conversation.toolProgressLimit" },
|
|
33
|
+
{ message: "The assistant exceeded the configured final reply limit.", messageCode: "assistant.conversation.finalReplyLimit" },
|
|
34
|
+
{ message: "Conversation updates exceed the configured history budget. Renew the context with a continuity briefing before continuing.",
|
|
35
|
+
messageCode: "assistant.conversation.historyBudgetExceeded" },
|
|
36
|
+
{ message: "The continuity briefing exceeds the configured history budget.", messageCode: "assistant.conversation.briefingBudgetExceeded" },
|
|
37
|
+
{ message: "Assistant provider is not configured.", messageCode: "assistant.provider.notConfigured" },
|
|
38
|
+
{ message: "The previous submission was interrupted. Check delivery before submitting it again.",
|
|
39
|
+
messageCode: "assistant.conversation.submissionInterrupted" },
|
|
40
|
+
{ message: "The application restarted before this answer completed. The request was not replayed.",
|
|
41
|
+
messageCode: "assistant.conversation.restartInterrupted" },
|
|
42
|
+
{ message: "Copied into a stopped development environment. This request was not replayed.",
|
|
43
|
+
messageCode: "assistant.conversation.copiedInterrupted" },
|
|
44
|
+
{ message: "The application operation result could not be saved. Restore storage access and retry saving.",
|
|
45
|
+
messageCode: "assistant.conversation.operationResultUnsaved" },
|
|
46
|
+
{ message: "Conversation progress could not be saved. Restore access to its storage before continuing.",
|
|
47
|
+
messageCode: "assistant.conversation.progressUnsaved" },
|
|
48
|
+
{ message: "The conversation request exceeded its time limit.", messageCode: "assistant.conversation.requestTimedOut" },
|
|
49
|
+
{ message: "Work stopped.", messageCode: "assistant.conversation.workStopped" },
|
|
50
|
+
{ message: "Stopped by user.", messageCode: "assistant.conversation.stoppedByUser" }
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function stateErrorFields(error) {
|
|
54
|
+
const known = STATE_ERROR_MESSAGES.find(entry => entry.message === error);
|
|
55
|
+
return known ? { errorMessageCode: known.messageCode, errorMessageParams: {} } : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { deliveryFailure, failure, stateErrorFields };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { normalizeConversationConfiguration } from "./configuration.js";
|
|
4
|
+
import { hasUnfinishedConversationRewind, createConversationRuntimeReplacement,
|
|
5
|
+
releaseConversationRuntimeBinding, finishConversationRuntimeReplacement,
|
|
6
|
+
conversationNativeIdentity } from "./runtimeStateUpgrade.js";
|
|
7
|
+
import { createConversationChangeover, conversationContinuity, conversationHistoryVersions } from "./continuity.js";
|
|
8
|
+
import { failure } from "./conversationRefusals.js";
|
|
9
|
+
import { pendingSuccessor, retainedSegments } from "./conversationReads.js";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Replacing an open conversation's native conversation: a renewal, an engine or model change,
|
|
15
|
+
* or a selection, with its continuity briefing and the predecessor it retains.
|
|
16
|
+
* @param {object} dependencies
|
|
17
|
+
* @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
|
|
18
|
+
* @param {import("./provider.js").ConversationProviderFactory} dependencies.providers
|
|
19
|
+
* @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
|
|
20
|
+
* @param {import("./conversationEntries.js").ConversationEntries} dependencies.entries
|
|
21
|
+
* @param {import("./conversationReads.js").ConversationReads} dependencies.reads
|
|
22
|
+
* @param {import("./conversationDelivery.js").ConversationDelivery} dependencies.delivery
|
|
23
|
+
* @param {boolean} dependencies.completedEnvelope
|
|
24
|
+
* @param {string | undefined} dependencies.defaultIntegrationId
|
|
25
|
+
* @param {number} dependencies.maximumContinuity
|
|
26
|
+
*/
|
|
27
|
+
function createConversationReplacement({ storage, providers, events, entries, reads, delivery, completedEnvelope, defaultIntegrationId,
|
|
28
|
+
maximumContinuity }) {
|
|
29
|
+
/**
|
|
30
|
+
* @param {ConversationEntry} entry
|
|
31
|
+
* @param {object} context
|
|
32
|
+
* @param {object} input
|
|
33
|
+
* @param {boolean} selection Whether this selects an engine or model instead of replacing the native conversation.
|
|
34
|
+
* @param {string} representation
|
|
35
|
+
*/
|
|
36
|
+
async function replace(entry, context, input, selection, representation) {
|
|
37
|
+
const operation = selection ? "select" : "replace";
|
|
38
|
+
if (completedEnvelope && input?.engine === "api") throw failure("Completed envelopes require a native engine.", "conversation_completed_envelopes_need_native_engine", 400, { messageCode: "assistant.conversation.completedEnvelopesNeedNativeEngine" });
|
|
39
|
+
await events.access(context, entry.id, operation, entry);
|
|
40
|
+
if (entry.conversation?.commands) {
|
|
41
|
+
if (!selection) throw failure("This conversation retains its original replacement policy.", "conversation_replacement_policy_retained", 400, { messageCode: "assistant.conversation.replacementPolicyRetained" });
|
|
42
|
+
return entry.conversation.commands.select(input, context);
|
|
43
|
+
}
|
|
44
|
+
if (entry.conversation?.native?.scoped) throw failure("This scoped conversation retains its original selected profile.", "conversation_selected_profile_retained", 409, { messageCode: "assistant.conversation.selectedProfileRetained" });
|
|
45
|
+
const keys = selection ? ["operationId", "expectedSegmentId", "engine", "configuration", "retireNative"]
|
|
46
|
+
: ["operationId", "expectedSegmentId", "reason", "engine", "configuration", "briefing"];
|
|
47
|
+
if (!input || !/^[\w-]{1,128}$/u.test(input.operationId || "") || typeof input.expectedSegmentId !== "string" ||
|
|
48
|
+
!input.expectedSegmentId || (selection ? typeof input.engine !== "string" : !["engine-change", "model-change", "renewal"].includes(input.reason)) ||
|
|
49
|
+
input.briefing !== undefined && typeof input.briefing !== "string" ||
|
|
50
|
+
input.retireNative !== undefined && typeof input.retireNative !== "boolean" ||
|
|
51
|
+
Object.keys(input).some(key => !keys.includes(key))) {
|
|
52
|
+
throw failure("Replacement needs an operationId, exact predecessor segment, and an engine-change, model-change or renewal reason.", "conversation_replacement_request_invalid", 400, { messageCode: "assistant.conversation.replacementRequestInvalid" });
|
|
53
|
+
}
|
|
54
|
+
if (entry.active) throw failure("Stop the current turn before replacing its native conversation.", "conversation_busy", 409, { messageCode: "assistant.conversation.stopBeforeReplace" });
|
|
55
|
+
if (entry.storageFailure) throw entry.storageFailure;
|
|
56
|
+
if (entry.conversation) {
|
|
57
|
+
const state = await entries.readState(entry);
|
|
58
|
+
const saved = await entry.conversation.state.read();
|
|
59
|
+
const retry = saved?.replacement?.operationId === input.operationId;
|
|
60
|
+
let nativeResult;
|
|
61
|
+
if (state.segmentId !== input.expectedSegmentId && !retry) throw failure("The native predecessor has changed. Read the conversation before replacing it.", "conversation_predecessor_changed", 409, { messageCode: "assistant.conversation.predecessorChanged" });
|
|
62
|
+
if (selection) {
|
|
63
|
+
if (!entry.conversation.selection?.write) throw failure("This host requires its authorized assistant selection operation.", "conversation_host_selection_required", 409, { messageCode: "assistant.conversation.hostSelectionRequired" });
|
|
64
|
+
await entry.conversation.selection.write(input, context);
|
|
65
|
+
} else {
|
|
66
|
+
if (!input.briefing?.trim()) throw failure("Native replacement requires a continuity briefing.", "conversation_replacement_briefing_required", 400, { messageCode: "assistant.conversation.replacementBriefingRequired" });
|
|
67
|
+
const replacement = createConversationChangeover({ state: entry.conversation.state,
|
|
68
|
+
identity: entry.conversation.identity, presentation: entry.conversation.presentation,
|
|
69
|
+
agent: { async closeSession() { await entry.provider.dispose({ changeover: true, forgetConversationBinding: true }); return { ok: true }; } } });
|
|
70
|
+
nativeResult = await replacement.replace({ engineId: entry.engine, messages: await entry.conversation.transcript.history(),
|
|
71
|
+
operationId: input.operationId, expectedId: input.expectedSegmentId.startsWith(`${entry.engine}:`)
|
|
72
|
+
? input.expectedSegmentId.slice(entry.engine.length + 1) : "", handover: input.briefing || "" });
|
|
73
|
+
if (retry && saved.replacement.status === "accepted") {
|
|
74
|
+
return representation === "native" ? nativeResult : { operationId: input.operationId, segmentId: entry.segmentId, duplicate: true };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
await entries.rebindHostConversation(entry, context);
|
|
78
|
+
entry.goal = null;
|
|
79
|
+
const receipt = { operationId: input.operationId, segmentId: entry.segmentId, ...(retry ? { duplicate: true } : {}) };
|
|
80
|
+
await events.emit(entry, { type: "replaced", reason: input.reason || "model-change", engine: entry.engine, ...receipt });
|
|
81
|
+
return representation === "native" ? nativeResult : receipt;
|
|
82
|
+
}
|
|
83
|
+
const request = { ...structuredClone(input), operation };
|
|
84
|
+
// Receipt identity must survive JSON storage without picking up a later
|
|
85
|
+
// runtime's connection default when the same operation is retried.
|
|
86
|
+
if (request.engine === undefined) delete request.engine;
|
|
87
|
+
if (request.briefing === undefined) delete request.briefing;
|
|
88
|
+
if (request.configuration === undefined) delete request.configuration;
|
|
89
|
+
else if (request.configuration) request.configuration = normalizeConversationConfiguration(request.configuration);
|
|
90
|
+
let state = await entries.readState(entry);
|
|
91
|
+
if (hasUnfinishedConversationRewind(state)) {
|
|
92
|
+
throw failure("This conversation has an unfinished Undo operation from an earlier runtime. Inspect its saved native and local history offline before replacing it; no history was changed.", "conversation_rewind_inspection_before_replace", 409, { messageCode: "assistant.conversation.rewindInspectionBeforeReplace" });
|
|
93
|
+
}
|
|
94
|
+
const completed = state.predecessors.find(item => item.replacement?.operationId === input.operationId);
|
|
95
|
+
if (completed) {
|
|
96
|
+
if (!isDeepStrictEqual(completed.replacement, request)) throw failure("This replacement operationId already belongs to another request.", "conversation_replacement_operation_conflict", 409, { messageCode: "assistant.conversation.replacementOperationConflict" });
|
|
97
|
+
return { operationId: input.operationId, segmentId: completed.successorId, duplicate: true };
|
|
98
|
+
}
|
|
99
|
+
let pending = state.replacement;
|
|
100
|
+
if (pending && !isDeepStrictEqual(pending.request, request)) throw failure("Retry the unfinished native replacement before starting another one.", "conversation_replacement_unfinished", 409, { messageCode: "assistant.conversation.replacementUnfinished" });
|
|
101
|
+
if (state.segmentId !== input.expectedSegmentId) throw failure("The native predecessor has changed. Read the conversation before replacing it.", "conversation_predecessor_changed", 409, { messageCode: "assistant.conversation.predecessorChanged" });
|
|
102
|
+
if (!selection && retainedSegments(state).some(segment => segment.request)) throw failure("Resolve pending delivery before replacing native history. Another engine may be selected while its receipt stays pending.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.pendingDeliveryBeforeReplace" });
|
|
103
|
+
if (!selection && !pending && pendingSuccessor(state)) throw failure("Deliver the previous replacement's briefing before replacing native history again.", "conversation_briefing_before_replacement", 409, { messageCode: "assistant.conversation.briefingBeforeReplacement" });
|
|
104
|
+
if (entry.executionFailure && !pending) throw entry.executionFailure;
|
|
105
|
+
const engine = pending?.engine || input.engine || state.engine;
|
|
106
|
+
const configuration = pending?.configuration || (input.configuration
|
|
107
|
+
? normalizeConversationConfiguration(input.configuration, { engine, defaultIntegrationId }) : state.configuration);
|
|
108
|
+
const driver = providers.createDriver(engine, entry.host);
|
|
109
|
+
driver.validateConfiguration(configuration);
|
|
110
|
+
const predecessorId = conversationNativeIdentity(state);
|
|
111
|
+
const nativeReplacement = !selection && state.engine !== "api" && predecessorId &&
|
|
112
|
+
(state.engine !== "claude" || state.binding.sent);
|
|
113
|
+
const retainNativeBinding = input.retireNative !== true && engine === state.engine &&
|
|
114
|
+
(engine !== "opencode" || configuration.integrationId === state.configuration.integrationId);
|
|
115
|
+
if (selection && engine !== "api" && retainNativeBinding && !pending) {
|
|
116
|
+
// The production native owners apply compatible settings to the running
|
|
117
|
+
// conversation themselves. Selecting a model is not process retirement.
|
|
118
|
+
await storage.write(entry.id, async transaction => {
|
|
119
|
+
const metadata = await transaction.readMetadata();
|
|
120
|
+
const current = metadata.runtime;
|
|
121
|
+
current.predecessors.push({ segmentId: current.segmentId, engine: current.engine,
|
|
122
|
+
configuration: current.configuration, binding: current.binding, seen: current.seen,
|
|
123
|
+
request: current.request, successorId: current.segmentId, replacement: request });
|
|
124
|
+
current.configuration = structuredClone(configuration);
|
|
125
|
+
await transaction.writeMetadata(metadata);
|
|
126
|
+
});
|
|
127
|
+
const receipt = { operationId: input.operationId, segmentId: state.segmentId };
|
|
128
|
+
await events.emit(entry, { type: "replaced", reason: "model-change", engine, ...receipt });
|
|
129
|
+
return receipt;
|
|
130
|
+
}
|
|
131
|
+
if (!pending) {
|
|
132
|
+
const history = (await reads.readTranscript(entry)).filter(turn => !turn.metadata?.runtime?.supersededBy);
|
|
133
|
+
let destination = null;
|
|
134
|
+
if (selection) {
|
|
135
|
+
if (retainNativeBinding) destination = state;
|
|
136
|
+
else if (input.retireNative !== true && engine !== state.engine && driver.createBinding) {
|
|
137
|
+
destination = retainedSegments(state).findLast(segment => segment.engine === engine &&
|
|
138
|
+
(engine !== "opencode" || segment.configuration.integrationId === configuration.integrationId));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (engine !== "api" && (input.briefing || "").length > maximumContinuity) throw new Error("The continuity briefing exceeds the configured history budget.");
|
|
142
|
+
const continuity = destination ? { text: destination.continuity, attachments: destination.continuityAttachments || [] }
|
|
143
|
+
: engine === "api" ? conversationContinuity({ history, briefing: input.briefing, maximumCharacters: maximumContinuity })
|
|
144
|
+
: { text: undefined, attachments: [] };
|
|
145
|
+
pending = createConversationRuntimeReplacement({ request, engine, configuration: structuredClone(configuration),
|
|
146
|
+
reason: selection ? state.engine === engine ? "model-change" : "engine-change" : input.reason,
|
|
147
|
+
segmentId: destination?.segmentId || randomUUID(), continuity,
|
|
148
|
+
seen: destination ? destination.seen : engine === "api" ? conversationHistoryVersions(history) : {}, destination });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function saveReplacement() {
|
|
152
|
+
return storage.write(entry.id, async transaction => {
|
|
153
|
+
const metadata = await transaction.readMetadata();
|
|
154
|
+
if (metadata.runtime.segmentId !== input.expectedSegmentId || metadata.runtime.replacement &&
|
|
155
|
+
!isDeepStrictEqual(metadata.runtime.replacement.request, request)) {
|
|
156
|
+
throw failure("The saved replacement changed before it could be written.", "conversation_replacement_changed_before_write", 409, { messageCode: "assistant.conversation.replacementChangedBeforeWrite" });
|
|
157
|
+
}
|
|
158
|
+
metadata.runtime.replacement = pending;
|
|
159
|
+
await transaction.writeMetadata(metadata);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function closeSession() {
|
|
164
|
+
try { await entry.provider.dispose(); }
|
|
165
|
+
catch (error) { entry.executionFailure = error; entry.error = error.message; throw error; }
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let provider;
|
|
170
|
+
const bindingOwner = {};
|
|
171
|
+
async function replacementTransaction(callback) {
|
|
172
|
+
// These common storage/host facilities allocate an inert successor handle.
|
|
173
|
+
// Native process/thread creation stays in ordinary Send, after this commit.
|
|
174
|
+
await events.access(context, entry.id, operation, entry);
|
|
175
|
+
if (pending.segmentId === state.segmentId) {
|
|
176
|
+
pending.binding = (await entries.readState(entry)).binding || null;
|
|
177
|
+
}
|
|
178
|
+
if (!Object.hasOwn(pending, "binding")) {
|
|
179
|
+
pending.binding = driver.createBinding ? await driver.createBinding() : null;
|
|
180
|
+
await saveReplacement();
|
|
181
|
+
}
|
|
182
|
+
provider = await entries.openProvider(entry, driver, pending.binding, pending.segmentId, bindingOwner);
|
|
183
|
+
try {
|
|
184
|
+
await events.access(context, entry.id, operation, entry);
|
|
185
|
+
await storage.write(entry.id, async transaction => {
|
|
186
|
+
const metadata = await transaction.readMetadata();
|
|
187
|
+
state = metadata.runtime;
|
|
188
|
+
if (state.segmentId !== input.expectedSegmentId || !isDeepStrictEqual(state.replacement?.request, request)) {
|
|
189
|
+
throw failure("The saved replacement changed before its binding was released.", "conversation_replacement_changed_before_release", 409, { messageCode: "assistant.conversation.replacementChangedBeforeRelease" });
|
|
190
|
+
}
|
|
191
|
+
await callback({
|
|
192
|
+
async releaseBinding(replacement) {
|
|
193
|
+
if (replacement && conversationNativeIdentity(state) !== replacement.previous.conversationId) {
|
|
194
|
+
throw failure("The native predecessor identity changed before its binding was released.", "conversation_predecessor_changed_before_release", 409, { messageCode: "assistant.conversation.predecessorChangedBeforeRelease" });
|
|
195
|
+
}
|
|
196
|
+
releaseConversationRuntimeBinding(state, pending, request, engine);
|
|
197
|
+
},
|
|
198
|
+
async write(value) {
|
|
199
|
+
finishConversationRuntimeReplacement(state, { engine, selection, retireNative: input.retireNative, pending, value });
|
|
200
|
+
await transaction.writeMetadata(metadata);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
} catch (error) { await provider.dispose(); throw error; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (nativeReplacement) {
|
|
208
|
+
const replacement = createConversationChangeover({
|
|
209
|
+
state: {
|
|
210
|
+
read: delivery.changeoverState(entry).read,
|
|
211
|
+
async write(value) {
|
|
212
|
+
pending.preparedAt = value.replacement.preparedAt;
|
|
213
|
+
await saveReplacement();
|
|
214
|
+
},
|
|
215
|
+
transaction: replacementTransaction
|
|
216
|
+
},
|
|
217
|
+
identity: {
|
|
218
|
+
async inspect(expectedId) {
|
|
219
|
+
const { runtime } = await storage.read(entry.id, transaction => transaction.readMetadata());
|
|
220
|
+
if (runtime.segmentId !== input.expectedSegmentId || conversationNativeIdentity(runtime) !== expectedId) {
|
|
221
|
+
throw failure("The native predecessor identity changed or is incomplete.", "conversation_predecessor_incomplete", 409, { messageCode: "assistant.conversation.predecessorIncomplete" });
|
|
222
|
+
}
|
|
223
|
+
return { bindingNames: ["binding"], previous: { engineId: runtime.engine, conversationId: expectedId } };
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
agent: { closeSession }
|
|
227
|
+
});
|
|
228
|
+
await replacement.replace({ engineId: engine, messages: [], operationId: input.operationId,
|
|
229
|
+
expectedId: predecessorId, handover: input.briefing || "" });
|
|
230
|
+
} else {
|
|
231
|
+
// API conversations, retained-engine selection and unstarted native handles
|
|
232
|
+
// have no native predecessor replacement. Keep their common facilities.
|
|
233
|
+
if (!state.replacement) await saveReplacement();
|
|
234
|
+
await closeSession();
|
|
235
|
+
await replacementTransaction(async transaction => {
|
|
236
|
+
await transaction.releaseBinding();
|
|
237
|
+
await transaction.write();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
entries.adoptBinding(entry, { engine, driver, provider, bindingOwner, segmentId: pending.segmentId });
|
|
241
|
+
Object.assign(entry, { goal: null, executionFailure: null, error: pending.submission?.error || "" });
|
|
242
|
+
const receipt = { operationId: input.operationId, segmentId: pending.segmentId };
|
|
243
|
+
await events.emit(entry, { type: "replaced", reason: pending.reason, engine, ...receipt });
|
|
244
|
+
return receipt;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return Object.freeze({
|
|
248
|
+
replace
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @typedef {ReturnType<typeof createConversationReplacement>} ConversationReplacement */
|
|
253
|
+
|
|
254
|
+
export { createConversationReplacement };
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { createConversationDeliveryTiming, recordNoDeliveryTiming } from "./deliveryTiming.js";
|
|
3
|
+
import { codexAppServerTurnStateFromAgentRun } from "./codexTurn.js";
|
|
4
|
+
import { conversationAttachmentIds } from "./attachments.js";
|
|
5
|
+
import { failure } from "./conversationRefusals.js";
|
|
6
|
+
import { retainedSegments, submittedMessage } from "./conversationReads.js";
|
|
7
|
+
import { serial } from "./conversationEvents.js";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One message an open conversation sends: its validated input, who sent it and how, and its
|
|
13
|
+
* admission as the turn delivers it. admitSend creates it.
|
|
14
|
+
* @typedef {object} ConversationRequest
|
|
15
|
+
* @property {{ messageId: string, text: string, origin: string, attachments: object[], data?: object, goal?: object }} input
|
|
16
|
+
* @property {any} [originalInput] A native caller's own input, kept for the native representation.
|
|
17
|
+
* @property {string} operation The operation it is authorized as: send, steer, wake or goal.
|
|
18
|
+
* @property {object} context
|
|
19
|
+
* @property {string} at
|
|
20
|
+
* @property {(stage: string) => void} deliveryTiming
|
|
21
|
+
* @property {boolean} [completedEnvelope]
|
|
22
|
+
* @property {string[]} [excludedMessageIds]
|
|
23
|
+
* @property {boolean} [nativeToolAttempt]
|
|
24
|
+
* @property {boolean} steering Whether it steers a running native turn.
|
|
25
|
+
* @property {PromiseWithResolvers<any>} admission Resolves with its receipt once the turn admits it.
|
|
26
|
+
* @property {Map<string, object>} progress Its completed commentary and thinking, by message id.
|
|
27
|
+
* @property {ReturnType<typeof import("./tools.js").createConversationTools>} [tools]
|
|
28
|
+
* @property {string} [turnId]
|
|
29
|
+
* @property {string} [nativeTurnId]
|
|
30
|
+
* @property {string} [nativeThreadId]
|
|
31
|
+
* @property {string} [goalMessageId]
|
|
32
|
+
* @property {string} [continuedBy] The message whose admission sealed it.
|
|
33
|
+
* @property {object | null} [answer]
|
|
34
|
+
* @property {boolean} [accepted]
|
|
35
|
+
* @property {boolean} [admissionSettled]
|
|
36
|
+
* @property {boolean} [admissionPublished]
|
|
37
|
+
* @property {boolean} [sealed] Its output is final; late native snapshots no longer change it.
|
|
38
|
+
* @property {boolean} [finished]
|
|
39
|
+
* @property {Promise<void>} [deliveryCommitted]
|
|
40
|
+
* @property {object} [recovered] The saved state of a turn a duplicate delivery recovered.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Whether a saved request and a new message carry the same content. */
|
|
44
|
+
const sameRequest = (left, right) => left.text === right.text && (left.origin || "user") === right.origin &&
|
|
45
|
+
isDeepStrictEqual(left.goal, right.goal) && isDeepStrictEqual(left.data, right.data) &&
|
|
46
|
+
isDeepStrictEqual((left.attachments || []).map(file => file.attachmentId), right.attachments.map(file => file.attachmentId));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sending a message: its validation, its admission into the conversation queue, and the
|
|
50
|
+
* turn it starts or the running turn it steers.
|
|
51
|
+
* @param {object} dependencies
|
|
52
|
+
* @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
|
|
53
|
+
* @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
|
|
54
|
+
* @param {import("./conversationEntries.js").ConversationEntries} dependencies.entries
|
|
55
|
+
* @param {import("./conversationReads.js").ConversationReads} dependencies.reads
|
|
56
|
+
* @param {import("./conversationTurns.js").ConversationTurns} dependencies.turns
|
|
57
|
+
* @param {import("../types.js").ConversationDeliveryTimingObserver | undefined} dependencies.onDeliveryTiming
|
|
58
|
+
* @param {readonly string[]} dependencies.deliveryHostStages
|
|
59
|
+
* @param {boolean} dependencies.completedEnvelope
|
|
60
|
+
* @param {number} dependencies.maximumInput
|
|
61
|
+
*/
|
|
62
|
+
function createConversationSending({ storage, events, entries, reads, turns, onDeliveryTiming, deliveryHostStages, completedEnvelope, maximumInput }) {
|
|
63
|
+
/**
|
|
64
|
+
* @param {ConversationEntry} entry
|
|
65
|
+
* @param {object} context
|
|
66
|
+
* @param {object} input
|
|
67
|
+
* @param {string} [origin]
|
|
68
|
+
* @param {object} [goal]
|
|
69
|
+
* @param {string} [representation]
|
|
70
|
+
* @param {object} [wakeOptions]
|
|
71
|
+
*/
|
|
72
|
+
async function send(entry, context, input, origin = "user", goal, representation, wakeOptions) {
|
|
73
|
+
const deliveryTiming = createConversationDeliveryTiming({ engine: entry.engine,
|
|
74
|
+
conversationId: entry.id, messageId: input?.messageId, report: onDeliveryTiming, hostStages: deliveryHostStages });
|
|
75
|
+
deliveryTiming("received");
|
|
76
|
+
const grant = new Set();
|
|
77
|
+
try {
|
|
78
|
+
await events.access(context, entry.id, goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send", entry, grant);
|
|
79
|
+
if (entry.conversation?.commands) {
|
|
80
|
+
if (origin !== "user" || goal) throw failure("This conversation supports its original application Send operation.", "conversation_original_send_only", 400, { messageCode: "assistant.conversation.originalSendOnly" });
|
|
81
|
+
return entry.conversation.commands.send(input, context);
|
|
82
|
+
}
|
|
83
|
+
if (entry.conversation?.native?.scoped) return entry.provider.sendNative({ input, context });
|
|
84
|
+
const originalInput = representation === "native" ? input : undefined;
|
|
85
|
+
const message = originalInput ? { ...input, text: input.displayMessage ?? input.message ?? input.text ?? "" } : input;
|
|
86
|
+
const admission = await serial(entry, () => admitSend(entry, context, message, origin, goal, originalInput, wakeOptions, deliveryTiming, grant));
|
|
87
|
+
const result = await (admission.receipt || admission.promise);
|
|
88
|
+
if (!entry.conversation) return result;
|
|
89
|
+
if (representation === "native") return result.nativeResult;
|
|
90
|
+
if (result.error) throw result.error;
|
|
91
|
+
return result.receipt;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
deliveryTiming("failed");
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Called while holding the conversation queue, including Claude's stop-then-Send goal commands.
|
|
100
|
+
* Every command keeps the ordinary receipt path.
|
|
101
|
+
* @param {ConversationEntry} entry
|
|
102
|
+
* @param {object} context
|
|
103
|
+
* @param {object} input
|
|
104
|
+
* @param {string} [origin]
|
|
105
|
+
* @param {object} [goal]
|
|
106
|
+
* @param {object} [originalInput]
|
|
107
|
+
* @param {object} [wakeOptions]
|
|
108
|
+
* @param {(stage: string) => void} [deliveryTiming]
|
|
109
|
+
* @param {Set<string>} [grant]
|
|
110
|
+
*/
|
|
111
|
+
async function admitSend(entry, context, input, origin = "user", goal, originalInput, wakeOptions, deliveryTiming = recordNoDeliveryTiming, grant) {
|
|
112
|
+
deliveryTiming("preparation-started");
|
|
113
|
+
const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
|
|
114
|
+
await events.access(context, entry.id, operation, entry, grant);
|
|
115
|
+
if (entry.cancelledMessages.has(input?.messageId)) {
|
|
116
|
+
throw Object.assign(failure("This message was cancelled. Send it again as a new message.", "conversation_message_cancelled", 409,
|
|
117
|
+
{ messageCode: "assistant.conversation.messageCancelled" }), { delivery: "not-sent" });
|
|
118
|
+
}
|
|
119
|
+
await entry.conversation?.admission(context, operation);
|
|
120
|
+
deliveryTiming("authorized");
|
|
121
|
+
if (goal) context?.signal?.throwIfAborted();
|
|
122
|
+
let suppliedExclusions;
|
|
123
|
+
if (wakeOptions !== undefined) {
|
|
124
|
+
if (!completedEnvelope || entry.conversation || origin !== "application" || goal ||
|
|
125
|
+
!wakeOptions || typeof wakeOptions !== "object" || Array.isArray(wakeOptions) ||
|
|
126
|
+
Object.keys(wakeOptions).some(name => name !== "excludedMessageIds") ||
|
|
127
|
+
!Array.isArray(wakeOptions.excludedMessageIds) ||
|
|
128
|
+
wakeOptions.excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
|
|
129
|
+
new Set(wakeOptions.excludedMessageIds).size !== wakeOptions.excludedMessageIds.length) {
|
|
130
|
+
throw failure("Native history exclusions require a server-owned completed application wake and exact message IDs.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.historyExclusionsInvalid" });
|
|
131
|
+
}
|
|
132
|
+
suppliedExclusions = [...wakeOptions.excludedMessageIds];
|
|
133
|
+
}
|
|
134
|
+
const attachmentIds = conversationAttachmentIds(input?.attachmentIds);
|
|
135
|
+
if (!input || typeof input.messageId !== "string" || !input.messageId.trim() || input.messageId.length > 128 ||
|
|
136
|
+
entry.engine !== "api" && !/^[\w-]{1,128}$/u.test(input.messageId) ||
|
|
137
|
+
typeof input.text !== "string" || !input.text.trim() && !attachmentIds.length || input.text.length > maximumInput) {
|
|
138
|
+
throw failure("Bounded text or attachment IDs and a stable messageId are required.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.messageContentRequired" });
|
|
139
|
+
}
|
|
140
|
+
if (!originalInput && input.attachments?.length) throw failure("Supply attachmentIds; only the application's resolver can supply file content.", "conversation_attachment_ids_only", 400, { messageCode: "assistant.conversation.attachmentIdsOnly" });
|
|
141
|
+
if (attachmentIds.length && !reads.capabilities(entry).attachments) throw failure("File attachments are not configured for this conversation.", "conversation_attachments_not_configured", 400, { messageCode: "assistant.conversation.attachmentsNotConfigured" });
|
|
142
|
+
if (input.steer !== undefined && typeof input.steer !== "boolean") throw failure("steer must be a boolean.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.steerNotBoolean" });
|
|
143
|
+
if (input.steer && !entry.driver.capabilities.steering) throw failure("This connection does not support steering.", "conversation_steering_unsupported", 400, { messageCode: "assistant.conversation.steeringUnsupported" });
|
|
144
|
+
let data;
|
|
145
|
+
if (input.data !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
if (!input.data || typeof input.data !== "object" || Array.isArray(input.data)) throw new TypeError();
|
|
148
|
+
const encoded = JSON.stringify(input.data);
|
|
149
|
+
if (input.text.length + encoded.length > maximumInput) throw new TypeError();
|
|
150
|
+
data = JSON.parse(encoded);
|
|
151
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError();
|
|
152
|
+
} catch {
|
|
153
|
+
throw failure("Application data must be a JSON object within the conversation input limit.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.applicationDataInvalid" });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const message = { messageId: input.messageId, text: input.text.trim(), origin,
|
|
157
|
+
attachments: attachmentIds.map(attachmentId => ({ attachmentId })), ...(data !== undefined ? { data } : {}), ...(goal ? { goal } : {}) };
|
|
158
|
+
if (entry.storageFailure) throw entry.storageFailure;
|
|
159
|
+
if (entry.executionFailure) throw entry.executionFailure;
|
|
160
|
+
if (goal && goal.expectedSegmentId !== entry.segmentId) throw failure("The conversation changed before this goal command was sent.", "conversation_goal_changed", 409, { messageCode: "assistant.conversation.goalConversationChanged" });
|
|
161
|
+
const pending = entry.active?.requests.get(message.messageId);
|
|
162
|
+
if (pending && entry.engine !== "api" && !pending.admissionSettled) {
|
|
163
|
+
if (suppliedExclusions && !isDeepStrictEqual(pending.excludedMessageIds || [], suppliedExclusions)) throw failure("This messageId already belongs to different native history exclusions.", "conversation_message_id_exclusions_conflict", 409, { messageCode: "assistant.conversation.messageIdExclusionsConflict" });
|
|
164
|
+
if (!entry.conversation && !sameRequest(pending.input, message)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
|
|
165
|
+
return { promise: pending.admission.promise };
|
|
166
|
+
}
|
|
167
|
+
const existing = await reads.findSubmittedTurn(entry, message.messageId);
|
|
168
|
+
deliveryTiming("history-read");
|
|
169
|
+
const nativeState = entry.engine !== "api" && (await entries.readState(entry));
|
|
170
|
+
deliveryTiming("state-read");
|
|
171
|
+
const nativePending = nativeState?.request;
|
|
172
|
+
if (existing) {
|
|
173
|
+
const previous = submittedMessage(existing);
|
|
174
|
+
if (!entry.conversation && !sameRequest(previous, message)) {
|
|
175
|
+
throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
|
|
176
|
+
}
|
|
177
|
+
if (!entry.conversation && !nativePending) return { receipt: { status: "accepted", messageId: message.messageId, turnId: existing.turnId, origin, duplicate: true } };
|
|
178
|
+
}
|
|
179
|
+
if (pending && !entry.conversation) {
|
|
180
|
+
if (!sameRequest(pending.input, message)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
|
|
181
|
+
if (suppliedExclusions && !isDeepStrictEqual(pending.excludedMessageIds || [], suppliedExclusions)) throw failure("This messageId already belongs to different native history exclusions.", "conversation_message_id_exclusions_conflict", 409, { messageCode: "assistant.conversation.messageIdExclusionsConflict" });
|
|
182
|
+
return { promise: pending.admission.promise };
|
|
183
|
+
}
|
|
184
|
+
const working = entry.active;
|
|
185
|
+
if (entry.conversation && ["codex", "opencode", "claude"].includes(entry.engine) && ["send", "goal"].includes(operation) && working?.finishing) {
|
|
186
|
+
// No native dispatch has occurred for this request. Finish the previous
|
|
187
|
+
// waiter before reserving the same authored message or goal request.
|
|
188
|
+
await working.done;
|
|
189
|
+
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming, grant);
|
|
190
|
+
}
|
|
191
|
+
const nativeSteering = !working && (nativeState.nativeTurn?.active || entry.driver.canonicalTranscript && nativeState.binding?.codexAppServerRun &&
|
|
192
|
+
codexAppServerTurnStateFromAgentRun(nativeState.binding.codexAppServerRun).active);
|
|
193
|
+
if (completedEnvelope && entry.engine === "claude" &&
|
|
194
|
+
(working?.request.completedEnvelope || nativePending?.completedEnvelope === true || origin === "application" && !goal) &&
|
|
195
|
+
(working || input.steer || nativeSteering)) {
|
|
196
|
+
throw failure("Completed Claude responses cannot share a native steering turn. Wait for the response or stop it.", "conversation_not_steerable", 409, { messageCode: "assistant.conversation.completedClaudeNotSteerable" });
|
|
197
|
+
}
|
|
198
|
+
if (working && !input.steer && !goal && !entry.conversation) throw failure("Wait for this answer or use steer to send another instruction.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForAnswer" });
|
|
199
|
+
if (input.steer && (!working && !nativeSteering || working?.finishing) && !(nativePending?.attempted || nativePending?.inspectionOnly)) {
|
|
200
|
+
const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state ||
|
|
201
|
+
(await reads.lastTranscriptTurn(entry))?.metadata?.runtime?.status;
|
|
202
|
+
const completed = working ? !working.controller.signal.aborted && !working.timedOut : ["complete", "completed"].includes(previousState);
|
|
203
|
+
throw Object.assign(failure("There is no active native turn to steer. Send a new message.",
|
|
204
|
+
completed ? "conversation_steer_turn_finished" : "conversation_not_steerable", 409, { messageCode: "assistant.conversation.noActiveTurnToSteer" }), { delivery: "not-sent" });
|
|
205
|
+
}
|
|
206
|
+
if (goal && working?.finishing) throw failure("Wait for the current turn to finish saving before starting the goal.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForTurnSaving" });
|
|
207
|
+
if (working && (!working.request.admissionSettled || working.steering)) throw failure("Wait for the pending instruction to be admitted before steering again.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForInstructionAdmission" });
|
|
208
|
+
const barrier = working ? Promise.withResolvers() : null;
|
|
209
|
+
if (barrier) working.steering = barrier.promise;
|
|
210
|
+
const release = () => {
|
|
211
|
+
barrier?.resolve();
|
|
212
|
+
if (working && working.steering === barrier?.promise) working.steering = null;
|
|
213
|
+
};
|
|
214
|
+
let configuration;
|
|
215
|
+
let markedResponse = false;
|
|
216
|
+
let nativeToolAttempt;
|
|
217
|
+
let excludedMessageIds;
|
|
218
|
+
let requestAt = new Date().toISOString();
|
|
219
|
+
// The bound state was read above unless this is an API conversation.
|
|
220
|
+
try { configuration = entry.conversation ? (nativeState || await entries.readState(entry)).configuration : await storage.write(entry.id, async transaction => {
|
|
221
|
+
const metadata = await transaction.readMetadata();
|
|
222
|
+
if (metadata.runtime.replacement) throw failure("Finish the pending native replacement before sending another message.", "conversation_replacement_pending_before_send", 409, { messageCode: "assistant.conversation.replacementPendingBeforeSend" });
|
|
223
|
+
if (retainedSegments(metadata.runtime).some(segment => segment.segmentId !== entry.segmentId && segment.request?.messageId === message.messageId)) {
|
|
224
|
+
throw failure("This message has uncertain delivery in a retained conversation. Select that engine and check its receipt before submitting it again.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.retainedDeliveryUncertain" });
|
|
225
|
+
}
|
|
226
|
+
if (metadata.runtime.request) {
|
|
227
|
+
if (suppliedExclusions && metadata.runtime.request.messageId !== message.messageId) {
|
|
228
|
+
throw failure("Check delivery of the previous instruction before preparing another completed response.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.previousInstructionUnchecked" });
|
|
229
|
+
}
|
|
230
|
+
if (entry.engine === "api") throw failure("The previous submission has uncertain delivery. It will not be resent automatically.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.previousSubmissionUncertain" });
|
|
231
|
+
if (metadata.runtime.request.messageId === message.messageId) {
|
|
232
|
+
if (!sameRequest(metadata.runtime.request, message)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
|
|
233
|
+
if (suppliedExclusions && !isDeepStrictEqual(metadata.runtime.request.excludedMessageIds || [], suppliedExclusions)) {
|
|
234
|
+
throw failure("This messageId already belongs to different native history exclusions.", "conversation_message_id_exclusions_conflict", 409, { messageCode: "assistant.conversation.messageIdExclusionsConflict" });
|
|
235
|
+
}
|
|
236
|
+
requestAt = metadata.runtime.request.at;
|
|
237
|
+
}
|
|
238
|
+
} else metadata.runtime.request = { ...message, at: requestAt,
|
|
239
|
+
...(completedEnvelope && origin === "application" && !goal ? { completedEnvelope: true, excludedMessageIds: suppliedExclusions || [],
|
|
240
|
+
...(entry.engine === "claude" ? { nativeToolAttempt: false } : {}) } : {}),
|
|
241
|
+
...(entry.engine !== "api" ? { attempted: false } : {}), ...(input.steer ? { steering: true } : {}) };
|
|
242
|
+
markedResponse = origin === "application" && !goal && metadata.runtime.request?.messageId === message.messageId &&
|
|
243
|
+
metadata.runtime.request.completedEnvelope === true;
|
|
244
|
+
if (markedResponse) excludedMessageIds = structuredClone(metadata.runtime.request.excludedMessageIds || []);
|
|
245
|
+
if (entry.engine === "claude" && markedResponse && typeof metadata.runtime.request.nativeToolAttempt === "boolean") nativeToolAttempt = metadata.runtime.request.nativeToolAttempt;
|
|
246
|
+
await transaction.writeMetadata(metadata);
|
|
247
|
+
return metadata.runtime.configuration;
|
|
248
|
+
}); } catch (error) { release(); throw error; }
|
|
249
|
+
deliveryTiming("request-saved");
|
|
250
|
+
/** @type {ConversationRequest} */
|
|
251
|
+
const request = { input: message, originalInput, operation, context, at: requestAt, deliveryTiming,
|
|
252
|
+
...(markedResponse ? { completedEnvelope: true, excludedMessageIds } : {}),
|
|
253
|
+
...(typeof nativeToolAttempt === "boolean" ? { nativeToolAttempt } : {}), steering: Boolean(working || input.steer && nativeSteering),
|
|
254
|
+
admission: Promise.withResolvers(), progress: new Map() };
|
|
255
|
+
request.admission.promise.catch(() => {});
|
|
256
|
+
if (working) {
|
|
257
|
+
working.requests.set(message.messageId, request);
|
|
258
|
+
working.submit(request).catch(error => request.admission.reject(error)).finally(release);
|
|
259
|
+
return { promise: request.admission.promise };
|
|
260
|
+
}
|
|
261
|
+
turns.start(entry, request, configuration);
|
|
262
|
+
return { promise: request.admission.promise };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
send,
|
|
267
|
+
admitSend
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @typedef {ReturnType<typeof createConversationSending>} ConversationSending */
|
|
272
|
+
|
|
273
|
+
export { createConversationSending };
|