@jskit-ai/assistant-runtime 0.2.8 → 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 (69) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/canonical-conversation/vite.config.mjs +11 -0
  4. package/fixtures/conversation/main.js +2 -1
  5. package/fixtures/conversation/vite.config.mjs +4 -3
  6. package/locales/en.json +1 -0
  7. package/migrations/assistant_config_initial.cjs +1 -5
  8. package/migrations/assistant_transcripts_initial.cjs +3 -9
  9. package/migrations/assistant_transcripts_summary.cjs +28 -0
  10. package/migrations/assistant_turn_requests.cjs +2 -1
  11. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  12. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  13. package/package.json +17 -10
  14. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  15. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  16. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  17. package/src/client/composables/conversationGoal.js +266 -0
  18. package/src/client/composables/conversationHistory.js +215 -0
  19. package/src/client/composables/conversationRuntime.js +624 -0
  20. package/src/client/composables/useAssistantConversation.js +208 -840
  21. package/src/client/composables/useAssistantRuntime.js +60 -45
  22. package/src/client/providers/AssistantClientProvider.js +1 -0
  23. package/src/client/support/assistantRuntimeState.js +6 -34
  24. package/src/client/support/conversationFailures.js +30 -0
  25. package/src/client/support/conversationRestoreSupport.js +13 -5
  26. package/src/client/support/subscribeAssistantConversation.js +32 -9
  27. package/src/client/support/workspaceScopeSupport.js +13 -22
  28. package/src/client/types.js +46 -0
  29. package/src/server/AssistantProvider.js +45 -17
  30. package/src/server/actions.js +162 -201
  31. package/src/server/conversationOperations.js +141 -0
  32. package/src/server/createAssistantRuntime.js +83 -44
  33. package/src/server/createAssistantSettingsActions.js +69 -47
  34. package/src/server/hostedAssistant.js +21 -0
  35. package/src/server/index.js +1 -0
  36. package/src/server/registerConversationSubscriptions.js +34 -11
  37. package/src/server/registerFastifyConversations.js +18 -6
  38. package/src/server/registerRoutes.js +5 -2
  39. package/src/server/registerSettingsRoutes.js +1 -1
  40. package/src/server/repositories/assistantConfigRepository.js +10 -20
  41. package/src/server/repositories/conversationsRepository.js +35 -10
  42. package/src/server/repositories/messagesRepository.js +14 -10
  43. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  44. package/src/server/repositories/turnRequestsRepository.js +6 -1
  45. package/src/server/services/assistantConfigService.js +25 -23
  46. package/src/server/services/chatService.js +57 -61
  47. package/src/server/services/transcriptService.js +15 -7
  48. package/src/server/sqlConversationStorage.js +90 -18
  49. package/src/server/support/assistantServerConfig.js +1 -1
  50. package/src/server/support/assistantStreamWriter.js +7 -1
  51. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  52. package/src/server/types.js +38 -0
  53. package/src/shared/assistantSurfaces.js +2 -2
  54. package/test/assistantConversationBinding.browser.test.js +115 -1
  55. package/test/assistantConversationOperations.test.js +5 -5
  56. package/test/assistantRuntimeState.test.js +1 -16
  57. package/test/chatServiceLifecycle.test.js +62 -154
  58. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  59. package/test/featureRuntime.test.js +12 -6
  60. package/test/lazyAppConfig.test.js +3 -5
  61. package/test/packageMetadata.test.js +7 -1
  62. package/test/sqlConversationStorage.mysql.test.js +89 -0
  63. package/test/sqlConversationStorage.test.js +161 -16
  64. package/test/support/memoryAssistantRows.js +6 -1
  65. package/test/support/scriptedModel.js +32 -0
  66. package/test/turnRequests.test.js +4 -2
  67. package/test/usersForeignKeysMigration.test.js +46 -0
  68. package/test/workspacesForeignKeysMigration.test.js +66 -0
  69. package/src/client/support/accessDenied.js +0 -6
