@jskit-ai/assistant-runtime 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.
@@ -215,6 +215,34 @@ function createChatService({
215
215
  return { conversationId: response.meta?.conversationId, messageId: request.messageId, status: record.status };
216
216
  }
217
217
 
218
+ // Turns on one conversation share its runtime entry. The last of them to
219
+ // settle disposes it, so a finished chat keeps no runtime state; a turn that
220
+ // starts while that disposal runs waits for it and opens the entry again.
221
+ const conversationTurns = new Map();
222
+ async function openConversationTurn(conversationId, open) {
223
+ while (conversationTurns.get(conversationId)?.releasing) await conversationTurns.get(conversationId).releasing;
224
+ const turns = conversationTurns.get(conversationId) || { count: 0, handle: null, releasing: null };
225
+ conversationTurns.set(conversationId, turns);
226
+ turns.count += 1;
227
+ try {
228
+ turns.handle = await open();
229
+ return turns.handle;
230
+ } catch (error) {
231
+ await releaseConversationTurn(conversationId);
232
+ throw error;
233
+ }
234
+ }
235
+ async function releaseConversationTurn(conversationId) {
236
+ const turns = conversationTurns.get(conversationId);
237
+ turns.count -= 1;
238
+ if (turns.count > 0) return;
239
+ // A failed disposal keeps the entry, as before: the next turn reuses it and
240
+ // runtime close retries its save.
241
+ turns.releasing = Promise.resolve(turns.handle?.dispose()).catch(() => {})
242
+ .finally(() => conversationTurns.delete(conversationId));
243
+ await turns.releasing;
244
+ }
245
+
218
246
  async function streamChat(payload = {}, options = {}) {
219
247
  const assistantSurface = requireAssistantSurface(appConfig, payload?.targetSurfaceId);
220
248
  const context = normalizeObject(options.context);
@@ -320,85 +348,91 @@ function createChatService({
320
348
  const runtimeContext = { ...assistantContext, assistantRequest: {
321
349
  conversation, workspace, history: source.history, resolvedAttachments
322
350
  } };
323
- let handle;
324
- try { handle = await conversationRuntime.open({ id: conversationId, context: runtimeContext, configuration }); }
325
- catch (error) {
326
- if (error.code !== "conversation_configuration_mismatch") throw error;
327
- handle = await conversationRuntime.open({ id: conversationId, context: runtimeContext });
328
- await handle.configure(configuration);
329
- }
330
- let turnId = null;
331
- let displayedId = null;
332
- let displayedText = "";
333
- const release = await handle.subscribe(event => {
334
- if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
335
- if (!turnId || event.turnId !== turnId) return;
336
- if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
337
- if (displayedId !== event.messageId) {
338
- if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
339
- displayedId = event.messageId;
340
- displayedText = "";
341
- }
342
- if (event.text === displayedText) return;
343
- if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
344
- else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
345
- displayedText = event.text;
346
- } else if (event.type === "tool") {
347
- const call = event.call;
348
- if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
349
- toolCallId: call.id, name: call.name, arguments: call.arguments });
350
- else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
351
- toolCallId: call.id, name: call.name, ok: call.result.ok === true,
352
- ...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
351
+ const handle = await openConversationTurn(conversationId, async () => {
352
+ try { return await conversationRuntime.open({ id: conversationId, context: runtimeContext, configuration }); }
353
+ catch (error) {
354
+ if (error.code !== "conversation_configuration_mismatch") throw error;
355
+ const opened = await conversationRuntime.open({ id: conversationId, context: runtimeContext });
356
+ await opened.configure(configuration);
357
+ return opened;
353
358
  }
354
359
  });
355
- let cancellation;
356
- const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
357
- let streamed = false;
358
360
  try {
359
- const meta = { type: ASSISTANT_STREAM_EVENT_TYPES.META, messageId: source.messageId, conversationId,
360
- provider: aiClient.provider, model: aiClient.defaultModel };
361
- await turnRequests.update(claim, { meta });
362
- streamWriter.sendMeta(meta);
363
- streamed = true;
364
- options.abortSignal?.addEventListener("abort", cancel, { once: true });
365
- options.abortSignal?.throwIfAborted();
366
- const receipt = await handle.send({ messageId: source.messageId, text: source.input,
367
- ...(source.attachmentIds !== undefined ? { attachmentIds: source.attachmentIds } : {}) });
368
- turnId = receipt.turnId;
369
- const snapshot = await handle.wait();
370
- const turn = snapshot.conversationLog.find(entry => entry.turnId === turnId);
371
- const status = turn?.metadata?.runtime?.status;
372
- if (status !== "complete") {
373
- const reason = turn?.metadata?.runtime?.error || snapshot.error;
374
- const error = reason ? new Error(reason) : Object.assign(new Error("Assistant request failed."), {
375
- messageCode: "assistant.runtime.request.failed", messageParams: {} });
376
- if (status === "cancelled") error.name = "AbortError";
377
- throw error;
361
+ let turnId = null;
362
+ let displayedId = null;
363
+ let displayedText = "";
364
+ const release = await handle.subscribe(event => {
365
+ if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
366
+ if (!turnId || event.turnId !== turnId) return;
367
+ if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
368
+ if (displayedId !== event.messageId) {
369
+ if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
370
+ displayedId = event.messageId;
371
+ displayedText = "";
372
+ }
373
+ if (event.text === displayedText) return;
374
+ if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
375
+ else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
376
+ displayedText = event.text;
377
+ } else if (event.type === "tool") {
378
+ const call = event.call;
379
+ if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
380
+ toolCallId: call.id, name: call.name, arguments: call.arguments });
381
+ else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
382
+ toolCallId: call.id, name: call.name, ok: call.result.ok === true,
383
+ ...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
384
+ }
385
+ });
386
+ let cancellation;
387
+ const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
388
+ let streamed = false;
389
+ try {
390
+ const meta = { type: ASSISTANT_STREAM_EVENT_TYPES.META, messageId: source.messageId, conversationId,
391
+ provider: aiClient.provider, model: aiClient.defaultModel };
392
+ await turnRequests.update(claim, { meta });
393
+ streamWriter.sendMeta(meta);
394
+ streamed = true;
395
+ options.abortSignal?.addEventListener("abort", cancel, { once: true });
396
+ options.abortSignal?.throwIfAborted();
397
+ const receipt = await handle.send({ messageId: source.messageId, text: source.input,
398
+ ...(source.attachmentIds !== undefined ? { attachmentIds: source.attachmentIds } : {}) });
399
+ turnId = receipt.turnId;
400
+ const snapshot = await handle.wait();
401
+ const turn = snapshot.conversationLog.find(entry => entry.turnId === turnId);
402
+ const status = turn?.metadata?.runtime?.status;
403
+ if (status !== "complete") {
404
+ const reason = turn?.metadata?.runtime?.error || snapshot.error;
405
+ const error = reason ? new Error(reason) : Object.assign(new Error("Assistant request failed."), {
406
+ messageCode: "assistant.runtime.request.failed", messageParams: {} });
407
+ if (status === "cancelled") error.name = "AbortError";
408
+ throw error;
409
+ }
410
+ const text = normalizeText(turn.assistant?.text);
411
+ if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
412
+ await transcriptService.completeConversation(assistantSurface, conversationId,
413
+ { status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
414
+ streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
415
+ streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
416
+ return { conversationId, messageId: source.messageId, status: "completed" };
417
+ } catch (error) {
418
+ const aborted = isAbortError(error) || options.abortSignal?.aborted;
419
+ const status = aborted ? "aborted" : "failed";
420
+ if (!streamed) throw error;
421
+ await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
422
+ streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
423
+ code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
424
+ message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
425
+ ...streamErrorMessageFields(error, aborted),
426
+ status: aborted ? 499 : errorStatus(error) });
427
+ streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
428
+ return { conversationId, messageId: source.messageId, status };
429
+ } finally {
430
+ options.abortSignal?.removeEventListener("abort", cancel);
431
+ release();
432
+ await cancellation;
378
433
  }
379
- const text = normalizeText(turn.assistant?.text);
380
- if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
381
- await transcriptService.completeConversation(assistantSurface, conversationId,
382
- { status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
383
- streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
384
- streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
385
- return { conversationId, messageId: source.messageId, status: "completed" };
386
- } catch (error) {
387
- const aborted = isAbortError(error) || options.abortSignal?.aborted;
388
- const status = aborted ? "aborted" : "failed";
389
- if (!streamed) throw error;
390
- await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
391
- streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
392
- code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
393
- message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
394
- ...streamErrorMessageFields(error, aborted),
395
- status: aborted ? 499 : errorStatus(error) });
396
- streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
397
- return { conversationId, messageId: source.messageId, status };
398
434
  } finally {
399
- options.abortSignal?.removeEventListener("abort", cancel);
400
- release();
401
- await cancellation;
435
+ await releaseConversationTurn(conversationId);
402
436
  }
403
437
  }
404
438
  }
