@jskit-ai/assistant-core 0.2.1 → 0.2.3

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 (61) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +41 -11
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +272 -10
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +34 -14
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. package/test/openCodeRuntime.test.js +25 -78
@@ -76,10 +76,10 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
76
76
  return { text: prepared, content: input.content };
77
77
  }
78
78
 
79
- async function nativeStorageTarget({ native, context }) {
79
+ function withNativeStorageTarget({ native, context }, operation) {
80
80
  const prepared = native.preparation.storage(context);
81
- return native.owner.ensure(null, async () =>
82
- native.owner.startPreparedProcess(await prepared.process())).catch(prepared.failure);
81
+ return native.owner.withStorageProcess(async () =>
82
+ native.owner.startPreparedProcess(await prepared.process()), operation, prepared.failure);
83
83
  }
84
84
 
85
85
  async function disposeNative({ native, options }) {
@@ -127,17 +127,18 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
127
127
  const prepared = await native.preparation.existing(input, context, { operation: "delete" });
128
128
  return native.owner.deletePreparedConversation(prepared, input);
129
129
  },
130
- async listConversationStorage(prepared) {
131
- const target = await nativeStorageTarget(prepared);
130
+ listConversationStorage(prepared) {
132
131
  const { binding, context } = prepared;
133
- return (await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
134
- .map((row) => ({ ...binding, conversationId: row.id }));
132
+ return withNativeStorageTarget(prepared, async target =>
133
+ (await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
134
+ .map((row) => ({ ...binding, conversationId: row.id })));
135
135
  },
136
- async retireConversationHistory(prepared) {
137
- const target = await nativeStorageTarget(prepared);
138
- // Native status is scoped to the saved project instance, not the control server.
139
- const client = openCodeServerForDirectory(target.server, prepared.binding.workdir).client;
140
- return retireOpenCodeConversationHistory(target.server.client, client, prepared.binding, prepared.context);
136
+ retireConversationHistory(prepared) {
137
+ return withNativeStorageTarget(prepared, target => {
138
+ // Native status is scoped to the saved project instance, not the control server.
139
+ const client = openCodeServerForDirectory(target.server, prepared.binding.workdir).client;
140
+ return retireOpenCodeConversationHistory(target.server.client, client, prepared.binding, prepared.context);
141
+ });
141
142
  },
142
143
  closeProject({ native, input }) {
143
144
  return native.owner.closeProject(input, native.preparation.projectCleanup());
@@ -489,13 +490,15 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
489
490
  const start = host.opencode
490
491
  ? async () => facilities.runtime.startPreparedProcess(await facilities.prepareServer())
491
492
  : () => facilities.start({ signal, connection });
493
+ // A managed host's saved connections reach its running server in place.
494
+ const savedConnections = host.opencode ? async () => (await facilities.prepareServer()).connections : null;
492
495
  if (!native) {
493
496
  native = await facilities.runtime.acquire(binding.directory, async () => {
494
- const shared = await facilities.runtime.ensure(facilities.selected, start);
497
+ const shared = await facilities.runtime.ensure(facilities.selected, start, savedConnections);
495
498
  return { key: binding.directory, workdir: binding.workdir, upstreamSessionId: binding.sessionId,
496
499
  abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, binding.workdir) };
497
500
  });
498
- } else await facilities.runtime.ensure(facilities.selected, start);
501
+ } else await facilities.runtime.ensure(facilities.selected, start, savedConnections);
499
502
  signal.throwIfAborted();
500
503
  await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
501
504
  const catalogue = await native.server.client.providers({ signal });
@@ -320,7 +320,7 @@ export async function inspectPersistentConversationAdmission(options) {
320
320
  if (!record.providerConversationId) return { admission: "unknown", turnId: "" };
321
321
  const state = requireSuccess(await operations.readConversation(sessionId,
322
322
  inputFor(record, { messageId: input.messageId }), options.context));
323
- return { admission: state.admitted ? "accepted" : "unknown", turnId: state.runId };
323
+ return { admission: state.admitted ? "accepted" : state.notAdmitted ? "not-admitted" : "unknown", turnId: state.runId };
324
324
  }
325
325
 
326
326
  export async function preparePersistentConversationChangeover(options) {
@@ -353,7 +353,10 @@ export async function inspectPersistentConversationDelivery(options) {
353
353
  const result = await createConversationChangeover({ ...continuity, agent: {
354
354
  inspectMessageAdmission: request => inspectMessageAdmission(sessionId, request, context),
355
355
  sendMessage() { throw new Error("Receipt inspection cannot submit another native prompt."); }
356
- } }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata });
356
+ } }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata, inspectOnly: true });
357
+ if (result?.notSent) return { status: "not-sent", messageId: input.messageId,
358
+ error: "This message was not sent. Review it before sending a new message.",
359
+ messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
357
360
  accepted = result?.delivered === true;
358
361
  } else if (!accepted) {
359
362
  if (row && row.user?.receipt !== false) return { status: "accepted", messageId: input.messageId, turnId: row.turnId, duplicate: true };
@@ -28,6 +28,9 @@ function failure(message, code, statusCode = 409, { messageCode, messageParams }
28
28
  return Object.assign(new Error(message), { code, statusCode }, messageCode ? { messageCode, messageParams: messageParams || {} } : {});
29
29
  }
30
30
 
31
+ // The newest messages cancelled in one open conversation, refused if sent again.
32
+ const MAX_CANCELLED_MESSAGES = 100;
33
+
31
34
  // A native delivery result keeps its own sentence and messageCode when it has one.
32
35
  function deliveryFailure(delivered) {
33
36
  const code = delivered?.code || "conversation_delivery_uncertain";
@@ -1558,6 +1561,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1558
1561
  deliveryTiming?.("preparation-started");
1559
1562
  const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
1560
1563
  await access(context, entry.id, operation, entry, grant);
1564
+ if (entry.cancelledMessages.has(input?.messageId)) {
1565
+ throw Object.assign(failure("This message was cancelled. Send it again as a new message.", "conversation_message_cancelled", 409,
1566
+ { messageCode: "assistant.conversation.messageCancelled" }), { delivery: "not-sent" });
1567
+ }
1561
1568
  await entry.conversation?.admission(context, operation);
1562
1569
  deliveryTiming?.("authorized");
1563
1570
  if (goal) context?.signal?.throwIfAborted();
@@ -1738,6 +1745,34 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1738
1745
  return { stopped: true };
1739
1746
  }
1740
1747
 
1748
+ // Cancel of one message in a runtime-owned conversation never waits for the
1749
+ // conversation queue. The message is recorded as cancelled at once, so a send
1750
+ // of it still queued, or arriving later, is refused as not sent; its own
1751
+ // submission stops, ending any turn it started. Its unconfirmed request or
1752
+ // pending native attempt is discarded afterwards, in queue order. A message
1753
+ // already accepted stays history.
1754
+ async function cancelDelivery(entry, context, messageId, representation) {
1755
+ entry.cancelledMessages.delete(messageId);
1756
+ entry.cancelledMessages.add(messageId);
1757
+ if (entry.cancelledMessages.size > MAX_CANCELLED_MESSAGES) entry.cancelledMessages.delete(entry.cancelledMessages.values().next().value);
1758
+ const interrupted = Boolean(entry.active?.requests.has(messageId));
1759
+ if (interrupted) await stop(entry, context, undefined, representation);
1760
+ void serial(entry, async () => {
1761
+ await access(context, entry.id, "cancel", entry);
1762
+ if (entry.conversation) {
1763
+ await nativeChangeover(entry, context, null, () => { throw new Error("Cancel never sends a message."); }).discard({ messageId });
1764
+ return;
1765
+ }
1766
+ await storage.write(entry.id, async transaction => {
1767
+ const metadata = await transaction.readMetadata();
1768
+ if (metadata.runtime.request?.messageId !== messageId) return;
1769
+ delete metadata.runtime.request;
1770
+ await transaction.writeMetadata(metadata);
1771
+ });
1772
+ }).catch(() => {});
1773
+ return { ok: true, cancelled: true, messageId, interrupted };
1774
+ }
1775
+
1741
1776
  async function readGoal(entry, context, representation) {
1742
1777
  await access(context, entry.id, "readGoal", entry);
1743
1778
  if (!capabilities(entry).goals) return null;
@@ -1892,6 +1927,11 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1892
1927
  return { saved: true };
1893
1928
  }
1894
1929
 
1930
+ function notSentReceipt(messageId) {
1931
+ return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
1932
+ messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
1933
+ }
1934
+
1895
1935
  async function recoverDelivery(entry, context, messageId, { observation: suppliedObservation, allowActive = false } = {}) {
1896
1936
  await access(context, entry.id, "inspectDelivery", entry);
1897
1937
  if (entry.active && !allowActive) throw failure("Wait for the current submission before checking delivery.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForSubmission" });
@@ -1920,8 +1960,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1920
1960
  ["codex", "claude", "opencode"].includes(segment.engine) && segment.request.attempted === false && !segment.request.inspectionOnly &&
1921
1961
  isDeepStrictEqual(segment.request, savedRequests[0].request))) {
1922
1962
  await access(context, entry.id, "inspectDelivery", entry);
1923
- return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
1924
- messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
1963
+ return notSentReceipt(messageId);
1925
1964
  }