@@ -1,839 +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
- const goalTargetCurrent = ref(false);
114
- const goalError = ref("");
115
- const goalView = shallowRef(null);
116
- const goalLoadError = ref("");
117
- const accessDenied = ref(false);
118
- const delivery = createAssistantMessageDelivery();
119
- const pendingMessages = new Map();
120
- const deliveryChecks = new Map();
121
- const olderPages = ref([]);
122
- let historyRevision = 0;
123
- let deniedHistoryRevision = 0;
124
- const loadingMore = ref(false);
125
- const loadMoreError = ref("");
126
- const turns = computed(() => {
127
- const saved = mergeConversationLogPages([
128
- ...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
129
- ]).conversationLog;
130
- // Use the original overlay against the full loaded saved span. A synthetic
131
- // live older turn must not replace its authored request or saved progress.
132
- const messages = (snapshot.value?.turns || []).flatMap(conversationTurnMessages);
133
- return mergeConversationStream(saved, { messages });
134
- });
135
- const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
136
- const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
137
- const editable = computed(() => active.value && !accessDenied.value);
138
- const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
139
- // A deferred message keeps the subscription while its view is hidden, so it
140
- // is admitted against live state. A failed read leaves that state stale.
141
- const deferredWaits = ref(0);
142
- const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
143
- const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
144
- const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
145
- const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
146
- const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
147
- const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
148
- (queueWhileSending !== false || !delivery.state.sending) &&
149
- !delivery.state.messages.some(message => message.status === "uncertain" &&
150
- (snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
151
- const savedDraft = toValue(draftStorage);
152
- if (savedDraft && (typeof savedDraft.key !== "string" || !savedDraft.key ||
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
- // Original 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;
219
- let goalRead = null;
220
- let goalSegment = null;
221
- let goalReadController = null;
222
- let goalReadQueued = false;
223
- const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
224
- [...readers.values()].some(reader => toValue(reader.goal) === true));
225
- watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
226
-
227
- function receiveState(state, { initial = false, canonical = false } = {}) {
228
- if (!current.value) return;
229
- const previous = snapshot.value;
230
- const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
231
- previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
232
- JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
233
- if (canonical) {
234
- historyRevision += 1;
235
- olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
236
- loadMoreError.value = "";
237
- } else if (olderPages.value.length) {
238
- // Transcript patches may evict the head of the bounded live page before
239
- // its canonical read completes. Retain saved rows, never display overlays.
240
- const first = state.conversationLog?.[0]?.turnId;
241
- const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
242
- if (evicted.length) {
243
- historyRevision += 1;
244
- olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
245
- }
246
- }
247
- snapshot.value = state;
248
- loading.value = false;
249
- error.value = "";
250
- accessDenied.value = false;
251
- delivery.reconcile(state.turns);
252
- if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
253
- retryCancels();
254
- if (state.segmentId !== goalSegment) {
255
- // Keep the open goal menu while its first native identity arrives. The
256
- // pending read disables commands until it supplies the new exact target.
257
- goalTargetCurrent.value = false;
258
- goalLoadError.value = "";
259
- goalReadController?.abort();
260
- }
261
- if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
262
- goalSegment = state.segmentId;
263
- }
264
- function receiveError(failure) {
265
- if (!current.value) return;
266
- loading.value = false;
267
- error.value = failureMessage(failure);
268
- accessDenied.value = isAccessDenied(failure);
269
- if (accessDenied.value) {
270
- deniedHistoryRevision += 1;
271
- clearConversationView();
272
- goalTargetCurrent.value = false;
273
- }
274
- }
275
- // Shared by access denial and actor change. Access denial keeps the open goal
276
- // command, queued goal read and question form; an actor change clears them.
277
- function clearConversationView() {
278
- delivery.reset();
279
- snapshot.value = null;
280
- historyRevision += 1;
281
- olderPages.value = [];
282
- draft.value = "";
283
- draftMessageId.value = "";
284
- draftAttachments.value = [];
285
- draftRetry.value = null;
286
- goalError.value = "";
287
- goalView.value = null;
288
- goalLoadError.value = "";
289
- goalReadController?.abort();
290
- }
291
- function receiveEvent(event) {
292
- if (event.type === "transcript" && olderPages.value.some(page =>
293
- page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
294
- historyRevision += 1;
295
- olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
296
- }
297
- if (event.type === "goal") void refreshGoal();
298
- for (const [token, reader] of [...readers]) {
299
- if (!current.value) return;
300
- if (!readers.has(token) || toValue(reader.active) === false || typeof reader.onEvent !== "function") continue;
301
- // Presentation hooks own their errors; one reader must not interrupt
302
- // another reader or the subscription's canonical state and delivery.
303
- try { void Promise.resolve(reader.onEvent(event)).catch(() => {}); }
304
- catch {}
305
- }
306
- }
307
- async function readLoadedHistory({ current: subscriptionCurrent }) {
308
- const denial = deniedHistoryRevision;
309
- const valid = () => {
310
- if (denial !== deniedHistoryRevision) {
311
- throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
312
- }
313
- return current.value && listening.value && subscriptionCurrent();
314
- };
315
- // A concurrent Load older operation extends the desired span. Retry one
316
- // refresh in this same read, rather than dropping that newly loaded page.
317
- while (valid()) {
318
- const revision = historyRevision;
319
- const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
320
- const oldest = oldestTurn?.turnId;
321
- const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
322
- const latest = await api.readConversation(identity.conversationId);
323
- if (!valid()) return latest;
324
- const pages = [];
325
- let page = normalizeConversationLogPage(latest);
326
- const cursors = new Set();
327
- while (oldest && page.pagination.hasMoreBefore) {
328
- const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
329
- const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
330
- const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
331
- if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
332
- if (!cursor || cursors.has(cursor)) {
333
- throw Object.assign(new Error("Older conversation history could not be refreshed. Reload chat to try again."), {
334
- messageCode: "assistant.runtime.history.refreshFailed"
335
- });
336
- }
337
- cursors.add(cursor);
338
- page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
339
- beforeTurnId: cursor, limit: latest.pagination.limit
340
- }));
341
- if (!valid()) return latest;
342
- // A removed oldest row can make this page reach farther back than the
343
- // reader loaded. Keep the original visible boundary and page metadata.
344
- const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
345
- if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
346
- const roles = ["system", "user", "assistant"];
347
- const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
348
- return { ...turn, paginationPartial: true,
349
- ...Object.fromEntries(excluded.map(role => [role, null])),
350
- messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
351
- });
352
- pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
353
- ...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
354
- hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
355
- } }));
356
- }
357
- if (revision !== historyRevision) continue;
358
- return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
359
- }
360
- return null;
361
- }
362
- watch(listening, enabled => {
363
- subscription?.();
364
- subscription = null;
365
- historyRevision += 1;
366
- if (!enabled) return;
367
- loading.value = true;
368
- subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
369
- targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
370
- ...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
371
- read: readLoadedHistory, onState: receiveState,
372
- onError: receiveError, onEvent: receiveEvent
373
- });
374
- }, { immediate: true, flush: "sync" });
375
- watch(current, value => {
376
- if (value) return;
377
- for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
378
- clearConversationView();
379
- goalAction.value = "";
380
- goalReadQueued = false;
381
- questions.reset();
382
- }, { flush: "sync" });
383
- onScopeDispose(() => {
384
- disposed.value = true;
385
- subscription?.();
386
- stopPlacement();
387
- for (const pending of pendingMessages.values()) pending.controller.abort();
388
- pendingMessages.clear();
389
- for (const check of deliveryChecks.values()) check.abort();
390
- goalReadController?.abort();
391
- delivery.reset();
392
- });
393
-
394
- async function send(payload, { messageId = crypto.randomUUID(), onAccepted } = {}) {
395
- const retry = delivery.find(messageId);
396
- if (retry?.status === "failed" && retry.payload.goalRequest) return changeGoal(retry.payload.goalRequest.action, {}, { messageId });
397
- if (!canSubmit.value) return false;
398
- if (retry && retry.status !== "failed") return false;
399
- if (delivery.state.messages.some(message => message.status === "uncertain" &&
400
- text(message.payload?.message) === text((retry?.payload || payload).message))) return false;
401
- const request = retry?.payload?.request || payload.request;
402
- const requestedSteering = request?.steer === true;
403
- if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
404
- const steering = requestedSteering || steerable.value;
405
- const policy = workingPolicy.value;
406
- const deferred = policy.deferWhileWorking && !retry && !steering;
407
- // An application queue can admit ordinary messages without native steering
408
- // or waiting for ready. It still owns admission, permissions and receipts.
409
- const appAdmission = policy.admitWhileWorking && !steering;
410
- if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
411
- if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
412
- const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request ||
413
- authoredRequest(payload, {
414
- attachmentIds: payload.displayAttachments?.map(file => file.attachmentId),
415
- steer: steerable.value
416
- }) };
417
- return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
418
- }
419
- function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
420
- if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
421
- const retry = delivery.find(messageId);
422
- const captured = retry?.payload || payload;
423
- if (!captured?.request || typeof captured.request !== "object" || Array.isArray(captured.request) || captured.goalRequest) {
424
- throw new TypeError("Prepared submission requires an authored conversation request.");
425
- }
426
- if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
427
- return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
428
- }
429
- async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
430
- questionText = "", onAccepted } = {}) {
431
- if (!canSubmit.value) return false;
432
- const retry = draftRetryMatches.value ? draftRetry.value : null;
433
- if (!retry) draftRetry.value = null;
434
- const draftSnapshot = retry?.draftSnapshot || draft.value;
435
- const captured = retry?.payload?.request ? retry.payload : payload;
436
- const kind = retry?.submissionKind || submissionKind;
437
- const questionTextSnapshot = retry?.questionTextSnapshot || questionText;
438
- const id = retry?.messageId || messageId;
439
- const sequence = ++draftSubmissionSequence;
440
- draftRetry.value = null;
441
- draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
442
- const result = await send(captured, { messageId: id, onAccepted });
443
- if (!current.value) return false;
444
- const accepted = result !== false && result?.ok !== false;
445
- if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
446
- draft.value = draftSnapshot;
447
- const optimistic = delivery.find(id) || {
448
- createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
449
- };
450
- draftRetry.value = { draftSnapshot, messageId: id, optimistic, payload: optimistic.payload || captured,
451
- questionTextSnapshot, submissionKind: kind };
452
- if (!unmatchedOptimisticMessages(turns.value, [optimistic], { receiptsOnly: true }).length) settleDraftRetry(draftRetry.value);
453
- }
454
- return result;
455
- }
456
- // A deferred message waits only while the working turn cannot take it. It
457
- // steers as soon as that turn accepts steering, or sends once it is ready.
458
- // Its view may be hidden. While loading or after a failed read there is no
459
- // current state to admit it against, so the unsent message keeps waiting;
460
- // only Stop, Cancel, retirement or lost access end it.
461
- function waitForAdmission({ controller }) {
462
- return new Promise(resolve => {
463
- let stop;
464
- const finish = admission => {
465
- stop?.();
466
- controller.signal.removeEventListener("abort", check);
467
- resolve(admission);
468
- };
469
- const check = () => {
470
- if (controller.signal.aborted || accessDenied.value) finish(false);
471
- else if (!admissible.value) return;
472
- else if (snapshot.value.status === "ready") finish("send");
473
- else if (steerable.value) finish("steer");
474
- else if (snapshot.value.status !== "working") finish(false);
475
- };
476
- stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
477
- controller.signal.addEventListener("abort", check, { once: true });
478
- check();
479
- });
480
- }
481
-
482
- async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
483
- if (pendingMessages.has(messageId)) return false;
484
- // Register before the original serial tail so Stop/retirement also reaches
485
- // a local follower whose deliver callback has not started yet.
486
- const pending = { controller: new AbortController(), started: false, dispatched: false };
487
- const { controller } = pending;
488
- pendingMessages.set(messageId, pending);
489
- if (deferred) deferredWaits.value += 1;
490
- try {
491
- const result = await delivery.send(payload, {
492
- messageId, queue, isCurrent: () => current.value, onAccepted,
493
- receiptTurns: turns, uncertainOnError: true,
494
- async deliver(submission) {
495
- pending.started = true;
496
- let admission = null;
497
- try {
498
- if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
499
- if (deferred) {
500
- // Refresh through the same subscription after a queued predecessor;
501
- // its receipt may have arrived before the working snapshot.
502
- await subscription?.reload();
503
- if (snapshot.value?.status === "working" && !steerable.value) {
504
- delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
505
- }
506
- admission = await waitForAdmission(pending);
507
- delivery.wait(messageId, "");
508
- if (!admission || controller.signal.aborted || !admissible.value ||
509
- snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
510
- if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
511
- return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
512
- }
513
- }
514
- let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
515
- if (admission === "steer") {
516
- request = { ...request, steer: true };
517
- const retained = delivery.find(messageId);
518
- if (retained) retained.payload.request = request;
519
- }
520
- if (prepare) {
521
- if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
522
- if (!request || typeof request !== "object" || Array.isArray(request)) {
523
- throw new TypeError("Application preparation must return its conversation request or false.");
524
- }
525
- }
526
- pending.dispatched = true;
527
- for (let attempt = 0; ; attempt += 1) {
528
- try {
529
- return await (submission.goalRequest
530
- ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
531
- : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
532
- } catch (failure) {
533
- // The server identifies this exact pre-admission race. Keep the
534
- // same queued words/attachments/identity and try ordinary Send
535
- // once. A timeout, generic rejection or uncertain send never
536
- // grants another dispatch.
537
- if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
538
- failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
539
- failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
540
- failure.details.delivery.messageId !== messageId)) throw failure;
541
- const retained = delivery.find(messageId);
542
- if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
543
- if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
544
- const { steer: _finishedTurn, ...nextRequest } = request;
545
- request = nextRequest;
546
- // Retain the new dispatch intent if Send itself fails; Retry
547
- // must not loop back through the obsolete steering request.
548
- retained.payload.request = nextRequest;
549
- retained.payload.submissionKind = "send";
550
- }
551
- }
552
- }
553
- catch (failure) {
554
- if (controller.signal.aborted) return false;
555
- if (prepare && !pending.dispatched) return { ok: false, error: failureMessage(failure),
556
- ...(failure.code ? { code: failure.code } : {}) };
557
- const rejection = failure.details?.delivery;
558
- const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
559
- rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
560
- // A definite rejection keeps its code so the application can offer
561
- // the matching recovery, such as freeing assistant context.
562
- 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),
563
- ...(failure.code ? { code: failure.code } : {}) };
564
- throw failure;
565
- } finally {
566
- if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
567
- }
568
- }
569
- });
570
- return result;
571
- } catch (failure) {
572
- // Delivery keeps its exact authored payload until an explicit receipt check.
573
- if (prepare) return { ok: false, status: "uncertain", error: failureMessage(failure),
574
- ...(failure.code ? { code: failure.code } : {}) };
575
- return false;
576
- } finally {
577
- if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
578
- if (deferred) deferredWaits.value -= 1;
579
- if (current.value) subscription?.reload();
580
- }
581
- }
582
- // Cancel means cancel, and the server decides. Cancel ends the message's
583
- // unsent request and running delivery check, then asks the server, which
584
- // always records the cancel. The message stays, shown as cancelling, until
585
- // the server agrees; when the server cannot be reached it stays with Cancel
586
- // again, and the next snapshot after reconnecting retries it.
587
- async function cancelMessage(messageId) {
588
- pendingMessages.get(messageId)?.controller.abort();
589
- deliveryChecks.get(messageId)?.abort();
590
- if (!available.value || !delivery.cancel(messageId)) return false;
591
- try {
592
- const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
593
- if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
594
- if (!current.value) return false;
595
- delivery.remove(messageId);
596
- subscription?.reload();
597
- return true;
598
- } catch (failure) {
599
- // The server's own refusal names itself; anything else did not reach it.
600
- if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
601
- return false;
602
- }
603
- }
604
- function retryCancels() {
605
- for (const message of delivery.state.messages) {
606
- if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
607
- }
608
- }
609
-
610
- // The original history control keeps older pages separate from the live page.
611
- async function loadMore({ complete } = {}) {
612
- const finish = (result) => {
613
- if (typeof complete === "function") complete(result);
614
- };
615
- const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
616
- const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
617
- String(turns.value[0]?.turnId || "").trim();
618
- if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
619
- finish({ changed: false, loaded: false });
620
- return false;
621
- }
622
- const revision = historyRevision;
623
- loadingMore.value = true;
624
- loadMoreError.value = "";
625
- try {
626
- const page = await api.readConversation(identity.conversationId, {
627
- beforeTurnId, limit: snapshot.value.pagination.limit
628
- });
629
- if (!available.value || revision !== historyRevision) {
630
- finish({ changed: false, loaded: false });
631
- return false;
632
- }
633
- const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
634
- const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
635
- historyRevision += 1;
636
- olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
637
- const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
638
- const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
639
- const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
640
- nextMessageCount !== previousMessageCount));
641
- finish({ changed, loaded: true });
642
- return true;
643
- } catch (failure) {
644
- if (current.value) {
645
- loadMoreError.value = String(failureMessage(failure) || failure || t("jskit.assistant.runtime.history.loadFailed"));
646
- if (isAccessDenied(failure)) receiveError(failure);
647
- }
648
- finish({ changed: false, loaded: false });
649
- return false;
650
- } finally { loadingMore.value = false; }
651
- }
652
-
653
- function refreshGoal() {
654
- if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
655
- if (goalRead) { goalReadQueued = true; return goalRead; }
656
- const segment = snapshot.value.segmentId;
657
- const capabilities = snapshot.value.capabilities;
658
- const controller = new AbortController();
659
- goalReadController = controller;
660
- const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
661
- signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
662
- })).then(result => {
663
- if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
664
- // Hosts may supply a same-read target and product view. Standalone reads
665
- // retain their existing goal/null contract and captured conversation target.
666
- goalView.value = result && Object.hasOwn(result, "target") ? result : {
667
- status: "available", goal: result, target: { segmentId: segment, capabilities }
668
- };
669
- // A background read does not invalidate this observation. Its supplied
670
- // goal target may intentionally differ from the visible chat segment.
671
- goalTargetCurrent.value = true;
672
- goalLoadError.value = "";
673
- return true;
674
- }).catch(failure => {
675
- if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
676
- goalLoadError.value = failureMessage(failure, t("jskit.assistant.runtime.goal.unavailable"));
677
- }
678
- return false;
679
- }).finally(() => {
680
- if (goalRead !== job) return;
681
- goalRead = null;
682
- goalReadController = null;
683
- if (goalReadQueued) {
684
- goalReadQueued = false;
685
- void refreshGoal();
686
- }
687
- });
688
- goalRead = job;
689
- return job;
690
- }
691
- async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
692
- const view = goalView.value;
693
- const target = view?.target;
694
- const command = target?.capabilities?.goalCommands?.[action];
695
- // Pause and Cancel never depend on chat delivery: each is pinned to the goal
696
- // it stops and goes straight to the goal operation, even while a message is
697
- // sending or its receipt is unknown. Set and Resume wait for those.
698
- const stopping = action === "pause" || action === "cancel";
699
- if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
700
- !["message", "control"].includes(command?.delivery) ||
701
- !stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
702
- if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
703
- goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
704
- return false;
705
- }
706
- const retry = stopping ? null : delivery.find(messageId);
707
- if (retry && retry.status !== "failed") return false;
708
- const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
709
- expectedGoalId: view.goal?.id || null,
710
- ...(action === "set" ? { objective: String(input.objective || "").trim(),
711
- ...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
712
- goalAction.value = action;
713
- goalError.value = "";
714
- try {
715
- if (retry || !stopping && command.delivery === "message") {
716
- const result = await submitRequest(retry?.payload || {
717
- message: action === "set" ? request.objective
718
- : t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
719
- goalRequest: request
720
- }, { messageId });
721
- if (result === false || result?.ok === false) {
722
- if (current.value) {
723
- goalError.value = result?.error || delivery.find(messageId)?.error || t("jskit.assistant.runtime.goal.changeFailed");
724
- }
725
- return false;
726
- }
727
- return result;
728
- }
729
- const result = await api.updateConversationGoal(identity.conversationId,
730
- command.delivery === "message" ? { ...request, messageId } : request);
731
- if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
732
- return result;
733
- } catch (failure) {
734
- if (current.value) {
735
- goalError.value = failureMessage(failure);
736
- if (isAccessDenied(failure)) receiveError(failure);
737
- }
738
- return false;
739
- } finally {
740
- if (current.value) {
741
- goalAction.value = "";
742
- await goalRead;
743
- await refreshGoal();
744
- subscription?.reload();
745
- }
746
- }
747
- }
748
- const goalState = computed(() => {
749
- const view = goalView.value;
750
- if (!readsGoals.value) return null;
751
- const capabilities = view?.target?.capabilities || {};
752
- const commands = capabilities.goalCommands || {};
753
- const loading = !goalTargetCurrent.value && !goalLoadError.value;
754
- const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
755
- const stopPending = Boolean(goalAction.value) || !goalAvailable || !available.value;
756
- return {
757
- enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
758
- loading,
759
- available: Boolean(goalAvailable),
760
- action: goalAction.value,
761
- working: snapshot.value?.status === "working",
762
- tokenBudgetSupported: capabilities.goalBudgets === true,
763
- goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
764
- sampledAt: Date.parse(view.goal.updatedAt) } : null,
765
- pending: stopPending || delivery.state.sending || delivery.state.messages.some(message => message.status === "uncertain"),
766
- stopPending,
767
- error: goalError.value || goalLoadError.value || view?.error || "",
768
- pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
769
- cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
770
- ...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
771
- ["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
772
- };
773
- });
774
- async function cancel() {
775
- if (!editable.value || stopping.value) return false;
776
- stopping.value = true;
777
- for (const pending of pendingMessages.values()) {
778
- if (!pending.dispatched) pending.controller.abort();
779
- }
780
- try { return await api.cancelConversation(identity.conversationId); }
781
- catch (failure) { receiveError(failure); return false; }
782
- finally { stopping.value = false; if (current.value) subscription?.reload(); }
783
- }
784
- async function inspectDelivery(messageId) {
785
- if (!available.value) return false;
786
- const message = delivery.find(messageId);
787
- if (message?.checking) return false;
788
- const check = new AbortController();
789
- deliveryChecks.set(messageId, check);
790
- if (message) message.checking = true;
791
- try {
792
- const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
793
- if (!current.value || check.signal.aborted) return false;
794
- if (receipt.status === "accepted") {
795
- delivery.accept(messageId);
796
- if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
797
- }
798
- // The server recorded this message as cancelled, perhaps from another tab.
799
- else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
800
- else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
801
- else if (receipt.status === "unknown" && message?.status === "uncertain") {
802
- message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
803
- }
804
- subscription?.reload();
805
- return receipt;
806
- } catch (failure) {
807
- if (check.signal.aborted) return false;
808
- if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
809
- receiveError(failure);
810
- return false;
811
- }
812
- finally {
813
- if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
814
- if (message) message.checking = false;
815
- }
816
- }
817
-
818
- const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
819
- snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
820
- error, accessDenied, loading, stopping, delivery, turns,
821
- hasMoreBefore, loadingMore, loadMoreError, loadMore,
822
- send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
823
- reload(options) { const job = subscription?.reload(options); void refreshGoal(); return job; } };
824
- runtime.application = application ? application(runtime) : null;
825
- return runtime;
826
- }
827
-
828
- // Original bounded-command history belongs to this mounted view. It has no
829
- // canonical delivery receipt, retained server conversation or live subscription.
830
- function useBoundedTask({ command, endpoint, scope, input, result, onResult }, { active, data, presentation }) {
831
- if (typeof command?.run !== "function" || !("isRunning" in command) ||
832
- 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") {
833
149
  throw new TypeError("boundedTask requires its command and input/result mappings.");
834
150
  }
835
151
  if (endpoint === undefined || scope === undefined) throw new TypeError("boundedTask requires its endpoint and application scope.");
836
- 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 });
837
164
  const draft = ref("");
