@jskit-ai/assistant-runtime 0.1.215 → 0.1.217

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.
@@ -122,6 +122,7 @@ export default defineConfig({
122
122
  if (denied || !state) return json({ error: "Access denied.", code: "conversation_forbidden" }, 403);
123
123
  if (request.method === "GET" && !match[2]) {
124
124
  return json(pageLimits.has(id) ? { ...state, ...await transcript.readConversationLogPage(id, {
125
+ pageUnit: "messages",
125
126
  beforeTurnId: url.searchParams.get("beforeTurnId") || "",
126
127
  limit: url.searchParams.get("limit") || pageLimits.get(id)
127
128
  }) } : state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.215",
3
+ "version": "0.1.217",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -12,7 +12,7 @@
12
12
  "./server": "./src/server/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/assistant-core": "0.1.222",
15
+ "@jskit-ai/assistant-core": "0.1.224",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
@@ -84,7 +84,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
84
84
  const error = ref("");
85
85
  const loading = ref(true);
86
86
  const stopping = ref(false);
87
- const goalPending = ref(false);
87
+ const goalAction = ref("");
88
88
  const goalTargetCurrent = ref(false);
89
89
  const goalError = ref("");
90
90
  const goalView = shallowRef(null);
@@ -115,7 +115,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
115
115
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
116
116
  const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
117
117
  const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
118
- const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
118
+ const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
119
119
  (queueWhileSending !== false || !delivery.state.sending) &&
120
120
  !delivery.state.messages.some(message => message.status === "uncertain" &&
121
121
  (snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
@@ -221,6 +221,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
221
221
  // Keep the open goal menu while its first native identity arrives. The
222
222
  // pending read disables commands until it supplies the new exact target.
223
223
  goalTargetCurrent.value = false;
224
+ goalLoadError.value = "";
224
225
  goalReadController?.abort();
225
226
  }
226
227
  if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
@@ -274,7 +275,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
274
275
  // refresh in this same read, rather than dropping that newly loaded page.
275
276
  while (valid()) {
276
277
  const revision = historyRevision;
277
- const oldest = olderPages.value[0]?.conversationLog?.[0]?.turnId;
278
+ const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
279
+ const oldest = oldestTurn?.turnId;
280
+ const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
278
281
  const latest = await api.readConversation(identity.conversationId);
279
282
  if (!valid()) return latest;
280
283
  const pages = [];
@@ -282,7 +285,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
282
285
  const cursors = new Set();
283
286
  while (oldest && page.pagination.hasMoreBefore) {
284
287
  const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
285
- if (cursor && compareTurnIds(cursor, oldest) <= 0) break;
288
+ const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
289
+ const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
290
+ if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
286
291
  if (!cursor || cursors.has(cursor)) throw new Error("Older conversation history could not be refreshed. Reload chat to try again.");
287
292
  cursors.add(cursor);
288
293
  page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
@@ -291,7 +296,14 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
291
296
  if (!valid()) return latest;
292
297
  // A removed oldest row can make this page reach farther back than the
293
298
  // reader loaded. Keep the original visible boundary and page metadata.
294
- const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0);
299
+ const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
300
+ if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
301
+ const roles = ["system", "user", "assistant"];
302
+ const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
303
+ return { ...turn, paginationPartial: true,
304
+ ...Object.fromEntries(excluded.map(role => [role, null])),
305
+ messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
306
+ });
295
307
  pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
296
308
  ...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
297
309
  hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
@@ -327,7 +339,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
327
339
  draftAttachments.value = [];
328
340
  draftRetry.value = null;
329
341
  goalError.value = "";
330
- goalPending.value = false;
342
+ goalAction.value = "";
331
343
  goalView.value = null;
332
344
  goalLoadError.value = "";
333
345
  goalReadQueued = false;
@@ -430,7 +442,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
430
442
  if (pendingMessages.has(messageId)) return false;
431
443
  // Register before the original serial tail so Stop/retirement also reaches
432
444
  // a local follower whose deliver callback has not started yet.
433
- const pending = { controller: new AbortController(), started: false, dispatched: false, deferred, appAdmission };
445
+ const pending = { controller: new AbortController(), started: false, dispatched: false };
434
446
  const { controller } = pending;
435
447
  pendingMessages.set(messageId, pending);
436
448
  try {
@@ -501,7 +513,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
501
513
  const finish = (result) => {
502
514
  if (typeof complete === "function") complete(result);
503
515
  };
504
- const beforeTurnId = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).oldestTurnId ||
516
+ const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
517
+ const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
505
518
  String(turns.value[0]?.turnId || "").trim();
506
519
  if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
507
520
  finish({ changed: false, loaded: false });
@@ -519,10 +532,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
519
532
  return false;
520
533
  }
521
534
  const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
535
+ const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
522
536
  historyRevision += 1;
523
537
  olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
524
538
  const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
525
- const changed = Boolean(nextOldestTurnId && nextOldestTurnId !== previousOldestTurnId);
539
+ const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
540
+ const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
541
+ nextMessageCount !== previousMessageCount));
526
542
  finish({ changed, loaded: true });
527
543
  return true;
528
544
  } catch (failure) {
@@ -577,7 +593,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
577
593
  const view = goalView.value;
578
594
  const target = view?.target;
579
595
  const command = target?.capabilities?.goalCommands?.[action];
580
- if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value || !goalTargetCurrent.value ||
596
+ if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
581
597
  !["message", "control"].includes(command?.delivery) ||
582
598
  delivery.state.messages.some(message => message.status === "uncertain")) return false;
583
599
  if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
@@ -590,7 +606,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
590
606
  expectedGoalId: view.goal?.id || null,
591
607
  ...(action === "set" ? { objective: String(input.objective || "").trim(),
592
608
  ...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
593
- goalPending.value = true;
609
+ goalAction.value = action;
594
610
  goalError.value = "";
595
611
  try {
596
612
  if (retry || command.delivery === "message") {
@@ -615,7 +631,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
615
631
  return false;
616
632
  } finally {
617
633
  if (current.value) {
618
- goalPending.value = false;
634
+ goalAction.value = "";
619
635
  await goalRead;
620
636
  await refreshGoal();
621
637
  subscription?.reload();
@@ -627,14 +643,19 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
627
643
  if (!readsGoals.value) return null;
628
644
  const capabilities = view?.target?.capabilities || {};
629
645
  const commands = capabilities.goalCommands || {};
646
+ const loading = !goalTargetCurrent.value && !goalLoadError.value;
647
+ const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
630
648
  return {
631
- enabled: active.value && Boolean((view?.status === "available" && !goalLoadError.value) || goalError.value),
649
+ enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
650
+ loading,
651
+ available: Boolean(goalAvailable),
652
+ action: goalAction.value,
632
653
  tokenBudgetSupported: capabilities.goalBudgets === true,
633
- goal: view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
654
+ goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
634
655
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
635
- pending: goalPending.value || !goalTargetCurrent.value || !available.value || delivery.state.sending ||
656
+ pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
636
657
  delivery.state.messages.some(message => message.status === "uncertain"),
637
- error: goalError.value,
658
+ error: goalError.value || goalLoadError.value,
638
659
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
639
660
  cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
640
661
  ...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
@@ -645,7 +666,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
645
666
  if (!editable.value || stopping.value) return false;
646
667
  stopping.value = true;
647
668
  for (const pending of pendingMessages.values()) {
648
- if ((pending.deferred || pending.appAdmission) && !pending.dispatched) pending.controller.abort();
669
+ if (!pending.dispatched) pending.controller.abort();
649
670
  }
650
671
  try { return await api.cancelConversation(identity.conversationId); }
651
672
  catch (failure) { receiveError(failure); return false; }
@@ -295,6 +295,12 @@ test("a supplied goal view keeps its pinned target and refreshes through the ret
295
295
  failedRead = true;
296
296
  await f.page.evaluate(() => window.conversationFixture.current().refreshGoal());
297
297
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalLoadError.value)).toBe("Goal status is unavailable.");
298
+ await expect(f.primary.getByRole("button", { name: "Goal unavailable", exact: true })).toBeVisible();
299
+ assert.deepEqual(await f.page.evaluate(() => {
300
+ const state = window.conversationFixture.current().goalState.value;
301
+ return { available: state.available, goal: state.goal, pending: state.pending };
302
+ }), { available: false, goal: null, pending: true });
303
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
298
304
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().error.value), "",
299
305
  "Passive goal failure does not enter conversation or app-wide request recovery");
300
306
  await expect(f.input).toBeEnabled();
@@ -877,6 +883,41 @@ test("opted-in nonsteerable follow-ups use the original serial queue and dispatc
877
883
  assert.equal((await f.sent()).at(-1).input.steer, true);
878
884
  });
879
885
 
886
+ for (const steer of [false, true]) {
887
+ test(`Stop cancels locally queued native ${steer ? "steering" : "startup"} follow-ups`, options, async t => {
888
+ const f = await fixture(t);
889
+ await f.command("steering", { id: "chat:1", enabled: true });
890
+ await f.command("mode", { mode: "hold" });
891
+ await f.page.evaluate(() => {
892
+ window.firstNative = window.conversationFixture.current().send({ message: "First held instruction" });
893
+ });
894
+ await expect.poll(async () => (await f.state()).held).toBe(1);
895
+ await f.command("question", { id: "chat:1", text: "Starting native work", pending: true });
896
+ await f.notify("chat:1");
897
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
898
+ await f.page.evaluate(steer => {
899
+ window.queuedNative = window.conversationFixture.current().send({ message: "Queued native follow-up",
900
+ request: { text: "Queued native follow-up", steer } });
901
+ }, steer);
902
+ assert.equal((await f.sent()).length, 1);
903
+ const stopGate = Promise.withResolvers();
904
+ t.after(() => stopGate.resolve());
905
+ await f.page.route("**/cancel", async route => { await stopGate.promise; await route.continue(); });
906
+ await f.page.evaluate(() => { window.stoppingNative = window.conversationFixture.current().cancel(); });
907
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().stopping.value)).toBe(true);
908
+ const canSendDuringStop = await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value);
909
+ stopGate.resolve();
910
+ await f.page.evaluate(() => window.stoppingNative);
911
+ await f.command("mode", { mode: "accepted" });
912
+ await f.command("accept");
913
+ await f.page.evaluate(() => window.firstNative);
914
+ const queuedResult = await f.page.evaluate(() => window.queuedNative);
915
+ assert.equal((await f.sent()).length, 1, "Stop must prevent a local follower from dispatching after the first receipt");
916
+ assert.equal(queuedResult, false);
917
+ assert.equal(canSendDuringStop, false, "new sends wait for Stop confirmation");
918
+ });
919
+ }
920
+
880
921
  for (const action of ["stop", "actor", "denied"]) {
881
922
  test(`opted-in local followers cannot dispatch after ${action}`, options, async t => {
882
923
  const f = await fixture(t);
@@ -1628,7 +1669,7 @@ test("paged history uses the original page reader and keeps older messages throu
1628
1669
  user: { role: "user", messageId: `history-${index + 1}`, text: `Saved question ${index + 1}` },
1629
1670
  ...(index < 4 ? { assistant: { role: "assistant", messageId: `answer-${index + 1}`, text: `Saved answer ${index + 1}` } } : {})
1630
1671
  }));
1631
- await f.command("history", { id: "chat:1", turns, limit: 2 });
1672
+ await f.command("history", { id: "chat:1", turns, limit: 4 });
1632
1673
  await f.notify("chat:1");
1633
1674
  await expect(f.primary.getByText("Saved question 5", { exact: true })).toHaveCount(1);
1634
1675
  await expect(f.primary.getByText("Saved question 1", { exact: true })).toHaveCount(0);
@@ -1639,7 +1680,7 @@ test("paged history uses the original page reader and keeps older messages throu
1639
1680
  assert.deepEqual(await f.page.evaluate(() => window.historyCompletion), { changed: true, loaded: true });
1640
1681
  await expect(f.primary.getByText("Saved question 2", { exact: true })).toHaveCount(1);
1641
1682
  const paged = (await f.state()).requests.filter(request => request.query.beforeTurnId);
1642
- assert.deepEqual(paged.map(request => request.query), [{ beforeTurnId: "000004", limit: "2" }]);
1683
+ assert.deepEqual(paged.map(request => request.query), [{ beforeTurnId: 'message:["000003","assistant"]', limit: "4" }]);
1643
1684
  await f.page.evaluate(() => window.conversationFixture.socket.notify("chat:1", {
1644
1685
  type: "message", turnId: "000005", messageId: "live-answer", role: "assistant", status: "inProgress", text: "Live answer",
1645
1686
  streaming: { revision: 1, messages: [{ turnId: "000005", messageId: "live-answer", role: "assistant", status: "inProgress", text: "Live answer" }] }
@@ -2230,7 +2271,7 @@ test("canonical incoming messages preserve paged history and the scrolled-up rea
2230
2271
  user: { messageId: `loaded-user-${index}`, role: "user", text: `Loaded question ${index + 1}. ` + "Keep the earlier discussion. ".repeat(6) },
2231
2272
  assistant: { messageId: `loaded-answer-${index}`, role: "assistant", text: `Loaded answer ${index + 1}. ` + "Keep the earlier answer. ".repeat(6) }
2232
2273
  }));
2233
- await f.command("history", { id: "chat:1", turns, limit: 6 });
2274
+ await f.command("history", { id: "chat:1", turns, limit: 12 });
2234
2275
  await f.notify("chat:1");
2235
2276
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2236
2277
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
@@ -2246,7 +2287,7 @@ test("canonical incoming messages preserve paged history and the scrolled-up rea
2246
2287
  await f.input.fill("Keep this unsent draft");
2247
2288
  await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(2, 7); });
2248
2289
  turns.push({ turnId: "000025", user: { messageId: "incoming-user", role: "user", text: "New incoming question" } });
2249
- await f.command("history", { id: "chat:1", turns, limit: 6 });
2290
+ await f.command("history", { id: "chat:1", turns, limit: 12 });
2250
2291
  await f.notify("chat:1");
2251
2292
  await expect(f.primary.getByText("New incoming question", { exact: true })).toHaveCount(1);
2252
2293
  const ids = await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId));