1926
1965
  const request = existing ? submittedMessage(existing) : metadata.runtime.request;
1927
1966
  if (!request || request.messageId !== messageId || !entry.provider.inspectAdmission) return receipt || { status: "unknown", messageId };
@@ -1930,6 +1969,17 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1930
1969
  const observation = suppliedObservation || await entry.provider.inspectAdmission({ ...request, nativeTurnId: existing?.metadata?.runtime?.nativeTurnId,
1931
1970
  context, configuration: metadata.runtime.configuration });
1932
1971
  await access(context, entry.id, "inspectDelivery", entry);
1972
+ // The engine proved it never admitted this saved request. Record that it
1973
+ // was not sent, as a definite rejection would, so Retry may send it.
1974
+ if (!existing && observation.notAdmitted && pending && !pending.inspectionOnly && !entry.active) {
1975
+ await storage.write(entry.id, async transaction => {
1976
+ const current = await transaction.readMetadata();
1977
+ if (current.runtime.request?.messageId !== messageId) return;
1978
+ current.runtime.request.attempted = false;
1979
+ await transaction.writeMetadata(current);
1980
+ });
1981
+ return notSentReceipt(messageId);
1982
+ }
1933
1983
  if (!observation.accepted) return { ...(receipt || { status: "unknown", messageId }),
1934
1984
  ...(observation.recoveryLimitation ? { recoveryLimitation: observation.recoveryLimitation } : {}) };