@@ -117,7 +117,8 @@ test("host access wraps only canonical conversation definitions and receives the
117
117
  });
118
118
  assert.deepEqual(wrapped.map(({ id }) => id), [
119
119
  actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, subscribeActionId,
120
- actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
120
+ actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead,
121
+ actionIds.conversationGoalUpdate
121
122
  ]);
122
123
  assert.equal(findDefinition(definitions, actionIds.conversationSubscribe), undefined);
123
124
  for (const definition of wrapped) {
@@ -33,7 +33,14 @@ async function fixture(t, width = 1280, { acceptedDraft = false, draftWhileLoadi
33
33
  const state = async () => (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
34
34
  const sent = async () => (await state()).requests.filter(request => request.suffix === "/messages");
35
35
  const notify = id => page.evaluate(id => window.conversationFixture.socket.notify(id), id);
36
- return { page, primary, input, command, state, sent, notify };
36
+ // An unknown delivery check rereads the conversation. A receipt confirmed
37
+ // before the server serves that read would settle it from canonical history.
38
+ const inspectionReread = id => expect.poll(async () => {
39
+ const { requests } = await state();
40
+ const inspection = requests.findLastIndex(request => request.id === id && request.suffix.endsWith("/inspect"));
41
+ return inspection >= 0 && requests.slice(inspection + 1).some(request => request.method === "GET" && request.id === id && !request.suffix);
42
+ }).toBe(true);
43
+ return { page, primary, input, command, state, sent, notify, inspectionReread };
37
44
  }
38
45
 
39
46
  test("inspecting a restored no-admission request releases Send while preserving newer draft and uploads", options, async t => {
@@ -180,6 +187,142 @@ test("optional goal controls follow declared command semantics and budgets witho
180
187
  assert.equal((await f.sent()).length, 0, "Goal commands use the existing goal operation, not ordinary send");
181
188
  });
182
189
 
190
+ test("a paused goal stays cancellable while a message is sending or its delivery is unknown", options, async t => {
191
+ const f = await fixture(t);
192
+ await f.command("goals", { id: "chat:1", budgets: false, commands: {
193
+ set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
194
+ pause: { delivery: "control", interruptsTurn: true }, cancel: { delivery: "message", interruptsTurn: true }
195
+ } });
196
+ await f.notify("chat:1");
197
+ const goals = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST").map(request => request.input);
198
+ const pausedGoal = async objective => {
199
+ await f.primary.getByRole("button", { name: "Set goal", exact: true }).click();
200
+ await f.page.getByRole("textbox", { name: "Goal objective" }).fill(objective);
201
+ await f.page.getByRole("button", { name: "Start goal", exact: true }).click();
202
+ await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
203
+ await f.page.getByRole("button", { name: "Pause goal", exact: true }).click();
204
+ await expect(f.primary.getByRole("button", { name: "Goal paused", exact: true })).toBeVisible();
205
+ await f.page.keyboard.press("Escape");
206
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
207
+ };
208
+ const cancelPausedGoal = async () => {
209
+ await f.primary.getByRole("button", { name: "Goal paused", exact: true }).click();
210
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toBeDisabled();
211
+ const cancel = f.page.getByRole("button", { name: "Cancel goal", exact: true });
212
+ await expect(cancel).toBeEnabled();
213
+ await cancel.click();
214
+ await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toBeVisible();
215
+ await f.page.keyboard.press("Escape");
216
+ await expect(f.page.getByRole("textbox", { name: "Goal objective" })).toHaveCount(0);
217
+ const sent = await goals();
218
+ assert.equal(sent.at(-1).action, "cancel");
219
+ assert.ok(sent.at(-1).messageId, "A message-delivered Cancel keeps its receipt identity");
220
+ assert.equal(sent.at(-1).expectedGoalId, sent.at(-2).expectedGoalId);
221
+ };
222
+
223
+ await pausedGoal("Finish while a message is sending");
224
+ await f.command("mode", { mode: "hold" });
225
+ await f.input.fill("Still sending");
226
+ await f.input.press("Enter");
227
+ await expect.poll(async () => (await f.state()).held).toBe(1);
228
+ await f.command("mode", { mode: "accepted" });
229
+ await cancelPausedGoal();
230
+ assert.equal((await f.state()).held, 1, "Cancel did not wait for the message still sending");
231
+ await f.command("accept");
232
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.sending)).toBe(false);
233
+
234
+ await pausedGoal("Finish while a delivery is unknown");
235
+ await f.command("mode", { mode: "uncertain" });
236
+ await f.input.fill("Deslop");
237
+ await f.input.press("Enter");
238
+ await expect(f.primary.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
239
+ await f.command("mode", { mode: "accepted" });
240
+ await cancelPausedGoal();
241
+ assert.deepEqual((await goals()).map(command => command.action), ["set", "pause", "cancel", "set", "pause", "cancel"]);
242
+ });
243
+
244
+ test("Cancel ends a running delivery check, keeps the message until the server agrees and retries after the server was unreachable", options, async t => {
245
+ const f = await fixture(t);
246
+ await f.command("mode", { mode: "uncertain" });
247
+ await f.input.fill("Say OK and just that");
248
+ await f.input.press("Enter");
249
+ const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
250
+ await expect(check).toBeVisible();
251
+ const [original] = await f.sent();
252
+ const message = f.primary.getByText("Say OK and just that", { exact: true });
253
+ const cancelling = f.primary.getByRole("status").filter({ hasText: "Cancelling…" });
254
+ const cancel = f.primary.getByRole("button", { name: "Cancel", exact: true });
255
+ const cancels = async () => (await f.state()).requests.filter(request => request.suffix.endsWith("/cancel")).length;
256
+
257
+ // The server cannot be reached: the message stays, with Cancel again.
258
+ await f.page.route("**/deliveries/*/cancel", route => route.abort("internetdisconnected"));
259
+ const result = await f.page.evaluate(async messageId => {
260
+ const runtime = window.conversationFixture.current();
261
+ const checking = runtime.inspectDelivery(messageId);
262
+ const wasChecking = runtime.delivery.find(messageId)?.checking === true;
263
+ const cancelled = runtime.cancelMessage(messageId);
264
+ const whileWaiting = runtime.delivery.find(messageId)?.cancelling === true;
265
+ return { wasChecking, whileWaiting, receipt: await checking, cancelled: await cancelled };
266
+ }, original.input.messageId);
267
+ assert.deepEqual(result, { wasChecking: true, whileWaiting: true, receipt: false, cancelled: false });
268
+ await expect(f.primary.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
269
+ await expect(message).toBeVisible();
270
+ await expect(cancel).toBeEnabled();
271
+ await f.page.unroute("**/deliveries/*/cancel");
272
+
273
+ // Reconnecting retries the cancel; the message stays, cancelling, until the
274
+ // server answers, and an unconfirmed snapshot meanwhile does not change that.
275
+ await f.command("hold-cancels", { hold: true });
276
+ await f.notify("chat:1");
277
+ await expect(cancelling).toBeVisible();
278
+ await expect.poll(cancels).toBe(1);
279
+ await expect(message).toBeVisible();
280
+ assert.equal((await f.state()).states["chat:1"].pendingRequest.messageId, original.input.messageId);
281
+ await f.command("answer-cancels");
282
+ await expect(message).toHaveCount(0);
283
+ await expect(check).toHaveCount(0);
284
+
285
+ // The server dropped the request, so neither a snapshot nor a reload brings it back.
286
+ assert.equal((await f.state()).states["chat:1"].pendingRequest, null);
287
+ await f.notify("chat:1");
288
+ await f.page.reload();
289
+ await expect(f.input).toBeEnabled();
290
+ await expect(message).toHaveCount(0);
291
+ assert.equal(await cancels(), 1);
292
+ });
293
+
294
+ test("a Cancel the server never received is retried after a page reload", options, async t => {
295
+ const f = await fixture(t);
296
+ const attach = async () => {
297
+ await f.page.evaluate(() => {
298
+ window.savedBinding = window.conversationFixture.acquireConversation({
299
+ conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "unfinished-cancel" }
300
+ });
301
+ window.conversationFixture.target("chat:2");
302
+ });
303
+ await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
304
+ };
305
+ await attach();
306
+ await f.command("mode", { mode: "uncertain" });
307
+ await f.input.fill("Cancel me across a reload");
308
+ await f.input.press("Enter");
309
+ await expect(f.primary.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
310
+ await f.page.route("**/deliveries/*/cancel", route => route.abort("internetdisconnected"));
311
+ await f.primary.getByRole("button", { name: "Cancel", exact: true }).click();
312
+ await expect(f.primary.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
313
+ await f.page.unroute("**/deliveries/*/cancel");
314
+ await f.command("hold-cancels", { hold: true });
315
+ await f.page.reload();
316
+ await attach();
317
+ const message = f.primary.getByText("Cancel me across a reload", { exact: true });
318
+ await expect(f.primary.getByRole("status").filter({ hasText: "Cancelling…" })).toBeVisible();
319
+ await expect(message).toBeVisible();
320
+ await f.command("answer-cancels");
321
+ await expect(message).toHaveCount(0);
322
+ const cancels = (await f.state()).requests.filter(request => request.suffix.endsWith("/cancel"));
323
+ assert.deepEqual(cancels.map(request => request.id), ["chat:2"]);
324
+ });
325
+
183
326
  test("the first native goal preserves absent identity and later controls capture its actual thread", options, async t => {
184
327
  const f = await fixture(t);
185
328
  await f.input.fill("Keep this ordinary draft");
@@ -413,6 +556,7 @@ test("explicit delivery inspection acknowledges uncertain attachments without cl
413
556
  await check.click();
414
557
  await expect(check).toBeVisible();
415
558
  assert.deepEqual((await attachments()).acknowledged, [], "An unknown receipt does not accept the upload");
559
+ await f.inspectionReread("chat:1");
416
560
  await f.command("confirm", { id: "chat:1" });
417
561
  await check.click();
418
562
  await expect(check).toHaveCount(0);
@@ -639,7 +783,9 @@ test("canonical receipts settle a held response and uncertain delivery stays ins
639
783
  const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
640
784
  await expect(check).toBeVisible();
641
785
  await expect(f.primary.getByText("Keep this exact uncertain request", { exact: true })).toHaveCount(1);
642
- await expect(f.primary.getByRole("button", { name: /^(Retry|Edit|Cancel)$/u })).toHaveCount(0);
786
+ // Never resent or edited while unknown; only the server's Cancel is offered.
787
+ await expect(f.primary.getByRole("button", { name: /^(Retry|Edit)$/u })).toHaveCount(0);
788
+ await expect(f.primary.getByRole("button", { name: "Cancel", exact: true })).toBeEnabled();
643
789
  const checkBox = await check.boundingBox();
644
790
  assert.ok(checkBox.width >= 48 && checkBox.height >= 48);
645
791
  await f.input.fill("Preserve the newer draft");
@@ -731,7 +877,17 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
731
877
  assert.equal(restored.id, saved.messages[0].id);
732
878
  assert.deepEqual(restored.payload, saved.messages[0].payload);
733
879
  assert.equal((await f.sent()).length, 1, "restoration never dispatches a saved request");
734
- assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), !unconfirmed);
880
+ // The server is ready, so a different authored draft may proceed; only the
881
+ // restored uncertain words themselves stay blocked until inspection settles them.
882
+ assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), true);
883
+ if (unconfirmed) {
884
+ assert.equal(await f.page.evaluate(() => {
885
+ const runtime = window.savedBinding.runtime.value;
886
+ runtime.draft.value = "Exact original saved words";
887
+ return runtime.canSubmit.value;
888
+ }), false, "the uncertain request's own words cannot be sent again");
889
+ await f.page.evaluate(draft => { window.savedBinding.runtime.value.draft.value = draft; }, saved.draft);
890
+ }
735
891
  if (unconfirmed) {
736
892
  const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
737
893
  try { await expect(check).toBeEnabled(); }
@@ -746,9 +902,9 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
746
902
  await f.page.screenshot({ path: `/tmp/jskit-saved-delivery-reload-${status}-20261006.png` });
747
903
  throw failure;
748
904
  }
749
- for (const action of ["Retry", "Cancel", "Edit"]) {
750
- await expect(f.primary.getByRole("button", { name: action, exact: true })).toHaveCount(0);
751
- }
905
+ // Never resent or edited while unknown; only the server's Cancel is offered.
906
+ await expect(f.primary.getByRole("button", { name: /^(Retry|Edit)$/u })).toHaveCount(0);
907
+ await expect(f.primary.getByRole("button", { name: "Cancel", exact: true })).toBeEnabled();
752
908
  // The fixture's original inspector matches any same-ID row. Keep the
753
909
  // nonreceipt projection proof above, then inspect genuine absence.
754
910
  await f.command("history", { id: "chat:2", turns: [], limit: 20 });
@@ -833,7 +989,7 @@ test("a finished-turn error without a definite rejection status remains uncertai
833
989
  assert.equal((await f.sent()).length, 1);
834
990
  });
835
991
 
836
- for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
992
+ for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable", "conversation_message_cancelled"]) {
837
993
  test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
838
994
  const f = await fixture(t);
839
995
  await f.command("mode", { mode: "rejected" });
@@ -842,6 +998,8 @@ test(`an explicit pre-admission rejection retains original retry identity and th
842
998
  await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
843
999
  await f.notify("chat:1");
844
1000
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
1001
+ }
1002
+ if (code !== "ACTION_VALIDATION_FAILED") {
845
1003
  await f.page.route("**/messages", async route => {
846
1004
  const response = await route.fetch();
847
1005
  await route.fulfill(response.status() === 400
@@ -887,7 +1045,8 @@ test(`an explicit pre-admission rejection retains original retry identity and th
887
1045
  await f.input.fill("Keep my newer draft");
888
1046
  await f.primary.getByRole("button", { name: action, exact: true }).click();
889
1047
  await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toHaveCount(0);
890
- assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId), null);
1048
+ // Cancel removes the message once the server agrees.
1049
+ await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId)).toBe(null);
891
1050
  await expect(f.input).toHaveValue(action === "Edit" ? `${text}\n\nKeep my newer draft` : "Keep my newer draft");
892
1051
  }
