@jskit-ai/assistant-core 0.1.214 → 0.1.216

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.214",
12
- "@jskit-ai/http-runtime": "0.1.234",
13
- "@jskit-ai/kernel": "0.1.236",
11
+ "@jskit-ai/assistant-core": "0.1.216",
12
+ "@jskit-ai/http-runtime": "0.1.236",
13
+ "@jskit-ai/kernel": "0.1.238",
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.1.53",
18
- "@jskit-ai/connectors-catalog": "0.1.53"
17
+ "@jskit-ai/connectors-core": "0.1.55",
18
+ "@jskit-ai/connectors-catalog": "0.1.55"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.214",
3
+ "version": "0.1.216",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -47,8 +47,8 @@
47
47
  "@ai-sdk/perplexity": "^4.0.43",
48
48
  "@ai-sdk/togetherai": "^3.0.49",
49
49
  "@ai-sdk/xai": "^4.0.58",
50
- "@jskit-ai/resource-core": "0.1.178",
51
- "@jskit-ai/resource-crud-core": "0.1.178",
50
+ "@jskit-ai/resource-core": "0.1.180",
51
+ "@jskit-ai/resource-crud-core": "0.1.180",
52
52
  "@mdi/js": "^7.4.47",
53
53
  "@openrouter/ai-sdk-provider": "^3.0.0",
54
54
  "ai": "^7.0.101",
@@ -57,8 +57,8 @@
57
57
  "ws": "^8.20.1"
58
58
  },
59
59
  "peerDependencies": {
60
- "@jskit-ai/http-runtime": "0.1.234",
61
- "@jskit-ai/kernel": "0.1.236",
60
+ "@jskit-ai/http-runtime": "0.1.236",
61
+ "@jskit-ai/kernel": "0.1.238",
62
62
  "vue": "^3.5.13",
63
63
  "vuetify": "^4.0.0"
64
64
  },
@@ -139,7 +139,8 @@
139
139
  class="assistant-transcript__message assistant-transcript__message--user"
140
140
  :class="{
141
141
  'assistant-transcript__message--pending': entry.turn.optimistic?.status === 'pending',
142
- 'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed'
142
+ 'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed',
143
+ 'assistant-transcript__message--unconfirmed': Boolean(entry.delivery)
143
144
  }"
144
145
  >
145
146
  <div
@@ -175,6 +176,12 @@
175
176
  <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
176
177
  <span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
177
178
  </div>
179
+ <div v-if="entry.delivery" class="assistant-transcript__unconfirmed-delivery">
180
+ <AssistantConversationStatus
181
+ :message="{ ...entry.delivery, text: 'Delivery unconfirmed' }"
182
+ @check-delivery="emit('check-delivery', $event)"
183
+ />
184
+ </div>
178
185
  <slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
179
186
  <div
180
187
  v-if="entry.turn.optimistic?.status === 'failed'"