1935
1985
  if (entry.driver.canonicalTranscript) {
@@ -2046,7 +2096,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2046
2096
  throw new Error("Receipt inspection cannot submit another native prompt.");
2047
2097
  });
2048
2098
  const delivered = await sender.send({ engineId: entry.engine,
2049
- messages: await entry.conversation.transcript.history(), input: { messageId } });
2099
+ messages: await entry.conversation.transcript.history(), input: { messageId }, inspectOnly: true });
2100
+ if (delivered?.notSent) return notSentReceipt(messageId);
2050
2101
  if (!delivered?.delivered) throw deliveryFailure(delivered);
2051
2102
  }
2052
2103
  const row = (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId);
@@ -2057,7 +2108,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2057
2108
  await access(context, entry.id, "inspectDelivery", entry);
2058
2109
  // Native-only acceptance is evidence of delivery, not permission to
2059
2110
  // manufacture an authored row or use a native turn ID as its receipt.
2060
- return observation.accepted ? { status: "accepted", messageId, duplicate: true } : { status: "unknown", messageId };
2111
+ if (observation.accepted) return { status: "accepted", messageId, duplicate: true };
2112
+ return observation.notAdmitted && !entry.active ? notSentReceipt(messageId) : { status: "unknown", messageId };
2061
2113
  }
