@jskit-ai/assistant-runtime 0.2.9 → 0.2.10

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
@@ -1,883 +1,166 @@
1
1
  import { computed, effectScope, getCurrentInstance, getCurrentScope, inject, onScopeDispose, proxyRefs, ref, shallowRef, toValue, watch } from "vue";
2
2
  import { getClientAppConfig } from "@jskit-ai/kernel/client";
3
- import { createAssistantMessageDelivery, retainAssistantConversation, unmatchedOptimisticMessages } from "@jskit-ai/assistant-core/client/conversation-delivery";
3
+ import { retainAssistantConversation } from "@jskit-ai/assistant-core/client/conversation-delivery";
4
4
  import { createAssistantTextSubmission } from "@jskit-ai/assistant-core/client/conversation";
5
- import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversation-questions";
6
5
  import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
7
- import {
8
- conversationTurnMessages, conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages, mergeConversationStream,
9
- normalizeConversationLogPage, normalizeConversationLogPagination
10
- } from "@jskit-ai/assistant-core/shared/conversation";
6
+ import { conversationTurnsFromMessages } from "@jskit-ai/assistant-core/shared/conversation";
11
7
  import { useRealtimeSocket } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
12
8
  import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
13
9
  import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
14
10
  import { resolveAssistantRuntimeRoute, resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
15
11
  import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
16
- import { isAccessDenied } from "../support/accessDenied.js";
17
- import { subscribeAssistantConversation, refreshedHistoryPages, patchLoadedHistoryPages } from "../support/subscribeAssistantConversation.js";
12
+ import { failureMessage } from "../support/conversationFailures.js";
18
13
  import { useJskitText } from "../i18n.js";
14
+ import { authoredRequest, createConversationRuntime, isQuestionConfiguration, resolveWorkingPolicy, text } from "./conversationRuntime.js";
19
15
 
20
- const text = value => String(toValue(value) ?? "").trim();
21
- const { serverMessage, t } = useJskitText();
22
- // A failure in the active language when its code or messageCode is JSKIT's English;
23
- // otherwise its message as sent. A result's error text is read the same way.
24
- const failureMessage = (failure, fallback = "") => failure?.message ? serverMessage(failure) : fallback;
25
- const resultError = result => failureMessage({ ...result, message: result?.error });
26
- // A conversation snapshot's error names its runtime sentence with errorMessageCode.
16
+ /** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantApi} AssistantApi */
17
+ /** @typedef {import("vue").MaybeRefOrGetter<string>} TextOption */
18
+
19
+ /**
20
+ * The application defaults `configureAssistantConversations` provides to every binding.
21
+ * @typedef {object} ConversationDefaults
22
+ * @property {import("vue").MaybeRefOrGetter<string | undefined>} actorKey
23
+ * @property {AssistantApi | null | undefined} api
24
+ * @property {(path: string, options: object) => Promise<any>} request
25
+ * @property {"dispatch" | "accepted"} clearDraftOn
26
+ */
27
+
28
+ /**
29
+ * An application command whose assistant returns one final result: the bounded task mode of a binding.
30
+ * @typedef {object} BoundedTask
31
+ * @property {{ run: (request: { path: string, payload: object }) => Promise<any>, isRunning: import("vue").MaybeRefOrGetter<boolean> }} command
32
+ * @property {TextOption} endpoint
33
+ * @property {import("vue").MaybeRefOrGetter<unknown>} scope The application scope a result belongs to.
34
+ * @property {(messages: object[]) => object} input
35
+ * @property {(response: any) => object} result
36
+ * @property {(response: any, submitted: { message: object, scope: unknown }) => void} [onResult]
37
+ */
38
+
39
+ /**
40
+ * What `useAssistantConversation` and `useAssistantConversationFactory` accept; the assistant conversation guide
41
+ * describes each option.
42
+ * @typedef {object} ConversationBindingOptions
43
+ * @property {TextOption} [conversationId]
44
+ * @property {TextOption} [endpoint]
45
+ * @property {TextOption} [surfaceId]
46
+ * @property {TextOption} [hostSurfaceId]
47
+ * @property {import("vue").MaybeRefOrGetter<string | undefined>} [workspaceSlug]
48
+ * @property {import("vue").MaybeRefOrGetter<string | undefined>} [actorKey]
49
+ * @property {AssistantApi | null} [api]
50
+ * @property {import("../types.js").RealtimeSocket} [socket]
51
+ * @property {import("vue").MaybeRefOrGetter<boolean>} [active]
52
+ * @property {(event: object) => unknown} [onEvent]
53
+ * @property {"dispatch" | "accepted"} [clearDraftOn]
54
+ * @property {boolean} [queueWhileSending]
55
+ * @property {boolean | ((snapshot: any) => boolean)} [deferWhileWorking]
56
+ * @property {boolean | ((snapshot: any) => boolean)} [admitWhileWorking]
57
+ * @property {boolean} [draftWhileLoading]
58
+ * @property {import("vue").MaybeRefOrGetter<{ storage: Storage, key: string } | null | undefined>} [draftStorage]
59
+ * @property {((runtime: import("../types.js").ConversationRuntime) => unknown) | null} [application]
60
+ * @property {BoundedTask | null} [boundedTask]
61
+ * @property {import("vue").MaybeRefOrGetter<unknown>} [data]
62
+ * @property {import("vue").MaybeRefOrGetter<import("@jskit-ai/assistant-core/client/types").AssistantAttachments | null | undefined>} [attachments]
63
+ * @property {import("vue").MaybeRefOrGetter<import("@jskit-ai/assistant-core/client/types").AssistantSuggestions | null | undefined>} [suggestions]
64
+ * @property {import("vue").MaybeRefOrGetter<import("@jskit-ai/assistant-core/client/types").AssistantModelChoices | null | undefined>} [models]
65
+ * @property {import("vue").MaybeRefOrGetter<boolean | object | null | undefined>} [questions]
66
+ * @property {import("vue").MaybeRefOrGetter<boolean | object | null | undefined>} [goal]
67
+ * @property {import("vue").MaybeRefOrGetter<Record<string, any> | undefined>} [presentation]
68
+ */
69
+
70
+ /**
71
+ * What a binding takes from the application's setup. A bounded task takes only the application.
72
+ * @typedef {object} ConversationBindingSetup
73
+ * @property {import("vue").App} app
74
+ * @property {ConversationDefaults} defaults
75
+ * @property {import("../types.js").SurfaceRouteContext} [routeContext]
76
+ * @property {import("../types.js").PlacementRuntime} [placement]
77
+ * @property {import("../types.js").WorkspaceScopeSupport} [workspaceScope]
78
+ * @property {import("../types.js").RealtimeSocket} [socket]
79
+ * @property {Record<string, any>} [config]
80
+ */
81
+
82
+ const { t } = useJskitText();
83
+ /**
84
+ * A conversation snapshot's error, which names its runtime sentence with errorMessageCode.
85
+ * @param {any} state
86
+ */
27
87
  const stateError = state => failureMessage({ message: state?.error,
28
88
  messageCode: state?.errorMessageCode, messageParams: state?.errorMessageParams });
29
89
  // Keep the injection key stable across optimized package and raw Vue imports.
30
90
  const conversationDefaultsKey = Symbol.for("jskit.assistant-runtime.conversation.defaults");
31
- const compareTurnIds = (left, right) => String(left).localeCompare(String(right), undefined, { numeric: true });
32
91
 
33
- /** Configure this Vue application's existing conversation binding before mounting. */
34
- function configureAssistantConversations(app, { actorKey, api = null, request, clearDraftOn = "dispatch" } = {}) {
92
+ /**
93
+ * An application that configures nothing: the placement user, the standard API, and Send clears the draft.
94
+ * @type {ConversationDefaults}
95
+ */
96
+ const NO_CONVERSATION_DEFAULTS = Object.freeze({
97
+ actorKey: undefined,
98
+ api: undefined,
99
+ request: assistantHttpClient.request,
100
+ clearDraftOn: "dispatch"
101
+ });
102
+
103
+ /** A submission with no further lifetime guard of its own. */
104
+ function alwaysCurrent() {
105
+ return true;
106
+ }
107
+ /** A submission that shows no pending state of its own. */
108
+ function ignoreSubmit() {}
109
+ /** A submission whose acceptance needs no further cleanup. */
110
+ function ignoreAcceptance() {}
111
+ /** A bounded task result the application does not present itself. */
112
+ function ignoreResult() {}
113
+
114
+ /**
115
+ * Sets this Vue application's conversation binding defaults before mounting.
116
+ * @param {import("vue").App} app
117
+ * @param {{ actorKey?: import("vue").MaybeRefOrGetter<string | undefined>, api?: AssistantApi,
118
+ * request?: (path: string, options: object) => Promise<any>, clearDraftOn?: "dispatch" | "accepted" }} [defaults]
119
+ */
120
+ function configureAssistantConversations(app, { actorKey, api, request = assistantHttpClient.request, clearDraftOn = "dispatch" } = {}) {
121
+ // readable-check: allow method-probe - input validation: an application hands configureAssistantConversations its Vue application
35
122
  if (typeof app?.provide !== "function") throw new TypeError("Conversation defaults require a Vue application.");
36
- if (request !== undefined && typeof request !== "function") throw new TypeError("Conversation request must be a function.");
123
+ // readable-check: allow method-probe - input validation: an application may hand configureAssistantConversations its own request
124
+ if (typeof request !== "function") throw new TypeError("Conversation request must be a function.");
37
125
  if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
38
126
  app.provide(conversationDefaultsKey, Object.freeze({ actorKey, api, request, clearDraftOn }));
39
127
  }
40
- // These errors are raised before send admission by the action pipeline or
41
- // admitSend. Provider, storage, attachment-resolution and transport errors can
42
- // occur after dispatch and must retain an uncertain receipt.
43
- const SEND_REJECTIONS = new Set([
44
- "ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
45
- "conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed",
46
- "conversation_message_cancelled"
47
- ]);
48
-
49
- function isQuestionConfiguration(value) {
50
- return value === true || Boolean(value && typeof value === "object" &&
51
- !["questions", "choices", "capture"].some(key => key in value));
52
- }
53
128
 
54
129
  function questionsAnswered(prompts) {
55
130
  return prompts.questions.every(question => String(prompts.answers[question.name] || "").trim());
56
131
  }
57
132
 
58
- /** The authored conversation request for a composer payload without one. */
59
- function authoredRequest(payload, { attachmentIds, steer }) {
60
- return {
61
- text: String(payload.message || ""),
62
- ...(payload.data !== undefined ? { data: payload.data } : {}),
63
- ...(attachmentIds?.length ? { attachmentIds } : {}),
64
- ...(steer ? { steer: true } : {})
65
- };
66
- }
67
-
68
- function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
69
- const current = String(currentDraft || "");
70
- const submitted = String(submittedDraft || "");
71
- if (current === submitted) return "";
72
- return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
133
+ /**
134
+ * Whether a question owner formats the draft as its answers.
135
+ * @param {any} prompts The conversation's own question form, an application's question owner, or none.
136
+ */
137
+ function formatsAnswers(prompts) {
138
+ // readable-check: allow method-probe - optional operation: an application's own question owner may have no capture, and then the draft is sent as typed
139
+ return typeof prompts?.capture === "function";
73
140
  }
74
141
 
75
- function resolveWorkingPolicy(snapshot, { deferWhileWorking, admitWhileWorking }) {
76
- const resolve = (name, option) => {
77
- const value = typeof option === "function" ? option(snapshot) : option;
78
- if (typeof value !== "boolean") throw new TypeError(`${name} must return a boolean.`);
79
- return value;
80
- };
81
- const deferred = resolve("deferWhileWorking", deferWhileWorking);
82
- const admitted = resolve("admitWhileWorking", admitWhileWorking);
83
- if (deferred && admitted) throw new TypeError("Choose either admitWhileWorking or deferWhileWorking.");
84
- return { deferWhileWorking: deferred, admitWhileWorking: admitted };
85
- }
86
-
87
- function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled }) {
88
- const disposed = ref(false);
89
- const placementRevision = ref(0);
90
- const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
91
- const current = computed(() => {
92
- placementRevision.value;
93
- const actor = toValue(actorKey);
94
- return !disposed.value && (actor === undefined ? String(placement.getContext()?.user?.id || "") : text(actor)) === identity.actorKey;
95
- });
96
- const active = computed(() => current.value && [...readers.values()].some(reader => toValue(reader.active) !== false));
97
- const snapshot = shallowRef(null);
98
- const draft = ref("");
99
- const draftMessageId = ref("");
100
- const draftAttachments = ref([]);
101
- const draftRetry = ref(null);
102
- const draftRetryMatches = computed(() => {
103
- const retry = draftRetry.value;
104
- const current = String(draft.value || "");
105
- const submitted = String(retry?.draftSnapshot || "");
106
- return Boolean(retry && submitted && (current === submitted || current.startsWith(submitted)));
107
- });
108
- let draftSubmissionSequence = 0;
109
- const error = ref("");
110
- const loading = ref(true);
111
- const stopping = ref(false);
112
- const goalAction = ref("");
113
- // An accepted Set or Resume waiting for its goal keeps Pause and Cancel usable.
114
- const goalSettling = ref(false);
115
- const goalTargetCurrent = ref(false);
116
- const goalError = ref("");
117
- const goalView = shallowRef(null);
118
- const goalLoadError = ref("");
119
- const accessDenied = ref(false);
120
- const delivery = createAssistantMessageDelivery();
121
- const pendingMessages = new Map();
122
- const deliveryChecks = new Map();
123
- const olderPages = ref([]);
124
- let historyRevision = 0;
125
- let deniedHistoryRevision = 0;
126
- const loadingMore = ref(false);
127
- const loadMoreError = ref("");
128
- const turns = computed(() => {
129
- const saved = mergeConversationLogPages([
130
- ...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
131
- ]).conversationLog;
132
- // Use the original overlay against the full loaded saved span. A synthetic
133
- // live older turn must not replace its authored request or saved progress.
134
- const messages = (snapshot.value?.turns || []).flatMap(conversationTurnMessages);
135
- return mergeConversationStream(saved, { messages });
136
- });
137
- const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
138
- const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
139
- const editable = computed(() => active.value && !accessDenied.value);
140
- const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
141
- // A deferred message keeps the subscription while its view is hidden, so it
142
- // is admitted against live state. A failed read leaves that state stale.
143
- const deferredWaits = ref(0);
144
- const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
145
- const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
146
- const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
147
- const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
148
- const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
149
- const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
150
- (queueWhileSending !== false || !delivery.state.sending) &&
151
- !delivery.state.messages.some(message => message.status === "uncertain" &&
152
- (snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
153
- const savedDraft = toValue(draftStorage);
154
- if (savedDraft && (typeof savedDraft.key !== "string" || !savedDraft.key ||
155
- !["getItem", "setItem", "removeItem"].every(name => typeof savedDraft.storage?.[name] === "function"))) {
156
- throw new TypeError("draftStorage requires an explicit key and storage with getItem, setItem and removeItem.");
157
- }
158
- // Original composer state is restored verbatim. A read never reconstructs or
159
- // sends an old request; the explicit retry keeps its original payload and ID.
160
- try {
161
- const saved = savedDraft ? JSON.parse(savedDraft.storage.getItem(savedDraft.key) || "null") : null;
162
- draft.value = typeof saved?.draft === "string" ? saved.draft : "";
163
- draftAttachments.value = Array.isArray(saved?.attachments)
164
- ? saved.attachments.filter(attachment => typeof attachment?.attachmentId === "string") : [];
165
- const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
166
- typeof message?.id === "string" && typeof message.text === "string" &&
167
- typeof message.payload?.message === "string") : [];
168
- const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
169
- const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
170
- return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
171
- t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
172
- });
173
- delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
174
- // A cancel the server had not agreed to before this page closed is retried.
175
- for (const message of messages) {
176
- if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
177
- }
178
- } catch {
179
- draft.value = "";
180
- draftAttachments.value = [];
181
- }
182
- function persistDraft() {
183
- if (!savedDraft || !current.value || accessDenied.value) return;
184
- try {
185
- const saved = { draft: draft.value,
186
- attachments: draftAttachments.value.filter(attachment => attachment?.attachmentId),
187
- messages: delivery.state.messages };
188
- if (saved.draft || saved.attachments.length || saved.messages.length) {
189
- savedDraft.storage.setItem(savedDraft.key, JSON.stringify(saved));
190
- } else savedDraft.storage.removeItem(savedDraft.key);
191
- } catch {
192
- // The in-memory delivery UI remains usable if storage is full or blocked.
193
- }
194
- }
195
- persistDraft();
196
- watch([draft, draftAttachments, () => delivery.state.messages], persistDraft, { deep: true, flush: "sync" });
197
- function settleDraftRetry(retry = draftRetry.value) {
198
- if (!retry || retry.messageId !== draftRetry.value?.messageId) return false;
199
- draft.value = draftAfterAcceptedSubmission(draft.value, retry.draftSnapshot);
200
- draftRetry.value = null;
201
- return true;
202
- }
203
- watch(turns, value => {
204
- const retry = draftRetry.value;
205
- if (retry && !unmatchedOptimisticMessages(value, [retry.optimistic], { receiptsOnly: true }).length) settleDraftRetry(retry);
206
- });
207
- const questionConfiguration = computed(() => {
208
- for (const reader of readers.values()) {
209
- const option = toValue(reader.questions);
210
- if (toValue(reader.active) !== false && isQuestionConfiguration(option)) return option;
211
- }
212
- return null;
213
- });
214
- // The form belongs to the conversation, including while only voice retains it.
215
- // Reader options control presentation; they do not retire its answer state.
216
- const questions = useAssistantQuestions({
217
- message: () => available.value ? latestAssistantMessageAwaitingUserReply(turns.value) : "",
218
- extraChoice: () => questionConfiguration.value?.extraChoice || null
219
- });
220
- let subscription;
221
- let goalRead = null;
222
- let goalSegment = null;
223
- let goalReadController = null;
224
- let goalReadQueued = false;
225
- let goalCommandSequence = 0;
226
- const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
227
- [...readers.values()].some(reader => toValue(reader.goal) === true));
228
- watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
229
-
230
- function receiveState(state, { initial = false, canonical = false } = {}) {
231
- if (!current.value) return;
232
- const previous = snapshot.value;
233
- const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
234
- previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
235
- JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
236
- if (canonical) {
237
- historyRevision += 1;
238
- olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
239
- loadMoreError.value = "";
240
- } else if (olderPages.value.length) {
241
- // Transcript patches may evict the head of the bounded live page before
242
- // its canonical read completes. Retain saved rows, never display overlays.
243
- const first = state.conversationLog?.[0]?.turnId;
244
- const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
245
- if (evicted.length) {
246
- historyRevision += 1;
247
- olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
248
- }
249
- }
250
- snapshot.value = state;
251
- loading.value = false;
252
- error.value = "";
253
- accessDenied.value = false;
254
- delivery.reconcile(state.turns);
255
- if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
256
- retryCancels();
257
- if (state.segmentId !== goalSegment) {
258
- // Keep the open goal menu while its first native identity arrives. The
259
- // pending read disables commands until it supplies the new exact target.
260
- goalTargetCurrent.value = false;
261
- goalLoadError.value = "";
262
- goalReadController?.abort();
263
- }
264
- if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
265
- goalSegment = state.segmentId;
266
- }
267
- function receiveError(failure) {
268
- if (!current.value) return;
269
- loading.value = false;
270
- error.value = failureMessage(failure);
271
- accessDenied.value = isAccessDenied(failure);
272
- if (accessDenied.value) {
273
- deniedHistoryRevision += 1;
274
- clearConversationView();
275
- goalTargetCurrent.value = false;
276
- }
277
- }
278
- // Shared by access denial and actor change. Access denial keeps the open goal
279
- // command, queued goal read and question form; an actor change clears them.
280
- function clearConversationView() {
281
- delivery.reset();
282
- snapshot.value = null;
283
- historyRevision += 1;
284
- olderPages.value = [];
285
- draft.value = "";
286
- draftMessageId.value = "";
287
- draftAttachments.value = [];
288
- draftRetry.value = null;
289
- goalError.value = "";
290
- goalView.value = null;
291
- goalLoadError.value = "";
292
- goalReadController?.abort();
293
- }
294
- function receiveEvent(event) {
295
- if (event.type === "transcript" && olderPages.value.some(page =>
296
- page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
297
- historyRevision += 1;
298
- olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
299
- }
300
- if (event.type === "goal") void refreshGoal();
301
- for (const [token, reader] of [...readers]) {
302
- if (!current.value) return;
303
- if (!readers.has(token) || toValue(reader.active) === false || typeof reader.onEvent !== "function") continue;
304
- // Presentation hooks own their errors; one reader must not interrupt
305
- // another reader or the subscription's canonical state and delivery.
306
- try { void Promise.resolve(reader.onEvent(event)).catch(() => {}); }
307
- catch {}
308
- }
309
- }
310
- async function readLoadedHistory({ current: subscriptionCurrent }) {
311
- const denial = deniedHistoryRevision;
312
- const valid = () => {
313
- if (denial !== deniedHistoryRevision) {
314
- throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
315
- }
316
- return current.value && listening.value && subscriptionCurrent();
317
- };
318
- // A concurrent Load older operation extends the desired span. Retry one
319
- // refresh in this same read, rather than dropping that newly loaded page.
320
- while (valid()) {
321
- const revision = historyRevision;
322
- const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
323
- const oldest = oldestTurn?.turnId;
324
- const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
325
- const latest = await api.readConversation(identity.conversationId);
326
- if (!valid()) return latest;
327
- const pages = [];
328
- let page = normalizeConversationLogPage(latest);
329
- const cursors = new Set();
330
- while (oldest && page.pagination.hasMoreBefore) {
331
- const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
332
- const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
333
- const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
334
- if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
335
- if (!cursor || cursors.has(cursor)) {
336
- throw Object.assign(new Error("Older conversation history could not be refreshed. Reload chat to try again."), {
337
- messageCode: "assistant.runtime.history.refreshFailed"
338
- });
339
- }
340
- cursors.add(cursor);
341
- page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
342
- beforeTurnId: cursor, limit: latest.pagination.limit
343
- }));
344
- if (!valid()) return latest;
345
- // A removed oldest row can make this page reach farther back than the
346
- // reader loaded. Keep the original visible boundary and page metadata.
347
- const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
348
- if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
349
- const roles = ["system", "user", "assistant"];
350
- const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
351
- return { ...turn, paginationPartial: true,
352
- ...Object.fromEntries(excluded.map(role => [role, null])),
353
- messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
354
- });
355
- pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
356
- ...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
357
- hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
358
- } }));
359
- }
360
- if (revision !== historyRevision) continue;
361
- return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
362
- }
363
- return null;
364
- }
365
- watch(listening, enabled => {
366
- subscription?.();
367
- subscription = null;
368
- historyRevision += 1;
369
- if (!enabled) return;
370
- loading.value = true;
371
- subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
372
- targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
373
- ...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
374
- read: readLoadedHistory, onState: receiveState,
375
- onError: receiveError, onEvent: receiveEvent
376
- });
377
- }, { immediate: true, flush: "sync" });
378
- watch(current, value => {
379
- if (value) return;
380
- for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
381
- clearConversationView();
382
- goalAction.value = "";
383
- goalSettling.value = false;
384
- goalReadQueued = false;
385
- questions.reset();
386
- }, { flush: "sync" });
387
- onScopeDispose(() => {
388
- disposed.value = true;
389
- subscription?.();
390
- stopPlacement();
391
- for (const pending of pendingMessages.values()) pending.controller.abort();
392
- pendingMessages.clear();
393
- for (const check of deliveryChecks.values()) check.abort();
394
- goalReadController?.abort();
395
- delivery.reset();
396
- });
397
-
398
- async function send(payload, { messageId = crypto.randomUUID(), onAccepted } = {}) {
399
- const retry = delivery.find(messageId);
400
- if (retry?.status === "failed" && retry.payload.goalRequest) return changeGoal(retry.payload.goalRequest.action, {}, { messageId });
401
- if (!canSubmit.value) return false;
402
- if (retry && retry.status !== "failed") return false;
403
- if (delivery.state.messages.some(message => message.status === "uncertain" &&
404
- text(message.payload?.message) === text((retry?.payload || payload).message))) return false;
405
- const request = retry?.payload?.request || payload.request;
406
- const requestedSteering = request?.steer === true;
407
- if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
408
- const steering = requestedSteering || steerable.value;
409
- const policy = workingPolicy.value;
410
- const deferred = policy.deferWhileWorking && !retry && !steering;
411
- // An application queue can admit ordinary messages without native steering
412
- // or waiting for ready. It still owns admission, permissions and receipts.
413
- const appAdmission = policy.admitWhileWorking && !steering;
414
- if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
415
- if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
416
- const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request ||
417
- authoredRequest(payload, {
418
- attachmentIds: payload.displayAttachments?.map(file => file.attachmentId),
419
- steer: steerable.value
420
- }) };
421
- return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
422
- }
423
- function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
424
- if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
425
- const retry = delivery.find(messageId);
426
- const captured = retry?.payload || payload;
427
- if (!captured?.request || typeof captured.request !== "object" || Array.isArray(captured.request) || captured.goalRequest) {
428
- throw new TypeError("Prepared submission requires an authored conversation request.");
429
- }
430
- if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
431
- return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
432
- }
433
- async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
434
- questionText = "", onAccepted } = {}) {
435
- if (!canSubmit.value) return false;
436
- const retry = draftRetryMatches.value ? draftRetry.value : null;
437
- if (!retry) draftRetry.value = null;
438
- const draftSnapshot = retry?.draftSnapshot || draft.value;
439
- const captured = retry?.payload?.request ? retry.payload : payload;
440
- const kind = retry?.submissionKind || submissionKind;
441
- const questionTextSnapshot = retry?.questionTextSnapshot || questionText;
442
- const id = retry?.messageId || messageId;
443
- const sequence = ++draftSubmissionSequence;
444
- draftRetry.value = null;
445
- draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
446
- const result = await send(captured, { messageId: id, onAccepted });
447
- if (!current.value) return false;
448
- const accepted = result !== false && result?.ok !== false;
449
- if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
450
- draft.value = draftSnapshot;
451
- const optimistic = delivery.find(id) || {
452
- createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
453
- };
454
- draftRetry.value = { draftSnapshot, messageId: id, optimistic, payload: optimistic.payload || captured,
455
- questionTextSnapshot, submissionKind: kind };
456
- if (!unmatchedOptimisticMessages(turns.value, [optimistic], { receiptsOnly: true }).length) settleDraftRetry(draftRetry.value);
457
- }
458
- return result;
459
- }
460
- // A deferred message waits only while the working turn cannot take it. It
461
- // steers as soon as that turn accepts steering, or sends once it is ready.
462
- // Its view may be hidden. While loading or after a failed read there is no
463
- // current state to admit it against, so the unsent message keeps waiting;
464
- // only Stop, Cancel, retirement or lost access end it.
465
- function waitForAdmission({ controller }) {
466
- return new Promise(resolve => {
467
- let stop;
468
- const finish = admission => {
469
- stop?.();
470
- controller.signal.removeEventListener("abort", check);
471
- resolve(admission);
472
- };
473
- const check = () => {
474
- if (controller.signal.aborted || accessDenied.value) finish(false);
475
- else if (!admissible.value) return;
476
- else if (snapshot.value.status === "ready") finish("send");
477
- else if (steerable.value) finish("steer");
478
- else if (snapshot.value.status !== "working") finish(false);
479
- };
480
- stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
481
- controller.signal.addEventListener("abort", check, { once: true });
482
- check();
483
- });
484
- }
485
-
486
- async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
487
- if (pendingMessages.has(messageId)) return false;
488
- // Register before the original serial tail so Stop/retirement also reaches
489
- // a local follower whose deliver callback has not started yet.
490
- const pending = { controller: new AbortController(), started: false, dispatched: false };
491
- const { controller } = pending;
492
- pendingMessages.set(messageId, pending);
493
- if (deferred) deferredWaits.value += 1;
494
- try {
495
- const result = await delivery.send(payload, {
496
- // A goal command settles on the server's goal answer, not on its
497
- // transcript turn, which can arrive while its goal is still changing.
498
- messageId, queue, isCurrent: () => current.value, onAccepted,
499
- receiptTurns: payload.goalRequest ? null : turns, uncertainOnError: true,
500
- async deliver(submission) {
501
- pending.started = true;
502
- let admission = null;
503
- try {
504
- if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
505
- if (deferred) {
506
- // Refresh through the same subscription after a queued predecessor;
507
- // its receipt may have arrived before the working snapshot.
508
- await subscription?.reload();
509
- if (snapshot.value?.status === "working" && !steerable.value) {
510
- delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
511
- }
512
- admission = await waitForAdmission(pending);
513
- delivery.wait(messageId, "");
514
- if (!admission || controller.signal.aborted || !admissible.value ||
515
- snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
516
- if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
517
- return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
518
- }
519
- }
520
- let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
521
- if (admission === "steer") {
522
- request = { ...request, steer: true };
523
- const retained = delivery.find(messageId);
524
- if (retained) retained.payload.request = request;
525
- }
526
- if (prepare) {
527
- if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
528
- if (!request || typeof request !== "object" || Array.isArray(request)) {
529
- throw new TypeError("Application preparation must return its conversation request or false.");
530
- }
531
- }
532
- pending.dispatched = true;
533
- for (let attempt = 0; ; attempt += 1) {
534
- try {
535
- return await (submission.goalRequest
536
- ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
537
- : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
538
- } catch (failure) {
539
- // The server identifies this exact pre-admission race. Keep the
540
- // same queued words/attachments/identity and try ordinary Send
541
- // once. A timeout, generic rejection or uncertain send never
542
- // grants another dispatch.
543
- if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
544
- failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
545
- failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
546
- failure.details.delivery.messageId !== messageId)) throw failure;
547
- const retained = delivery.find(messageId);
548
- if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
549
- if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
550
- const { steer: _finishedTurn, ...nextRequest } = request;
551
- request = nextRequest;
552
- // Retain the new dispatch intent if Send itself fails; Retry
553
- // must not loop back through the obsolete steering request.
554
- retained.payload.request = nextRequest;
555
- retained.payload.submissionKind = "send";
556
- }
557
- }
558
- }
559
- catch (failure) {
560
- if (controller.signal.aborted) return false;
561
- if (prepare && !pending.dispatched) return { ok: false, error: failureMessage(failure),
562
- ...(failure.code ? { code: failure.code } : {}) };
563
- const rejection = failure.details?.delivery;
564
- const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
565
- rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
566
- // A definite rejection keeps its code so the application can offer
567
- // the matching recovery, such as freeing assistant context.
568
- 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),
569
- ...(failure.code ? { code: failure.code } : {}) };
570
- throw failure;
571
- } finally {
572
- if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
573
- }
574
- }
575
- });
576
- return result;
577
- } catch (failure) {
578
- // Delivery keeps its exact authored payload until an explicit receipt check.
579
- if (prepare) return { ok: false, status: "uncertain", error: failureMessage(failure),
580
- ...(failure.code ? { code: failure.code } : {}) };
581
- return false;
582
- } finally {
583
- if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
584
- if (deferred) deferredWaits.value -= 1;
585
- if (current.value) subscription?.reload();
586
- }
587
- }
588
- // Cancel means cancel, and the server decides. Cancel ends the message's
589
- // unsent request and running delivery check, then asks the server, which
590
- // always records the cancel. The message stays, shown as cancelling, until
591
- // the server agrees; when the server cannot be reached it stays with Cancel
592
- // again, and the next snapshot after reconnecting retries it.
593
- async function cancelMessage(messageId) {
594
- pendingMessages.get(messageId)?.controller.abort();
595
- deliveryChecks.get(messageId)?.abort();
596
- if (!available.value || !delivery.cancel(messageId)) return false;
597
- try {
598
- const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
599
- if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
600
- if (!current.value) return false;
601
- delivery.remove(messageId);
602
- subscription?.reload();
603
- return true;
604
- } catch (failure) {
605
- // The server's own refusal names itself; anything else did not reach it.
606
- if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
607
- return false;
608
- }
609
- }
610
- function retryCancels() {
611
- for (const message of delivery.state.messages) {
612
- if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
613
- }
614
- }
615
-
616
- // The original history control keeps older pages separate from the live page.
617
- async function loadMore({ complete } = {}) {
618
- const finish = (result) => {
619
- if (typeof complete === "function") complete(result);
620
- };
621
- const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
622
- const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
623
- String(turns.value[0]?.turnId || "").trim();
624
- if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
625
- finish({ changed: false, loaded: false });
626
- return false;
627
- }
628
- const revision = historyRevision;
629
- loadingMore.value = true;
630
- loadMoreError.value = "";
631
- try {
632
- const page = await api.readConversation(identity.conversationId, {
633
- beforeTurnId, limit: snapshot.value.pagination.limit
634
- });
635
- if (!available.value || revision !== historyRevision) {
636
- finish({ changed: false, loaded: false });
637
- return false;
638
- }
639
- const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
640
- const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
641
- historyRevision += 1;
642
- olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
643
- const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
644
- const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
645
- const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
646
- nextMessageCount !== previousMessageCount));
647
- finish({ changed, loaded: true });
648
- return true;
649
- } catch (failure) {
650
- if (current.value) {
651
- loadMoreError.value = String(failureMessage(failure) || failure || t("jskit.assistant.runtime.history.loadFailed"));
652
- if (isAccessDenied(failure)) receiveError(failure);
653
- }
654
- finish({ changed: false, loaded: false });
655
- return false;
656
- } finally { loadingMore.value = false; }
657
- }
658
-
659
- function refreshGoal() {
660
- if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
661
- if (goalRead) { goalReadQueued = true; return goalRead; }
662
- const segment = snapshot.value.segmentId;
663
- const capabilities = snapshot.value.capabilities;
664
- const controller = new AbortController();
665
- goalReadController = controller;
666
- const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
667
- signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
668
- })).then(result => {
669
- if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
670
- // Hosts may supply a same-read target and product view. Standalone reads
671
- // retain their existing goal/null contract and captured conversation target.
672
- goalView.value = result && Object.hasOwn(result, "target") ? result : {
673
- status: "available", goal: result, target: { segmentId: segment, capabilities }
674
- };
675
- // A background read does not invalidate this observation. Its supplied
676
- // goal target may intentionally differ from the visible chat segment.
677
- goalTargetCurrent.value = true;
678
- goalLoadError.value = "";
679
- return true;
680
- }).catch(failure => {
681
- if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
682
- goalLoadError.value = failureMessage(failure, t("jskit.assistant.runtime.goal.unavailable"));
683
- }
684
- return false;
685
- }).finally(() => {
686
- if (goalRead !== job) return;
687
- goalRead = null;
688
- goalReadController = null;
689
- if (goalReadQueued) {
690
- goalReadQueued = false;
691
- void refreshGoal();
692
- }
693
- });
694
- goalRead = job;
695
- return job;
696
- }
697
- async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
698
- const view = goalView.value;
699
- const target = view?.target;
700
- const command = target?.capabilities?.goalCommands?.[action];
701
- // Pause and Cancel never depend on chat delivery: each is pinned to the goal
702
- // it stops and goes straight to the goal operation, even while a message is
703
- // sending or its receipt is unknown. Set and Resume wait for those.
704
- const stopping = action === "pause" || action === "cancel";
705
- if (!available.value || view?.status !== "available" || goalLoadError.value ||
706
- goalAction.value && !(stopping && goalSettling.value) || !goalTargetCurrent.value ||
707
- !["message", "control"].includes(command?.delivery) ||
708
- !stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
709
- if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
710
- goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
711
- return false;
712
- }
713
- const retry = stopping ? null : delivery.find(messageId);
714
- if (retry && retry.status !== "failed") return false;
715
- const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
716
- expectedGoalId: view.goal?.id || null,
717
- ...(action === "set" ? { objective: String(input.objective || "").trim(),
718
- ...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
719
- const before = view.goal ? { id: view.goal.id, status: view.goal.status } : null;
720
- const sequence = ++goalCommandSequence;
721
- let accepted = false;
722
- goalAction.value = action;
723
- goalSettling.value = false;
724
- goalError.value = "";
725
- try {
726
- let result;
727
- if (retry || !stopping && command.delivery === "message") {
728
- result = await submitRequest(retry?.payload || {
729
- message: action === "set" ? request.objective
730
- : t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
731
- goalRequest: request
732
- }, { messageId });
733
- if (result === false || result?.ok === false) {
734
- if (current.value) {
735
- goalError.value = result?.error || delivery.find(messageId)?.error || t("jskit.assistant.runtime.goal.changeFailed");
736
- }
737
- return false;
738
- }
739
- } else {
740
- result = await api.updateConversationGoal(identity.conversationId,
741
- command.delivery === "message" ? { ...request, messageId } : request);
742
- if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
743
- }
744
- accepted = true;
745
- return result;
746
- } catch (failure) {
747
- if (current.value) {
748
- goalError.value = failureMessage(failure);
749
- if (isAccessDenied(failure)) receiveError(failure);
750
- }
751
- return false;
752
- } finally {
753
- // The command stays Starting, Resuming, Pausing or Cancelling until a goal
754
- // read made after the server's answer arrives; until then the control
755
- // shows no stale goal or enabled form. An accepted Set or Resume also
756
- // waits while its goal waits behind the running turn.
757
- if (current.value && goalCommandSequence === sequence) {
758
- await goalRead;
759
- await refreshGoal();
760
- if (accepted && !stopping) await awaitGoalChange(before, sequence);
761
- subscription?.reload();
762
- }
763
- if (goalCommandSequence === sequence) {
764
- goalAction.value = "";
765
- goalSettling.value = false;
766
- }
767
- }
768
- }
769
- // An accepted Set or Resume changes the server's goal, now or once the turn
770
- // it waits behind has ended. When the conversation stops working without
771
- // that change, a fresh read decides and the reason is shown. A later Pause
772
- // or Cancel ends the wait.
773
- const goalChangedFrom = before => {
774
- const goal = goalView.value?.goal;
775
- return Boolean(goal) && (goal.id !== before?.id || goal.status !== before?.status);
776
- };
777
- async function awaitGoalChange(before, sequence) {
778
- const settled = () => !current.value || goalCommandSequence !== sequence || goalChangedFrom(before);
779
- await subscription?.reload();
780
- if (!settled()) goalSettling.value = true;
781
- while (!settled() && snapshot.value?.status === "working") {
782
- await new Promise(resolve => {
783
- const stop = watch([goalView, () => snapshot.value?.status, current, goalAction], () => { stop(); resolve(); });
784
- });
785
- }
786
- if (settled()) return;
787
- await goalRead;
788
- await refreshGoal();
789
- if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
790
- }
791
- const goalState = computed(() => {
792
- const view = goalView.value;
793
- if (!readsGoals.value) return null;
794
- const capabilities = view?.target?.capabilities || {};
795
- const commands = capabilities.goalCommands || {};
796
- const loading = !goalTargetCurrent.value && !goalLoadError.value;
797
- const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
798
- const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
799
- return {
800
- enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
801
- loading,
802
- available: Boolean(goalAvailable),
803
- action: goalAction.value,
804
- working: snapshot.value?.status === "working",
805
- tokenBudgetSupported: capabilities.goalBudgets === true,
806
- goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
807
- sampledAt: Date.parse(view.goal.updatedAt) } : null,
808
- pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
809
- delivery.state.messages.some(message => message.status === "uncertain"),
810
- stopPending,
811
- error: goalError.value || goalLoadError.value || view?.error || "",
812
- pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
813
- cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
814
- ...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
815
- ["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
816
- };
817
- });
818
- async function cancel() {
819
- if (!editable.value || stopping.value) return false;
820
- stopping.value = true;
821
- for (const pending of pendingMessages.values()) {
822
- if (!pending.dispatched) pending.controller.abort();
823
- }
824
- try { return await api.cancelConversation(identity.conversationId); }
825
- catch (failure) { receiveError(failure); return false; }
826
- finally { stopping.value = false; if (current.value) subscription?.reload(); }
827
- }
828
- async function inspectDelivery(messageId) {
829
- if (!available.value) return false;
830
- const message = delivery.find(messageId);
831
- if (message?.checking) return false;
832
- const check = new AbortController();
833
- deliveryChecks.set(messageId, check);
834
- if (message) message.checking = true;
835
- try {
836
- const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
837
- if (!current.value || check.signal.aborted) return false;
838
- if (receipt.status === "accepted") {
839
- delivery.accept(messageId);
840
- if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
841
- }
842
- // The server recorded this message as cancelled, perhaps from another tab.
843
- else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
844
- else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
845
- else if (receipt.status === "unknown" && message?.status === "uncertain") {
846
- message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
847
- }
848
- subscription?.reload();
849
- return receipt;
850
- } catch (failure) {
851
- if (check.signal.aborted) return false;
852
- if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
853
- receiveError(failure);
854
- return false;
855
- }
856
- finally {
857
- if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
858
- if (message) message.checking = false;
859
- }
860
- }
861
-
862
- const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
863
- snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
864
- error, accessDenied, loading, stopping, delivery, turns,
865
- hasMoreBefore, loadingMore, loadMoreError, loadMore,
866
- send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
867
- reload(options) { const job = subscription?.reload(options); void refreshGoal(); return job; } };
868
- runtime.application = application ? application(runtime) : null;
869
- return runtime;
870
- }
871
-
872
- // Original bounded-command history belongs to this mounted view. It has no
873
- // canonical delivery receipt, retained server conversation or live subscription.
874
- function useBoundedTask({ command, endpoint, scope, input, result, onResult }, { active, data, presentation }) {
875
- if (typeof command?.run !== "function" || !("isRunning" in command) ||
876
- typeof input !== "function" || typeof result !== "function") {
142
+ /**
143
+ * Checks the bounded task an application hands a binding.
144
+ * @param {BoundedTask} boundedTask
145
+ */
146
+ // readable-check: allow method-probe - input validation: an application hands the binding its bounded task
147
+ function assertBoundedTask({ command, endpoint, scope, input, result, onResult }) {
148
+ if (typeof command?.run !== "function" || !("isRunning" in command) || typeof input !== "function" || typeof result !== "function") {
877
149
  throw new TypeError("boundedTask requires its command and input/result mappings.");
878
150
  }
879
151
  if (endpoint === undefined || scope === undefined) throw new TypeError("boundedTask requires its endpoint and application scope.");
880
- if (onResult !== undefined && typeof onResult !== "function") throw new TypeError("boundedTask.onResult must be a function.");
152
+ if (typeof onResult !== "function") throw new TypeError("boundedTask.onResult must be a function.");
153
+ }
154
+
155
+ /**
156
+ * A bounded-command history belongs to this mounted view. It has no canonical delivery receipt, retained server
157
+ * conversation or live subscription.
158
+ * @param {BoundedTask} boundedTask
159
+ * @param {{ active: import("vue").MaybeRefOrGetter<boolean>, data: import("vue").MaybeRefOrGetter<unknown>,
160
+ * presentation: import("vue").MaybeRefOrGetter<Record<string, any> | undefined> }} view
161
+ */
162
+ function useBoundedTask({ command, endpoint, scope, input, result, onResult = ignoreResult }, { active, data, presentation }) {
163
+ assertBoundedTask({ command, endpoint, scope, input, result, onResult });
881
164
  const draft = ref("");
882
165
  const messages = ref([]);
883
166
  const busy = computed(() => toValue(command.isRunning) === true);
@@ -894,12 +177,15 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
894
177
  const response = await command.run({ path: toValue(endpoint), payload: input(messages.value) });
895
178
  if (!response || disposed || toValue(scope) !== capturedScope) return;
896
179
  messages.value.push(result(response));
897
- onResult?.(response, { message, scope: capturedScope });
180
+ onResult(response, { message, scope: capturedScope });
898
181
  }
899
182
 
900
- const runtime = shallowRef(Object.freeze({ draft, messages, submit,
183
+ const runtime = shallowRef({
184
+ draft,
185
+ messages,
186
+ submit,
901
187
  clearHistory() { messages.value = []; }
902
- }));
188
+ });
903
189
  const adapter = computed(() => {
904
190
  const display = toValue(presentation) || {};
905
191
  return {
@@ -920,27 +206,45 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
920
206
  return { runtime, adapter, error: computed(() => "") };
921
207
  }
922
208
 
923
- function conversationBindingSetup({ socket, boundedTask = null } = {}) {
209
+ /**
210
+ * What a binding takes from the application's setup: the shell's route, placement and socket, and the application's
211
+ * conversation defaults.
212
+ * @param {{ socket?: import("../types.js").RealtimeSocket, boundedTask?: BoundedTask | null }} options
213
+ * @returns {ConversationBindingSetup}
214
+ */
215
+ function conversationBindingSetup({ socket, boundedTask }) {
924
216
  const app = getCurrentInstance()?.appContext.app;
925
217
  if (!app) throw new Error("Conversation binding must be acquired from the application setup.");
926
- if (boundedTask !== null) return { app };
218
+ if (boundedTask != null) return { app, defaults: NO_CONVERSATION_DEFAULTS };
927
219
  const routeContext = useSurfaceRouteContext();
928
- const placement = inject(SHELL_WEB_PLACEMENT_RUNTIME_KEY, null);
929
- if (typeof placement?.getContext !== "function" || typeof placement?.subscribe !== "function") {
930
- throw new Error("Conversation binding requires the application's placement runtime.");
931
- }
932
- return { app, routeContext, placement, defaults: inject(conversationDefaultsKey, {}), workspaceScope: useWorkspaceWebScopeSupport(),
933
- socket: socket || useRealtimeSocket({ required: true }), config: getClientAppConfig() };
220
+ /** @type {import("../types.js").PlacementRuntime} */
221
+ const placement = inject(SHELL_WEB_PLACEMENT_RUNTIME_KEY);
222
+ // readable-check: allow required-collaborators - safety invariant: a binding outside the shell would otherwise fail only once a conversation is targeted
223
+ if (!placement) throw new Error("Conversation binding requires the application's placement runtime.");
224
+ return {
225
+ app,
226
+ defaults: inject(conversationDefaultsKey, NO_CONVERSATION_DEFAULTS),
227
+ routeContext,
228
+ placement,
229
+ workspaceScope: useWorkspaceWebScopeSupport(),
230
+ socket: socket || useRealtimeSocket({ required: true }),
231
+ config: getClientAppConfig()
232
+ };
934
233
  }
935
234
 
235
+ /**
236
+ * One view's binding to a retained conversation, or to a bounded task.
237
+ * @param {ConversationBindingOptions} options
238
+ * @param {ConversationBindingSetup} [setup] The factory's setup, captured once in the application's setup.
239
+ */
936
240
  function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
937
- actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
241
+ actorKey: suppliedActorKey, api: suppliedApi, socket: suppliedSocket, active = true, onEvent,
938
242
  clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, admitWhileWorking = false, draftWhileLoading = false,
939
- draftStorage = null, application = null, boundedTask = null,
940
- data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
941
- const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
243
+ draftStorage, application, boundedTask,
244
+ data, attachments, suggestions, models, questions, goal, presentation }, setup) {
245
+ const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults } = setup ||
942
246
  conversationBindingSetup({ socket: suppliedSocket, boundedTask });
943
- const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn ?? "dispatch";
247
+ const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn;
944
248
  const actorKey = computed(() => {
945
249
  const actor = toValue(suppliedActorKey);
946
250
  return actor === undefined ? toValue(defaults.actorKey) : actor;
@@ -951,8 +255,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
951
255
  if (!["boolean", "function"].includes(typeof admitWhileWorking)) throw new TypeError("admitWhileWorking must be a boolean.");
952
256
  resolveWorkingPolicy(null, { deferWhileWorking, admitWhileWorking });
953
257
  if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
954
- if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
955
- if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
258
+ // readable-check: allow method-probe - input validation: an application may hand the binding a factory for its own runtime extension
259
+ if (application != null && typeof application !== "function") throw new TypeError("application must be a factory.");
260
+ if (boundedTask != null) return useBoundedTask(boundedTask, { active, data, presentation });
956
261
  const socket = suppliedSocket || setupSocket;
957
262
  const runtime = shallowRef(null);
958
263
  const initialDraft = ref("");
@@ -980,12 +285,17 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
980
285
  const route = resolveAssistantRuntimeRoute(surface, toValue(workspaceSlug) === undefined
981
286
  ? workspaceScope.readRouteScope(routeContext).workspaceSlug : text(workspaceSlug));
982
287
  const actor = toValue(actorKey);
983
- return Object.freeze({ conversationId: text(conversationId), targetSurfaceId: target, hostSurfaceId: host || target,
984
- workspaceSlug: route.workspaceSlug, configured: route.configured,
288
+ return Object.freeze({
289
+ conversationId: text(conversationId),
290
+ targetSurfaceId: target,
291
+ hostSurfaceId: host || target,
292
+ workspaceSlug: route.workspaceSlug,
293
+ configured: route.configured,
985
294
  actorKey: actor === undefined ? String(routeContext.placementContext.value?.user?.id || "") : text(actor),
986
- endpoint: text(endpoint) || route.endpoint });
295
+ endpoint: text(endpoint) || route.endpoint
296
+ });
987
297
  });
988
- // The original composer owns input before a conversation ID is available.
298
+ // The binding's own composer owns input before a conversation ID is available.
989
299
  // It is scoped to the real actor/surface/workspace, never a placeholder ID.
990
300
  const draftScope = computed(() => {
991
301
  const scope = identity.value;
@@ -1004,9 +314,10 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1004
314
  if (!target.configured || !target.conversationId || !target.actorKey || !target.targetSurfaceId) return;
1005
315
  const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
1006
316
  target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
1007
- retained = retainAssistantConversation(app, key, readers => createConversation(target, {
1008
- readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
1009
- api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
317
+ retained = retainAssistantConversation(app, key, (readers, retain) => createConversationRuntime({
318
+ identity: target, readers, retain, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking,
319
+ draftStorage, application, goalReadEnabled: toValue(goal) === true,
320
+ api: suppliedApi ?? defaults.api ?? createAssistantApi({ request: defaults.request,
1010
321
  resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
1011
322
  }), { active, questions, goal, onEvent });
1012
323
  runtime.value = retained.runtime;
@@ -1039,7 +350,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1039
350
  if (retry && retry.status !== "failed") return false;
1040
351
  if (!retry && owner?.canSubmit === false) return false;
1041
352
  const prompts = questionOwner.value;
1042
- const formatsQuestions = typeof prompts?.capture === "function";
353
+ const formatsQuestions = formatsAnswers(prompts);
1043
354
  if (!retry && formatsQuestions && prompts.questions?.length &&
1044
355
  !questionsAnswered(prompts)) return false;
1045
356
  const answer = !retry && formatsQuestions ? prompts.capture(current.draft.value) : null;
@@ -1057,8 +368,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1057
368
  // standard element. Creation, routing and access policy remain preparation.
1058
369
  function submitPrepared(payload, { messageId = crypto.randomUUID(), retryMessageId = "", retry,
1059
370
  working = false, clearDraft = true, attachments = attachmentOwner.value,
1060
- isCurrent = () => true, onSubmit, onAccepted, prepare } = {}) {
371
+ isCurrent = alwaysCurrent, onSubmit, onAccepted, prepare } = {}) {
1061
372
  const current = runtime.value;
373
+ // readable-check: allow method-probe - input validation: an application hands a prepared submission its own preparation
1062
374
  if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
1063
375
  if (!current?.current.value || !isCurrent()) return false;
1064
376
  const id = retryMessageId || messageId;
@@ -1077,7 +389,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1077
389
  }
1078
390
 
1079
391
  function submitCaptured(current, captured, { messageId, retry, clearDraft = true, attachments,
1080
- isCurrent = () => true, onSubmit, onAccepted, prepare } = {}) {
392
+ isCurrent = alwaysCurrent, onSubmit = ignoreSubmit, onAccepted = ignoreAcceptance, prepare }) {
1081
393
  const waitForAcceptance = clearDraftOn === "accepted";
1082
394
  const attachmentIds = [...(captured.request?.attachmentIds || captured.attachmentIds ||
1083
395
  (captured.displayAttachments || []).map(file => file.attachmentId))];
@@ -1097,7 +409,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1097
409
  catch (failure) { current.error.value = failureMessage(failure, t("jskit.assistant.runtime.attachments.clearFailed")); }
1098
410
  }
1099
411
  }
1100
- onAccepted?.();
412
+ onAccepted();
1101
413
  }
1102
414
  if (!retry && clearDraft) {
1103
415
  if (waitForAcceptance) {
@@ -1105,7 +417,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1105
417
  captured = { ...captured, draft: current.draft.value };
1106
418
  } else current.draft.value = "";
1107
419
  }
1108
- onSubmit?.();
420
+ onSubmit();
1109
421
  const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
1110
422
  : current.send(captured, { messageId, onAccepted: acknowledge });
1111
423
  // A restored uncertain receipt may have no retained acceptance callback.
@@ -1132,7 +444,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1132
444
  const state = current?.snapshot.value;
1133
445
  const files = state?.capabilities?.attachments ? attachmentOwner.value : null;
1134
446
  const prompts = current?.available.value ? questionOwner.value : null;
1135
- const formatsQuestions = typeof prompts?.capture === "function";
447
+ const formatsQuestions = formatsAnswers(prompts);
1136
448
  const draft = current?.draft.value ?? initialDraft.value;
1137
449
  const editable = canDraftWhileLoading.value ? !current || current.editable.value : current?.available.value;
1138
450
  const message = formatsQuestions ? prompts.capture(draft).message : draft;
@@ -1181,12 +493,18 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1181
493
  return { runtime, adapter, submitPrepared, error: computed(() => runtime.value?.error.value || stateError(runtime.value?.snapshot.value)) };
1182
494
  }
1183
495
 
1184
- /** The standard element and custom text/voice presentation share the supplied binding. */
496
+ /**
497
+ * The standard element and custom text/voice presentation share the supplied binding.
498
+ * @param {ConversationBindingOptions} options
499
+ */
1185
500
  function useAssistantConversation(options) {
1186
501
  return createConversationBinding(options);
1187
502
  }
1188
503
 
1189
- /** Dynamic application tasks acquire the same binding without mounting a hidden view. */
504
+ /**
505
+ * Dynamic application tasks acquire the same binding without mounting a hidden view.
506
+ * @param {ConversationBindingOptions} [options] Shared by every binding the factory acquires.
507
+ */
1190
508
  function useAssistantConversationFactory(options = {}) {
1191
509
  const setup = conversationBindingSetup(options);
1192
510
  const owner = getCurrentScope();
@@ -1196,7 +514,13 @@ function useAssistantConversationFactory(options = {}) {
1196
514
  const scope = owner.run(() => effectScope());
1197
515
  try {
1198
516
  const binding = scope.run(() => createConversationBinding({ ...options, ...input }, setup));
1199
- return { ...binding, release: () => scope.stop() };
517
+ return {
518
+ runtime: binding.runtime,
519
+ adapter: binding.adapter,
520
+ submitPrepared: binding.submitPrepared,
521
+ error: binding.error,
522
+ release: () => scope.stop()
523
+ };
1200
524
  } catch (failure) {
1201
525
  scope.stop();
1202
526
  throw failure;