838
165
  const messages = ref([]);
839
166
  const busy = computed(() => toValue(command.isRunning) === true);
@@ -850,12 +177,15 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
850
177
  const response = await command.run({ path: toValue(endpoint), payload: input(messages.value) });
851
178
  if (!response || disposed || toValue(scope) !== capturedScope) return;
852
179
  messages.value.push(result(response));
853
- onResult?.(response, { message, scope: capturedScope });
180
+ onResult(response, { message, scope: capturedScope });
854
181
  }
855
182
 
856
- const runtime = shallowRef(Object.freeze({ draft, messages, submit,
183
+ const runtime = shallowRef({
184
+ draft,
185
+ messages,
186
+ submit,
857
187
  clearHistory() { messages.value = []; }
858
- }));
188
+ });
859
189
  const adapter = computed(() => {
860
190
  const display = toValue(presentation) || {};
861
191
  return {
@@ -876,27 +206,45 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
876
206
  return { runtime, adapter, error: computed(() => "") };
877
207
  }
878
208
 
879
- 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 }) {
880
216
  const app = getCurrentInstance()?.appContext.app;
881
217
  if (!app) throw new Error("Conversation binding must be acquired from the application setup.");
882
- if (boundedTask !== null) return { app };
218
+ if (boundedTask != null) return { app, defaults: NO_CONVERSATION_DEFAULTS };
883
219
  const routeContext = useSurfaceRouteContext();
884
- const placement = inject(SHELL_WEB_PLACEMENT_RUNTIME_KEY, null);
885
- if (typeof placement?.getContext !== "function" || typeof placement?.subscribe !== "function") {
886
- throw new Error("Conversation binding requires the application's placement runtime.");
887
- }
888
- return { app, routeContext, placement, defaults: inject(conversationDefaultsKey, {}), workspaceScope: useWorkspaceWebScopeSupport(),
889
- 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
+ };
890
233
  }
