@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
@@ -0,0 +1,266 @@
1
+ import { computed, ref, shallowRef, toValue, watch } from "vue";
2
+ import { failureMessage, isAccessDenied, resultError } from "../support/conversationFailures.js";
3
+ import { useJskitText } from "../i18n.js";
4
+
5
+ const { t } = useJskitText();
6
+
7
+ /**
8
+ * A retained conversation's goal: the goal read for the current segment and the Set, Resume, Pause and Cancel
9
+ * commands. The conversation calls its lifecycle operations; it starts no watcher of its own.
10
+ * @param {{
11
+ * api: import("@jskit-ai/assistant-core/client/types").AssistantApi,
12
+ * identity: import("../types.js").ConversationIdentity,
13
+ * readers: Map<symbol, import("../types.js").ConversationReader>,
14
+ * goalReadEnabled: boolean,
15
+ * current: import("vue").ComputedRef<boolean>,
16
+ * active: import("vue").ComputedRef<boolean>,
17
+ * available: import("vue").ComputedRef<boolean>,
18
+ * accessDenied: import("vue").Ref<boolean>,
19
+ * snapshot: import("vue").ShallowRef<any>,
20
+ * delivery: import("@jskit-ai/assistant-core/client/types").AssistantMessageDelivery,
21
+ * submitRequest: (payload: object, options: { messageId: string }) => Promise<any>,
22
+ * receiveError: (failure: any) => void,
23
+ * reloadSubscription: () => Promise<unknown> | undefined
24
+ * }} conversation
25
+ */
26
+ function createConversationGoal({ api, identity, readers, goalReadEnabled, current, active, available, accessDenied, snapshot,
27
+ delivery, submitRequest, receiveError, reloadSubscription }) {
28
+ const goalAction = ref("");
29
+ // An accepted Set or Resume waiting for its goal keeps Pause and Cancel usable.
30
+ const goalSettling = ref(false);
31
+ const goalTargetCurrent = ref(false);
32
+ const goalError = ref("");
33
+ const goalView = shallowRef(null);
34
+ const goalLoadError = ref("");
35
+ let goalRead = null;
36
+ let goalSegment = null;
37
+ let goalReadController = null;
38
+ let goalReadQueued = false;
39
+ let goalCommandSequence = 0;
40
+ const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
41
+ [...readers.values()].some(reader => toValue(reader.goal) === true));
42
+
43
+ /**
44
+ * Follows a new conversation state: a new segment needs a new goal target, and a changed goal a fresh read.
45
+ * @param {any} state
46
+ * @param {any} previous The state it replaces.
47
+ * @param {{ initial: boolean }} options
48
+ */
49
+ function receiveState(state, previous, { initial }) {
50
+ const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
51
+ previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
52
+ JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
53
+ if (state.segmentId !== goalSegment) {
54
+ // Keep the open goal menu while its first native identity arrives. The
55
+ // pending read disables commands until it supplies the new exact target.
56
+ goalTargetCurrent.value = false;
57
+ goalLoadError.value = "";
58
+ abortRead();
59
+ }
60
+ if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
61
+ goalSegment = state.segmentId;
62
+ }
63
+ /** Clears the goal shown with the conversation view. The open command, queued read and target stay. */
64
+ function clear() {
65
+ goalError.value = "";
66
+ goalView.value = null;
67
+ goalLoadError.value = "";
68
+ abortRead();
69
+ }
70
+ /** After lost access, commands wait for a target read with access again. */
71
+ function forgetTarget() {
72
+ goalTargetCurrent.value = false;
73
+ }
74
+ /** After the actor changed, no open command or queued read survives. */
75
+ function retire() {
76
+ goalAction.value = "";
77
+ goalSettling.value = false;
78
+ goalReadQueued = false;
79
+ }
80
+ /** Stops the goal read in flight. */
81
+ function abortRead() {
82
+ // readable-check: allow optional-collaborator - data: the controller of the goal read in flight, none between reads
83
+ goalReadController?.abort();
84
+ }
85
+ /** A goal command whose delivery was confirmed no longer shows its earlier error. */
86
+ function clearCommandError() {
87
+ goalError.value = "";
88
+ }
89
+
90
+ function refreshGoal() {
91
+ if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
92
+ if (goalRead) { goalReadQueued = true; return goalRead; }
93
+ const segment = snapshot.value.segmentId;
94
+ const capabilities = snapshot.value.capabilities;
95
+ const controller = new AbortController();
96
+ goalReadController = controller;
97
+ const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
98
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
99
+ })).then(result => {
100
+ if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
101
+ // Hosts may supply a same-read target and product view. Standalone reads
102
+ // retain their existing goal/null contract and captured conversation target.
103
+ goalView.value = result && Object.hasOwn(result, "target") ? result : {
104
+ status: "available", goal: result, target: { segmentId: segment, capabilities }
105
+ };
106
+ // A background read does not invalidate this observation. Its supplied
107
+ // goal target may intentionally differ from the visible chat segment.
108
+ goalTargetCurrent.value = true;
109
+ goalLoadError.value = "";
110
+ return true;
111
+ }).catch(failure => {
112
+ if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
113
+ goalLoadError.value = failureMessage(failure, t("jskit.assistant.runtime.goal.unavailable"));
114
+ }
115
+ return false;
116
+ }).finally(() => {
117
+ if (goalRead !== job) return;
118
+ goalRead = null;
119
+ goalReadController = null;
120
+ if (goalReadQueued) {
121
+ goalReadQueued = false;
122
+ void refreshGoal();
123
+ }
124
+ });
125
+ goalRead = job;
126
+ return job;
127
+ }
128
+ async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
129
+ const view = goalView.value;
130
+ const target = view?.target;
131
+ const command = target?.capabilities?.goalCommands?.[action];
132
+ // Pause and Cancel never depend on chat delivery: each is pinned to the goal
133
+ // it stops and goes straight to the goal operation, even while a message is
134
+ // sending or its receipt is unknown. Set and Resume wait for those.
135
+ const stopping = action === "pause" || action === "cancel";
136
+ if (!available.value || view?.status !== "available" || goalLoadError.value ||
137
+ goalAction.value && !(stopping && goalSettling.value) || !goalTargetCurrent.value ||
138
+ !["message", "control"].includes(command?.delivery) ||
139
+ !stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
140
+ if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
141
+ goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
142
+ return false;
143
+ }
144
+ const retry = stopping ? null : delivery.find(messageId);
145
+ if (retry && retry.status !== "failed") return false;
146
+ const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
147
+ expectedGoalId: view.goal?.id || null,
148
+ ...(action === "set" ? { objective: String(input.objective || "").trim(),
149
+ ...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
150
+ const before = view.goal ? { id: view.goal.id, status: view.goal.status } : null;
151
+ const sequence = ++goalCommandSequence;
152
+ let accepted = false;
153
+ goalAction.value = action;
154
+ goalSettling.value = false;
155
+ goalError.value = "";
156
+ try {
157
+ let result;
158
+ if (retry || !stopping && command.delivery === "message") {
159
+ result = await submitRequest(retry?.payload || {
160
+ message: action === "set" ? request.objective
161
+ : t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
162
+ goalRequest: request
163
+ }, { messageId });
164
+ if (result === false || result?.ok === false) {
165
+ if (current.value) {
166
+ goalError.value = result?.error || delivery.find(messageId)?.error || t("jskit.assistant.runtime.goal.changeFailed");
167
+ }
168
+ return false;
169
+ }
170
+ } else {
171
+ result = await api.updateConversationGoal(identity.conversationId,
172
+ command.delivery === "message" ? { ...request, messageId } : request);
173
+ if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
174
+ }
175
+ accepted = true;
176
+ return result;
177
+ } catch (failure) {
178
+ if (current.value) {
179
+ goalError.value = failureMessage(failure);
180
+ if (isAccessDenied(failure)) receiveError(failure);
181
+ }
182
+ return false;
183
+ } finally {
184
+ // The command stays Starting, Resuming, Pausing or Cancelling until a goal
185
+ // read made after the server's answer arrives; until then the control
186
+ // shows no stale goal or enabled form. An accepted Set or Resume also
187
+ // waits while its goal waits behind the running turn.
188
+ if (current.value && goalCommandSequence === sequence) {
189
+ await goalRead;
190
+ await refreshGoal();
191
+ if (accepted && !stopping) await awaitGoalChange(before, sequence);
192
+ reloadSubscription();
193
+ }
194
+ if (goalCommandSequence === sequence) {
195
+ goalAction.value = "";
196
+ goalSettling.value = false;
197
+ }
198
+ }
199
+ }
200
+ // An accepted Set or Resume changes the server's goal, now or once the turn
201
+ // it waits behind has ended. When the conversation stops working without
202
+ // that change, a fresh read decides and the reason is shown. A later Pause
203
+ // or Cancel ends the wait.
204
+ const goalChangedFrom = before => {
205
+ const goal = goalView.value?.goal;
206
+ return Boolean(goal) && (goal.id !== before?.id || goal.status !== before?.status);
207
+ };
208
+ async function awaitGoalChange(before, sequence) {
209
+ const settled = () => !current.value || goalCommandSequence !== sequence || goalChangedFrom(before);
210
+ await reloadSubscription();
211
+ if (!settled()) goalSettling.value = true;
212
+ while (!settled() && snapshot.value?.status === "working") {
213
+ await new Promise(resolve => {
214
+ const stop = watch([goalView, () => snapshot.value?.status, current, goalAction], () => { stop(); resolve(); });
215
+ });
216
+ }
217
+ if (settled()) return;
218
+ await goalRead;
219
+ await refreshGoal();
220
+ if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
221
+ }
222
+ const goalState = computed(() => {
223
+ const view = goalView.value;
224
+ if (!readsGoals.value) return null;
225
+ const capabilities = view?.target?.capabilities || {};
226
+ const commands = capabilities.goalCommands || {};
227
+ const loading = !goalTargetCurrent.value && !goalLoadError.value;
228
+ const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
229
+ const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
230
+ return {
231
+ enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
232
+ loading,
233
+ available: Boolean(goalAvailable),
234
+ action: goalAction.value,
235
+ working: snapshot.value?.status === "working",
236
+ tokenBudgetSupported: capabilities.goalBudgets === true,
237
+ goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
238
+ sampledAt: Date.parse(view.goal.updatedAt) } : null,
239
+ pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
240
+ delivery.state.messages.some(message => message.status === "uncertain"),
241
+ stopPending,
242
+ error: goalError.value || goalLoadError.value || view?.error || "",
243
+ pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
244
+ cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
245
+ ...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
246
+ ["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
247
+ };
248
+ });
249
+
250
+ return {
251
+ readsGoals,
252
+ goalState,
253
+ goalView,
254
+ goalLoadError,
255
+ refreshGoal,
256
+ changeGoal,
257
+ receiveState,
258
+ clear,
259
+ forgetTarget,
260
+ retire,
261
+ abortRead,
262
+ clearCommandError
263
+ };
264
+ }
265
+
266
+ export { createConversationGoal };
@@ -0,0 +1,215 @@
1
+ import { computed, ref } from "vue";
2
+ import {
3
+ conversationTurnMessages,
4
+ mergeConversationLogPages,
5
+ mergeConversationStream,
6
+ normalizeConversationLogPage,
7
+ normalizeConversationLogPagination
8
+ } from "@jskit-ai/assistant-core/shared/conversation";
9
+ import { failureMessage, isAccessDenied } from "../support/conversationFailures.js";
10
+ import { patchLoadedHistoryPages, refreshedHistoryPages } from "../support/subscribeAssistantConversation.js";
11
+ import { useJskitText } from "../i18n.js";
12
+
13
+ const { t } = useJskitText();
14
+ const compareTurnIds = (left, right) => String(left).localeCompare(String(right), undefined, { numeric: true });
15
+
16
+ /** A Load older request whose view does not follow the result. */
17
+ function ignoreLoadResult() {}
18
+
19
+ /**
20
+ * The saved history a conversation's views have loaded: the live page from its subscription plus the older pages a
21
+ * reader asked for, kept apart so a canonical read can refresh both.
22
+ * @param {{
23
+ * api: import("@jskit-ai/assistant-core/client/types").AssistantApi,
24
+ * identity: import("../types.js").ConversationIdentity,
25
+ * snapshot: import("vue").ShallowRef<any>,
26
+ * current: import("vue").ComputedRef<boolean>,
27
+ * listening: import("vue").ComputedRef<boolean>,
28
+ * available: import("vue").ComputedRef<boolean>,
29
+ * receiveError: (failure: any) => void
30
+ * }} conversation
31
+ */
32
+ function createConversationHistory({ api, identity, snapshot, current, listening, available, receiveError }) {
33
+ const olderPages = ref([]);
34
+ let historyRevision = 0;
35
+ let deniedHistoryRevision = 0;
36
+ const loadingMore = ref(false);
37
+ const loadMoreError = ref("");
38
+ const turns = computed(() => {
39
+ const saved = mergeConversationLogPages([
40
+ ...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
41
+ ]).conversationLog;
42
+ // Use the original overlay against the full loaded saved span. A synthetic
43
+ // live older turn must not replace its authored request or saved progress.
44
+ const messages = (snapshot.value?.turns || []).flatMap(conversationTurnMessages);
45
+ return mergeConversationStream(saved, { messages });
46
+ });
47
+ const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
48
+ const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
49
+
50
+ /**
51
+ * Takes the older pages a new state brings, before the live page is replaced by it.
52
+ * @param {any} state
53
+ * @param {any} previous The live page the state replaces.
54
+ * @param {{ initial: boolean, canonical: boolean }} options
55
+ */
56
+ function receiveState(state, previous, { initial, canonical }) {
57
+ if (canonical) {
58
+ historyRevision += 1;
59
+ olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
60
+ loadMoreError.value = "";
61
+ } else if (olderPages.value.length) {
62
+ // Transcript patches may evict the head of the bounded live page before
63
+ // its canonical read completes. Retain saved rows, never display overlays.
64
+ const first = state.conversationLog?.[0]?.turnId;
65
+ const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
66
+ if (evicted.length) {
67
+ historyRevision += 1;
68
+ olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Applies a transcript event to the older page that holds its turn.
74
+ * @param {any} event
75
+ */
76
+ function receivePatch(event) {
77
+ if (event.type === "transcript" && olderPages.value.some(page =>
78
+ page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
79
+ historyRevision += 1;
80
+ olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
81
+ }
82
+ }
83
+ /** Drops the older pages, as when the view is cleared. */
84
+ function clear() {
85
+ historyRevision += 1;
86
+ olderPages.value = [];
87
+ }
88
+ /** Makes a read in flight stale, as when the subscription restarts. */
89
+ function invalidate() {
90
+ historyRevision += 1;
91
+ }
92
+ /** Makes a read in flight fail as denied, after the viewer lost access. */
93
+ function deny() {
94
+ deniedHistoryRevision += 1;
95
+ }
96
+
97
+ /**
98
+ * Rereads the live page and every older page already loaded, for the subscription's canonical read.
99
+ * @param {{ current: () => boolean }} read Whether the subscription still wants this read.
100
+ */
101
+ async function readLoadedHistory({ current: subscriptionCurrent }) {
102
+ const denial = deniedHistoryRevision;
103
+ const valid = () => {
104
+ if (denial !== deniedHistoryRevision) {
105
+ throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
106
+ }
107
+ return current.value && listening.value && subscriptionCurrent();
108
+ };
109
+ // A concurrent Load older operation extends the desired span. Retry one
110
+ // refresh in this same read, rather than dropping that newly loaded page.
111
+ while (valid()) {
112
+ const revision = historyRevision;
113
+ const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
114
+ const oldest = oldestTurn?.turnId;
115
+ const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
116
+ const latest = await api.readConversation(identity.conversationId);
117
+ if (!valid()) return latest;
118
+ const pages = [];
119
+ let page = normalizeConversationLogPage(latest);
120
+ const cursors = new Set();
121
+ while (oldest && page.pagination.hasMoreBefore) {
122
+ const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
123
+ const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
124
+ const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
125
+ if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
126
+ if (!cursor || cursors.has(cursor)) {
127
+ throw Object.assign(new Error("Older conversation history could not be refreshed. Reload chat to try again."), {
128
+ messageCode: "assistant.runtime.history.refreshFailed"
129
+ });
130
+ }
131
+ cursors.add(cursor);
132
+ page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
133
+ beforeTurnId: cursor, limit: latest.pagination.limit
134
+ }));
135
+ if (!valid()) return latest;
136
+ // A removed oldest row can make this page reach farther back than the
137
+ // reader loaded. Keep the original visible boundary and page metadata.
138
+ const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
139
+ if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
140
+ const roles = ["system", "user", "assistant"];
141
+ const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
142
+ return { ...turn, paginationPartial: true,
143
+ ...Object.fromEntries(excluded.map(role => [role, null])),
144
+ messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
145
+ });
146
+ pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
147
+ ...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
148
+ hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
149
+ } }));
150
+ }
151
+ if (revision !== historyRevision) continue;
152
+ return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Loads the page before the oldest loaded turn, kept separate from the live page.
159
+ * @param {{ complete?: (result: { changed: boolean, loaded: boolean }) => void }} [options]
160
+ */
161
+ async function loadMore({ complete = ignoreLoadResult } = {}) {
162
+ const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
163
+ const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
164
+ String(turns.value[0]?.turnId || "").trim();
165
+ if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
166
+ complete({ changed: false, loaded: false });
167
+ return false;
168
+ }
169
+ const revision = historyRevision;
170
+ loadingMore.value = true;
171
+ loadMoreError.value = "";
172
+ try {
173
+ const page = await api.readConversation(identity.conversationId, {
174
+ beforeTurnId, limit: snapshot.value.pagination.limit
175
+ });
176
+ if (!available.value || revision !== historyRevision) {
177
+ complete({ changed: false, loaded: false });
178
+ return false;
179
+ }
180
+ const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
181
+ const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
182
+ historyRevision += 1;
183
+ olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
184
+ const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
185
+ const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
186
+ const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
187
+ nextMessageCount !== previousMessageCount));
188
+ complete({ changed, loaded: true });
189
+ return true;
190
+ } catch (failure) {
191
+ if (current.value) {
192
+ loadMoreError.value = String(failureMessage(failure) || failure || t("jskit.assistant.runtime.history.loadFailed"));
193
+ if (isAccessDenied(failure)) receiveError(failure);
194
+ }
195
+ complete({ changed: false, loaded: false });
196
+ return false;
197
+ } finally { loadingMore.value = false; }
198
+ }
199
+
200
+ return {
201
+ turns,
202
+ hasMoreBefore,
203
+ loadingMore,
204
+ loadMoreError,
205
+ receiveState,
206
+ receivePatch,
207
+ clear,
208
+ invalidate,
209
+ deny,
210
+ readLoadedHistory,
211
+ loadMore
212
+ };
213
+ }
214
+
215
+ export { createConversationHistory };