@jskit-ai/assistant-runtime 0.2.9 → 0.2.11

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 (66) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/conversation/main.js +2 -1
  4. package/fixtures/conversation/vite.config.mjs +4 -3
  5. package/migrations/assistant_config_initial.cjs +1 -5
  6. package/migrations/assistant_transcripts_initial.cjs +3 -9
  7. package/migrations/assistant_transcripts_summary.cjs +28 -0
  8. package/migrations/assistant_turn_requests.cjs +2 -1
  9. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  10. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  11. package/package.json +17 -10
  12. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  13. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  14. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  15. package/src/client/composables/conversationGoal.js +266 -0
  16. package/src/client/composables/conversationHistory.js +215 -0
  17. package/src/client/composables/conversationRuntime.js +624 -0
  18. package/src/client/composables/useAssistantConversation.js +208 -884
  19. package/src/client/composables/useAssistantRuntime.js +60 -45
  20. package/src/client/providers/AssistantClientProvider.js +1 -0
  21. package/src/client/support/assistantRuntimeState.js +6 -34
  22. package/src/client/support/conversationFailures.js +30 -0
  23. package/src/client/support/conversationRestoreSupport.js +13 -5
  24. package/src/client/support/subscribeAssistantConversation.js +32 -9
  25. package/src/client/support/workspaceScopeSupport.js +13 -22
  26. package/src/client/types.js +46 -0
  27. package/src/server/AssistantProvider.js +45 -17
  28. package/src/server/actions.js +162 -201
  29. package/src/server/conversationOperations.js +141 -0
  30. package/src/server/createAssistantRuntime.js +83 -44
  31. package/src/server/createAssistantSettingsActions.js +69 -47
  32. package/src/server/hostedAssistant.js +21 -0
  33. package/src/server/index.js +1 -0
  34. package/src/server/registerConversationSubscriptions.js +34 -11
  35. package/src/server/registerFastifyConversations.js +18 -6
  36. package/src/server/registerRoutes.js +5 -2
  37. package/src/server/registerSettingsRoutes.js +1 -1
  38. package/src/server/repositories/assistantConfigRepository.js +10 -20
  39. package/src/server/repositories/conversationsRepository.js +35 -10
  40. package/src/server/repositories/messagesRepository.js +14 -10
  41. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  42. package/src/server/repositories/turnRequestsRepository.js +6 -1
  43. package/src/server/services/assistantConfigService.js +25 -23
  44. package/src/server/services/chatService.js +57 -61
  45. package/src/server/services/transcriptService.js +15 -7
  46. package/src/server/sqlConversationStorage.js +90 -18
  47. package/src/server/support/assistantServerConfig.js +1 -1
  48. package/src/server/support/assistantStreamWriter.js +7 -1
  49. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  50. package/src/server/types.js +38 -0
  51. package/src/shared/assistantSurfaces.js +2 -2
  52. package/test/assistantConversationOperations.test.js +5 -5
  53. package/test/assistantRuntimeState.test.js +1 -16
  54. package/test/chatServiceLifecycle.test.js +62 -154
  55. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  56. package/test/featureRuntime.test.js +12 -6
  57. package/test/lazyAppConfig.test.js +3 -5
  58. package/test/packageMetadata.test.js +7 -1
  59. package/test/sqlConversationStorage.mysql.test.js +89 -0
  60. package/test/sqlConversationStorage.test.js +161 -16
  61. package/test/support/memoryAssistantRows.js +6 -1
  62. package/test/support/scriptedModel.js +32 -0
  63. package/test/turnRequests.test.js +4 -2
  64. package/test/usersForeignKeysMigration.test.js +46 -0
  65. package/test/workspacesForeignKeysMigration.test.js +66 -0
  66. package/src/client/support/accessDenied.js +0 -6