891
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
+ */
892
240
  function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
893
- actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
241
+ actorKey: suppliedActorKey, api: suppliedApi, socket: suppliedSocket, active = true, onEvent,
894
242
  clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, admitWhileWorking = false, draftWhileLoading = false,
895
- draftStorage = null, application = null, boundedTask = null,
896
- data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
897
- 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 ||
898
246
  conversationBindingSetup({ socket: suppliedSocket, boundedTask });
899
- const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn ?? "dispatch";
247
+ const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn;
900
248
  const actorKey = computed(() => {
901
249
  const actor = toValue(suppliedActorKey);
902
250
  return actor === undefined ? toValue(defaults.actorKey) : actor;
@@ -907,8 +255,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
907
255
  if (!["boolean", "function"].includes(typeof admitWhileWorking)) throw new TypeError("admitWhileWorking must be a boolean.");
908
256
  resolveWorkingPolicy(null, { deferWhileWorking, admitWhileWorking });
909
257
  if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
910
- if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
911
- 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 });
912
261
  const socket = suppliedSocket || setupSocket;
913
262
  const runtime = shallowRef(null);
914
263
  const initialDraft = ref("");
@@ -936,12 +285,17 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
936
285
  const route = resolveAssistantRuntimeRoute(surface, toValue(workspaceSlug) === undefined
937
286
  ? workspaceScope.readRouteScope(routeContext).workspaceSlug : text(workspaceSlug));
