@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.
package/README.md CHANGED
@@ -34,7 +34,8 @@ modes) and `packages/agent-docs/site/guide/app-extras/assistant-conversation.md`
34
34
  With the existing realtime diagnostic option (`JSKIT_REALTIME_DEBUG=1`, or
35
35
  `config.realtime.debug=true`), conversation subscriptions emit one `pending`
36
36
  warning if admission/snapshot reading has not returned within the client's
37
- 10-second acknowledgement budget. The warning names the current stage and the
37
+ 10-second budget, after which the client shows a loading notice and keeps waiting
38
+ (it gives up only after 120 seconds). The warning names the current stage and the
38
39
  server-generated epoch; a conversation identity is included only after its
39
40
  successful authorized open. Observer release also records the current stage and
40
41
  reason. While diagnostics are enabled, every stage transition is recorded at
@@ -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/locales/en.json CHANGED
@@ -130,7 +130,8 @@
130
130
  },
131
131
  "errors": {
132
132
  "assistant_history_inspection_required": "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
133
- "assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now."
133
+ "assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.",
134
+ "assistant_subscription_waiting": "Chat is still loading. Waiting for the server; Reload chat to try now."
134
135
  }
135
136
  }
136
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.1",
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.1",
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.0",
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,
@@ -1009,10 +1052,15 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1009
1052
  } else current.draft.value = "";
1010
1053
  }
1011
1054
  onSubmit?.();
1055
+ const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
1056
+ : current.send(captured, { messageId, onAccepted: acknowledge });
1012
1057
  // A restored uncertain receipt may have no retained acceptance callback.
1013
- // Inspection can acknowledge it, but must never run preparation or Send.
1058
+ // Inspection can acknowledge it. Only the server's proof that the message
1059
+ // never reached the AI (which marks it failed) lets Retry send it again.
1014
1060
  if (retry?.status === "uncertain") return current.inspectDelivery(messageId).then(receipt => {
1015
1061
  if (!current.current.value || !isCurrent()) return false;
1062
+ if (receipt?.status === "not-sent" && receipt.messageId === messageId &&
1063
+ current.delivery.find(messageId)?.status === "failed") return dispatch();
1016
1064
  if (receipt?.status !== "accepted") {
1017
1065
  const error = current.error.value || t("jskit.assistant.runtime.delivery.notConfirmed");
1018
1066
  const pending = current.delivery.find(messageId);
@@ -1022,8 +1070,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1022
1070
  acknowledge();
1023
1071
  return { ...receipt, ok: true };
1024
1072
  });
1025
- return prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
1026
- : current.send(captured, { messageId, onAccepted: acknowledge });
1073
+ return dispatch();
1027
1074
  }
1028
1075
  const adapter = computed(() => {
1029
1076
  const current = runtime.value;
@@ -1050,6 +1097,8 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1050
1097
  hasMoreBefore: current?.hasMoreBefore.value || false,
1051
1098
  loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
1052
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,
1053
1102
  assistantLabel: display.assistantLabel || t("jskit.assistant.runtime.conversation.assistantLabel"),
1054
1103
  welcomeMessage: display.welcomeMessage ?? t("jskit.assistant.runtime.conversation.welcomeMessage"),
1055
1104
  systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
@@ -1071,6 +1120,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1071
1120
  else if (canDraftWhileLoading.value) initialDraft.value = value;
1072
1121
  }, submit,
1073
1122
  resend: messageId => submit({ retryMessageId: messageId }), stop: () => current?.cancel(),
1123
+ cancel: messageId => current?.cancelMessage(messageId),
1074
1124
  checkDelivery: messageId => current?.inspectDelivery(messageId), loadMore: input => current?.loadMore(input), reload: () => current?.reload() }
1075
1125
  };
1076
1126
  });
@@ -16,6 +16,12 @@ function patchLoadedHistoryPages(pages = [], patch) {
16
16
  }
17
17
 
18
18
  const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
19
+ // A connected server may need longer than the normal budget to answer (an
20
+ // overloaded host took 15 s). Abandoning it then discards the server's work and
21
+ // repeats it, so a slow answer only shows a loading notice; it is treated as
22
+ // lost, unsubscribed and retried only after the much longer cap.
23
+ const SUBSCRIPTION_NOTICE_MS = 10_000;
24
+ const SUBSCRIPTION_LOST_MS = 120_000;
19
25
 
20
26
  function subscribeAssistantConversation({
21
27
  socket, conversationId, targetSurfaceId, hostSurfaceId, workspaceSlug,
@@ -44,6 +50,7 @@ function subscribeAssistantConversation({
44
50
  // otherwise only the next connect would recover the chat.
45
51
  let retryTimer = null;
46
52
  let retryDelay = 1_000;
53
+ let waitingTimer = null;
47
54
  const completed = new Map();
48
55
  const settledTurns = new Set();
49
56
  const pendingReads = new Set();
@@ -187,12 +194,14 @@ function subscribeAssistantConversation({
187
194
  if (epoch === null) pending.push(payload);
188
195
  else deliver(payload);
189
196
  }
190
- function clearRetry() {
197
+ function clearTimers() {
191
198
  clearTimeout(retryTimer);
192
199
  retryTimer = null;
200
+ clearTimeout(waitingTimer);
201
+ waitingTimer = null;
193
202
  }
194
203
  function subscribe({ preserveHistory = false } = {}) {
195
- clearRetry();
204
+ clearTimers();
196
205
  if (disposed || !socket.connected) return;
197
206
  const attempt = ++generation;
198
207
  epoch = null;
@@ -205,8 +214,12 @@ function subscribeAssistantConversation({
205
214
  settledTurns.clear();
206
215
  reloadInFlight = null;
207
216
  reloadQueued = false;
208
- socket.timeout(10_000).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
217
+ waitingTimer = setTimeout(() => onError(Object.assign(
218
+ new Error("Chat is still loading. Waiting for the server; Reload chat to try now."),
219
+ { code: "assistant_subscription_waiting" })), SUBSCRIPTION_NOTICE_MS);
220
+ socket.timeout(SUBSCRIPTION_LOST_MS).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
209
221
  if (disposed || attempt !== generation) return;
222
+ clearTimers();
210
223
  if (error || response?.ok !== true) {
211
224
  listening = false;
212
225
  pending = [];
@@ -232,7 +245,7 @@ function subscribeAssistantConversation({
232
245
  });
233
246
  }
234
247
  function disconnected() {
235
- clearRetry();
248
+ clearTimers();
236
249
  generation += 1;
237
250
  epoch = null;
238
251
  pending = [];
@@ -248,7 +261,7 @@ function subscribeAssistantConversation({
248
261
  function release() {
249
262
  if (disposed) return;
250
263
  disposed = true;
251
- clearRetry();
264
+ clearTimers();
252
265
  generation += 1;
253
266
  pending = [];
254
267
  completed.clear();
@@ -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,