@jskit-ai/assistant-runtime 0.2.2 → 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.
@@ -9,7 +9,7 @@ export default defineConfig({
9
9
  plugins: [vue(), {
10
10
  name: "controlled-canonical-assistant-api",
11
11
  configureServer(server) {
12
- let states, requests, held, mode, denied;
12
+ let states, requests, held, mode, denied, holdCancels, heldCancels;
13
13
  const pageLimits = new Map();
14
14
  // Exercise the existing page reader over this fixture's controlled rows.
15
15
  const transcript = createConversationTranscript({ storage: {
@@ -27,6 +27,7 @@ export default defineConfig({
27
27
  streaming: { revision: 0, messages: [] }, capabilities: { steering: false, goals: false, attachments: true } }];
28
28
  }));
29
29
  requests = []; held = []; mode = "accepted"; denied = false; pageLimits.clear();
30
+ holdCancels = false; heldCancels = [];
30
31
  }
31
32
  reset();
32
33
  function accept(state, input) {
@@ -60,6 +61,8 @@ export default defineConfig({
60
61
  if (url.pathname === "/fixture/reset") { reset(); return json({ ok: true }); }
61
62
  if (url.pathname === "/fixture/state") return json({ requests, states: Object.fromEntries(states), held: held.length });
62
63
  if (url.pathname === "/fixture/mode") { mode = input.mode; return json({ ok: true }); }
64
+ if (url.pathname === "/fixture/hold-cancels") { holdCancels = input.hold; return json({ ok: true }); }
65
+ if (url.pathname === "/fixture/answer-cancels") { for (const agree of heldCancels.splice(0)) agree(); return json({ ok: true }); }
63
66
  if (url.pathname === "/fixture/deny") { denied = input.denied; return json({ ok: true }); }
64
67
  if (url.pathname === "/fixture/error") { states.get(input.id).error = input.error; return json({ ok: true }); }
65
68
  if (url.pathname === "/fixture/interim-reply") { states.get(input.id).interimReply = input.reply; return json({ ok: true }); }
@@ -168,10 +171,27 @@ export default defineConfig({
168
171
  return json(accept(state, input), 202);
169
172
  }
170
173
  if (match[2] === "/cancel") { state.status = "ready"; return json({ stopped: true }); }
174
+ // The server always records a cancel and drops the unconfirmed request.
175
+ if (match[2].startsWith("/deliveries/") && match[2].endsWith("/cancel")) {
176
+ const messageId = decodeURIComponent(match[2].split("/")[2]);
177
+ const agree = () => {
178
+ state.cancelled = [...state.cancelled || [], messageId];
179
+ if (state.pendingRequest?.messageId === messageId) { state.pendingRequest = null; state.status = "ready"; }
180
+ json({ ok: true, cancelled: true, messageId });
181
+ };
182
+ if (holdCancels) heldCancels.push(agree);
183
+ else agree();
184
+ return;
185
+ }
171
186
  if (match[2].startsWith("/deliveries/") && match[2].endsWith("/inspect")) {
172
187
  const messageId = decodeURIComponent(match[2].split("/")[2]);
188
+ if (state.cancelled?.includes(messageId)) return json({ status: "cancelled", messageId });
173
189
  const receipt = state.conversationLog.find(turn => turn.user?.messageId === messageId);
174
- if (!receipt && mode === "not-sent") return json({ status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." });
190
+ if (!receipt && mode === "not-sent") {
191
+ // Like the runtime, record the proven request as unattempted: reads stop reporting it unconfirmed.
192
+ if (state.pendingRequest?.messageId === messageId) Object.assign(state, { status: "ready", pendingRequest: null });
193
+ return json({ status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." });
194
+ }
175
195
  return json(receipt ? { status: "accepted", messageId, turnId: receipt.turnId } : { status: "unknown", messageId });
176
196
  }
177
197
  return json({ error: "Unknown fixture operation" }, 404);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -13,19 +13,19 @@
13
13
  "./locales/en.json": "./locales/en.json"
14
14
  },
15
15
  "dependencies": {
16
- "@jskit-ai/assistant-core": "0.2.2",
16
+ "@jskit-ai/assistant-core": "0.2.3",
17
17
  "json-rest-schema": "^1.0.17"
18
18
  },
19
19
  "peerDependencies": {
20
- "@jskit-ai/http-runtime": "0.2.0",
21
- "@jskit-ai/http-web": "0.2.1",
22
- "@jskit-ai/kernel": "0.2.0",
23
- "@jskit-ai/realtime": "0.2.0",
24
- "@jskit-ai/shell-web": "0.2.0",
20
+ "@jskit-ai/http-runtime": "0.2.1",
21
+ "@jskit-ai/http-web": "0.2.2",
22
+ "@jskit-ai/kernel": "0.2.1",
23
+ "@jskit-ai/realtime": "0.2.1",
24
+ "@jskit-ai/shell-web": "0.2.1",
25
25
  "@tanstack/vue-query": "^5.90.5",
26
26
  "vue": "^3.5.13",
27
27
  "vuetify": "^4.0.0",
28
- "@jskit-ai/database-runtime": "0.1.249"
28
+ "@jskit-ai/database-runtime": "0.1.250"
29
29
  },
30
30
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
31
31
  "jskit": {
@@ -42,7 +42,8 @@ function configureAssistantConversations(app, { actorKey, api = null, request, c
42
42
  // occur after dispatch and must retain an uncertain receipt.
43
43
  const SEND_REJECTIONS = new Set([
44
44
  "ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
45
- "conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed"
45
+ "conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed",
46
+ "conversation_message_cancelled"
46
47
  ]);
47
48
 
48
49
  function isQuestionConfiguration(value) {
@@ -116,6 +117,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
116
117
  const accessDenied = ref(false);
117
118
  const delivery = createAssistantMessageDelivery();
118
119
  const pendingMessages = new Map();
120
+ const deliveryChecks = new Map();
119
121
  const olderPages = ref([]);
120
122
  let historyRevision = 0;
121
123
  let deniedHistoryRevision = 0;
@@ -158,10 +160,14 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
158
160
  typeof message.payload?.message === "string") : [];
159
161
  const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
160
162
  const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
161
- return { ...message, status, checking: false, error: message.error || (status === "accepted" ? "" :
163
+ return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
162
164
  t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
163
165
  });
164
166
  delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
167
+ // A cancel the server had not agreed to before this page closed is retried.
168
+ for (const message of messages) {
169
+ if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
170
+ }
165
171
  } catch {
166
172
  draft.value = "";
167
173
  draftAttachments.value = [];
@@ -239,6 +245,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
239
245
  accessDenied.value = false;
240
246
  delivery.reconcile(state.turns);
241
247
  if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
248
+ retryCancels();
242
249
  if (state.segmentId !== goalSegment) {
243
250
  // Keep the open goal menu while its first native identity arrives. The
244
251
  // pending read disables commands until it supplies the new exact target.
@@ -374,6 +381,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
374
381
  stopPlacement();
375
382
  for (const pending of pendingMessages.values()) pending.controller.abort();
376
383
  pendingMessages.clear();
384
+ for (const check of deliveryChecks.values()) check.abort();
377
385
  goalReadController?.abort();
378
386
  delivery.reset();
379
387
  });
@@ -561,11 +569,32 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
561
569
  if (current.value) subscription?.reload();
562
570
  }
563
571
  }
564
- function cancelMessage(messageId) {
565
- const pending = pendingMessages.get(messageId);
566
- if (!pending) return false;
567
- pending.controller.abort();
568
- return true;
572
+ // Cancel means cancel, and the server decides. Cancel ends the message's
573
+ // unsent request and running delivery check, then asks the server, which
574
+ // always records the cancel. The message stays, shown as cancelling, until
575
+ // the server agrees; when the server cannot be reached it stays with Cancel
576
+ // again, and the next snapshot after reconnecting retries it.
577
+ async function cancelMessage(messageId) {
578
+ pendingMessages.get(messageId)?.controller.abort();
579
+ deliveryChecks.get(messageId)?.abort();
580
+ if (!available.value || !delivery.cancel(messageId)) return false;
581
+ try {
582
+ const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
583
+ if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
584
+ if (!current.value) return false;
585
+ delivery.remove(messageId);
586
+ subscription?.reload();
587
+ return true;
588
+ } catch (failure) {
589
+ // The server's own refusal names itself; anything else did not reach it.
590
+ if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
591
+ return false;
592
+ }
593
+ }
594
+ function retryCancels() {
595
+ for (const message of delivery.state.messages) {
596
+ if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
597
+ }
569
598
  }
570
599
 
571
600
  // The original history control keeps older pages separate from the live page.
@@ -653,14 +682,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
653
682
  const view = goalView.value;
654
683
  const target = view?.target;
655
684
  const command = target?.capabilities?.goalCommands?.[action];
685
+ // Pause and Cancel never depend on chat delivery: each is pinned to the goal
686
+ // it stops and goes straight to the goal operation, even while a message is
687
+ // sending or its receipt is unknown. Set and Resume wait for those.
688
+ const stopping = action === "pause" || action === "cancel";
656
689
  if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
657
690
  !["message", "control"].includes(command?.delivery) ||
658
- delivery.state.messages.some(message => message.status === "uncertain")) return false;
691
+ !stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
659
692
  if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
660
693
  goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
661
694
  return false;
662
695
  }
663
- const retry = delivery.find(messageId);
696
+ const retry = stopping ? null : delivery.find(messageId);
664
697
  if (retry && retry.status !== "failed") return false;
665
698
  const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
666
699
  expectedGoalId: view.goal?.id || null,
@@ -669,7 +702,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
669
702
  goalAction.value = action;
670
703
  goalError.value = "";
671
704
  try {
672
- if (retry || command.delivery === "message") {
705
+ if (retry || !stopping && command.delivery === "message") {
673
706
  const result = await submitRequest(retry?.payload || {
674
707
  message: action === "set" ? request.objective
675
708
  : t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
@@ -683,7 +716,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
683
716
  }
684
717
  return result;
685
718
  }
686
- const result = await api.updateConversationGoal(identity.conversationId, request);
719
+ const result = await api.updateConversationGoal(identity.conversationId,
720
+ command.delivery === "message" ? { ...request, messageId } : request);
687
721
  if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
688
722
  return result;
689
723
  } catch (failure) {
@@ -719,6 +753,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
719
753
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
720
754
  pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
721
755
  delivery.state.messages.some(message => message.status === "uncertain"),
756
+ stopPending: Boolean(goalAction.value) || !goalAvailable || !available.value,
722
757
  error: goalError.value || goalLoadError.value || view?.error || "",
723
758
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
724
759
  cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
@@ -740,14 +775,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
740
775
  if (!available.value) return false;
741
776
  const message = delivery.find(messageId);
742
777
  if (message?.checking) return false;
778
+ const check = new AbortController();
779
+ deliveryChecks.set(messageId, check);
743
780
  if (message) message.checking = true;
744
781
  try {
745
- const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
746
- if (!current.value) return false;
782
+ const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
783
+ if (!current.value || check.signal.aborted) return false;
747
784
  if (receipt.status === "accepted") {
748
785
  delivery.accept(messageId);
749
786
  if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
750
787
  }
788
+ // The server recorded this message as cancelled, perhaps from another tab.
789
+ else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
751
790
  else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
752
791
  else if (receipt.status === "unknown" && message?.status === "uncertain") {
753
792
  message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
@@ -755,11 +794,15 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
755
794
  subscription?.reload();
756
795
  return receipt;
757
796
  } catch (failure) {
797
+ if (check.signal.aborted) return false;
758
798
  if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
759
799
  receiveError(failure);
760
800
  return false;
761
801
  }
762
- finally { if (message) message.checking = false; }
802
+ finally {
803
+ if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
804
+ if (message) message.checking = false;
805
+ }
763
806
  }
764
807
 
765
808
  const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
@@ -1054,6 +1097,8 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1054
1097
  hasMoreBefore: current?.hasMoreBefore.value || false,
1055
1098
  loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
1056
1099
  loading: current?.loading.value || false, working: !current?.error.value && state?.status === "working" && !current.delivery.state.sending,
1100
+ // The server cancels any unconfirmed message, so its Cancel is offered.
1101
+ cancellableDelivery: true,
1057
1102
  assistantLabel: display.assistantLabel || t("jskit.assistant.runtime.conversation.assistantLabel"),
1058
1103
  welcomeMessage: display.welcomeMessage ?? t("jskit.assistant.runtime.conversation.welcomeMessage"),
1059
1104
  systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
@@ -1075,6 +1120,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1075
1120
  else if (canDraftWhileLoading.value) initialDraft.value = value;
1076
1121
  }, submit,
1077
1122
  resend: messageId => submit({ retryMessageId: messageId }), stop: () => current?.cancel(),
1123
+ cancel: messageId => current?.cancelMessage(messageId),
1078
1124
  checkDelivery: messageId => current?.inspectDelivery(messageId), loadMore: input => current?.loadMore(input), reload: () => current?.reload() }
1079
1125
  };
1080
1126
  });
@@ -5,6 +5,7 @@ const actionIds = Object.freeze({
5
5
  conversationCancel: "assistant.conversation.cancel",
6
6
  conversationSubscribe: "assistant.conversation.subscribe",
7
7
  conversationInspectDelivery: "assistant.conversation.delivery.inspect",
8
+ conversationCancelDelivery: "assistant.conversation.delivery.cancel",
8
9
  conversationGoalRead: "assistant.conversation.goal.read",
9
10
  conversationGoalUpdate: "assistant.conversation.goal.update",
10
11
  conversationConfigure: "assistant.conversation.configure",
@@ -225,6 +225,21 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
225
225
  return conversation.inspectDelivery({ messageId: input.messageId });
226
226
  }
227
227
  },
228
+ {
229
+ id: actionIds.conversationCancelDelivery,
230
+ version: 1,
231
+ kind: "command",
232
+ channels: ["api", "internal"],
233
+ surfaces: runtimeSurfaces,
234
+ permission: { require: "authenticated" },
235
+ input: conversationInspectInputValidator,
236
+ output: null,
237
+ idempotency: "none",
238
+ async execute(input, context) {
239
+ const conversation = await openConversation(input, context);
240
+ return conversation.cancelDelivery({ messageId: input.messageId });
241
+ }
242
+ },
228
243
  {
229
244
  id: actionIds.conversationGoalRead,
230
245
  version: 1,
@@ -385,6 +385,25 @@ function registerConversationRoutes(
385
385
  reply.code(200).send(response);
386
386
  });
387
387
 
388
+ router.register("POST", `${routeBase}/deliveries/:messageId/cancel`, {
389
+ auth: "required", visibility,
390
+ params: composeSchemaDefinitions([params, assistantConversationMessageIdInputValidator], {
391
+ mode: "patch", context: "assistant-runtime conversation delivery cancel route params"
392
+ }),
393
+ meta: { tags: ["assistant"], summary: "Cancel one unsent or unconfirmed assistant message." }
394
+ }, async function assistantConversationCancelDeliveryRoute(request, reply) {
395
+ const routeState = resolveRouteRequestState(request, {
396
+ resolveCurrentAppConfig, requiresWorkspace, workspaceScopeSupport
397
+ });
398
+ const response = await request.executeAction({
399
+ actionId: actionIds.conversationCancelDelivery,
400
+ surface: routeState.hostSurfaceId,
401
+ input: { ...routeState.actionInput, conversationId: request.input.params.conversationId,
402
+ messageId: request.input.params.messageId }
403
+ });
404
+ reply.code(200).send(response);
405
+ });
406
+
388
407
  for (const method of ["GET", "POST"]) {
389
408
  router.register(method, `${routeBase}/goal`, {
390
409
  auth: "required", visibility, params,
@@ -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);
@@ -2329,9 +2493,25 @@ test("prepared Retry sends an uncertain message again only after the server prov
2329
2493
  assert.equal(unknown.status, "uncertain");
2330
2494
  assert.equal((await f.sent()).length, 1);
2331
2495
  await f.command("mode", { mode: "not-sent" });
2332
- const retried = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
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." },
2333
2507
  { retryMessageId: "proven-retry", prepare: captured => captured.request }));
2334
- assert.equal(retried.ok, true);
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));
2335
2515
  const sent = await f.sent();
2336
2516
  assert.equal(sent.length, 2);
2337
2517
  assert.equal(sent[1].input.messageId, "proven-retry");
@@ -2394,7 +2574,8 @@ test("transient subscription errors keep loaded history and recover through the
2394
2574
  user: { messageId: `question-${index}`, role: "user", text: `Private saved question ${index}. ` + "Keep the original discussion. ".repeat(8) },
2395
2575
  assistant: { messageId: `answer-${index}`, role: "assistant", text: `Private saved answer ${index}. ` + "Read the saved answer without losing your place. ".repeat(8) }
2396
2576
  }));
2397
- 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 });
2398
2579
  await f.notify("chat:1");
2399
2580
  const body = f.primary.locator(".assistant-transcript__body");
2400
2581
  await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
@@ -2500,7 +2681,25 @@ for (const width of [390, 768, 1280]) {
2500
2681
  await body.hover();
2501
2682
  await f.page.mouse.wheel(0, -300);
2502
2683
  await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
2503
- 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();
2504
2703
  await f.input.fill("Unsent while history refreshes");
2505
2704
  await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(3, 8); });
2506
2705
  const previousIds = turns.slice(6).map(turn => turn.turnId);
@@ -2522,7 +2721,7 @@ for (const width of [390, 768, 1280]) {
2522
2721
  await expect.poll(() => Boolean(release)).toBe(true);
2523
2722
  assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), previousIds);
2524
2723
  assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
2525
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2724
+ await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
2526
2725
  await expect(f.input).toHaveValue("Unsent while history refreshes");
2527
2726
  await expect(f.input).toBeFocused();
2528
2727
  assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
@@ -2533,7 +2732,7 @@ for (const width of [390, 768, 1280]) {
2533
2732
  await expect(f.primary.getByText("Authoritatively replaced saved answer", { exact: true })).toHaveCount(1);
2534
2733
  await expect(f.primary.getByText(/Atomic saved question (7|10)\./)).toHaveCount(0);
2535
2734
  assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
2536
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2735
+ await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
2537
2736
  await expect(f.input).toHaveValue("Unsent while history refreshes");
2538
2737
  await expect(f.input).toBeFocused();
2539
2738
  assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
@@ -36,12 +36,18 @@ async function fixture(t, { workspace = false, conversationDataSchema = null, ap
36
36
  const dispatched = [Promise.withResolvers(), Promise.withResolvers()];
37
37
  const scopeInputs = [];
38
38
  const knownIds = new Set(["chat:one", "chat:two"]);
39
- const access = { allowed: true };
39
+ const access = { allowed: true, authorized: [], hold: null };
40
40
  const storage = createFileConversationStorage({ directory });
41
41
  const runtime = createConversationRuntime({
42
42
  storage,
43
- authorize: ({ context: actor, conversationId }) => access.allowed && actor?.actor?.id === "owner" && knownIds.has(conversationId) &&
44
- (!workspace || actor.workspace?.id === trustedWorkspace.id),
43
+ async authorize({ context: actor, conversationId, operation }) {
44
+ access.authorized.push(operation);
45
+ // A test can hold one operation's authorization, and with it the conversation queue.
46
+ const hold = access.hold?.operation === operation && access.hold;
47
+ if (hold) { access.hold = null; hold.entered.resolve(); await hold.release; }
48
+ return access.allowed && actor?.actor?.id === "owner" && knownIds.has(conversationId) &&
49
+ (!workspace || actor.workspace?.id === trustedWorkspace.id);
50
+ },
45
51
  connections: {
46
52
  async resolve({ context: actor }) {
47
53
  assert.equal(actor.actor.id, "owner");
@@ -215,9 +221,9 @@ test("file-backed conversation routes return admission receipts and cancel one c
215
221
  const f = await fixture(t);
216
222
  assert.deepEqual(f.catalogue.listDefinitions().map(({ id }) => id), [
217
223
  actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
218
- actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
224
+ actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
219
225
  ]);
220
- assert.equal(f.routes.length, 6, "A supplied runtime does not register the old chat or optional settings routes");
226
+ assert.equal(f.routes.length, 7, "A supplied runtime does not register the old chat or optional settings routes");
221
227
  const initial = await f.invoke("GET");
222
228
  assert.equal(initial.status, 200);
223
229
  assert.equal(initial.payload.id, "chat:one");
@@ -255,11 +261,62 @@ test("file-backed conversation routes return admission receipts and cancel one c
255
261
  assert.equal((await readdir(f.directory)).length, 2, "Only the host's two conversation records are persisted");
256
262
  });
257
263
 
264
+ test("Cancel of one message stops its own submission and always answers", async t => {
265
+ const f = await fixture(t);
266
+ const first = await f.invoke("POST", "/messages", { body: { messageId: "first", text: "First question" } });
267
+ assert.equal(first.payload.status, "accepted");
268
+ await f.dispatched[0].promise;
269
+ const cancelled = await f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "first" });
270
+ assert.equal(cancelled.status, 200);
271
+ assert.deepEqual(cancelled.payload, { ok: true, cancelled: true, messageId: "first", interrupted: true });
272
+ assert.equal(f.requests[0].signal.aborted, true, "the turn this message started stops");
273
+ const never = await f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "never-sent" });
274
+ assert.deepEqual(never.payload, { ok: true, cancelled: true, messageId: "never-sent", interrupted: false });
275
+ assert.equal((await f.invoke("GET")).payload.status, "ready");
276
+ assert.equal(f.requests.length, 1, "Cancel never submits a model request");
277
+ });
278
+
279
+ test("Cancel answers while earlier work holds the conversation queue and the queued send is refused as not sent", async t => {
280
+ const f = await fixture(t);
281
+ const entered = Promise.withResolvers();
282
+ const release = Promise.withResolvers();
283
+ f.access.hold = { operation: "inspectDelivery", entered, release: release.promise };
284
+ const inspecting = f.invoke("POST", "/deliveries/:messageId/inspect", { messageId: "earlier" });
285
+ inspecting.catch(() => {});
286
+ let sending;
287
+ let cancelling;
288
+ let timer;
289
+ try {
290
+ await entered.promise;
291
+ sending = f.invoke("POST", "/messages", { body: { messageId: "queued", text: "Queued question" } });
292
+ sending.catch(() => {});
293
+ // The send is authorized and waits in the queue behind the held inspection.
294
+ while (!f.access.authorized.includes("send")) await new Promise(resolve => setImmediate(resolve));
295
+ await new Promise(resolve => setImmediate(resolve));
296
+ cancelling = f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "queued" });
297
+ const answer = await Promise.race([cancelling,
298
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), 2_000); })]);
299
+ assert.notEqual(answer, null, "Cancel answered while earlier work held the conversation queue");
300
+ assert.deepEqual(answer.payload, { ok: true, cancelled: true, messageId: "queued", interrupted: false });
301
+ } finally {
302
+ clearTimeout(timer);
303
+ release.resolve();
304
+ }
305
+ await inspecting.catch(() => {});
306
+ await cancelling;
307
+ await assert.rejects(sending, { code: "conversation_message_cancelled", statusCode: 409 });
308
+ await assert.rejects(f.invoke("POST", "/messages", { body: { messageId: "queued", text: "Queued question" } }),
309
+ { code: "conversation_message_cancelled" }, "a later send of the cancelled message is refused too");
310
+ assert.equal(f.requests.length, 0, "the cancelled message never reaches the model");
311
+ const next = await f.invoke("POST", "/messages", { body: { messageId: "next", text: "Next question" } });
312
+ assert.equal(next.payload.status, "accepted", "the conversation stays usable");
313
+ });
314
+
258
315
  test("conversation routes keep authenticated ownership and reject unknown identities", async t => {
259
316
  const f = await fixture(t);
260
317
  const other = { ...f.context, actor: { id: "other-user" } };
261
318
  for (const [method, suffix] of [["GET", ""], ["POST", "/messages"], ["POST", "/cancel"],
262
- ["POST", "/deliveries/:messageId/inspect"], ["GET", "/goal"], ["POST", "/goal"]]) {
319
+ ["POST", "/deliveries/:messageId/inspect"], ["POST", "/deliveries/:messageId/cancel"], ["GET", "/goal"], ["POST", "/goal"]]) {
263
320
  await assert.rejects(f.invoke(method, suffix, { actor: other, body: {
264
321
  action: "set", expectedSegmentId: "segment", objective: "A goal", messageId: "forged", text: "A forged request", actor: f.context.actor, context: f.context,
265
322
  host: { nativeTools: true }, configuration: { systemPrompt: "Override" }, engine: "codex"
@@ -296,7 +353,7 @@ test("workspace conversation actions use the host's resolved workspace and optio
296
353
  const options = { ...f.options, assistantConfigService: { getSettings() {}, updateSettings() {} } };
297
354
  assert.deepEqual(createAssistantActions(options).map(({ id }) => id), [
298
355
  actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
299
- actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
356
+ actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
300
357
  actionIds.settingsRead, actionIds.settingsUpdate
301
358
  ]);
302
359
  const router = createRouter();
@@ -414,10 +471,10 @@ test("the supplied runtime route/action path imports without SQL or the old chat
414
471
  const { registerRoutes } = await import(${JSON.stringify(new URL("../src/server/registerRoutes.js", import.meta.url).href)});
415
472
  const conversationRuntime = { async open() { throw new Error("Registration cannot open conversations."); } };
416
473
  const options = { config: ${JSON.stringify(config)}, conversationRuntime };
417
- assert.equal(createAssistantActions(options).length, 7);
474
+ assert.equal(createAssistantActions(options).length, 8);
418
475
  const routes = [];
419
476
  registerRoutes({ register(...args) { routes.push(args); } }, options);
420
- assert.equal(routes.length, 6);
477
+ assert.equal(routes.length, 7);
421
478
  `], { encoding: "utf8" });
422
479
  assert.equal(result.status, 0, result.stderr);
423
480
  });
@@ -162,6 +162,9 @@ async function fixture(t, { diagnostics = false, onSetup } = {}) {
162
162
  stopSubscriptions();
163
163
  stopProbe();
164
164
  await transport.shutdown();
165
+ // Realtime closes only Socket.IO; the fixture owns its HTTP server.
166
+ http.closeAllConnections();
167
+ await new Promise((resolve) => http.close(resolve));
165
168
  await runtime.close();
166
169
  await rm(directory, { recursive: true, force: true });
167
170
  });
@@ -908,6 +911,8 @@ async function hostSubscriptionFixture(t, authService = null, conversationRuntim
908
911
  for (const client of clients) client.disconnect();
909
912
  release();
910
913
  await transport.shutdown();
914
+ http.closeAllConnections();
915
+ await new Promise((resolve) => http.close(resolve));
911
916
  });
912
917
  http.listen(0, "127.0.0.1");
913
918
  await once(http, "listening");
@@ -73,7 +73,7 @@ async function* completionStream(completion = {}) {
73
73
  yield { choices: [{ delta: {}, finish_reason: completion.toolCall ? "tool_calls" : "stop" }] };
74
74
  }
75
75
 
76
- function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true } = {}) {
76
+ function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime } = {}) {
77
77
  const pendingCompletions = [...completions];
78
78
  const completionRequests = [];
79
79
  const transcriptMessages = [];
@@ -165,7 +165,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
165
165
  ...createMemoryAssistantRows({ messages: transcriptMessages }),
166
166
  aiClientFactory: dependencies.aiClientFactory, toolCatalog: dependencies.serviceToolCatalog, attachments
167
167
  });
168
- const chatService = createChatService({ ...dependencies, conversationRuntime });
168
+ const chatService = createChatService({ ...dependencies, conversationRuntime: wrapRuntime(conversationRuntime) });
169
169
 
170
170
  const streamWriter = {};
171
171
  for (const method of [
@@ -555,3 +555,49 @@ test("attachment identifiers use the conversation runtime's shape before any fil
555
555
  }
556
556
  assert.deepEqual(calls, []);
557
557
  });
558
+
559
+ function countDisposals(runtime) {
560
+ const counts = { opened: 0, disposed: 0 };
561
+ return { counts, runtime: { ...runtime, async open(input) {
562
+ const handle = await runtime.open(input);
563
+ counts.opened += 1;
564
+ return { ...handle, async dispose(options) { counts.disposed += 1; return handle.dispose(options); } };
565
+ } } };
566
+ }
567
+
568
+ test("each settled turn releases its conversation, and a later turn opens it again", async () => {
569
+ const counted = {};
570
+ const harness = createHarness([textCompletion("First answer."), textCompletion("Second answer.")], {
571
+ wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
572
+ });
573
+
574
+ assert.equal((await harness.run("First question", { messageId: "message_1" })).status, "completed");
575
+ assert.deepEqual(counted.counts, { opened: 1, disposed: 1 }, "a finished turn keeps no runtime conversation open");
576
+ assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "completed");
577
+ assert.deepEqual(counted.counts, { opened: 2, disposed: 2 });
578
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["First answer.", "Second answer."]);
579
+ });
580
+
581
+ test("a conversation stays open until the last of its overlapping turns settles", async () => {
582
+ const counted = {};
583
+ const firstChunk = Promise.withResolvers();
584
+ const continueResponse = Promise.withResolvers();
585
+ async function* held() {
586
+ yield { choices: [{ delta: { content: "Held answer." } }] };
587
+ firstChunk.resolve();
588
+ await continueResponse.promise;
589
+ yield { choices: [{ delta: {}, finish_reason: "stop" }] };
590
+ }
591
+ const harness = createHarness([held()], {
592
+ wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
593
+ });
594
+
595
+ const first = harness.run("First question", { messageId: "message_1" });
596
+ await firstChunk.promise;
597
+ // A second tab sends while the first answer is still running; the runtime refuses it.
598
+ assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "failed");
599
+ assert.equal(counted.counts.disposed, 0, "the refused turn must not close the conversation the first turn still uses");
600
+ continueResponse.resolve();
601
+ assert.equal((await first).status, "completed");
602
+ assert.deepEqual(counted.counts, { opened: 2, disposed: 1 });
603
+ });
@@ -229,11 +229,11 @@ test("one AssistantFeature composes host access and declared data while settings
229
229
  await host.start();
230
230
  try {
231
231
  assert.equal(registrations, 1);
232
- assert.equal(wrapped.length, 10);
232
+ assert.equal(wrapped.length, 11);
233
233
  assert.ok(wrapped.includes(access.subscribeActionId));
234
234
  assert.ok(!wrapped.includes(actionIds.conversationSubscribe));
235
- assert.equal(actions.listDefinitions().length, 12);
236
- assert.equal(routes.length, 11);
235
+ assert.equal(actions.listDefinitions().length, 13);
236
+ assert.equal(routes.length, 12);
237
237
  assert.ok(routes.filter(route => route.path.endsWith("/settings")).every(route => route.contract.auth === "required"));
238
238
  assert.ok(routes.filter(route => !route.path.endsWith("/settings")).every(route => route.contract.auth === "public"));
239
239
  assert.equal(actions.getDefinition(actionIds.settingsRead).permission.require, "authenticated");