893
1052
  assert.equal((await f.sent()).length, 4, "Cancel and Edit do not dispatch another prompt");
@@ -1557,13 +1716,18 @@ test("a voice preview cannot acknowledge sending or uncertain delivery and dedup
1557
1716
  const second = (await f.sent())[1].input.messageId;
1558
1717
  await preview(second, "An uncertain preview is not an acknowledgement.");
1559
1718
  await expect(f.primary.getByText("An uncertain preview is not an acknowledgement.", { exact: true })).toHaveCount(0);
1560
- await expect(f.primary.getByText("Vibe64", { exact: true })).toBeVisible();
1719
+ // The uncertain notice stays attached to the user's own bubble, not a system row.
1720
+ const uncertainBubble = f.primary.locator(".assistant-transcript__message--user").filter({ hasText: "Keep this uncertain request." });
1721
+ await expect(uncertainBubble.locator(".assistant-transcript__optimistic-failure")).toBeVisible();
1722
+ await expect(uncertainBubble.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
1723
+ await expect(f.primary.locator(".assistant-transcript__system")).toHaveCount(0);
1561
1724
  assert.deepEqual((await delivery()).messages, [{ id: second, status: "uncertain" }]);
1562
1725
  assert.equal((await delivery()).history.length, 1);
1563
1726
  await check.click();
1564
1727
  await expect(check).toBeEnabled();
1565
1728
  assert.deepEqual((await delivery()).messages, [{ id: second, status: "uncertain" }]);
1566
1729
  await expect(f.input).toHaveValue("Keep this uncertain request.");
1730
+ await f.inspectionReread("chat:1");
1567
1731
  await f.command("confirm", { id: "chat:1" });
1568
1732
  await check.click();
1569
1733
  await expect(check).toHaveCount(0);
@@ -2311,6 +2475,50 @@ test("prepared binding preserves retry identity, newer composer input and inspec
2311
2475
  assert.deepEqual(await f.page.evaluate(() => window.taskClearedFiles), [["original-file"]]);
2312
2476
  });
2313
2477
 
2478
+ test("prepared Retry sends an uncertain message again only after the server proves it was never sent", options, async t => {
2479
+ const f = await fixture(t);
2480
+ await f.page.evaluate(() => {
2481
+ window.provenBinding = window.conversationFixture.acquireConversation({ conversationId: "chat:3", active: false });
2482
+ window.provenReader = window.conversationFixture.acquireConversation({ conversationId: "chat:3" });
2483
+ });
2484
+ await expect.poll(() => f.page.evaluate(() => window.provenReader.runtime.value.available.value)).toBe(true);
2485
+ await f.command("mode", { mode: "uncertain" });
2486
+ const uncertain = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Send me once." },
2487
+ { messageId: "proven-retry", prepare: captured => captured.request }));
2488
+ assert.equal(uncertain.status, "uncertain");
2489
+ assert.equal((await f.sent()).length, 1);
2490
+ // An unknown receipt still never resends.
2491
+ const unknown = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
2492
+ { retryMessageId: "proven-retry", prepare: captured => captured.request }));
2493
+ assert.equal(unknown.status, "uncertain");
2494
+ assert.equal((await f.sent()).length, 1);
2495
+ await f.command("mode", { mode: "not-sent" });
2496
+ // Hold the resend until a read after the not-sent proof has arrived: the
2497
+ // proven request is saved as unattempted, so it no longer reads as unconfirmed.
2498
+ const sendGate = Promise.withResolvers();
2499
+ t.after(() => sendGate.resolve());
2500
+ let sendHeld = false;
2501
+ await f.page.route("**/messages", async route => {
2502
+ sendHeld = true;
2503
+ await sendGate.promise;
2504
+ await route.continue().catch(() => {});
2505
+ });
2506
+ const retrying = f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
2507
+ { retryMessageId: "proven-retry", prepare: captured => captured.request }));
2508
+ await expect.poll(() => sendHeld).toBe(true);
2509
+ await f.page.evaluate(() => window.provenBinding.runtime.value.reload());
2510
+ sendGate.resolve();
2511
+ const retried = await retrying;
2512
+ await f.page.unroute("**/messages");
2513
+ // Acceptance is the HTTP receipt or the canonical row, whichever arrives first.
2514
+ assert.ok(retried && retried.ok !== false, JSON.stringify(retried));
2515
+ const sent = await f.sent();
2516
+ assert.equal(sent.length, 2);
2517
+ assert.equal(sent[1].input.messageId, "proven-retry");
2518
+ assert.equal(sent[1].input.text, "Send me once.");
2519
+ assert.equal(await f.page.evaluate(() => window.provenBinding.runtime.value.delivery.find("proven-retry")?.status ?? "accepted"), "accepted");
2520
+ });
2521
+
2314
2522
  test("acceptance settles a voice-retained conversation after its text view retargets without clearing the next composer", options, async t => {
2315
2523
  const f = await fixture(t, 1280, { acceptedDraft: true });
2316
2524
  await f.page.evaluate(() => window.conversationFixture.attachments(true));
@@ -2366,7 +2574,8 @@ test("transient subscription errors keep loaded history and recover through the
2366
2574
  user: { messageId: `question-${index}`, role: "user", text: `Private saved question ${index}. ` + "Keep the original discussion. ".repeat(8) },
2367
2575
  assistant: { messageId: `answer-${index}`, role: "assistant", text: `Private saved answer ${index}. ` + "Read the saved answer without losing your place. ".repeat(8) }
2368
2576
  }));