@@ -584,8 +591,11 @@ const displayEntries = computed(() => {
584
591
  const entries = [];
585
592
  for (const [index, turn] of props.turns.entries()) {
586
593
  const turnId = String(turn.turnId || index + 1);
594
+ // A delivery notice belongs to its exact user message, not the previous reply.
595
+ const userDelivery = turn.user && turn.system?.delivery && turn.system.delivery.messageId === turn.user.messageId
596
+ ? turn.system : null;
587
597
  const messages = [
588
- turn.system && { ...turn.system, role: "system" },
598
+ turn.system && !userDelivery && { ...turn.system, role: "system" },
589
599
  turn.user && { ...turn.user, role: "user" },
590
600
  ...conversationAgentMessages(turn)
591
601
  ].filter(Boolean);
@@ -604,6 +614,7 @@ const displayEntries = computed(() => {
604
614
  } else {
605
615
  entries.push({
606
616
  key, role, turn: { ...turn, turnId }, message: displayed,
617
+ delivery: role === "user" ? userDelivery : null,
607
618
  ...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
608
619
  });
609
620
  }
@@ -1431,6 +1442,26 @@ watch(() => props.visible, (visible) => {
1431
1442
  margin-top: -0.15rem;
1432
1443
  }
1433
1444
 
1445
+ .assistant-transcript__message--user.assistant-transcript__message--unconfirmed {
1446
+ background: rgba(var(--v-theme-warning), 0.08);
1447
+ border: 1px solid rgba(var(--v-theme-warning), 0.6);
1448
+ }
1449
+
1450
+ .assistant-transcript__unconfirmed-delivery {
1451
+ border-top: 1px solid rgba(var(--v-theme-warning), 0.4);
1452
+ font-size: 0.875rem;
1453
+ padding-top: 0.65rem;
1454
+ }
1455
+
1456
+ .assistant-transcript__unconfirmed-delivery :deep(p:first-child) {
1457
+ font-weight: 700;
1458
+ margin-bottom: 0.4rem;
1459
+ }
1460
+
1461
+ .assistant-transcript__unconfirmed-delivery :deep(p) {
1462
+ overflow-wrap: anywhere;
1463
+ }
1464
+
1434
1465
  .assistant-transcript__optimistic-failure {
1435
1466
  align-items: center;
1436
1467
  color: rgba(var(--v-theme-error), 0.92);
@@ -111,6 +111,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
111
111
  const ownerPendingSends = pendingSends;
112
112
  const current = () => pendingSends === ownerPendingSends && isCurrent();
113
113
  const predecessor = deliveryTail;
114
+ const knownUncertain = new Set(state.messages.filter(entry => entry.status === "uncertain").map(entry => entry.id));
114
115
  const settled = Promise.withResolvers();
115
116
  deliveryTail = settled.promise;
116
117
  ownerPendingSends.set(messageId, settled);
@@ -132,7 +133,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
132
133
  try {
133
134
  if (predecessor) await predecessor;
134
135
  if (!current()) return false;
135
- if (uncertainOnError && state.messages.some(entry => entry.id !== messageId && entry.status === "uncertain")) {
136
+ if (uncertainOnError && state.messages.some(entry => entry.id !== messageId && entry.status === "uncertain" && !knownUncertain.has(entry.id))) {
136
137
  fail(messageId, "Check the previous message's delivery before sending again.");
137
138
  return false;
138
139
  }
@@ -3,7 +3,7 @@ import { computed, onScopeDispose, ref, toValue, unref, watch } from "vue";
3
3
  /** Suggestion requests use an application-supplied agent; credentials stay on its server. */
4
4
  export function useAssistantSuggestions({
5
5
  active = true, requestKey = "", draft = "", configuration = {},
6
- generate, onSelect = () => false, debounceMs = 750
6
+ generate, onSelect = () => false, debounceMs = 750, minIntervalMs = 0
7
7
  } = {}) {
8
8
  const items = ref([]);
9
9
  const loading = ref(false);
@@ -18,6 +18,7 @@ export function useAssistantSuggestions({
18
18
  const visible = computed(() => eligible.value && (loading.value || items.value.length > 0));
19
19
  let timer;
20
20
  let request;
21
+ let lastStartedAt = -Infinity;
21
22
 
22
23
  function cancel() {
23
24
  clearTimeout(timer);
@@ -43,6 +44,7 @@ export function useAssistantSuggestions({
43
44
  const run = unref(generate);
44
45
  loading.value = true;
45
46
  timer = setTimeout(async () => {
47
+ lastStartedAt = Date.now();
46
48
  try {
47
49
  const result = await run(input);
48
50
  if (request !== controller) return;
@@ -58,7 +60,8 @@ export function useAssistantSuggestions({
58
60
  loading.value = false;
59
61
  }
60
62
  }
61
- }, Math.max(0, Number(toValue(debounceMs)) || 0));
63
+ }, Math.max(0, Number(toValue(debounceMs)) || 0,
64
+ lastStartedAt + Math.max(0, Number(toValue(minIntervalMs)) || 0) - Date.now()));
62
65
  }, { immediate: true, flush: "sync" });
63
66
 
64
67
  function currentSuggestion(value) {
@@ -80,7 +83,6 @@ export function useAssistantSuggestions({
80
83
  }
81
84
  function focus() {
82
85
  if (toValue(active) === false || composerFocused.value) return;
83
- dismissedKey.value = null;
84
86
  composerFocused.value = true;
85
87
  }
86
88
  function blur() {
@@ -467,7 +467,14 @@ export function createClaudeConversationOwner({
467
467
  if (!command) await entry.nativeTurn.updateState("active");
468
468
  if (applicationCommand && conversationTurn) {
469
469
  const value = deliveryResult();
470
- applicationCommand.active.delivery = { value, completion: entry.completion?.promise };
470
+ const active = applicationCommand.active;
471
+ const previousCompletion = active.completion;
472
+ if (active.finishIfCurrent) {
473
+ active.completion = Promise.withResolvers();
474
+ active.completion.promise.catch(() => {});
475
+ }
476
+ active.delivery = { value, completion: active.finishIfCurrent ? active.completion.promise : entry.completion?.promise };
477
+ if (active.finishIfCurrent) previousCompletion.resolve();
471
478
  // The original canonical admission and publication precede Core's same
472
479
  // accepted-request gate; native tools cannot race an unaccepted user.
473
480
  await applicationCommand.accept({ nativeResult: { value }, conversationTurn, nativeTurnId: entry.turn?.id });
@@ -721,13 +728,13 @@ export function createClaudeConversationOwner({
721
728
  command.signal.throwIfAborted();
722
729
  const entry = await acquire(command.context);
723
730
  const active = control.current;
724
- const applicationCommand = active?.tools ? { active, accept: command.accept } : null;
731
+ const applicationCommand = active && (active.tools || active.finishIfCurrent) ? { active, accept: command.accept } : null;
725
732
  if (applicationCommand) {
726
733
  if (entry.disposed || entry.command && entry.command !== active) throw new Error("This Claude conversation is closed or already working.");
727
734
  if (active.entry && active.entry !== entry) throw new Error("Claude's application tools belong to another native conversation.");
728
735
  active.entry = entry;
729
736
  entry.command = active;
730
- entry.toolSchemas = active.tools.schemas;
737
+ entry.toolSchemas = active.tools?.schemas || [];
731
738
  }
732
739
  const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch }, { applicationCommand });
733
740
  const completion = value.conversationTurn ? entry.completion?.promise : null;
@@ -753,7 +760,7 @@ export function createClaudeConversationOwner({
753
760
  completion.promise.catch(() => {});
754
761
  const active = { completion, onMessage, onEvent, onNativeToolUse, signal, tools, admitted: false, nativeTools: new Map(),
755
762
  messageId: input.messageId, goal: input.goal, nativeToolCount: 0, toolWork: new Set(), maximumToolCalls,
756
- context, failure: Promise.withResolvers() };
763
+ context, finishIfCurrent: command.finishIfCurrent, failure: Promise.withResolvers() };
757
764
  let failure;
758
765
  active.failure.promise.catch(error => { failure = error; });
759
766
  control.current = active;
@@ -810,6 +817,10 @@ export function createClaudeConversationOwner({
810
817
  catch (error) { problem = error; }
811
818
  }
812
819
  if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) continue;
820
+ if (!problem && typeof result === "function") {
821
+ try { result = await result(); }
822
+ catch (error) { problem = error; }
823
+ }
813
824
  problem = dispatchFailure || failure || problem;
814
825
  if (!problem && settling?.completion && (binding ? result.status !== "completed" : !result.ok)) {
815
826
  problem = Object.assign(new Error(result.error || (binding ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), { status: result.status });
@@ -832,7 +843,7 @@ export function createClaudeConversationOwner({
832
843
  }
833
844
  } else {
834
845
  await cancellation?.catch(() => {});
835
- if (tools && active.entry) {
846
+ if (active.entry) {
836
847
  try {
837
848
  if (problem || signal.aborted) await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
838
849
  } catch (error) {
@@ -927,6 +938,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
927
938
  messages: current.messages, error: entry.turn?.error || "", status };
928
939
  }
929
940
 
941
+ function resolveCompletion(result) {
942
+ entry.completion?.resolve(result);
943
+ if (entry.command?.finishIfCurrent) entry.command.completion.resolve(result);
944
+ }
945
+
930
946
  async function updateState(state, message = "") {
931
947
  if (!entry.turn) return;
932
948
  const active = ["starting", "active", "finalizing"].includes(state);
@@ -985,12 +1001,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
985
1001
  setState("interrupted", error.message);
986
1002
  await onEvent({ type: "save" });
987
1003
  await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
988
- entry.completion?.resolve(readResult());
1004
+ resolveCompletion(readResult());
989
1005
  if (entry.startupCleanup === startup) entry.startupCleanup = null;
990
1006
  throw error;
991
1007
  }
992
1008
  } else await onEvent({ type: "save" });
993
- entry.completion?.resolve(readResult());
1009
+ resolveCompletion(readResult());
994
1010
  if (entry.startupCleanup === startup) entry.startupCleanup = null;
995
1011
  return entry.exitProof;
996
1012
  };
@@ -1000,9 +1016,32 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1000
1016
  return entry.stopPending;
1001
1017
  }
1002
1018
 
1003
- async function publishEntryEvent(event) {
1019
+ async function publishEntryEvent(event, deferred = false) {
1004
1020
  if (entry.stopping) return;
1005
1021
  const binding = Boolean(process.releaseExecution);
1022
+ const command = entry.command;
1023
+ if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent) {
1024
+ const delivery = command.delivery;
1025
+ const completion = entry.completion;
1026
+ const owner = acceptedInput;
1027
+ // Release the native queue before the common run joins reserved steering.
1028
+ // That same accepted delivery then performs the original retained checkpoint.
1029
+ command.completion.resolve(async () => {
1030
+ if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
1031
+ command.signal.aborted || !currentReceiptOwner(owner)) {
1032
+ clearFinalAssistantResult();
1033
+ throw failure("Claude's completion belongs to a retired native input.");
1034
+ }
1035
+ try {
1036
+ await publishEntryEvent(event, true);
1037
+ return readResult();
1038
+ } catch (error) {
1039
+ clearFinalAssistantResult();
1040
+ throw error;
1041
+ }
1042
+ });
1043
+ return;
1044
+ }
1006
1045
  if (event.type === "admitted") {
1007
1046
  if (event.userEcho) entry.sent = true;
1008
1047
  if (!binding) await onEvent({ type: "save" });
@@ -1022,7 +1061,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1022
1061
  }
1023
1062
  if (event.type === "settled") {
1024
1063
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
1025
- entry.completion?.resolve(binding ? event : readResult());
1064
+ resolveCompletion(binding ? event : readResult());
1026
1065
  }
1027
1066
  return published;
1028
1067
  }
@@ -275,6 +275,9 @@ import { createClaudeConversationOwner } from "../src/server/conversation/claude
275
275
  import { mkdtemp, rm } from "node:fs/promises";
276
276
  import os from "node:os";
277
277
  import path from "node:path";
278
+ import { createConversationRuntime, createMemoryConversationStorage, createConversationTranscript } from "../src/server/conversation/index.js";
279
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
280
+ import { createSchema } from "json-rest-schema";
278
281
 
279
282
  // Exercise the original supplied-store Send/process/event owner, without the
280
283
  // standalone execution-release capability or application-tool branch.
@@ -292,9 +295,12 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
292
295
  select() {}, read: (_context, id) => saved.get(id),
293
296
  async save(entry) { saved.set(entry.id, JSON.stringify({ turnId: entry.turn?.id,
294
297
  state: entry.turn?.state, accountIdentity: entry.accountIdentity })); },
295
- async hasMessage() { return false; }
298
+ async hasMessage() { return false; },
299
+ readSessionConversations: () => ({ ids: [...saved.keys()], context })
296
300
  },
297
301
  preparation: {
302
+ context: async () => context,
303
+ entry: () => ({ options: { mainId: conversationId }, check() {} }),
298
304
  check() {}, account: () => ({ identity: behavior.account, providerId }),
299
305
  async configuration() { return { systemPrompt: "Retained host instructions", settings: {}, model: providerId === "deepseek" ? "deepseek-flash" : "sonnet" }; },
300
306
  async message(_entry, _input, { message }) { return message; }
@@ -321,10 +327,12 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
321
327
  if (event.type === "before-state") await beforeState?.(entry, event);
322
328
  if (event.type === "admit-message" && canonicalAdmission) {
323
329
  await behavior.beforeAdmit?.();
330
+ if (behavior.admit) return behavior.admit(event);
324
331
  return { turnId: `authored-${event.messageId}`, messages: [{ role: "user", messageId: event.messageId, text: event.message }] };
325
332
  }
326
333
  if (event.type === "message" && event.message.complete && event.message.role === "assistant") {
327
334
  await behavior.beforePublish?.();
335
+ if (behavior.publish) return behavior.publish(event.message);
328
336
  const turn = { id: `host-${messages.length}`, messages: [{ messageId: event.message.id,
329
337
  outputId: event.message.outputId || event.message.id, role: "assistant", text: event.message.text }] };
330
338
  messages.push(turn);
@@ -735,3 +743,199 @@ test("Claude verified native cleanup retains interrupted checkpoint refusal with
735
743
  assert.equal(f.entry.turn.active, false, "Original supplied store retained the truthful interruption");
736
744
  assert.equal(f.entry.turn.state, "interrupted");
737
745
  });
746
+
747
+ // Retain the original supplied-store/native process fixture and drive it through
748
+ // the actual common runtime, including its completion-only admitted receipt.
749
+ async function suppliedCommonFixture(t, { tools = true } = {}) {
750
+ const f = await publishedClaudeFixture(t, { canonicalAdmission: true });
751
+ const storage = createMemoryConversationStorage();
752
+ const transcript = createConversationTranscript({ storage });
753
+ const context = { actor: { id: "owner" }, surface: "app" };
754
+ const admissions = [];
755
+ const checkpoints = [];
756
+ const actions = createActionCatalogue();
757
+ actions.register({ contributorId: "lesson", domain: "lesson", actions: [{
758
+ id: "lesson.inspect", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
759
+ permission: { require: "authenticated" }, idempotency: "none",
760
+ input: { schema: createSchema({}), mode: "replace" },
761
+ output: { schema: createSchema({}), mode: "replace" }, execute: async () => ({})
762
+ }] });
763
+ let delivery;
764
+ f.behavior.admit = event => transcript.writeConversationUserMessage("lesson", {
765
+ messageId: event.messageId, text: event.message
766
+ });
767
+ f.behavior.publish = message => storage.write("lesson", async transaction => {
768
+ const turnId = await transaction.nextTurnId();
769
+ await transaction.appendMessage(turnId, { ...message, role: "assistant", messageId: message.id });
770
+ const turn = await transaction.readTurn(turnId);
771
+ f.messages.push(turn);
772
+ return turn;
773
+ });
774
+ const binding = { sessionId: "lesson", namespace: f.context.key, engine: "claude", runtime: { store: {} },
775
+ native: { owner: f.owner, preparation: { cleanup: () => ({ context: f.context, application: {
776
+ readContext: async () => f.context, terminals: { close: async () => ({ ok: true }) }
777
+ } }) } },
778
+ admission() {}, prepareInput: async input => input,
779
+ read: async () => ({ threadId: f.conversationId, configuration: { model: "native" },
780
+ turn: f.owner.snapshot(f.entry), delivery }),
781
+ readStream: async () => ({ messages: [] }),
782
+ state: { read: async () => delivery, write: async value => { delivery = structuredClone(value); } },
783
+ transcript: { history: async () => [], readConversationLog: () => transcript.readConversationLog("lesson"),
784
+ hasMessage: id => transcript.conversationMessageIdExists("lesson", id),
785
+ writeUserMessage: input => transcript.writeConversationUserMessage("lesson", input) },
786
+ ...(tools ? { applicationTools: { storage, prepareContext(actual, admission) {
787
+ admissions.push(admission); return actual;
788
+ } } } : {}) };
789
+ const runtime = createConversationRuntime({ ...(tools ? { actions } : {}), authorize: () => true,
790
+ host: { conversation: () => binding } });
791
+ t.after(() => runtime.close());
792
+ const conversation = await runtime.open({ id: "lesson", context });
793
+ f.setBeforeState((entry, event) => {
794
+ if (event.state !== "completed") return;
795
+ assert.equal(entry.turn.active, true, "Retained checkpoint still precedes saved idle state");
796
+ assert.equal(entry.nativeTurn.read().active, false);
797
+ const final = f.final();
798
+ assert.ok(final, "The original canonical FINAL is available at the checkpoint");
799
+ const admission = admissions.at(-1);
800
+ if (tools) admission.assertCompletionCurrent();
801
+ checkpoints.push({ final, admission });
802
+ });
803
+ async function loadContract(id = "contract") {
804
+ await f.frame({ type: "assistant", uuid: `tool-${id}`, message: { content: [
805
+ { type: "tool_use", id, name: "mcp__application__assistant_action_contract",
806
+ input: { actionId: "lesson.inspect", version: 1 } }
807
+ ] } });
808
+ return f.entry.process.options.onControlRequest({ subtype: "mcp_message", server_name: "application",
809
+ message: { jsonrpc: "2.0", id, method: "tools/call", params: { name: "assistant_action_contract",
810
+ arguments: { actionId: "lesson.inspect", version: 1 }, _meta: { "claudecode/toolUseId": id } } }
811
+ }, { signal: new AbortController().signal });
812
+ }
813
+ return { ...f, get entry() { return f.entry; }, runtime, conversation, binding, admissions, checkpoints, loadContract, testSignal: t.signal };
814
+ }
815
+
816
+ async function sendCommon(f, messageId) {
817
+ const receipt = await f.conversation.send({ messageId, text: "Prepare the question." });
818
+ while (!f.entry.command?.admitted) {
819
+ f.testSignal.throwIfAborted();
820
+ await new Promise(resolve => setImmediate(resolve));
821
+ }
822
+ return receipt;
823
+ }
824
+
825
+ for (const tools of [true, false]) test(`Claude supplied common runtime checkpoints after its completion fence with tools=${tools}`, { timeout: 10_000 }, async t => {
826
+ const f = await suppliedCommonFixture(t, { tools });
827
+ const receipt = await sendCommon(f, "common-question");
828
+ assert.equal(receipt.status, "accepted");
829
+ assert.equal(typeof f.entry.command.finishIfCurrent, "function");
830
+ if (tools) {
831
+ await f.loadContract();
832
+ assert.throws(() => f.admissions.at(-1).assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
833
+ } else assert.equal(f.entry.command.tools, undefined);
834
+ await f.frame({ type: "result", subtype: "success", result: "Where would you try the application?", uuid: "question-final" });
835
+ await f.conversation.wait();
836
+ assert.equal(f.checkpoints.length, 1);
837
+ assert.notEqual(f.checkpoints[0].final.conversationTurn.turnId, receipt.turnId,
838
+ "The true native FINAL keeps its separate original carrier");
839
+ assert.equal(f.entry.turn.state, "completed");
840
+ assert.equal(f.entry.command, null);
841
+ if (tools) f.admissions.at(-1).assertCompletionCurrent();
842
+ });
843
+
844
+ test("Claude supplied common completion joins accepted steering and checkpoints only its own final", { timeout: 10_000 }, async t => {
845
+ const f = await suppliedCommonFixture(t);
846
+ await sendCommon(f, "common-first");
847
+ await f.loadContract("first-contract");
848
+ const first = f.admissions.at(-1);
849
+ const legacyCompletion = f.entry.completion.promise;
850
+ const receipt = await f.conversation.send({ messageId: "common-successor", text: "Use this new question.", steer: true });
851
+ assert.equal(receipt.status, "accepted");
852
+ assert.equal(f.entry.completion.promise, legacyCompletion, "Original same-turn steering retains the direct wait owner");
853
+ while (f.entry.command.messageId !== "common-successor") {
854
+ f.testSignal.throwIfAborted();
855
+ await new Promise(resolve => setImmediate(resolve));
856
+ }
857
+ await f.loadContract("successor-contract");
858
+ assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
859
+ await f.frame({ type: "result", subtype: "success", result: "The successor question", uuid: "successor-final" });
860
+ await f.conversation.wait();
861
+ assert.equal((await legacyCompletion).status, "completed");
862
+ assert.equal(f.checkpoints.length, 1);
863
+ assert.equal(f.checkpoints[0].final.inputMessageId, claudeNativeMessageId("common-successor"));
864
+ assert.equal(f.checkpoints[0].admission.messageId, "common-successor");
865
+ assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
866
+ });
867
+
868
+ test("Claude supplied completion retains the original waiter when a reserved successor is rejected before dispatch", { timeout: 10_000 }, async t => {
869
+ const f = await suppliedCommonFixture(t);
870
+ await sendCommon(f, "unreplaced-first");
871
+ await f.loadContract();
872
+ const first = f.admissions.at(-1);
873
+ const legacyCompletion = f.entry.completion.promise;
874
+ const preparing = Promise.withResolvers();
875
+ const held = Promise.withResolvers();
876
+ f.binding.prepareInput = async () => { preparing.resolve(); await held.promise; };
877
+ const steering = f.conversation.send({ messageId: "never-dispatched-successor", text: "Rejected before native work.", steer: true });
878
+ void steering.catch(() => {});
879
+ await preparing.promise;
880
+ await f.frame({ type: "result", subtype: "success", result: "The still-current question", uuid: "unreplaced-final" });
881
+ assert.equal(f.checkpoints.length, 0);
882
+ held.reject(new Error("The host rejected preparation before dispatch."));
883
+ await assert.rejects(steering, /host rejected preparation/);
884
+ await f.conversation.wait();
885
+ assert.equal((await legacyCompletion).status, "completed");
886
+ assert.equal(f.checkpoints.length, 1);
887
+ assert.equal(f.checkpoints[0].final.inputMessageId, claudeNativeMessageId("unreplaced-first"));
888
+ first.assertCompletionCurrent();
889
+ });
890
+
891
+ for (const cancelled of [false, true]) test(`Claude supplied native settlement releases its queue while reserved steering waits, cancelled=${cancelled}`, { timeout: 10_000 }, async t => {
892
+ const f = await suppliedCommonFixture(t);
893
+ await sendCommon(f, "reserved-first");
894
+ await f.loadContract();
895
+ const first = f.admissions.at(-1);
896
+ const preparing = Promise.withResolvers();
897
+ const held = Promise.withResolvers();
898
+ f.binding.prepareInput = async input => { preparing.resolve(); await held.promise; return input; };
899
+ const steering = f.conversation.send({ messageId: "reserved-successor", text: "A reserved new instruction.", steer: true });
900
+ void steering.catch(() => {});
901
+ await preparing.promise;
902
+ const legacyCompletion = f.entry.completion.promise;
903
+ let cancellation;
904
+ try {
905
+ await f.frame({ type: "result", subtype: "success", result: "The prior question", uuid: "reserved-final" });
906
+ assert.equal(f.entry.nativeTurn.read().active, false);
907
+ assert.equal(f.entry.turn.active, true);
908
+ assert.equal(f.checkpoints.length, 0, "Native queue does not await the reserved common admission");
909
+ assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
910
+ if (cancelled) cancellation = f.conversation.cancel();
911
+ } finally { held.resolve(); }
912
+ await cancellation;
913
+ await assert.rejects(steering);
914
+ assert.equal((await legacyCompletion).status, "interrupted", "Legacy wait keeps its original settlement and cleanup result");
915
+ await f.conversation.wait();
916
+ assert.equal(f.checkpoints.length, 0);
917
+ assert.equal(f.final(), null, "Rejected steering or Stop cannot revive the prior FINAL");
918
+ assert.equal(f.entry.process, null);
919
+ assert.equal(f.entry.command, null);
920
+ assert.throws(() => first.assertCompletionCurrent());
921
+ });
922
+
923
+ test("Claude supplied common checkpoint failure preserves original refusal and native cleanup", { timeout: 10_000 }, async t => {
924
+ const f = await suppliedCommonFixture(t);
925
+ await sendCommon(f, "refused-common-question");
926
+ await f.loadContract();
927
+ const refusal = new Error("The original lesson checkpoint refused this completion.");
928
+ f.setBeforeState((entry, event) => {
929
+ if (event.state !== "completed") return;
930
+ assert.equal(entry.turn.active, true);
931
+ f.admissions.at(-1).assertCompletionCurrent();
932
+ throw refusal;
933
+ });
934
+ await f.frame({ type: "result", subtype: "success", result: "Never delivered", uuid: "refused-final" });
935
+ const state = await f.conversation.wait();
936
+ assert.equal(state.error, refusal.message);
937
+ assert.equal(f.final(), null);
938
+ assert.equal(f.entry.process, null);
939
+ assert.equal(f.entry.command, null);
940
+ assert.equal(f.entry.turn.active, false);
941
+ });
@@ -397,3 +397,18 @@ test("restored application requests retain their origin and attachment-only user
397
397
  assert.equal(delivery.turns()[0].user.text, "");
398
398
  assert.deepEqual(delivery.turns()[0].user.attachments, [{ attachmentId: "image" }]);
399
399
  });
400
+
401
+ test("a deliberate new message can follow known uncertainty without resending the old request", async () => {
402
+ const sent = [];
403
+ const delivery = createAssistantMessageDelivery({ deliver: async payload => {
404
+ sent.push(payload);
405
+ if (payload.messageId === "old") throw new Error("Lost receipt");
406
+ return { ok: true };
407
+ } });
408
+ await assert.rejects(delivery.send({ message: "Old request" }, { messageId: "old", uncertainOnError: true }));
409
+ assert.equal(await delivery.send({ message: "Old request" }, { messageId: "old", uncertainOnError: true }), false);
410
+ assert.deepEqual(await delivery.send({ message: "A new request" }, { messageId: "new", uncertainOnError: true }), { ok: true });
411
+ assert.deepEqual(sent.map(message => message.messageId), ["old", "new"]);
412
+ assert.equal(delivery.find("old").status, "uncertain");
413
+ assert.equal(delivery.find("new").status, "accepted");
414
+ });
@@ -87,7 +87,7 @@ test("suggestion previews preserve a draft and stale selections cannot submit",
87
87
  state.focus();
88
88
  t.mock.timers.tick(750);
89
89
  await flush();
90
- assert.equal(state.visible.value, true);
90
+ assert.equal(state.visible.value, false, "Refocusing does not regenerate dismissed suggestions");
91
91
  draft.value = "Keep my text";
92
92
  assert.equal(state.preview.value, "");
93
93
  assert.equal(state.select(suggestion("Next step")), false);
@@ -112,3 +112,36 @@ test("a failing suggestion agent leaves the composer independent and stops retry
112
112
  t.mock.timers.tick(60_000);
113
113
  assert.equal(requests, 1);
114
114
  });
115
+
116
+
117
+ test("suggestions wait five seconds and coalesce edited drafts through a thirty-second cooldown", async t => {
118
+ t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1_000 });
119
+ const scope = effectScope();
120
+ t.after(() => scope.stop());
121
+ const draft = ref("First");
122
+ const active = ref(true);
123
+ const calls = [];
124
+ const state = scope.run(() => useAssistantSuggestions({ draft, active, debounceMs: 5_000, minIntervalMs: 30_000,
125
+ generate: async input => { calls.push({ draft: input.draft, at: Date.now() }); return [suggestion(input.draft)]; }
126
+ }));
127
+ t.mock.timers.tick(4_999);
128
+ state.focus(); state.blur(); state.focus();
129
+ assert.equal(calls.length, 0);
130
+ t.mock.timers.tick(1);
131
+ await flush();
132
+ draft.value = "Second";
133
+ t.mock.timers.tick(15_000);
134
+ draft.value = "Newest";
135
+ state.focus();
136
+ t.mock.timers.tick(14_999);
137
+ assert.equal(calls.length, 1);
138
+ t.mock.timers.tick(1);
139
+ await flush();
140
+ assert.deepEqual(calls, [{ draft: "First", at: 6_000 }, { draft: "Newest", at: 36_000 }]);
141
+ t.mock.timers.tick(90_000);
142
+ assert.equal(calls.length, 2, "Cooldown does not poll when the input is unchanged");
143
+ draft.value = "Cancelled before dispatch";
144
+ active.value = false;
145
+ t.mock.timers.tick(30_000);
146
+ assert.equal(calls.length, 2);
147
+ });