@@ -2267,7 +2308,7 @@ for (const width of [390, 768, 1280]) {
2267
2308
  user: { role: "user", messageId: `atomic-user-${index}`, text: `Atomic saved question ${index + 1}. ` + "Retain visible discussion. ".repeat(8) },
2268
2309
  assistant: { role: "assistant", messageId: `atomic-answer-${index}`, text: `Atomic saved answer ${index + 1}. ` + "Retain visible answer. ".repeat(8) }
2269
2310
  }));
2270
- await f.command("history", { id: "chat:1", turns, limit: 6 });
2311
+ await f.command("history", { id: "chat:1", turns, limit: 12 });
2271
2312
  await f.notify("chat:1");
2272
2313
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2273
2314
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
@@ -2284,7 +2325,7 @@ for (const width of [390, 768, 1280]) {
2284
2325
  const fresh = turns.filter(turn => !["000007", "000010"].includes(turn.turnId));
2285
2326
  fresh[8] = { ...fresh[8], assistant: { ...fresh[8].assistant, text: "Authoritatively replaced saved answer" } };
2286
2327
  fresh.push({ turnId: "000025", user: { role: "user", messageId: "atomic-incoming", text: "Atomic new question" } });
2287
- await f.command("history", { id: "chat:1", turns: fresh, limit: 6 });
2328
+ await f.command("history", { id: "chat:1", turns: fresh, limit: 12 });
2288
2329
  let release;
2289
2330
  let held = false;
2290
2331
  const reads = "**/api/assistant/home/conversations/chat%3A1?**";
@@ -2912,6 +2953,11 @@ test("changing the observed native segment blocks cached goal controls until the
2912
2953
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.segmentId)).toBe("replacement-native-observation");
2913
2954
  await expect.poll(() => Boolean(releaseRead)).toBe(true);