2369
- await f.command("history", { id: "chat:1", turns, limit: 30 });
2577
+ // History pages count real messages: load all 30 turns' questions and answers.
2578
+ await f.command("history", { id: "chat:1", turns, limit: turns.length * 2 });
2370
2579
  await f.notify("chat:1");
2371
2580
  const body = f.primary.locator(".assistant-transcript__body");
2372
2581
  await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
@@ -2472,7 +2681,25 @@ for (const width of [390, 768, 1280]) {
2472
2681
  await body.hover();
2473
2682
  await f.page.mouse.wheel(0, -300);
2474
2683
  await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
2475
- await body.evaluate(element => { window.atomicHistoryBody = element; element.scrollTop = 420; });
2684
+ // The reader looks at question 13. The refresh removes saved turns 7 and 10
2685
+ // and shortens answer 11 above it, so the reader's rows must stay where they
2686
+ // are on screen, whatever scrollTop that takes at this width.
2687
+ const reader = body.locator(".assistant-transcript__turn").filter({ hasText: "Atomic saved question 13." });
2688
+ await reader.evaluate(row => {
2689
+ const scroller = row.closest(".assistant-transcript__body");
2690
+ window.atomicHistoryBody = scroller;
2691
+ scroller.scrollTop += row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
2692
+ });
2693
+ // Rows near the new position replace their size estimates on later frames.
2694
+ const readerTop = () => reader.evaluate(async row => {
2695
+ const scroller = row.closest(".assistant-transcript__body");
2696
+ const top = () => row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
2697
+ const before = [top(), scroller.scrollTop];
2698
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
2699
+ return before[0] === top() && before[1] === scroller.scrollTop ? Math.round(top()) : NaN;
2700
+ });
2701
+ await expect.poll(readerTop).not.toBeNaN();
2702
+ const readerOffset = await readerTop();
2476
2703
  await f.input.fill("Unsent while history refreshes");
2477
2704
  await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(3, 8); });
2478
2705
  const previousIds = turns.slice(6).map(turn => turn.turnId);
@@ -2494,7 +2721,7 @@ for (const width of [390, 768, 1280]) {
2494
2721
  await expect.poll(() => Boolean(release)).toBe(true);
2495
2722
  assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), previousIds);
2496
2723
  assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
2497
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2724
+ await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
2498
2725
  await expect(f.input).toHaveValue("Unsent while history refreshes");
2499
2726
  await expect(f.input).toBeFocused();
2500
2727
  assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
@@ -2505,7 +2732,7 @@ for (const width of [390, 768, 1280]) {
2505
2732
  await expect(f.primary.getByText("Authoritatively replaced saved answer", { exact: true })).toHaveCount(1);
2506
2733
  await expect(f.primary.getByText(/Atomic saved question (7|10)\./)).toHaveCount(0);
2507
2734
  assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
2508
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2735
+ await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
2509
2736
  await expect(f.input).toHaveValue("Unsent while history refreshes");
2510
2737
  await expect(f.input).toBeFocused();
2511
2738
  assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);