@@ -0,0 +1,624 @@
1
+ import { computed, onScopeDispose, ref, shallowRef, toValue, watch } from "vue";
2
+ import { toText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { createAssistantMessageDelivery, unmatchedOptimisticMessages } from "@jskit-ai/assistant-core/client/conversation-delivery";
4
+ import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversation-questions";
5
+ import { latestAssistantMessageAwaitingUserReply } from "@jskit-ai/assistant-core/shared/conversation";
6
+ import { failureMessage, isAccessDenied, resultError } from "../support/conversationFailures.js";
7
+ import { subscribeAssistantConversation } from "../support/subscribeAssistantConversation.js";
8
+ import { useJskitText } from "../i18n.js";
9
+ import { createConversationGoal } from "./conversationGoal.js";
10
+ import { createConversationHistory } from "./conversationHistory.js";
11
+
12
+ const { t } = useJskitText();
13
+
14
+ /**
15
+ * A maybe-ref value as trimmed text.
16
+ * @param {import("vue").MaybeRefOrGetter<unknown>} value
17
+ */
18
+ function text(value) {
19
+ return toText(toValue(value));
20
+ }
21
+
22
+ // These errors are raised before send admission by the action pipeline or
23
+ // admitSend. Provider, storage, attachment-resolution and transport errors can
24
+ // occur after dispatch and must retain an uncertain receipt.
25
+ const SEND_REJECTIONS = new Set([
26
+ "ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
27
+ "conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed",
28
+ "conversation_message_cancelled"
29
+ ]);
30
+
31
+ /**
32
+ * Whether a view's questions option configures the conversation's own question form, rather than supplying one.
33
+ * @param {unknown} value
34
+ */
35
+ function isQuestionConfiguration(value) {
36
+ return value === true || Boolean(value && typeof value === "object" &&
37
+ !["questions", "choices", "capture"].some(key => key in value));
38
+ }
39
+
40
+ /**
41
+ * The authored conversation request for a composer payload without one.
42
+ * @param {any} payload
43
+ * @param {{ attachmentIds?: string[], steer: boolean }} options
44
+ */
45
+ function authoredRequest(payload, { attachmentIds, steer }) {
46
+ return {
47
+ text: String(payload.message || ""),
48
+ ...(payload.data !== undefined ? { data: payload.data } : {}),
49
+ ...(attachmentIds?.length ? { attachmentIds } : {}),
50
+ ...(steer ? { steer: true } : {})
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The draft left once a submitted draft is accepted: text typed after it stays.
56
+ * @param {string} currentDraft
57
+ * @param {string} submittedDraft
58
+ */
59
+ function draftAfterAcceptedSubmission(currentDraft, submittedDraft) {
60
+ const current = String(currentDraft || "");
61
+ const submitted = String(submittedDraft || "");
62
+ if (current === submitted) return "";
63
+ return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
64
+ }
65
+
66
+ /**
67
+ * Whether the conversation defers or admits a message while it works, for one state.
68
+ * @param {any} snapshot
69
+ * @param {{ deferWhileWorking: boolean | ((snapshot: any) => boolean), admitWhileWorking: boolean | ((snapshot: any) => boolean) }} policy
70
+ */
71
+ function resolveWorkingPolicy(snapshot, { deferWhileWorking, admitWhileWorking }) {
72
+ const resolve = (name, option) => {
73
+ const value = typeof option === "function" ? option(snapshot) : option;
74
+ if (typeof value !== "boolean") throw new TypeError(`${name} must return a boolean.`);
75
+ return value;
76
+ };
77
+ const deferred = resolve("deferWhileWorking", deferWhileWorking);
78
+ const admitted = resolve("admitWhileWorking", admitWhileWorking);
79
+ if (deferred && admitted) throw new TypeError("Choose either admitWhileWorking or deferWhileWorking.");
80
+ return { deferWhileWorking: deferred, admitWhileWorking: admitted };
81
+ }
82
+
83
+ /** The subscription while the conversation is not listening: releasing or reloading it does nothing. */
84
+ const NO_SUBSCRIPTION = Object.freeze(Object.assign(function releaseNothing() {}, { reload() {} }));
85
+
86
+ /**
87
+ * The conversation runtime one or more views retain: its subscription, draft, delivery, history and goal.
88
+ * @param {{
89
+ * identity: import("../types.js").ConversationIdentity,
90
+ * api: import("@jskit-ai/assistant-core/client/types").AssistantApi,
91
+ * socket: import("../types.js").RealtimeSocket,
92
+ * actorKey: import("vue").ComputedRef<string | undefined>,
93
+ * placement: import("../types.js").PlacementRuntime,
94
+ * readers: Map<symbol, import("../types.js").ConversationReader>,
95
+ * retain: () => { runtime: object, release: () => void },
96
+ * queueWhileSending: boolean | undefined,
97
+ * deferWhileWorking: boolean | ((snapshot: any) => boolean),
98
+ * admitWhileWorking: boolean | ((snapshot: any) => boolean),
99
+ * draftStorage: import("vue").MaybeRefOrGetter<{ storage: Storage, key: string } | null | undefined>,
100
+ * application: ((runtime: object) => unknown) | null | undefined,
101
+ * goalReadEnabled: boolean
102
+ * }} options
103
+ */
104
+ function createConversationRuntime({ identity, api, socket, actorKey, placement, readers, retain, queueWhileSending,
105
+ deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled }) {
106
+ const disposed = ref(false);
107
+ const placementRevision = ref(0);
108
+ const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
109
+ const current = computed(() => {
110
+ placementRevision.value;
111
+ const actor = toValue(actorKey);
112
+ return !disposed.value && (actor === undefined ? String(placement.getContext()?.user?.id || "") : text(actor)) === identity.actorKey;
113
+ });
114
+ const active = computed(() => current.value && [...readers.values()].some(reader => toValue(reader.active) !== false));
115
+ const snapshot = shallowRef(null);
116
+ const draft = ref("");
117
+ const draftMessageId = ref("");
118
+ const draftAttachments = ref([]);
119
+ const draftRetry = ref(null);
120
+ const draftRetryMatches = computed(() => {
121
+ const retry = draftRetry.value;
122
+ const current = String(draft.value || "");
123
+ const submitted = String(retry?.draftSnapshot || "");
124
+ return Boolean(retry && submitted && (current === submitted || current.startsWith(submitted)));
125
+ });
126
+ let draftSubmissionSequence = 0;
127
+ const error = ref("");
128
+ const loading = ref(true);
129
+ const stopping = ref(false);
130
+ const accessDenied = ref(false);
131
+ const delivery = createAssistantMessageDelivery();
132
+ const pendingMessages = new Map();
133
+ const deliveryChecks = new Map();
134
+ const editable = computed(() => active.value && !accessDenied.value);
135
+ const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
136
+ // A deferred message keeps the subscription while its view is hidden, so it
137
+ // is admitted against live state. A failed read leaves that state stale.
138
+ const deferredWaits = ref(0);
139
+ const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
140
+ const history = createConversationHistory({ api, identity, snapshot, current, listening, available, receiveError });
141
+ const { turns } = history;
142
+ const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
143
+ const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
144
+ const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
145
+ const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
146
+ const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
147
+ (queueWhileSending !== false || !delivery.state.sending) &&
148
+ !delivery.state.messages.some(message => message.status === "uncertain" &&
149
+ (snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
150
+ const savedDraft = toValue(draftStorage);
151
+ if (savedDraft && (typeof savedDraft.key !== "string" || !savedDraft.key ||
152
+ // readable-check: allow method-probe - input validation: an application hands the binding its own draft storage
153
+ !["getItem", "setItem", "removeItem"].every(name => typeof savedDraft.storage?.[name] === "function"))) {
154
+ throw new TypeError("draftStorage requires an explicit key and storage with getItem, setItem and removeItem.");
155
+ }
156
+ // Saved composer state is restored verbatim. A read never reconstructs or
157
+ // sends an old request; the explicit retry keeps its original payload and ID.
158
+ try {
159
+ const saved = savedDraft ? JSON.parse(savedDraft.storage.getItem(savedDraft.key) || "null") : null;
160
+ draft.value = typeof saved?.draft === "string" ? saved.draft : "";
161
+ draftAttachments.value = Array.isArray(saved?.attachments)
162
+ ? saved.attachments.filter(attachment => typeof attachment?.attachmentId === "string") : [];
163
+ const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
164
+ typeof message?.id === "string" && typeof message.text === "string" &&
165
+ typeof message.payload?.message === "string") : [];
166
+ const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
167
+ const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
168
+ return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
169
+ t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
170
+ });
171
+ delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
172
+ // A cancel the server had not agreed to before this page closed is retried.
173
+ for (const message of messages) {
174
+ if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
175
+ }
176
+ } catch {
177
+ draft.value = "";
178
+ draftAttachments.value = [];
179
+ }
180
+ function persistDraft() {
181
+ if (!savedDraft || !current.value || accessDenied.value) return;
182
+ try {
183
+ const saved = { draft: draft.value,
184
+ attachments: draftAttachments.value.filter(attachment => attachment?.attachmentId),
185
+ messages: delivery.state.messages };
186
+ if (saved.draft || saved.attachments.length || saved.messages.length) {
187
+ savedDraft.storage.setItem(savedDraft.key, JSON.stringify(saved));
188
+ } else savedDraft.storage.removeItem(savedDraft.key);
189
+ } catch {
190
+ // The in-memory delivery UI remains usable if storage is full or blocked.
191
+ }
192
+ }
193
+ persistDraft();
194
+ watch([draft, draftAttachments, () => delivery.state.messages], persistDraft, { deep: true, flush: "sync" });
195
+ function settleDraftRetry(retry = draftRetry.value) {
196
+ if (!retry || retry.messageId !== draftRetry.value?.messageId) return false;
197
+ draft.value = draftAfterAcceptedSubmission(draft.value, retry.draftSnapshot);
198
+ draftRetry.value = null;
199
+ return true;
200
+ }
201
+ watch(turns, value => {
202
+ const retry = draftRetry.value;
203
+ if (retry && !unmatchedOptimisticMessages(value, [retry.optimistic], { receiptsOnly: true }).length) settleDraftRetry(retry);
204
+ });
205
+ const questionConfiguration = computed(() => {
206
+ for (const reader of readers.values()) {
207
+ const option = toValue(reader.questions);
208
+ if (toValue(reader.active) !== false && isQuestionConfiguration(option)) return option;
209
+ }
210
+ return null;
211
+ });
212
+ // The form belongs to the conversation, including while only voice retains it.
213
+ // Reader options control presentation; they do not retire its answer state.
214
+ const questions = useAssistantQuestions({
215
+ message: () => available.value ? latestAssistantMessageAwaitingUserReply(turns.value) : "",
216
+ extraChoice: () => questionConfiguration.value?.extraChoice || null
217
+ });
218
+ let subscription = NO_SUBSCRIPTION;
219
+ const goal = createConversationGoal({ api, identity, readers, goalReadEnabled, current, active, available, accessDenied,
220
+ snapshot, delivery, submitRequest, receiveError, reloadSubscription });
221
+ watch(goal.readsGoals, enabled => { if (enabled) void goal.refreshGoal(); });
222
+
223
+ function receiveState(state, { initial = false, canonical = false } = {}) {
224
+ if (!current.value) return;
225
+ const previous = snapshot.value;
226
+ history.receiveState(state, previous, { initial, canonical });
227
+ snapshot.value = state;
228
+ loading.value = false;
229
+ error.value = "";
230
+ accessDenied.value = false;
231
+ delivery.reconcile(state.turns);
232
+ if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
233
+ retryCancels();
234
+ goal.receiveState(state, previous, { initial });
235
+ }
236
+ function receiveError(failure) {
237
+ if (!current.value) return;
238
+ loading.value = false;
239
+ error.value = failureMessage(failure);
240
+ accessDenied.value = isAccessDenied(failure);
241
+ if (accessDenied.value) {
242
+ history.deny();
243
+ clearConversationView();
244
+ goal.forgetTarget();
245
+ }
246
+ }
247
+ // Shared by access denial and actor change. Access denial keeps the open goal
248
+ // command, queued goal read and question form; an actor change clears them.
249
+ function clearConversationView() {
250
+ delivery.reset();
251
+ snapshot.value = null;
252
+ history.clear();
253
+ draft.value = "";
254
+ draftMessageId.value = "";
255
+ draftAttachments.value = [];
256
+ draftRetry.value = null;
257
+ goal.clear();
258
+ }
259
+ function receiveEvent(event) {
260
+ history.receivePatch(event);
261
+ if (event.type === "goal") void goal.refreshGoal();
262
+ for (const [token, reader] of [...readers]) {
263
+ if (!current.value) return;
264
+ // readable-check: allow method-probe - optional operation: a reader retained through runtime.retain(), or a binding given no onEvent, has no presentation hook, so it is skipped
265
+ if (!readers.has(token) || toValue(reader.active) === false || typeof reader.onEvent !== "function") continue;
266
+ // Presentation hooks own their errors; one reader must not interrupt
267
+ // another reader or the subscription's canonical state and delivery.
268
+ try { void Promise.resolve(reader.onEvent(event)).catch(() => {}); }
269
+ catch {}
270
+ }
271
+ }
272
+ watch(listening, enabled => {
273
+ subscription();
274
+ subscription = NO_SUBSCRIPTION;
275
+ history.invalidate();
276
+ if (!enabled) return;
277
+ loading.value = true;
278
+ subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
279
+ targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
280
+ ...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
281
+ read: history.readLoadedHistory, onState: receiveState,
282
+ onError: receiveError, onEvent: receiveEvent
283
+ });
284
+ }, { immediate: true, flush: "sync" });
285
+ watch(current, value => {
286
+ if (value) return;
287
+ for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
288
+ clearConversationView();
289
+ goal.retire();
290
+ questions.reset();
291
+ }, { flush: "sync" });
292
+ onScopeDispose(() => {
293
+ disposed.value = true;
294
+ subscription();
295
+ stopPlacement();
296
+ for (const pending of pendingMessages.values()) pending.controller.abort();
297
+ pendingMessages.clear();
298
+ for (const check of deliveryChecks.values()) check.abort();
299
+ goal.abortRead();
300
+ delivery.reset();
301
+ });
302
+
303
+ async function send(payload, { messageId = crypto.randomUUID(), onAccepted } = {}) {
304
+ const retry = delivery.find(messageId);
305
+ if (retry?.status === "failed" && retry.payload.goalRequest) return goal.changeGoal(retry.payload.goalRequest.action, {}, { messageId });
306
+ if (!canSubmit.value) return false;
307
+ if (retry && retry.status !== "failed") return false;
308
+ if (delivery.state.messages.some(message => message.status === "uncertain" &&
309
+ text(message.payload?.message) === text((retry?.payload || payload).message))) return false;
310
+ const request = retry?.payload?.request || payload.request;
311
+ const requestedSteering = request?.steer === true;
312
+ if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
313
+ const steering = requestedSteering || steerable.value;
314
+ const policy = workingPolicy.value;
315
+ const deferred = policy.deferWhileWorking && !retry && !steering;
316
+ // An application queue can admit ordinary messages without native steering
317
+ // or waiting for ready. It still owns admission, permissions and receipts.
318
+ const appAdmission = policy.admitWhileWorking && !steering;
319
+ if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
320
+ if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
321
+ const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request ||
322
+ authoredRequest(payload, {
323
+ attachmentIds: payload.displayAttachments?.map(file => file.attachmentId),
324
+ steer: steerable.value
325
+ }) };
326
+ return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
327
+ }
328
+ function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
329
+ // readable-check: allow method-probe - input validation: an application hands a prepared submission its own preparation
330
+ if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
331
+ const retry = delivery.find(messageId);
332
+ const captured = retry?.payload || payload;
333
+ if (!captured?.request || typeof captured.request !== "object" || Array.isArray(captured.request) || captured.goalRequest) {
334
+ throw new TypeError("Prepared submission requires an authored conversation request.");
335
+ }
336
+ if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
337
+ return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
338
+ }
339
+ async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
340
+ questionText = "", onAccepted } = {}) {
341
+ if (!canSubmit.value) return false;
342
+ const retry = draftRetryMatches.value ? draftRetry.value : null;
343
+ if (!retry) draftRetry.value = null;
344
+ const draftSnapshot = retry?.draftSnapshot || draft.value;
345
+ const captured = retry?.payload?.request ? retry.payload : payload;
346
+ const kind = retry?.submissionKind || submissionKind;
347
+ const questionTextSnapshot = retry?.questionTextSnapshot || questionText;
348
+ const id = retry?.messageId || messageId;
349
+ const sequence = ++draftSubmissionSequence;
350
+ draftRetry.value = null;
351
+ draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
352
+ const result = await send(captured, { messageId: id, onAccepted });
353
+ if (!current.value) return false;
354
+ const accepted = result !== false && result?.ok !== false;
355
+ if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
356
+ draft.value = draftSnapshot;
357
+ const optimistic = delivery.find(id) || {
358
+ createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
359
+ };
360
+ draftRetry.value = { draftSnapshot, messageId: id, optimistic, payload: optimistic.payload || captured,
361
+ questionTextSnapshot, submissionKind: kind };
362
+ if (!unmatchedOptimisticMessages(turns.value, [optimistic], { receiptsOnly: true }).length) settleDraftRetry(draftRetry.value);
363
+ }
364
+ return result;
365
+ }
366
+ // A deferred message waits only while the working turn cannot take it. It
367
+ // steers as soon as that turn accepts steering, or sends once it is ready.
368
+ // Its view may be hidden. While loading or after a failed read there is no
369
+ // current state to admit it against, so the unsent message keeps waiting;
370
+ // only Stop, Cancel, retirement or lost access end it.
371
+ function waitForAdmission({ controller }) {
372
+ return new Promise(resolve => {
373
+ const check = () => {
374
+ if (controller.signal.aborted || accessDenied.value) finish(false);
375
+ else if (!admissible.value) return;
376
+ else if (snapshot.value.status === "ready") finish("send");
377
+ else if (steerable.value) finish("steer");
378
+ else if (snapshot.value.status !== "working") finish(false);
379
+ };
380
+ const stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
381
+ function finish(admission) {
382
+ stop();
383
+ controller.signal.removeEventListener("abort", check);
384
+ resolve(admission);
385
+ }
386
+ controller.signal.addEventListener("abort", check, { once: true });
387
+ check();
388
+ });
389
+ }
390
+
391
+ async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
392
+ if (pendingMessages.has(messageId)) return false;
393
+ // Register before the original serial tail so Stop/retirement also reaches
394
+ // a local follower whose deliver callback has not started yet.
395
+ const pending = { controller: new AbortController(), started: false, dispatched: false };
396
+ const { controller } = pending;
397
+ pendingMessages.set(messageId, pending);
398
+ if (deferred) deferredWaits.value += 1;
399
+ try {
400
+ const result = await delivery.send(payload, {
401
+ // A goal command settles on the server's goal answer, not on its
402
+ // transcript turn, which can arrive while its goal is still changing.
403
+ messageId, queue, isCurrent: () => current.value, onAccepted,
404
+ receiptTurns: payload.goalRequest ? null : turns, uncertainOnError: true,
405
+ async deliver(submission) {
406
+ pending.started = true;
407
+ let admission = null;
408
+ try {
409
+ if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
410
+ if (deferred) {
411
+ // Refresh through the same subscription after a queued predecessor;
412
+ // its receipt may have arrived before the working snapshot.
413
+ await reloadSubscription();
414
+ if (snapshot.value?.status === "working" && !steerable.value) {
415
+ delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
416
+ }
417
+ admission = await waitForAdmission(pending);
418
+ delivery.wait(messageId, "");
419
+ if (!admission || controller.signal.aborted || !admissible.value ||
420
+ snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
421
+ if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
422
+ return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
423
+ }
424
+ }
425
+ let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
426
+ if (admission === "steer") {
427
+ request = { ...request, steer: true };
428
+ const retained = delivery.find(messageId);
429
+ if (retained) retained.payload.request = request;
430
+ }
431
+ if (prepare) {
432
+ if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
433
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
434
+ throw new TypeError("Application preparation must return its conversation request or false.");
435
+ }
436
+ }
437
+ pending.dispatched = true;
438
+ for (let attempt = 0; ; attempt += 1) {
439
+ try {
440
+ return await (submission.goalRequest
441
+ ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
442
+ : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
443
+ } catch (failure) {
444
+ // The server identifies this exact pre-admission race. Keep the
445
+ // same queued words/attachments/identity and try ordinary Send
446
+ // once. A timeout, generic rejection or uncertain send never
447
+ // grants another dispatch.
448
+ if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
449
+ failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
450
+ failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
451
+ failure.details.delivery.messageId !== messageId)) throw failure;
452
+ const retained = delivery.find(messageId);
453
+ if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
454
+ if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
455
+ const { steer: _finishedTurn, ...nextRequest } = request;
456
+ request = nextRequest;
457
+ // Retain the new dispatch intent if Send itself fails; Retry
458
+ // must not loop back through the obsolete steering request.
459
+ retained.payload.request = nextRequest;
460
+ retained.payload.submissionKind = "send";
461
+ }
462
+ }
463
+ }
464
+ catch (failure) {
465
+ if (controller.signal.aborted) return false;
466
+ if (prepare && !pending.dispatched) return { ok: false, error: failureMessage(failure),
467
+ ...(failure.code ? { code: failure.code } : {}) };
468
+ const rejection = failure.details?.delivery;
469
+ const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
470
+ rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
471
+ // A definite rejection keeps its code so the application can offer
472
+ // the matching recovery, such as freeing assistant context.
473
+ if (notSent || SEND_REJECTIONS.has(failure.code) || failure.code === "conversation_steer_turn_finished" && failure.status === 409 && !failure.details?.delivery) return { ok: false, error: failureMessage(failure),
474
+ ...(failure.code ? { code: failure.code } : {}) };
475
+ throw failure;
476
+ } finally {
477
+ if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
478
+ }
479
+ }
480
+ });
481
+ return result;
482
+ } catch (failure) {
483
+ // Delivery keeps its exact authored payload until an explicit receipt check.
484
+ if (prepare) return { ok: false, status: "uncertain", error: failureMessage(failure),
485
+ ...(failure.code ? { code: failure.code } : {}) };
486
+ return false;
487
+ } finally {
488
+ if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
489
+ if (deferred) deferredWaits.value -= 1;
490
+ if (current.value) reloadSubscription();
491
+ }
492
+ }
493
+ // Cancel means cancel, and the server decides. Cancel ends the message's
494
+ // unsent request and running delivery check, then asks the server, which
495
+ // always records the cancel. The message stays, shown as cancelling, until
496
+ // the server agrees; when the server cannot be reached it stays with Cancel
497
+ // again, and the next snapshot after reconnecting retries it.
498
+ async function cancelMessage(messageId) {
499
+ pendingMessages.get(messageId)?.controller.abort();
500
+ deliveryChecks.get(messageId)?.abort();
501
+ if (!available.value || !delivery.cancel(messageId)) return false;
502
+ try {
503
+ const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
504
+ if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
505
+ if (!current.value) return false;
506
+ delivery.remove(messageId);
507
+ reloadSubscription();
508
+ return true;
509
+ } catch (failure) {
510
+ // The server's own refusal names itself; anything else did not reach it.
511
+ if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
512
+ return false;
513
+ }
514
+ }
515
+ function retryCancels() {
516
+ for (const message of delivery.state.messages) {
517
+ if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
518
+ }
519
+ }
520
+
521
+ async function cancel() {
522
+ if (!editable.value || stopping.value) return false;
523
+ stopping.value = true;
524
+ for (const pending of pendingMessages.values()) {
525
+ if (!pending.dispatched) pending.controller.abort();
526
+ }
527
+ try { return await api.cancelConversation(identity.conversationId); }
528
+ catch (failure) { receiveError(failure); return false; }
529
+ finally { stopping.value = false; if (current.value) reloadSubscription(); }
530
+ }
531
+ async function inspectDelivery(messageId) {
532
+ if (!available.value) return false;
533
+ const message = delivery.find(messageId);
534
+ if (message?.checking) return false;
535
+ const check = new AbortController();
536
+ deliveryChecks.set(messageId, check);
537
+ if (message) message.checking = true;
538
+ try {
539
+ const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
540
+ if (!current.value || check.signal.aborted) return false;
541
+ if (receipt.status === "accepted") {
542
+ delivery.accept(messageId);
543
+ if (receipt.messageId === messageId && message?.payload.goalRequest) goal.clearCommandError();
544
+ }
545
+ // The server recorded this message as cancelled, perhaps from another tab.
546
+ else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
547
+ else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
548
+ else if (receipt.status === "unknown" && message?.status === "uncertain") {
549
+ message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
550
+ }
551
+ reloadSubscription();
552
+ return receipt;
553
+ } catch (failure) {
554
+ if (check.signal.aborted) return false;
555
+ if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
556
+ receiveError(failure);
557
+ return false;
558
+ }
559
+ finally {
560
+ if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
561
+ if (message) message.checking = false;
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Reloads the conversation through its subscription; a reload while not listening does nothing.
567
+ * @param {{ resubscribe?: boolean }} [options]
568
+ */
569
+ function reloadSubscription(options) {
570
+ return subscription.reload(options);
571
+ }
572
+
573
+ const runtime = {
574
+ identity,
575
+ current,
576
+ active,
577
+ editable,
578
+ available,
579
+ canSubmit,
580
+ steerable,
581
+ queueWhileSending: queueing,
582
+ snapshot,
583
+ draft,
584
+ draftMessageId,
585
+ draftAttachments,
586
+ draftRetry,
587
+ draftRetryMatches,
588
+ settleDraftRetry,
589
+ error,
590
+ accessDenied,
591
+ loading,
592
+ stopping,
593
+ delivery,
594
+ turns,
595
+ hasMoreBefore: history.hasMoreBefore,
596
+ loadingMore: history.loadingMore,
597
+ loadMoreError: history.loadMoreError,
598
+ loadMore: history.loadMore,
599
+ send,
600
+ submitPrepared,
601
+ submitDraft,
602
+ cancel,
603
+ cancelMessage,
604
+ inspectDelivery,
605
+ changeGoal: goal.changeGoal,
606
+ refreshGoal: goal.refreshGoal,
607
+ goalState: goal.goalState,
608
+ goalView: goal.goalView,
609
+ goalLoadError: goal.goalLoadError,
610
+ questions,
611
+ retain,
612
+ reload(options) {
613
+ const job = reloadSubscription(options);
614
+ void goal.refreshGoal();
615
+ return job;
616
+ },
617
+ application: /** @type {unknown} */ (null)
618
+ };
619
+ // readable-check: allow fixed-result-shape - cycle: the application extension requires the conversation runtime it extends
620
+ if (application) runtime.application = application(runtime);
621
+ return runtime;
622
+ }
623
+
624
+ export { authoredRequest, createConversationRuntime, isQuestionConfiguration, resolveWorkingPolicy, text };