2914
2955
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.pending), true);
2956
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.loading), true);
2957
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.goal), null);
2958
+ await expect(f.primary.getByRole("button", { name: "Loading goal…", exact: true })).toBeVisible();
2959
+ await expect(f.page.getByRole("button", { name: "Pause goal", exact: true })).toHaveCount(0);
2960
+ await expect(f.page.getByRole("button", { name: "Resuming…", exact: true })).toHaveCount(0);
2915
2961
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("pause")), false);
2916
2962
  assert.deepEqual(commands, [], "A previous observation cannot authorize a command for the replacement thread");
2917
2963
  view.goal.id = "replacement-goal";
@@ -2971,3 +3017,71 @@ test("unknown delivery reports its current result and allows different idle inpu
2971
3017
  assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status,
2972
3018
  old.input.messageId), "uncertain");
2973
3019
  });
3020
+
3021
+
3022
+ for (const width of [1280, 390]) test(`ten real messages per history load across split turns at ${width}px`, options, async t => {
3023
+ const f = await fixture(t, width);
3024
+ const turns = [];
3025
+ let id = 0;
3026
+ for (let index = 1; index <= 16; index++) {
3027
+ const user = { role: "user", messageId: `ten-user-${index}`, text: `Ten question ${index}` };
3028
+ const assistant = index < 16 ? { role: "assistant", messageId: `ten-answer-${index}`, text: `Ten answer ${index}` } : null;
3029
+ turns.push({ turnId: String(++id).padStart(6, "0"), user, assistant, messages: [user, assistant].filter(Boolean) });
3030
+ for (let reasoning = 0; reasoning < 4; reasoning++) {
3031
+ const thought = { role: "thinking", messageId: `ten-thought-${index}-${reasoning}`, text: `Reasoning ${index} ${reasoning}` };
3032
+ turns.push({ turnId: String(++id).padStart(6, "0"), thinking: [thought], messages: [thought] });
3033
+ }
3034
+ }
3035
+ await f.command("history", { id: "chat:1", turns, limit: 10 });
3036
+ await f.notify("chat:1");
3037
+ const messageCount = () => f.page.evaluate(() => window.conversationFixture.current().turns.value
3038
+ .reduce((count, turn) => count + [turn.system, turn.user, turn.assistant].filter(Boolean).length, 0));
3039
+ await expect.poll(messageCount).toBe(10);
3040
+ await expect(f.primary.getByText("Ten question 11", { exact: true })).toHaveCount(0);
3041
+ await expect(f.primary.getByText("Ten answer 11", { exact: true })).toHaveCount(1);
3042
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
3043
+ await expect.poll(messageCount).toBe(20);
3044
+ await f.page.evaluate(() => { window.beforeTenMessageRefresh = window.conversationFixture.current().snapshot.value; });
3045
+ await f.notify("chat:1");
3046
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value !== window.beforeTenMessageRefresh)).toBe(true);
3047
+ await expect.poll(messageCount).toBe(20);
3048
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
3049
+ await expect.poll(messageCount).toBe(30);
3050
+ await expect(f.primary.getByText("Ten question 1", { exact: true })).toHaveCount(0);
3051
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore({
3052
+ complete: result => { window.tenMessageCompletion = result; }
3053
+ })), true);
3054
+ await expect.poll(messageCount).toBe(31);
3055
+ assert.deepEqual(await f.page.evaluate(() => window.tenMessageCompletion), { changed: true, loaded: true });
3056
+ await expect(f.primary.getByText("Ten question 1", { exact: true })).toHaveCount(1);
3057
+ await expect(f.primary.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
3058
+ assert.deepEqual(await f.sent(), []);
3059
+ });
3060
+
3061
+
3062
+ test("initial goal loading shows no goal commands until the empty goal is confirmed", options, async t => {
3063
+ const f = await fixture(t);
3064
+ const waiting = Promise.withResolvers();
3065
+ const entered = Promise.withResolvers();
3066
+ await f.page.route("**/conversations/*/goal", async route => {
3067
+ entered.resolve();
3068
+ await waiting.promise;
3069
+ return route.fulfill({ json: { status: "available", goal: null, target: { segmentId: "empty-goal",
3070
+ capabilities: { goals: true, goalBudgets: false, goalCommands: {
3071
+ set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false }
3072
+ } } } } });
3073
+ });
3074
+ try {
3075
+ await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
3076
+ await entered.promise;
3077
+ const loading = f.primary.getByRole("button", { name: "Loading goal…", exact: true });
3078
+ await expect(loading).toBeVisible();
3079
+ await loading.click();
3080
+ await expect(f.page.getByRole("button", { name: "Start goal", exact: true })).toHaveCount(0);
3081
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
3082
+ waiting.resolve();
3083
+ await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toBeVisible();
3084
+ await expect(f.page.getByRole("button", { name: "Start goal", exact: true })).toBeVisible();
3085
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
3086
+ } finally { waiting.resolve(); }
3087
+ });