938
287
  const actor = toValue(actorKey);
939
- return Object.freeze({ conversationId: text(conversationId), targetSurfaceId: target, hostSurfaceId: host || target,
940
- 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,
941
294
  actorKey: actor === undefined ? String(routeContext.placementContext.value?.user?.id || "") : text(actor),
942
- endpoint: text(endpoint) || route.endpoint });
295
+ endpoint: text(endpoint) || route.endpoint
296
+ });
943
297
  });
944
- // 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.
945
299
  // It is scoped to the real actor/surface/workspace, never a placeholder ID.
946
300
  const draftScope = computed(() => {
947
301
  const scope = identity.value;
@@ -960,9 +314,10 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
960
314
  if (!target.configured || !target.conversationId || !target.actorKey || !target.targetSurfaceId) return;
961
315
  const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
962
316
  target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
963
- retained = retainAssistantConversation(app, key, readers => createConversation(target, {
964
- readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
965
- 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,
966
321
  resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
967
322
  }), { active, questions, goal, onEvent });
968
323
  runtime.value = retained.runtime;
@@ -995,7 +350,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
995
350
  if (retry && retry.status !== "failed") return false;
996
351
  if (!retry && owner?.canSubmit === false) return false;
997
352
  const prompts = questionOwner.value;
998
- const formatsQuestions = typeof prompts?.capture === "function";
353
+ const formatsQuestions = formatsAnswers(prompts);
999
354
  if (!retry && formatsQuestions && prompts.questions?.length &&
1000
355
  !questionsAnswered(prompts)) return false;
1001
356
  const answer = !retry && formatsQuestions ? prompts.capture(current.draft.value) : null;
@@ -1013,8 +368,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1013
368
  // standard element. Creation, routing and access policy remain preparation.
1014
369
  function submitPrepared(payload, { messageId = crypto.randomUUID(), retryMessageId = "", retry,
1015
370
  working = false, clearDraft = true, attachments = attachmentOwner.value,
1016
- isCurrent = () => true, onSubmit, onAccepted, prepare } = {}) {
371
+ isCurrent = alwaysCurrent, onSubmit, onAccepted, prepare } = {}) {
1017
372
  const current = runtime.value;
373
+ // readable-check: allow method-probe - input validation: an application hands a prepared submission its own preparation
1018
374
  if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
1019
375
  if (!current?.current.value || !isCurrent()) return false;
1020
376
  const id = retryMessageId || messageId;
@@ -1033,7 +389,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1033
389
  }
1034
390
 
1035
391
  function submitCaptured(current, captured, { messageId, retry, clearDraft = true, attachments,
1036
- isCurrent = () => true, onSubmit, onAccepted, prepare } = {}) {
392
+ isCurrent = alwaysCurrent, onSubmit = ignoreSubmit, onAccepted = ignoreAcceptance, prepare }) {
1037
393
  const waitForAcceptance = clearDraftOn === "accepted";
1038
394
  const attachmentIds = [...(captured.request?.attachmentIds || captured.attachmentIds ||
1039
395
  (captured.displayAttachments || []).map(file => file.attachmentId))];
@@ -1053,7 +409,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1053
409
  catch (failure) { current.error.value = failureMessage(failure, t("jskit.assistant.runtime.attachments.clearFailed")); }
1054
410
  }
