@jskit-ai/assistant-core 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +49 -12
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +234 -29
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +13 -1
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +169 -10
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. package/test/openCodeRuntime.test.js +25 -78
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.2",
12
- "@jskit-ai/http-runtime": "0.2.0",
13
- "@jskit-ai/kernel": "0.2.0",
11
+ "@jskit-ai/assistant-core": "0.2.4",
12
+ "@jskit-ai/http-runtime": "0.2.1",
13
+ "@jskit-ai/kernel": "0.2.1",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.0",
18
- "@jskit-ai/connectors-catalog": "0.2.0"
17
+ "@jskit-ai/connectors-core": "0.2.1",
18
+ "@jskit-ai/connectors-catalog": "0.2.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -111,12 +111,12 @@ const adapter = reactive({
111
111
  discard(id) {
112
112
  if (id !== "temporary" || !previewCanTake.value) return;
113
113
  temporaryPresent.value = false;
114
- if (previewDelivery.find(id)?.status === "failed") previewDelivery.cancel(id);
114
+ if (previewDelivery.find(id)?.status === "failed") previewDelivery.remove(id);
115
115
  },
116
116
  edit(id) {
117
117
  if (id !== "temporary" || !previewCanTake.value) return;
118
118
  temporaryEditing.value = true;
119
- if (previewDelivery.find(id)?.status === "failed") previewDelivery.cancel(id);
119
+ if (previewDelivery.find(id)?.status === "failed") previewDelivery.remove(id);
120
120
  },