2062
2114
  const result = await recoverDelivery(entry, context, messageId);
2063
2115
  if (result.status !== "accepted" || entry.engine === "api") return result;
@@ -2299,7 +2351,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2299
2351
  function retireEntry(entry) {
2300
2352
  entry.disposed = true;
2301
2353
  entry.listeners.clear();
2302
- streams.clear(entry.id);
2354
+ // Live streams are kept per conversation id. A logical conversation streams
2355
+ // through its own owner, so the stream under its id belongs to another entry
2356
+ // with the same id, such as its session's Main conversation.
2357
+ if (!entry.conversation?.commands) streams.clear(entry.id);
2303
2358
  if (entries.get(entry.key) === entry) entries.delete(entry.key);
2304
2359
  }
2305
2360
 
@@ -2553,7 +2608,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2553
2608
  const key = conversation?.namespace || id;
2554
2609
  let entry = entries.get(key);
2555
2610
  if (!entry) {
2556
- entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: "" };
2611
+ entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: "",
2612
+ cancelledMessages: new Set() };
2557
2613
  entries.set(key, entry);
2558
2614
  entry.initializing = initialize(entry, configuration, engine).catch(error => {
2559
2615
  if (entries.get(key) === entry) entries.delete(key);
@@ -2606,6 +2662,20 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2606
2662
  await access(context, id, "read", entry);
2607
2663
  return read(entry);
2608
2664
  },
2665
+ // Cancel of one unsent or unconfirmed message always records it; an
2666
+ // application conversation supplies its own operation for that.
2667
+ async cancelDelivery({ messageId } = {}) {
2668
+ if (typeof messageId !== "string" || !messageId) throw new TypeError("A message id is required.");
2669
+ await access(context, id, "cancel", entry);
2670
+ if (entry.conversation?.commands) {
2671
+ if (typeof entry.conversation.commands.cancelDelivery !== "function") {
2672
+ throw failure("This conversation cannot cancel one message.", "conversation_unsupported", 400,
2673
+ { messageCode: "assistant.conversation.messageCancelUnsupported" });
2674
+ }
2675
+ return entry.conversation.commands.cancelDelivery({ messageId }, context);
2676
+ }
2677
+ return cancelDelivery(entry, context, messageId, representation);
2678
+ },
2609
2679
  async cancel(input) {
2610
2680
  await access(context, id, "cancel", entry);
2611
2681
  if (entry.conversation?.commands) return entry.conversation.commands.cancel(input, context);
@@ -2830,6 +2900,23 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2830
2900
  });
2831
2901
  return retained || cleanupUnopened();
2832
2902
  },
2903
+ // Trusted server lifecycle: the application deleted or archived a logical
2904
+ // conversation that it binds through its own commands. Its open entry, if
2905
+ // any, leaves memory and its handles close. Nothing is opened; native and
2906
+ // storage-owned conversations keep their own disposal.
2907
+ async retireOpenConversation({ namespace, sessionId }) {
2908
+ const entry = entries.get(namespace);
2909
+ if (!entry || entry.id !== sessionId) return { retired: false };
2910
+ if (!entry.conversation?.commands) {
2911
+ throw new TypeError("Only a logical conversation bound through its commands retires without disposal.");
2912
+ }
2913
+ await entry.initializing.catch(() => {});
2914
+ return serial(entry, async () => {
2915
+ if (entry.disposed || entries.get(namespace) !== entry) return { retired: false };
2916
+ retireEntry(entry);
2917
+ return { retired: true };
2918
+ });
2919
+ },
2833
2920
  // Host publication from the same native owner, through the existing entry.
2834
2921
  // This does not subscribe to a provider or retain another observer registry.
2835
2922
  async publishNative({ namespace, sessionId, event }) {
@@ -20,3 +20,7 @@ export {
20
20
  createCodexAppServerIsolation,
21
21
  readCodexToolFreeConfiguration
22
22
  } from "../conversation/codexConfiguration.js";
23
+ export {
24
+ codexDisabledProcessFeatureArguments,
25
+ codexHomeDisposablePaths
26
+ } from "../conversation/codexHomeStorage.js";
@@ -26,6 +26,31 @@ async function settleReaderAt(body, scrollTop) {
26
26
  }, scrollTop)).toBe(true);
