@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.
- package/examples/conversation/package.json +5 -5
- package/package.json +5 -5
- package/src/client/conversation/AssistantTranscript.vue +33 -2
- package/src/client/conversation/messageDelivery.js +2 -1
- package/src/client/conversation/useAssistantSuggestions.js +5 -3
- package/src/server/conversation/claudeTurn.js +48 -9
- package/test/claudeTurn.test.js +205 -1
- package/test/messageDelivery.test.js +15 -0
- package/test/suggestions.test.js +34 -1
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
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.
|
|
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.
|
|
51
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
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.
|
|
61
|
-
"@jskit-ai/kernel": "0.1.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1064
|
+
resolveCompletion(binding ? event : readResult());
|
|
1026
1065
|
}
|
|
1027
1066
|
return published;
|
|
1028
1067
|
}
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/suggestions.test.js
CHANGED
|
@@ -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,
|
|
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
|
+
});
|