@jskit-ai/assistant-runtime 0.1.201 → 0.1.204
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.
|
@@ -169,6 +169,7 @@ export default defineConfig({
|
|
|
169
169
|
if (match[2].startsWith("/deliveries/") && match[2].endsWith("/inspect")) {
|
|
170
170
|
const messageId = decodeURIComponent(match[2].split("/")[2]);
|
|
171
171
|
const receipt = state.conversationLog.find(turn => turn.user?.messageId === messageId);
|
|
172
|
+
if (!receipt && mode === "not-sent") return json({ status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." });
|
|
172
173
|
return json(receipt ? { status: "accepted", messageId, turnId: receipt.turnId } : { status: "unknown", messageId });
|
|
173
174
|
}
|
|
174
175
|
return json({ error: "Unknown fixture operation" }, 404);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.204",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -12,19 +12,19 @@
|
|
|
12
12
|
"./server": "./src/server/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
15
|
+
"@jskit-ai/assistant-core": "0.1.211",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/realtime": "0.1.
|
|
23
|
-
"@jskit-ai/shell-web": "0.1.
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.231",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.78",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.233",
|
|
22
|
+
"@jskit-ai/realtime": "0.1.230",
|
|
23
|
+
"@jskit-ai/shell-web": "0.1.237",
|
|
24
24
|
"@tanstack/vue-query": "^5.90.5",
|
|
25
25
|
"vue": "^3.5.13",
|
|
26
26
|
"vuetify": "^4.0.0",
|
|
27
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
27
|
+
"@jskit-ai/database-runtime": "0.1.233"
|
|
28
28
|
},
|
|
29
29
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
30
30
|
"jskit": {
|
|
@@ -6,18 +6,19 @@ import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversat
|
|
|
6
6
|
import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
|
|
7
7
|
import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
|
|
8
8
|
import {
|
|
9
|
-
conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages,
|
|
9
|
+
conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages, mergeConversationStream,
|
|
10
10
|
normalizeConversationLogPage, normalizeConversationLogPagination
|
|
11
11
|
} from "@jskit-ai/assistant-core/shared/conversation";
|
|
12
12
|
import { useRealtimeSocket } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
|
|
13
13
|
import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
|
|
14
14
|
import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
15
15
|
import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
|
|
16
|
-
import { subscribeAssistantConversation } from "../support/subscribeAssistantConversation.js";
|
|
16
|
+
import { subscribeAssistantConversation, refreshedHistoryPages, patchLoadedHistoryPages } from "../support/subscribeAssistantConversation.js";
|
|
17
17
|
|
|
18
18
|
const text = value => String(toValue(value) ?? "").trim();
|
|
19
19
|
// Keep the injection key stable across optimized package and raw Vue imports.
|
|
20
20
|
const conversationDefaultsKey = Symbol.for("jskit.assistant-runtime.conversation.defaults");
|
|
21
|
+
const compareTurnIds = (left, right) => String(left).localeCompare(String(right), undefined, { numeric: true });
|
|
21
22
|
|
|
22
23
|
/** Configure this Vue application's existing conversation binding before mounting. */
|
|
23
24
|
function configureAssistantConversations(app, { actorKey, api = null, request, clearDraftOn = "dispatch" } = {}) {
|
|
@@ -80,11 +81,21 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
80
81
|
const delivery = createAssistantMessageDelivery();
|
|
81
82
|
const pendingMessages = new Map();
|
|
82
83
|
const olderPages = ref([]);
|
|
84
|
+
let historyRevision = 0;
|
|
85
|
+
let deniedHistoryRevision = 0;
|
|
83
86
|
const loadingMore = ref(false);
|
|
84
87
|
const loadMoreError = ref("");
|
|
85
|
-
const turns = computed(() =>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const turns = computed(() => {
|
|
89
|
+
const saved = mergeConversationLogPages([
|
|
90
|
+
...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
|
|
91
|
+
]).conversationLog;
|
|
92
|
+
// Use the original overlay against the full loaded saved span. A synthetic
|
|
93
|
+
// live older turn must not replace its authored request or saved progress.
|
|
94
|
+
const messages = (snapshot.value?.turns || []).flatMap(turn => turn.messages || [
|
|
95
|
+
turn.system, turn.user, ...(turn.thinking || []), ...(turn.commentary || []), turn.assistant
|
|
96
|
+
].filter(Boolean));
|
|
97
|
+
return mergeConversationStream(saved, { messages });
|
|
98
|
+
});
|
|
88
99
|
const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
|
|
89
100
|
const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
|
|
90
101
|
const editable = computed(() => active.value && !accessDenied.value);
|
|
@@ -173,8 +184,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
173
184
|
previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
|
|
174
185
|
JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
|
|
175
186
|
if (canonical) {
|
|
176
|
-
|
|
187
|
+
historyRevision += 1;
|
|
188
|
+
olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
|
|
177
189
|
loadMoreError.value = "";
|
|
190
|
+
} else if (olderPages.value.length) {
|
|
191
|
+
// Transcript patches may evict the head of the bounded live page before
|
|
192
|
+
// its canonical read completes. Retain saved rows, never display overlays.
|
|
193
|
+
const first = state.conversationLog?.[0]?.turnId;
|
|
194
|
+
const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
|
|
195
|
+
if (evicted.length) {
|
|
196
|
+
historyRevision += 1;
|
|
197
|
+
olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
|
|
198
|
+
}
|
|
178
199
|
}
|
|
179
200
|
snapshot.value = state;
|
|
180
201
|
loading.value = false;
|
|
@@ -196,7 +217,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
196
217
|
error.value = failure.message;
|
|
197
218
|
accessDenied.value = [401, 403].includes(Number(failure.status || failure.statusCode));
|
|
198
219
|
if (accessDenied.value) {
|
|
220
|
+
deniedHistoryRevision += 1;
|
|
199
221
|
snapshot.value = null;
|
|
222
|
+
historyRevision += 1;
|
|
200
223
|
olderPages.value = [];
|
|
201
224
|
draft.value = "";
|
|
202
225
|
draftMessageId.value = "";
|
|
@@ -210,6 +233,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
210
233
|
}
|
|
211
234
|
}
|
|
212
235
|
function receiveEvent(event) {
|
|
236
|
+
if (event.type === "transcript" && olderPages.value.some(page =>
|
|
237
|
+
page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
|
|
238
|
+
historyRevision += 1;
|
|
239
|
+
olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
|
|
240
|
+
}
|
|
213
241
|
if (event.type === "goal") void refreshGoal();
|
|
214
242
|
for (const [token, reader] of [...readers]) {
|
|
215
243
|
if (!current.value) return;
|
|
@@ -220,15 +248,54 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
220
248
|
catch {}
|
|
221
249
|
}
|
|
222
250
|
}
|
|
251
|
+
async function readLoadedHistory({ current: subscriptionCurrent }) {
|
|
252
|
+
const denial = deniedHistoryRevision;
|
|
253
|
+
const valid = () => {
|
|
254
|
+
if (denial !== deniedHistoryRevision) throw Object.assign(new Error("Access denied."), { status: 403 });
|
|
255
|
+
return current.value && active.value && subscriptionCurrent();
|
|
256
|
+
};
|
|
257
|
+
// A concurrent Load older operation extends the desired span. Retry one
|
|
258
|
+
// refresh in this same read, rather than dropping that newly loaded page.
|
|
259
|
+
while (valid()) {
|
|
260
|
+
const revision = historyRevision;
|
|
261
|
+
const oldest = olderPages.value[0]?.conversationLog?.[0]?.turnId;
|
|
262
|
+
const latest = await api.readConversation(identity.conversationId);
|
|
263
|
+
if (!valid()) return latest;
|
|
264
|
+
const pages = [];
|
|
265
|
+
let page = normalizeConversationLogPage(latest);
|
|
266
|
+
const cursors = new Set();
|
|
267
|
+
while (oldest && page.pagination.hasMoreBefore) {
|
|
268
|
+
const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
|
|
269
|
+
if (cursor && compareTurnIds(cursor, oldest) <= 0) break;
|
|
270
|
+
if (!cursor || cursors.has(cursor)) throw new Error("Older conversation history could not be refreshed. Reload chat to try again.");
|
|
271
|
+
cursors.add(cursor);
|
|
272
|
+
page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
|
|
273
|
+
beforeTurnId: cursor, limit: latest.pagination.limit
|
|
274
|
+
}));
|
|
275
|
+
if (!valid()) return latest;
|
|
276
|
+
// A removed oldest row can make this page reach farther back than the
|
|
277
|
+
// reader loaded. Keep the original visible boundary and page metadata.
|
|
278
|
+
const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0);
|
|
279
|
+
pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
|
|
280
|
+
...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
|
|
281
|
+
hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
|
|
282
|
+
} }));
|
|
283
|
+
}
|
|
284
|
+
if (revision !== historyRevision) continue;
|
|
285
|
+
return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
223
289
|
watch(active, enabled => {
|
|
224
290
|
subscription?.();
|
|
225
291
|
subscription = null;
|
|
292
|
+
historyRevision += 1;
|
|
226
293
|
if (!enabled) return;
|
|
227
294
|
loading.value = true;
|
|
228
295
|
subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
|
|
229
296
|
targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
|
|
230
297
|
...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
|
|
231
|
-
read:
|
|
298
|
+
read: readLoadedHistory, onState: receiveState,
|
|
232
299
|
onError: receiveError, onEvent: receiveEvent
|
|
233
300
|
});
|
|
234
301
|
}, { immediate: true, flush: "sync" });
|
|
@@ -237,6 +304,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
237
304
|
for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
|
|
238
305
|
delivery.reset();
|
|
239
306
|
snapshot.value = null;
|
|
307
|
+
historyRevision += 1;
|
|
240
308
|
olderPages.value = [];
|
|
241
309
|
draft.value = "";
|
|
242
310
|
draftMessageId.value = "";
|
|
@@ -416,17 +484,19 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
416
484
|
finish({ changed: false, loaded: false });
|
|
417
485
|
return false;
|
|
418
486
|
}
|
|
487
|
+
const revision = historyRevision;
|
|
419
488
|
loadingMore.value = true;
|
|
420
489
|
loadMoreError.value = "";
|
|
421
490
|
try {
|
|
422
491
|
const page = await api.readConversation(identity.conversationId, {
|
|
423
492
|
beforeTurnId, limit: snapshot.value.pagination.limit
|
|
424
493
|
});
|
|
425
|
-
if (!available.value) {
|
|
494
|
+
if (!available.value || revision !== historyRevision) {
|
|
426
495
|
finish({ changed: false, loaded: false });
|
|
427
496
|
return false;
|
|
428
497
|
}
|
|
429
498
|
const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
499
|
+
historyRevision += 1;
|
|
430
500
|
olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
|
|
431
501
|
const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
432
502
|
const changed = Boolean(nextOldestTurnId && nextOldestTurnId !== previousOldestTurnId);
|
|
@@ -571,6 +641,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
571
641
|
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
|
|
572
642
|
if (!current.value) return false;
|
|
573
643
|
if (receipt.status === "accepted") delivery.accept(messageId);
|
|
644
|
+
else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, receipt.error);
|
|
574
645
|
subscription?.reload();
|
|
575
646
|
return receipt;
|
|
576
647
|
} catch (failure) { receiveError(failure); return false; }
|
|
@@ -5,6 +5,15 @@ import {
|
|
|
5
5
|
ASSISTANT_CONVERSATION_UNSUBSCRIBE
|
|
6
6
|
} from "../../shared/conversationRealtime.js";
|
|
7
7
|
|
|
8
|
+
// Private to the original client history/subscription coordination. Symbols
|
|
9
|
+
// survive local snapshot spreads but never enter the conversation wire format.
|
|
10
|
+
const refreshedHistoryPages = Symbol("assistant.refreshedHistoryPages");
|
|
11
|
+
|
|
12
|
+
function patchLoadedHistoryPages(pages = [], patch) {
|
|
13
|
+
return pages.map(page => page.conversationLog.some(turn => turn.turnId === patch?.turn?.turnId)
|
|
14
|
+
? applyConversationLogPatch(page, patch, { limit: page.pagination?.limit }) || page : page);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
|
|
9
18
|
|
|
10
19
|
function subscribeAssistantConversation({
|
|
@@ -72,13 +81,16 @@ function subscribeAssistantConversation({
|
|
|
72
81
|
const job = Promise.resolve().then(() => {
|
|
73
82
|
if (!current()) return null;
|
|
74
83
|
pendingReads.add(pendingRead);
|
|
75
|
-
return read();
|
|
84
|
+
return read({ current });
|
|
76
85
|
}).then(snapshot => {
|
|
77
86
|
if (!current()) return;
|
|
78
87
|
// Ported from the original history reader: retain only delivered updates
|
|
79
88
|
// received while this request was in flight before replacing its cache.
|
|
80
89
|
for (const patch of pendingRead.patches) {
|
|
81
90
|
snapshot = applyConversationLogPatch(snapshot, patch, { limit: snapshot.pagination?.limit });
|
|
91
|
+
if (snapshot[refreshedHistoryPages]) {
|
|
92
|
+
snapshot[refreshedHistoryPages] = patchLoadedHistoryPages(snapshot[refreshedHistoryPages], patch);
|
|
93
|
+
}
|
|
82
94
|
}
|
|
83
95
|
for (const [id, entry] of completed) if (settled.has(entry.turnId)) completed.delete(id);
|
|
84
96
|
receiveState(snapshot);
|
|
@@ -226,4 +238,4 @@ function subscribeAssistantConversation({
|
|
|
226
238
|
return Object.assign(release, { reload });
|
|
227
239
|
}
|
|
228
240
|
|
|
229
|
-
export { subscribeAssistantConversation };
|
|
241
|
+
export { subscribeAssistantConversation, refreshedHistoryPages, patchLoadedHistoryPages };
|
|
@@ -36,6 +36,54 @@ async function fixture(t, width = 1280, { acceptedDraft = false, draftWhileLoadi
|
|
|
36
36
|
return { page, primary, input, command, state, sent, notify };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
test("inspecting a restored no-admission request releases Send while preserving newer draft and uploads", options, async t => {
|
|
40
|
+
const f = await fixture(t, 390);
|
|
41
|
+
const attach = async () => {
|
|
42
|
+
await f.page.evaluate(() => {
|
|
43
|
+
window.savedBinding = window.conversationFixture.acquireConversation({
|
|
44
|
+
conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "not-sent-recovery" }
|
|
45
|
+
});
|
|
46
|
+
window.conversationFixture.target("chat:2");
|
|
47
|
+
});
|
|
48
|
+
await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
|
|
49
|
+
};
|
|
50
|
+
await attach();
|
|
51
|
+
await f.command("mode", { mode: "uncertain" });
|
|
52
|
+
await f.input.fill("Original unadmitted words");
|
|
53
|
+
await f.input.press("Enter");
|
|
54
|
+
const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
|
|
55
|
+
await expect(check).toBeVisible();
|
|
56
|
+
const [original] = await f.sent();
|
|
57
|
+
await f.input.fill("A separate newer draft");
|
|
58
|
+
await f.command("question", { id: "chat:2", text: "An unrelated completed answer.", pending: false });
|
|
59
|
+
await f.notify("chat:2");
|
|
60
|
+
await f.page.reload();
|
|
61
|
+
await expect(f.input).toBeEnabled();
|
|
62
|
+
await attach();
|
|
63
|
+
await expect(f.input).toHaveValue("A separate newer draft");
|
|
64
|
+
const send = f.primary.getByRole("button", { name: "Send message", exact: true });
|
|
65
|
+
await expect(send).toBeDisabled();
|
|
66
|
+
await f.page.evaluate(() => window.conversationFixture.attachments(true));
|
|
67
|
+
await f.primary.locator("input[type=file]").setInputFiles({ name: "newer.txt", mimeType: "text/plain", buffer: Buffer.from("Keep this upload") });
|
|
68
|
+
const uploads = () => f.page.evaluate(() => window.conversationFixture.attachmentState());
|
|
69
|
+
await expect.poll(async () => (await uploads()).ready.length).toBe(1);
|
|
70
|
+
await f.command("mode", { mode: "not-sent" });
|
|
71
|
+
await check.click();
|
|
72
|
+
await expect(check).toHaveCount(0);
|
|
73
|
+
await expect(send).toBeEnabled();
|
|
74
|
+
await expect(f.input).toHaveValue("A separate newer draft");
|
|
75
|
+
assert.equal((await f.sent()).length, 1, "Inspection never resends the old request or submits the new draft");
|
|
76
|
+
assert.deepEqual((await uploads()).acknowledged, []);
|
|
77
|
+
assert.deepEqual((await uploads()).deleted, []);
|
|
78
|
+
assert.equal((await uploads()).ready[0].fileName, "newer.txt");
|
|
79
|
+
await f.command("mode", { mode: "product-accepted" });
|
|
80
|
+
await send.click();
|
|
81
|
+
await expect.poll(async () => (await f.sent()).length).toBe(2);
|
|
82
|
+
const next = (await f.sent())[1];
|
|
83
|
+
assert.notEqual(next.input.messageId, original.input.messageId);
|
|
84
|
+
assert.equal(next.input.text, "A separate newer draft");
|
|
85
|
+
});
|
|
86
|
+
|
|
39
87
|
test("the canonical standard element keeps draft/focus/layout while applying the shared stream snapshot", options, async t => {
|
|
40
88
|
const f = await fixture(t, 390);
|
|
41
89
|
await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toHaveCount(0);
|
|
@@ -2172,3 +2220,262 @@ test("transient subscription errors keep loaded history and recover through the
|
|
|
2172
2220
|
assert.equal((await f.sent()).length, sentBeforeRecovery);
|
|
2173
2221
|
assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
2174
2222
|
});
|
|
2223
|
+
|
|
2224
|
+
test("canonical incoming messages preserve paged history and the scrolled-up reader", options, async t => {
|
|
2225
|
+
const f = await fixture(t, 390);
|
|
2226
|
+
const turns = Array.from({ length: 24 }, (_, index) => ({
|
|
2227
|
+
turnId: String(index + 1).padStart(6, "0"),
|
|
2228
|
+
user: { messageId: `loaded-user-${index}`, role: "user", text: `Loaded question ${index + 1}. ` + "Keep the earlier discussion. ".repeat(6) },
|
|
2229
|
+
assistant: { messageId: `loaded-answer-${index}`, role: "assistant", text: `Loaded answer ${index + 1}. ` + "Keep the earlier answer. ".repeat(6) }
|
|
2230
|
+
}));
|
|
2231
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2232
|
+
await f.notify("chat:1");
|
|
2233
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
|
|
2234
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2235
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2236
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(18);
|
|
2237
|
+
const body = f.primary.locator(".assistant-transcript__body");
|
|
2238
|
+
const previousScroll = await body.evaluate(element => element.scrollTop);
|
|
2239
|
+
await body.hover();
|
|
2240
|
+
await f.page.mouse.wheel(0, -300);
|
|
2241
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
|
|
2242
|
+
await body.evaluate(element => { element.scrollTop = 420; });
|
|
2243
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2244
|
+
await f.input.fill("Keep this unsent draft");
|
|
2245
|
+
await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(2, 7); });
|
|
2246
|
+
turns.push({ turnId: "000025", user: { messageId: "incoming-user", role: "user", text: "New incoming question" } });
|
|
2247
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2248
|
+
await f.notify("chat:1");
|
|
2249
|
+
await expect(f.primary.getByText("New incoming question", { exact: true })).toHaveCount(1);
|
|
2250
|
+
const ids = await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId));
|
|
2251
|
+
assert.deepEqual(ids, turns.slice(6).map(turn => turn.turnId), "A canonical incoming message must retain every loaded turn, including the row evicted from the latest page");
|
|
2252
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2253
|
+
await expect(f.input).toHaveValue("Keep this unsent draft");
|
|
2254
|
+
await expect(f.input).toBeFocused();
|
|
2255
|
+
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 7]);
|
|
2256
|
+
assert.deepEqual(await f.sent(), [], "History observation does not send or resend work");
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
for (const width of [390, 768, 1280]) {
|
|
2261
|
+
test(`canonical loaded-history refresh is atomic and removes saved rows at width ${width}`, options, async t => {
|
|
2262
|
+
const f = await fixture(t, width);
|
|
2263
|
+
const turns = Array.from({ length: 24 }, (_, index) => ({
|
|
2264
|
+
turnId: String(index + 1).padStart(6, "0"),
|
|
2265
|
+
user: { role: "user", messageId: `atomic-user-${index}`, text: `Atomic saved question ${index + 1}. ` + "Retain visible discussion. ".repeat(8) },
|
|
2266
|
+
assistant: { role: "assistant", messageId: `atomic-answer-${index}`, text: `Atomic saved answer ${index + 1}. ` + "Retain visible answer. ".repeat(8) }
|
|
2267
|
+
}));
|
|
2268
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2269
|
+
await f.notify("chat:1");
|
|
2270
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
|
|
2271
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2272
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2273
|
+
const body = f.primary.locator(".assistant-transcript__body");
|
|
2274
|
+
const previousScroll = await body.evaluate(element => element.scrollTop);
|
|
2275
|
+
await body.hover();
|
|
2276
|
+
await f.page.mouse.wheel(0, -300);
|
|
2277
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
|
|
2278
|
+
await body.evaluate(element => { window.atomicHistoryBody = element; element.scrollTop = 420; });
|
|
2279
|
+
await f.input.fill("Unsent while history refreshes");
|
|
2280
|
+
await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(3, 8); });
|
|
2281
|
+
const previousIds = turns.slice(6).map(turn => turn.turnId);
|
|
2282
|
+
const fresh = turns.filter(turn => !["000007", "000010"].includes(turn.turnId));
|
|
2283
|
+
fresh[8] = { ...fresh[8], assistant: { ...fresh[8].assistant, text: "Authoritatively replaced saved answer" } };
|
|
2284
|
+
fresh.push({ turnId: "000025", user: { role: "user", messageId: "atomic-incoming", text: "Atomic new question" } });
|
|
2285
|
+
await f.command("history", { id: "chat:1", turns: fresh, limit: 6 });
|
|
2286
|
+
let release;
|
|
2287
|
+
let held = false;
|
|
2288
|
+
const reads = "**/api/assistant/home/conversations/chat%3A1?**";
|
|
2289
|
+
await f.page.route(reads, async route => {
|
|
2290
|
+
if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
|
|
2291
|
+
held = true;
|
|
2292
|
+
const response = await route.fetch();
|
|
2293
|
+
await new Promise(resolve => { release = resolve; });
|
|
2294
|
+
await route.fulfill({ response });
|
|
2295
|
+
});
|
|
2296
|
+
await f.notify("chat:1");
|
|
2297
|
+
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2298
|
+
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), previousIds);
|
|
2299
|
+
assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
|
|
2300
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2301
|
+
await expect(f.input).toHaveValue("Unsent while history refreshes");
|
|
2302
|
+
await expect(f.input).toBeFocused();
|
|
2303
|
+
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
|
|
2304
|
+
release();
|
|
2305
|
+
await expect(f.primary.getByText("Atomic new question", { exact: true })).toHaveCount(1);
|
|
2306
|
+
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)),
|
|
2307
|
+
fresh.filter(turn => Number(turn.turnId) >= 7).map(turn => turn.turnId));
|
|
2308
|
+
await expect(f.primary.getByText("Authoritatively replaced saved answer", { exact: true })).toHaveCount(1);
|
|
2309
|
+
await expect(f.primary.getByText(/Atomic saved question (7|10)\./)).toHaveCount(0);
|
|
2310
|
+
assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
|
|
2311
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2312
|
+
await expect(f.input).toHaveValue("Unsent while history refreshes");
|
|
2313
|
+
await expect(f.input).toBeFocused();
|
|
2314
|
+
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
|
|
2315
|
+
assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
2316
|
+
assert.deepEqual(await f.sent(), []);
|
|
2317
|
+
await f.page.unroute(reads);
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
test("concurrent older-history loading extends canonical refresh without reviving revoked pages", options, async t => {
|
|
2322
|
+
const f = await fixture(t, 390);
|
|
2323
|
+
const turns = Array.from({ length: 24 }, (_, index) => ({
|
|
2324
|
+
turnId: String(index + 1).padStart(6, "0"),
|
|
2325
|
+
user: { role: "user", messageId: `concurrent-${index}`, text: `Concurrent saved question ${index + 1}` }
|
|
2326
|
+
}));
|
|
2327
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2328
|
+
await f.notify("chat:1");
|
|
2329
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
|
|
2330
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2331
|
+
turns.push({ turnId: "000025", user: { role: "user", messageId: "concurrent-new", text: "Concurrent new question" } });
|
|
2332
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2333
|
+
let release;
|
|
2334
|
+
let held = false;
|
|
2335
|
+
const reads = "**/api/assistant/home/conversations/chat%3A1?**";
|
|
2336
|
+
await f.page.route(reads, async route => {
|
|
2337
|
+
if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
|
|
2338
|
+
held = true;
|
|
2339
|
+
const response = await route.fetch();
|
|
2340
|
+
await new Promise(resolve => { release = resolve; });
|
|
2341
|
+
await route.fulfill({ response });
|
|
2342
|
+
});
|
|
2343
|
+
await f.notify("chat:1");
|
|
2344
|
+
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2345
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2346
|
+
release();
|
|
2347
|
+
await expect(f.primary.getByText("Concurrent new question", { exact: true })).toHaveCount(1);
|
|
2348
|
+
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), turns.slice(6).map(turn => turn.turnId));
|
|
2349
|
+
await f.page.unroute(reads);
|
|
2350
|
+
|
|
2351
|
+
held = false;
|
|
2352
|
+
release = null;
|
|
2353
|
+
await f.page.route(reads, async route => {
|
|
2354
|
+
if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
|
|
2355
|
+
held = true;
|
|
2356
|
+
const response = await route.fetch();
|
|
2357
|
+
await new Promise(resolve => { release = resolve; });
|
|
2358
|
+
await route.fulfill({ response });
|
|
2359
|
+
});
|
|
2360
|
+
await f.notify("chat:1");
|
|
2361
|
+
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2362
|
+
await f.command("deny", { denied: true });
|
|
2363
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), false);
|
|
2364
|
+
await expect(f.input).toBeDisabled();
|
|
2365
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(0);
|
|
2366
|
+
release();
|
|
2367
|
+
await expect(f.primary.locator(".assistant-transcript__error")).toContainText("Access denied.");
|
|
2368
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(0);
|
|
2369
|
+
assert.deepEqual(await f.sent(), []);
|
|
2370
|
+
await f.page.unroute(reads);
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
test("loaded saved history bridges transcript evictions and rejects a retired reader's page response", options, async t => {
|
|
2375
|
+
const f = await fixture(t, 390);
|
|
2376
|
+
const turns = Array.from({ length: 24 }, (_, index) => ({
|
|
2377
|
+
turnId: String(index + 1).padStart(6, "0"),
|
|
2378
|
+
user: { role: "user", messageId: `patch-history-${index}`, text: `Patch saved question ${index + 1}` }
|
|
2379
|
+
}));
|
|
2380
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2381
|
+
await f.notify("chat:1");
|
|
2382
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
|
|
2383
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2384
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2385
|
+
const incoming = { turnId: "000025", user: { role: "user", messageId: "patch-incoming", text: "Patch incoming question" } };
|
|
2386
|
+
await f.page.evaluate(turn => window.conversationFixture.socket.notify("chat:1", {
|
|
2387
|
+
type: "transcript", patch: { type: "upsert-turn", turn }
|
|
2388
|
+
}), incoming);
|
|
2389
|
+
await expect(f.primary.getByText("Patch incoming question", { exact: true })).toHaveCount(1);
|
|
2390
|
+
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)),
|
|
2391
|
+
[...turns.slice(6), incoming].map(turn => turn.turnId), "A bounded live patch must not lose its evicted saved row");
|
|
2392
|
+
await f.command("history", { id: "chat:1", turns: [...turns, incoming], limit: 6 });
|
|
2393
|
+
let release;
|
|
2394
|
+
let held = false;
|
|
2395
|
+
const reads = "**/api/assistant/home/conversations/chat%3A1?**";
|
|
2396
|
+
await f.page.route(reads, async route => {
|
|
2397
|
+
if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
|
|
2398
|
+
held = true;
|
|
2399
|
+
const response = await route.fetch();
|
|
2400
|
+
await new Promise(resolve => { release = resolve; });
|
|
2401
|
+
await route.fulfill({ response });
|
|
2402
|
+
});
|
|
2403
|
+
await f.notify("chat:1");
|
|
2404
|
+
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2405
|
+
await f.page.evaluate(() => {
|
|
2406
|
+
window.retiredHistoryReader = window.conversationFixture.current();
|
|
2407
|
+
window.conversationFixture.actor("different-actor");
|
|
2408
|
+
});
|
|
2409
|
+
await expect.poll(() => f.page.evaluate(() => window.retiredHistoryReader.current.value)).toBe(false);
|
|
2410
|
+
release();
|
|
2411
|
+
await expect.poll(() => f.page.evaluate(() => window.retiredHistoryReader.turns.value.length)).toBe(0);
|
|
2412
|
+
await expect.poll(() => f.page.evaluate(() => window.retiredHistoryReader.snapshot.value)).toBe(null);
|
|
2413
|
+
assert.deepEqual(await f.sent(), []);
|
|
2414
|
+
await f.page.unroute(reads);
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
test("canonical rewind replaces the loaded saved window without retaining removed turns", options, async t => {
|
|
2418
|
+
const f = await fixture(t, 768);
|
|
2419
|
+
const turns = Array.from({ length: 24 }, (_, index) => ({
|
|
2420
|
+
turnId: String(index + 1).padStart(6, "0"),
|
|
2421
|
+
user: { role: "user", messageId: `rewind-${index}`, text: `Rewind saved question ${index + 1}` }
|
|
2422
|
+
}));
|
|
2423
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2424
|
+
await f.notify("chat:1");
|
|
2425
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
|
|
2426
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2427
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2428
|
+
await f.command("history", { id: "chat:1", turns: turns.slice(0, 12), limit: 6 });
|
|
2429
|
+
await f.notify("chat:1");
|
|
2430
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)))
|
|
2431
|
+
.toEqual(turns.slice(6, 12).map(turn => turn.turnId));
|
|
2432
|
+
assert.deepEqual(await f.sent(), []);
|
|
2433
|
+
});
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
test("canonical loaded-history replay retains older completed output and its authored row", options, async t => {
|
|
2437
|
+
const f = await fixture(t, 390);
|
|
2438
|
+
const turns = Array.from({ length: 25 }, (_, index) => ({
|
|
2439
|
+
turnId: String(index + 1).padStart(6, "0"),
|
|
2440
|
+
user: { role: "user", messageId: `older-replay-user-${index}`, text: `Older replay question ${index + 1}` },
|
|
2441
|
+
...(index === 18 ? { commentary: [{ role: "commentary", messageId: "older-saved-progress", text: "Saved older progress" }] } : {})
|
|
2442
|
+
}));
|
|
2443
|
+
await f.command("history", { id: "chat:1", turns, limit: 6 });
|
|
2444
|
+
await f.notify("chat:1");
|
|
2445
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
|
|
2446
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
|
|
2447
|
+
let release;
|
|
2448
|
+
let held = false;
|
|
2449
|
+
const reads = "**/api/assistant/home/conversations/chat%3A1?**";
|
|
2450
|
+
await f.page.route(reads, async route => {
|
|
2451
|
+
if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
|
|
2452
|
+
held = true;
|
|
2453
|
+
const response = await route.fetch();
|
|
2454
|
+
await new Promise(resolve => { release = resolve; });
|
|
2455
|
+
await route.fulfill({ response });
|
|
2456
|
+
});
|
|
2457
|
+
await f.notify("chat:1");
|
|
2458
|
+
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2459
|
+
const assistant = { role: "assistant", messageId: "older-replayed-answer", text: "Delivered older completed output" };
|
|
2460
|
+
await f.page.evaluate(assistant => window.conversationFixture.socket.notify("chat:1", {
|
|
2461
|
+
type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "000019", assistant } }
|
|
2462
|
+
}), assistant);
|
|
2463
|
+
release();
|
|
2464
|
+
await expect(f.primary.getByText("Delivered older completed output", { exact: true })).toHaveCount(1);
|
|
2465
|
+
const saved = await f.page.evaluate(() => window.conversationFixture.current().turns.value.find(turn => turn.turnId === "000019"));
|
|
2466
|
+
assert.equal(saved.user.text, "Older replay question 19");
|
|
2467
|
+
assert.equal(saved.commentary[0].text, "Saved older progress");
|
|
2468
|
+
assert.equal(saved.assistant.text, "Delivered older completed output");
|
|
2469
|
+
assert.equal((await f.page.evaluate(() => window.conversationFixture.current().snapshot.value.conversationLog.length)), 6);
|
|
2470
|
+
await f.page.unroute(reads);
|
|
2471
|
+
await f.page.evaluate(() => window.conversationFixture.socket.notify("chat:1", {
|
|
2472
|
+
type: "message", status: "complete", turnId: "000019", messageId: "older-display-only-output", role: "assistant", text: "Live older completed reply",
|
|
2473
|
+
streaming: { revision: 1, messages: [] }
|
|
2474
|
+
}));
|
|
2475
|
+
await expect(f.primary.getByText("Live older completed reply", { exact: true })).toHaveCount(1);
|
|
2476
|
+
const display = await f.page.evaluate(() => window.conversationFixture.current().turns.value.find(turn => turn.turnId === "000019"));
|
|
2477
|
+
assert.equal(display.user.text, "Older replay question 19", "An older turn overlay cannot replace its authored saved row");
|
|
2478
|
+
assert.equal(display.commentary[0].text, "Saved older progress");
|
|
2479
|
+
assert.equal(display.assistant.text, "Live older completed reply");
|
|
2480
|
+
assert.deepEqual(await f.sent(), []);
|
|
2481
|
+
});
|