27
27
  }
28
28
 
29
+ // Prepended history starts at those estimates and materializes on a later frame,
30
+ // when scroll anchoring moves scrollTop. Read positions only once that settles.
31
+ async function settleLayout(body) {
32
+ await expect.poll(() => body.evaluate(async element => {
33
+ const [top, height] = [element.scrollTop, element.scrollHeight];
34
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
35
+ return element.scrollTop === top && element.scrollHeight === height;
36
+ })).toBe(true);
37
+ }
38
+
39
+ // A gesture's scroll ends after its last input event; a touch drag ends with
40
+ // its fling. A scrollend still queued from earlier scrolling must not count, or
41
+ // the reader is measured while the fling keeps moving it.
42
+ async function scrollGesture(body, lastEventType, perform) {
43
+ await body.evaluate((element, type) => {
44
+ element.gestureScrollEnded = new Promise(resolve => {
45
+ element.addEventListener(type, () => {
46
+ element.addEventListener("scrollend", () => resolve(), { once: true });
47
+ }, { once: true, passive: true });
48
+ });
49
+ }, lastEventType);
50
+ await perform();
51
+ await body.evaluate(element => element.gestureScrollEnded);
52
+ }
53
+
29
54
  const VIEWPORTS = Object.freeze([
30
55
  Object.freeze({ name: "phone", width: 390, height: 844 }),
31
56
  Object.freeze({ name: "compact", width: 800, height: 900 }),
@@ -266,10 +291,12 @@ test("conversation scrolling preserves history anchors and resets expansion on s
266
291
  await page.getByRole("button", { name: "Read more", exact: true }).click();
267
292
  await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
268
293
  const original = page.getByText("Conversation line 1:", { exact: false }).first();
294
+ await settleLayout(body);
269
295
  const originalTop = (await original.boundingBox()).y;
270
296
  // Measure history anchoring without Playwright first scrolling to the button.
271
297
  await page.getByRole("button", { name: "Load older messages", exact: true }).evaluate(element => element.click());
272
- await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
298
+ await settleLayout(body);
299
+ expect(Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
273
300
  const before = await body.evaluate((element) => element.scrollTop);
274
301
  await page.getByRole("button", { name: "Append reply", exact: true }).click();
275
302
  await expect(page.getByText("New reply.", { exact: false })).toBeAttached();
@@ -370,20 +397,18 @@ test("upward scrolling loads older history once and preserves retry and selectio
370
397
  const cdp = await page.context().newCDPSession(page);
371
398
  const x = bounds.x + bounds.width / 2;
372
399
  const y = bounds.y + 40;
373
- const stopped = body.evaluate(element => new Promise(resolve => {
374
- element.addEventListener("scrollend", () => resolve(), { once: true });
375
- }));
376
- await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
377
- for (let offset = 20; offset <= 380; offset += 20) {
378
- await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
379
- await page.evaluate(() => new Promise(requestAnimationFrame));
380
- }
381
- await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
382
- await stopped;
400
+ await scrollGesture(body, "touchend", async () => {
401
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
402
+ for (let offset = 20; offset <= 380; offset += 20) {
403
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
404
+ await page.evaluate(() => new Promise(requestAnimationFrame));
405
+ }
406
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
407
+ });
383
408
  await cdp.detach();
384
409
  } else {
385
410
  await body.hover();
386
- await page.mouse.wheel(0, -380);
411
+ await scrollGesture(body, "wheel", () => page.mouse.wheel(0, -380));
387
412
  }
388
413
  const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
389
414
  await expect(pending).toBeDisabled();
@@ -394,10 +419,12 @@ test("upward scrolling loads older history once and preserves retry and selectio
394
419
  element.dispatchEvent(new Event("scroll"));
395
420
  });
396
421
  const original = page.getByText("Conversation line 1:", { exact: false }).first();
422
+ await settleLayout(body);
397
423
  const originalTop = (await original.boundingBox()).y;
398
424
  await external("Complete history load");
399
425
  await expect(pending).toHaveCount(0);
400
- await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
426
+ await settleLayout(body);
427
+ expect(Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
401
428
  await expect(requests).toHaveText("History requests: 1");
402
429
 
403
430
  // Keyboard navigation also requests older history.
@@ -421,11 +448,7 @@ test("upward scrolling loads older history once and preserves retry and selectio
421
448
  // Removing the history control re-anchors the scroller, which would
422
449
  // cancel a keyboard scroll already in flight; press Home once it settles.
423
450
  await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
424
- await expect.poll(() => body.evaluate(async element => {
425
- const height = element.scrollHeight;
426
- await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
427
- return element.scrollHeight === height;
428
- })).toBe(true);
451
+ await settleLayout(body);
429
452
  await expect.poll(async () => {
430
453
  await body.focus();
431
454
  return body.evaluate(element => document.activeElement === element);
@@ -443,6 +466,99 @@ test("upward scrolling loads older history once and preserves retry and selectio
443
466
  }
444
467
  });
445
468
 
469
+ test("a reader's own upward scrolling leaves the latest message from its first frame", {
470
+ skip: !RUN_BROWSER_TEST,
471
+ timeout: 60_000
472
+ }, async () => {
473
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
474
+ const browser = await chromium.launch(createChromiumLaunchOptions());
475
+ try {
476
+ for (const width of [390, 1365]) {
477
+ const touch = width < 1000;
478
+ const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
479
+ const errors = [];
480
+ page.on("pageerror", error => errors.push(error.message));
481
+ await page.goto(`${vite.baseURL}/?controls=1&history=1`);
482
+ const body = page.locator(".assistant-transcript__body");
483
+ const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
484
+ const appendReply = async () => {
485
+ const replies = page.getByText("New reply.", { exact: false });
486
+ const count = await replies.count();
487
+ await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
488
+ await expect(replies).toHaveCount(count + 1);
489
+ await settleLayout(body);
490
+ };
491
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
492
+ await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
493
+ await settleLayout(body);
494
+ expect(await fromBottom()).toBeLessThanOrEqual(1);
495
+ await expect.poll(async () => {
496
+ await body.focus();
497
+ return body.evaluate(element => document.activeElement === element);
498
+ }).toBe(true);
499
+
500
+ // A short upward wheel or touch scroll stays within the 48px follow
501
+ // threshold, and a touch fling ends without further input events.
502
+ if (touch) {
503
+ const bounds = await body.boundingBox();
504
+ const cdp = await page.context().newCDPSession(page);
505
+ const [x, y] = [bounds.x + bounds.width / 2, bounds.y + 100];
506
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
507
+ for (const offset of [10, 20, 30]) {
508
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
509
+ }
510
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
511
+ await cdp.detach();
512
+ } else {
513
+ await body.hover();
514
+ await page.mouse.wheel(0, -20);
515
+ }
516
+ await expect.poll(fromBottom).toBeGreaterThan(1);
517
+ await settleLayout(body);
518
+ await appendReply();
519
+ expect(await fromBottom()).toBeGreaterThan(1);
520
+
521
+ // Returning to the end resumes following new replies.
522
+ await body.press("End");
523
+ await expect.poll(fromBottom).toBeLessThanOrEqual(1);
524
+ await appendReply();
525
+ expect(await fromBottom()).toBeLessThanOrEqual(1);
526
+
527
+ // The first frame of an upward scroll lands inside the follow threshold;
528
+ // the rest of it (a fling or smooth scroll) moves the reader into history
529
+ // without further input events.
530
+ await body.press("ArrowUp");
531
+ await expect.poll(fromBottom).toBeGreaterThan(1);
532
+ await settleLayout(body);
533
+ expect(await fromBottom()).toBeLessThanOrEqual(48);
534
+ await body.evaluate(element => { element.scrollTop = Math.round(element.scrollTop / 2); });
535
+ await settleLayout(body);
536
+ await appendReply();
537
+ expect(await fromBottom()).toBeGreaterThan(1_000);
538
+ await body.press("End");
539
+ await expect.poll(fromBottom).toBeLessThanOrEqual(1);
540
+ await appendReply();
541
+ expect(await fromBottom()).toBeLessThanOrEqual(1);
542
+
543
+ // The scroll event of the transcript's own follow scroll can arrive after
544
+ // the key press, before the reader has moved; it must not cancel the
545
+ // reader's scroll.
546
+ await body.evaluate(element => {
547
+ element.addEventListener("keydown", () => element.dispatchEvent(new Event("scroll")), { once: true });
548
+ });
549
+ await body.press("Home");
550
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
551
+ await appendReply();
552
+ expect(await body.evaluate(element => element.scrollTop)).toBe(0);
553
+ assert.deepEqual(errors, []);
554
+ await page.close();
555
+ }
556
+ } finally {
557
+ await browser.close();
558
+ await stopProcess(vite);
559
+ }
560
+ });
561
+
446
562
  test("retained hidden transcripts preserve readers and follow only at the latest message", {
447
563
  skip: !RUN_BROWSER_TEST,
448
564
  timeout: 90_000
@@ -265,7 +265,7 @@ async function fixture(t, options = {}) {
265
265
  await writeFile(account, "owner@example.test");
266
266
  await writeFile(command, `#!${process.execPath}
267
267
  import { createInterface } from "node:readline";
268
- import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
268
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
269
269
  import path from "node:path";
270
270
  const args = process.argv.slice(2);
271
271
  const structuredResponse = ${JSON.stringify(options.structuredResponse || null)};
@@ -308,6 +308,12 @@ async function fixture(t, options = {}) {
308
308
  if (frame.request.subtype === "interrupt") emit({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
309
309
  return;
310
310
  }
311
+ // A crash after reading the prompt but before recording it in history.
312
+ const dropped = path.join(process.env.CLAUDE_CONFIG_DIR, "dropped-once");
313
+ if (JSON.stringify(frame.message.content).includes("drop once") && !existsSync(dropped)) {
314
+ writeFileSync(dropped, "");
315
+ process.exit(1);
316
+ }
311
317
  record(frame);
312
318
  const text = typeof frame.message.content === "string" ? frame.message.content
313
319
  : frame.message.content.filter(part => part.type === "text").map(part => part.text).join(" ");
@@ -783,6 +789,25 @@ test("uncertain native delivery is reconciled from history without a second infe
783
789
  assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
784
790
  });
785
791
 
792
+ test("a Claude prompt missing from history after its process ended is not sent, and Retry sends it", async t => {
793
+ const f = await fixture(t);
794
+ const dropped = { messageId: "dropped-before-history", text: "drop once" };
795
+ await assert.rejects(f.conversation.send(dropped), error => error.delivery === "uncertain");
796
+ assert.equal((await f.conversation.wait()).status, "unconfirmed");
797
+ const receipt = await f.conversation.inspectDelivery({ messageId: dropped.messageId });
798
+ assert.equal(receipt.status, "not-sent");
799
+ assert.equal(receipt.messageCode, "assistant.conversation.messageNotSent");
800
+ // The proof is saved: a reload no longer restores an unconfirmed message.
801
+ assert.equal((await f.conversation.read()).status, "ready");
802
+ assert.equal((await f.conversation.inspectDelivery({ messageId: dropped.messageId })).status, "not-sent");
803
+ const sent = await f.conversation.send(dropped);
804
+ assert.equal(sent.messageId, dropped.messageId);
805
+ const finished = await f.conversation.wait();
806
+ assert.equal(finished.conversationLog.length, 1);
807
+ assert.equal(finished.conversationLog[0].assistant.text, "Answer: drop once");
808
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
809
+ });
810
+
786
811
  test("inspecting an older accepted Claude message leaves a different pending receipt untouched", async t => {
787
812
  const f = await fixture(t);
788
813
  const first = await f.conversation.send(input);