121
121
  update(id, text) {
122
122
  if (id === "temporary" && previewCanTake.value) temporaryWords.value = text;
@@ -25,6 +25,16 @@ const delivery = createAssistantMessageDelivery({ deliver(payload) {
25
25
  pendingRequest = Promise.withResolvers();
26
26
  return pendingRequest.promise;
27
27
  } });
28
+ // A stand-in for the application's server cancel: the recovery fixture answers
29
+ // from its buttons, the others agree at once.
30
+ let cancelAnswer;
31
+ function cancelMessage(id) {
32
+ if (!delivery.cancel(id)) return false;
33
+ cancelAnswer = Promise.withResolvers();
34
+ cancelAnswer.promise.then(agreed => agreed ? delivery.remove(id) : delivery.cancelFailed(id));
35
+ if (!recoveryFixture) cancelAnswer.resolve(true);
36
+ return true;
37
+ }
28
38
  function accept() {
29
39
  const payload = requests.value.at(-1);
30
40
  const at = new Date().toISOString();
@@ -56,8 +66,8 @@ const adapter = reactive({
56
66
  conversation: { cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
57
67
  composer: { draft, canSend: true, submitOnEnter: true, queueWhileSending },
58
68
  actions: {
59
- ...(recoveryFixture ? { cancel: id => delivery.remove(id) } : {}),
60
- setDraft(value) { draft.value = value; }
69
+ setDraft(value) { draft.value = value; },
70
+ cancel: cancelMessage
61
71
  }
62
72
  });
63
73
  </script>
@@ -66,6 +76,9 @@ const adapter = reactive({
66
76
  <main class="fixture">
67
77
  <nav>
68
78
  <button v-if="acceptedFixture" @click="turns.unshift({ turnId: 'earlier', user: { messageId: 'accepted-old', text: 'Earlier accepted message', at: '2026-10-16T11:05:00.000Z' } })">Load accepted history</button>
79
+ <button v-if="recoveryFixture" @click="delivery.find('uncertain').checking = true">Start delivery check</button>
80
+ <button v-if="recoveryFixture" @click="cancelAnswer?.resolve(false)">Server unreachable</button>
81
+ <button v-if="recoveryFixture" @click="cancelAnswer?.resolve(true)">Server agrees to cancel</button>
69
82
  <button @click="pendingRequest.reject(new Error('Delivery unavailable.'))">Fail request</button>
70
83
  <button @click="accept">Accept request</button>
71
84
  <button @click="attachments.attachments.push({ attachmentId: 'new-file', fileName: 'next.txt' }); configuration = { tone: 'detailed' }">Prepare newer input</button>
package/locales/en.json CHANGED
@@ -87,6 +87,8 @@
87
87
  "useSuggestion": "Use suggestion: {prompt}"
88
88
  },
89
89
  "delivery": {
90
+ "cancelling": "Cancelling…",
91
+ "cancelUnreachable": "Couldn't reach the server — Cancel again",
90
92
  "checkBeforeResend": "Check delivery before resending.",
91
93
  "checkPrevious": "Check the previous message's delivery before sending again.",
92
94
  "checking": "Checking…",
@@ -213,6 +215,8 @@
213
215
  "liveSettingsUnsupported": "This provider does not support live native settings.",
214
216
  "completedEnvelopesNeedStorage": "Completed envelopes require runtime-owned canonical storage.",
215
217
  "originalReadOnly": "This conversation uses its original read and subscription operations.",
218
+ "messageCancelUnsupported": "This conversation cannot cancel one message.",
219
+ "messageCancelled": "This message was cancelled. Send it again as a new message.",
216
220
  "authorizedSelectionRequired": "Use the conversation's authorized selection operation.",
217
221
  "eventDeliveryRetained": "This scoped conversation retains its original event delivery.",
218
222
  "engineChanged": "The selected native engine changed.",
@@ -371,8 +375,6 @@
371
375
  "conversation_receipt_unavailable": "The native receipt has no stored authored message.",
372
376
  "conversation_goal_unavailable": "The native goal is unavailable. Try reading its status again.",
373
377
  "assistant_provider_invalid_response": "Assistant provider returned an invalid response payload.",
374
- "assistant_opencode_connection_changing": "OpenCode is changing a connection. Retry after the connection change finishes.",
375
- "assistant_opencode_connection_in_use": "Close the other OpenCode conversations that use this connection, then retry this connection change. The saved connection has not changed.",
376
378
  "assistant_opencode_conversation_busy": "This conversation is still working.",
377
379
  "assistant_opencode_key_verification_failed": "OpenCode rejected the API-key verification request.",
378
380
  "assistant_opencode_turn_thread_mismatch": "The OpenCode predecessor history changed before handover generation.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -48,8 +48,8 @@
48
48
  "@ai-sdk/perplexity": "^4.0.43",
49
49
  "@ai-sdk/togetherai": "^3.0.49",
50
50
  "@ai-sdk/xai": "^4.0.58",
51
- "@jskit-ai/resource-core": "0.1.191",
52
- "@jskit-ai/resource-crud-core": "0.1.191",
51
+ "@jskit-ai/resource-core": "0.1.192",
52
+ "@jskit-ai/resource-crud-core": "0.1.192",
53
53
  "@mdi/js": "^7.4.47",
54
54
  "@openrouter/ai-sdk-provider": "^3.0.0",
55
55
  "ai": "^7.0.101",
@@ -58,8 +58,8 @@
58
58
  "ws": "^8.20.1"
59
59
  },
60
60
  "peerDependencies": {
61
- "@jskit-ai/http-runtime": "0.2.0",
62
- "@jskit-ai/kernel": "0.2.0",
61
+ "@jskit-ai/http-runtime": "0.2.1",
62
+ "@jskit-ai/kernel": "0.2.1",
63
63
  "vue": "^3.5.13",
64
64
  "vuetify": "^4.0.0"
65
65
  },
@@ -327,8 +327,15 @@ async function resend(id) {
327
327
  }
328
328
  catch { /* The delivery controller retains the error beside the failed message. */ }
329
329
  }
330
+ // The application's cancel asks its server and removes the message only once
331
+ // the server agrees. Without one, only a message the server already refused
332
+ // (failed) is removed.
330
333
  function cancel(id) {
331
- return props.adapter.actions?.cancel ? props.adapter.actions.cancel(id) : props.adapter.delivery?.cancel(id);
334
+ if (props.adapter.actions?.cancel) return props.adapter.actions.cancel(id);
335
+ const message = props.adapter.delivery?.find(id);
336
+ if (message?.status !== "failed") return false;
337
+ props.adapter.delivery.remove(id);
338
+ return true;
332
339
  }
333
340
  function edit(id) {
334
341
  if (props.adapter.actions?.edit) return props.adapter.actions.edit(id);
@@ -70,9 +70,9 @@ async function setGoal() {
70
70
  </template>
71
71
  <v-card class="assistant-goal__details pa-3" max-width="360">
72
72
  <strong role="status">{{ label }}</strong>
73
- <v-btn v-if="(running || (state.pauseBlocked && goal?.status === 'blocked')) && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? t('jskit.assistant.goal.pausing') : t('jskit.assistant.goal.pause') }}</v-btn>
73
+ <v-btn v-if="(running || (state.pauseBlocked && goal?.status === 'blocked')) && state.pause" size="small" :disabled="state.stopPending" @click="state.pause()">{{ state.action === 'pause' ? t('jskit.assistant.goal.pausing') : t('jskit.assistant.goal.pause') }}</v-btn>
74
74
  <v-btn v-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.action === 'resume' ? t('jskit.assistant.goal.resuming') : t('jskit.assistant.goal.resume') }}</v-btn>
75
- <v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.pending" @click="state.cancel()">{{ state.action === 'cancel' ? t('jskit.assistant.goal.cancelling') : t('jskit.assistant.goal.cancel') }}</v-btn>
75
+ <v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.stopPending" @click="state.cancel()">{{ state.action === 'cancel' ? t('jskit.assistant.goal.cancelling') : t('jskit.assistant.goal.cancel') }}</v-btn>
76
76
  <p v-if="goal" class="my-2">{{ goal.objective }}</p>
77
77
  <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
78
78
  <p v-if="elapsed" class="text-body-small my-2">{{ elapsedText }}</p>
@@ -65,10 +65,11 @@
65
65
  :class="{ 'assistant-transcript__body--settling': initialScrollPending }"
66
66
  tabindex="0"
67
67
  @keydown.capture="markKeyboardScrollIntent"
68
- @pointerdown.self="markUserScrollIntent"
68
+ @pointerdown.self="markUserScrollIntent()"
69
69
  @scroll.passive="updateLatestFollowFromScroll"
70
- @touchmove.passive="markUserScrollIntent"
71
- @wheel.passive="markUserScrollIntent"
70
+ @touchstart.passive="rememberTouch"
71
+ @touchmove.passive="markTouchScrollIntent"
72
+ @wheel.passive="markUserScrollIntent({ upward: $event.deltaY < 0 })"
72
73
  >
73
74
  <div
74
75
  v-if="welcomeMessage"
@@ -179,12 +180,15 @@
179
180
  </div>
180
181
  <slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
181
182
  <div
182
- v-if="entry.delivery || entry.turn.optimistic?.status === 'failed'"
183
+ v-if="entry.delivery || entry.turn.optimistic?.status === 'failed' || entry.turn.optimistic?.cancelling || entry.turn.optimistic?.cancelError"
183
184
  class="assistant-transcript__optimistic-failure"
184
185
  >
185
- <span role="status">{{ entry.delivery ? entry.delivery.delivery.error || t('jskit.assistant.delivery.unconfirmedShort') : t('jskit.assistant.transcript.failed', { error: entry.turn.optimistic.error || t('jskit.assistant.delivery.notSent') }) }}</span>
186
+ <span role="status">{{ entry.turn.optimistic?.cancelling ? t('jskit.assistant.delivery.cancelling')
187
+ : entry.turn.optimistic?.cancelError ? entry.turn.optimistic.cancelError
188
+ : entry.delivery ? entry.delivery.delivery.error || t('jskit.assistant.delivery.unconfirmedShort') : t('jskit.assistant.transcript.failed', { error: entry.turn.optimistic.error || t('jskit.assistant.delivery.notSent') }) }}</span>
186
189
  <div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
187
190
  <v-btn
191
+ v-if="!entry.turn.optimistic?.cancelling && !entry.turn.optimistic?.cancelError && (entry.delivery || entry.turn.optimistic?.status === 'failed')"
188
192
  class="assistant-transcript__delivery-action"
189
193
  color="error"
190
194
  size="x-small"
@@ -198,7 +202,7 @@
198
202
  {{ entry.delivery?.delivery.checking ? t('jskit.assistant.delivery.checking') : t('jskit.assistant.delivery.retry') }}
199
203
  </v-btn>
200
204
  <v-btn
201
- v-if="!entry.delivery || cancellableDelivery"
205
+ v-if="!entry.delivery || cancellableDelivery || entry.turn.optimistic?.cancelling || entry.turn.optimistic?.cancelError"
202
206
  class="assistant-transcript__delivery-action"
203
207
  size="x-small"
204
208
  type="button"
@@ -208,7 +212,7 @@
208
212
  {{ t("jskit.assistant.transcript.cancel") }}
209
213
  </v-btn>
210
214
  <v-btn
211
- v-if="!entry.delivery"
215
+ v-if="!entry.delivery && entry.turn.optimistic?.status === 'failed' && !entry.turn.optimistic.cancelling && !entry.turn.optimistic.cancelError"
212
216
  class="assistant-transcript__delivery-action"
213
217
  size="x-small"
214
218
  type="button"
@@ -328,6 +332,7 @@ import AssistantProgress from "./AssistantProgress.vue";
328
332
  import { parseNumberedQuestionPrompt } from "../../shared/conversation/numberedQuestionSugar.js";
329
333
  import { parseLongTextReviewBlocks } from "../../shared/conversation/longTextBlocks.js";
330
334
  import {
335
+ scrollDistanceFromBottom,
331
336
  scrollElementNearBottom
332
337
  } from "../../shared/conversation/scrollFollowState.js";
333
338
  import {
@@ -442,6 +447,9 @@ let retainedReaderScrollSnapshot = null;
442
447
  let loadMoreRequestVersion = 0;
443
448
  let pendingTailFollow = false;
444
449
  let previousScrollTop = 0;
450
+ let previousScrollHeight = 0;
451
+ let userScrollIntentUpward = false;
452
+ let lastTouchY = null;
445
453
  const USER_SCROLL_INTENT_RESET_MS = 600;
446
454
  const LOAD_MORE_THRESHOLD_PX = 160;
447
455
  const KEYBOARD_SCROLL_KEYS = new Set([
@@ -454,6 +462,7 @@ const KEYBOARD_SCROLL_KEYS = new Set([
454
462
  "PageUp",
455
463
  "Spacebar"
456
464
  ]);
465
+ const UPWARD_SCROLL_KEYS = new Set(["ArrowUp", "Home", "PageUp"]);
457
466
  const TIME_FORMAT = Object.freeze({
458
467
  year: "numeric",
459
468
  month: "short",
@@ -747,9 +756,10 @@ function resumePendingTailFollow() {
747
756
  }
748
757
  }
749
758
 
750
- function markUserScrollIntent() {
759
+ function markUserScrollIntent({ upward = false } = {}) {
751
760
  retainedReaderScrollSnapshot = null;
752
761
  userScrollIntent.value = true;
762
+ userScrollIntentUpward = upward;
753
763
  clearLiveBottomScroll();
754
764
  clearScheduledScrolls();
755
765
  if (userScrollIntentTimer && typeof window !== "undefined" && typeof window.clearTimeout === "function") {
@@ -778,7 +788,20 @@ function markKeyboardScrollIntent(event = {}) {
778
788
  ) {
779
789
  return;
780
790
  }
781
- markUserScrollIntent();
791
+ markUserScrollIntent({
792
+ upward: UPWARD_SCROLL_KEYS.has(event.key) || (event.shiftKey && [" ", "Spacebar"].includes(event.key))
793
+ });
794
+ }
795
+
796
+ function rememberTouch(event = {}) {
797
+ lastTouchY = event.touches?.[0]?.clientY ?? null;
798
+ }
799
+
800
+ function markTouchScrollIntent(event = {}) {
801
+ const touchY = event.touches?.[0]?.clientY ?? null;
802
+ // A finger moving down the screen scrolls the transcript up.
803
+ markUserScrollIntent({ upward: touchY !== null && lastTouchY !== null && touchY > lastTouchY });
804
+ lastTouchY = touchY;
782
805
  }
783
806
 
784
807
  function scrollToLatestMessageAfterLayout({
@@ -859,24 +882,38 @@ function updateLatestFollowFromScroll(event = {}) {
859
882
  const target = event?.currentTarget || bodyElement.value;
860
883
  if (retainedReaderScrollSnapshot?.restoring) {
861
884
  previousScrollTop = target?.scrollTop || 0;
885
+ previousScrollHeight = target?.scrollHeight || 0;
862
886
  return;
863
887
  }
864
888
  const scrollTop = target?.scrollTop || 0;
889
+ const scrollHeight = target?.scrollHeight || 0;
865
890
  const scrolledUp = scrollTop < previousScrollTop;
891
+ // Layout changes such as scroll anchoring move scrollTop together with the
892
+ // content height; moving up over unchanged content is the reader's own.
893
+ const readerMovedUp = scrolledUp && scrollHeight === previousScrollHeight;
866
894
  previousScrollTop = scrollTop;
895
+ previousScrollHeight = scrollHeight;
867
896
  // Keep following the reader while the request is pending, before rows prepend.
868
897
  if (loadMoreScrollSnapshot && !loadMoreScrollSnapshot.completed) {
869
898
  loadMoreScrollSnapshot.scrollTop = scrollTop;
870
899
  loadMoreScrollSnapshot.anchor = visibleHistoryAnchor(target);
871
900
  loadMoreScrollSnapshot.scrollHeight = target.scrollHeight;
872
901
  }
873
- const shouldFollow = scrollElementNearBottom(target);
874
- if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
902
+ // The reader's own upward scrolling leaves the latest message from its first
903
+ // frame, even within the follow threshold.
904
+ const userScrollsUp = userScrollIntent.value && userScrollIntentUpward;
905
+ const shouldFollow = scrollElementNearBottom(target) &&
906
+ !(userScrollsUp && scrollDistanceFromBottom(target) > 1);
907
+ if (!shouldFollow && !userScrollIntent.value && !readerMovedUp && followingLatest.value) {
875
908
  return;
876
909
  }
877
910
  followingLatest.value = shouldFollow;
878
911
  if (shouldFollow) {
879
- clearUserScrollIntent();
912
+ // A scroll event still at the end, queued before upward input, must not
913
+ // end that input's intent.
914
+ if (!userScrollsUp) {
915
+ clearUserScrollIntent();
916
+ }
880
917
  resumePendingTailFollow();
881
918
  return;
882
919
  }
@@ -100,7 +100,8 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
100
100
  } : null;
101
101
  return {
102
102
  optimistic: { error: message.error, id: message.id, status: message.status,
103
- ...(message.status === "pending" && message.waiting ? { waiting: message.waiting } : {}) },
103
+ ...(message.status === "pending" && message.waiting ? { waiting: message.waiting } : {}),
104
+ ...(message.cancelling ? { cancelling: true } : {}), ...(message.cancelError ? { cancelError: message.cancelError } : {}) },
104
105
  turnId: message.id,
105
106
  [role]: authored,
106
107
  ...(status ? { system: role === "system" ? { ...authored, ...status, text: `${authored.text}\n\n${status.text}` } : status } : {})
@@ -117,7 +118,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
117
118
  queue = false
118
119
  } = {}) {
119
120
  if ((state.sending && !queue) || pendingSends.has(messageId) || find(messageId)?.status === "uncertain" ||
120
- (!messageText(payload?.message) && !payload?.displayAttachments?.length)) return false;
121
+ find(messageId)?.cancelling || (!messageText(payload?.message) && !payload?.displayAttachments?.length)) return false;
121
122
  if (typeof deliver !== "function") throw new TypeError("Message delivery requires a deliver function.");
122
123
  const snapshot = JSON.parse(JSON.stringify(payload));
123
124
  const previous = find(messageId);
@@ -147,6 +148,8 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
147
148
  try {
148
149
  if (predecessor) await predecessor;
149
150
  if (!current()) return false;
151
+ // A queued message cancelled while it waited is never delivered.
152
+ if (find(messageId)?.cancelling) return false;
150
153
  if (uncertainOnError && state.messages.some(entry => entry.id !== messageId && entry.status === "uncertain" && !knownUncertain.has(entry.id))) {
151
154
  fail(messageId, t("jskit.assistant.delivery.checkPrevious"));
152
155
  return false;
@@ -240,15 +243,29 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
240
243
  return true;
241
244
  }
242
245
 
246
+ // Cancel of a pending, uncertain or failed message waits for the server: the
247
+ // message stays, shown as cancelling, until the caller removes it on the
248
+ // server's agreement or reports that the server could not be reached.
243
249
  function cancel(messageId) {
244
- if (find(messageId)?.status !== "failed") return false;
245
- remove(messageId);
250
+ const message = find(messageId);
251
+ if (!message || message.status === "accepted" || message.cancelling) return false;
252
+ message.cancelling = true;
253
+ message.cancelError = "";
246
254
  return true;
247
255
  }
248
256
 
257
+ function cancelFailed(messageId, error = "") {
258
+ const message = find(messageId);
259
+ if (!message) return;
260
+ message.cancelling = false;
261
+ message.cancelError = String(error || t("jskit.assistant.delivery.cancelUnreachable")).trim();
262
+ }
263
+
264
+ // Only a message that never reached the conversation returns to the draft.
249
265
  function edit(messageId, draft = "") {
250
266
  const message = find(messageId);
251
- if (!message || !cancel(messageId)) return null;
267
+ if (message?.status !== "failed" || message.cancelling) return null;
268
+ remove(messageId);
252
269
  return draftWithRestoredMessage(message.text, draft);
253
270
  }
254
271
 
@@ -269,7 +286,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
269
286
  state.sending = false;
270
287
  }
271
288
 
272
- return { accept, cancel, edit, find, reconcile, remove, resend, reset, restoreUncertain, rejectUnsent, send, state, turns, wait };
289
+ return { accept, cancel, cancelFailed, edit, find, reconcile, remove, resend, reset, restoreUncertain, rejectUnsent, send, state, turns, wait };
273
290
  }
274
291
 
275
292
  // The composer text after a failed or cancelled message returns to it: the
@@ -133,6 +133,11 @@ function createAssistantApi({ request, requestStream, resolveBasePath, resolveSu
133
133
  return requestConversation(conversationId, `/deliveries/${encodedMessageId}/inspect`, { method: "POST", signal });
134
134
  },
135
135
 
136
+ cancelConversationDelivery(conversationId, messageId, { signal } = {}) {
137
+ const encodedMessageId = encodeURIComponent(String(messageId || "").trim());
138
+ return requestConversation(conversationId, `/deliveries/${encodedMessageId}/cancel`, { method: "POST", signal });
139
+ },
140
+
136
141
  readConversationGoal(conversationId, { signal } = {}) {
137
142
  return requestConversation(conversationId, "/goal", { method: "GET", signal });
138
143
  },