1055
411
  }
1056
- onAccepted?.();
412
+ onAccepted();
1057
413
  }
1058
414
  if (!retry && clearDraft) {
1059
415
  if (waitForAcceptance) {
@@ -1061,7 +417,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1061
417
  captured = { ...captured, draft: current.draft.value };
1062
418
  } else current.draft.value = "";
1063
419
  }
1064
- onSubmit?.();
420
+ onSubmit();
1065
421
  const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
1066
422
  : current.send(captured, { messageId, onAccepted: acknowledge });
1067
423
  // A restored uncertain receipt may have no retained acceptance callback.
@@ -1088,7 +444,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1088
444
  const state = current?.snapshot.value;
1089
445
  const files = state?.capabilities?.attachments ? attachmentOwner.value : null;
1090
446
  const prompts = current?.available.value ? questionOwner.value : null;
1091
- const formatsQuestions = typeof prompts?.capture === "function";
447
+ const formatsQuestions = formatsAnswers(prompts);
1092
448
  const draft = current?.draft.value ?? initialDraft.value;
1093
449
  const editable = canDraftWhileLoading.value ? !current || current.editable.value : current?.available.value;
1094
450
  const message = formatsQuestions ? prompts.capture(draft).message : draft;
@@ -1137,12 +493,18 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1137
493
  return { runtime, adapter, submitPrepared, error: computed(() => runtime.value?.error.value || stateError(runtime.value?.snapshot.value)) };
1138
494
  }
1139
495
 
1140
- /** 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
+ */
1141
500
  function useAssistantConversation(options) {
1142
501
  return createConversationBinding(options);
1143
502
  }
1144
503
 
1145
- /** 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
+ */
1146
508
  function useAssistantConversationFactory(options = {}) {
1147
509
  const setup = conversationBindingSetup(options);
1148
510
  const owner = getCurrentScope();
@@ -1152,7 +514,13 @@ function useAssistantConversationFactory(options = {}) {
1152
514
  const scope = owner.run(() => effectScope());
1153
515
  try {
1154
516
  const binding = scope.run(() => createConversationBinding({ ...options, ...input }, setup));
1155
- 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
+ };
1156
524
  } catch (failure) {
1157
525
  scope.stop();
1158
526
  throw failure;