@jskit-ai/assistant-runtime 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/canonical-conversation/vite.config.mjs +22 -2
- package/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +60 -14
- package/src/server/actionIds.js +1 -0
- package/src/server/actions.js +15 -0
- package/src/server/registerRoutes.js +19 -0
- package/src/server/services/chatService.js +108 -74
- package/test/actionDefinitions.test.js +2 -1
- package/test/assistantConversationBinding.browser.test.js +214 -15
- package/test/assistantConversationOperations.test.js +66 -9
- package/test/assistantConversationSubscriptions.test.js +5 -0
- package/test/chatServiceLifecycle.test.js +48 -2
- package/test/featureRuntime.test.js +3 -3
|
@@ -9,7 +9,7 @@ export default defineConfig({
|
|
|
9
9
|
plugins: [vue(), {
|
|
10
10
|
name: "controlled-canonical-assistant-api",
|
|
11
11
|
configureServer(server) {
|
|
12
|
-
let states, requests, held, mode, denied;
|
|
12
|
+
let states, requests, held, mode, denied, holdCancels, heldCancels;
|
|
13
13
|
const pageLimits = new Map();
|
|
14
14
|
// Exercise the existing page reader over this fixture's controlled rows.
|
|
15
15
|
const transcript = createConversationTranscript({ storage: {
|
|
@@ -27,6 +27,7 @@ export default defineConfig({
|
|
|
27
27
|
streaming: { revision: 0, messages: [] }, capabilities: { steering: false, goals: false, attachments: true } }];
|
|
28
28
|
}));
|
|
29
29
|
requests = []; held = []; mode = "accepted"; denied = false; pageLimits.clear();
|
|
30
|
+
holdCancels = false; heldCancels = [];
|
|
30
31
|
}
|
|
31
32
|
reset();
|
|
32
33
|
function accept(state, input) {
|
|
@@ -60,6 +61,8 @@ export default defineConfig({
|
|
|
60
61
|
if (url.pathname === "/fixture/reset") { reset(); return json({ ok: true }); }
|
|
61
62
|
if (url.pathname === "/fixture/state") return json({ requests, states: Object.fromEntries(states), held: held.length });
|
|
62
63
|
if (url.pathname === "/fixture/mode") { mode = input.mode; return json({ ok: true }); }
|
|
64
|
+
if (url.pathname === "/fixture/hold-cancels") { holdCancels = input.hold; return json({ ok: true }); }
|
|
65
|
+
if (url.pathname === "/fixture/answer-cancels") { for (const agree of heldCancels.splice(0)) agree(); return json({ ok: true }); }
|
|
63
66
|
if (url.pathname === "/fixture/deny") { denied = input.denied; return json({ ok: true }); }
|
|
64
67
|
if (url.pathname === "/fixture/error") { states.get(input.id).error = input.error; return json({ ok: true }); }
|
|
65
68
|
if (url.pathname === "/fixture/interim-reply") { states.get(input.id).interimReply = input.reply; return json({ ok: true }); }
|
|
@@ -168,10 +171,27 @@ export default defineConfig({
|
|
|
168
171
|
return json(accept(state, input), 202);
|
|
169
172
|
}
|
|
170
173
|
if (match[2] === "/cancel") { state.status = "ready"; return json({ stopped: true }); }
|
|
174
|
+
// The server always records a cancel and drops the unconfirmed request.
|
|
175
|
+
if (match[2].startsWith("/deliveries/") && match[2].endsWith("/cancel")) {
|
|
176
|
+
const messageId = decodeURIComponent(match[2].split("/")[2]);
|
|
177
|
+
const agree = () => {
|
|
178
|
+
state.cancelled = [...state.cancelled || [], messageId];
|
|
179
|
+
if (state.pendingRequest?.messageId === messageId) { state.pendingRequest = null; state.status = "ready"; }
|
|
180
|
+
json({ ok: true, cancelled: true, messageId });
|
|
181
|
+
};
|
|
182
|
+
if (holdCancels) heldCancels.push(agree);
|
|
183
|
+
else agree();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
171
186
|
if (match[2].startsWith("/deliveries/") && match[2].endsWith("/inspect")) {
|
|
172
187
|
const messageId = decodeURIComponent(match[2].split("/")[2]);
|
|
188
|
+
if (state.cancelled?.includes(messageId)) return json({ status: "cancelled", messageId });
|
|
173
189
|
const receipt = state.conversationLog.find(turn => turn.user?.messageId === messageId);
|
|
174
|
-
if (!receipt && mode === "not-sent")
|
|
190
|
+
if (!receipt && mode === "not-sent") {
|
|
191
|
+
// Like the runtime, record the proven request as unattempted: reads stop reporting it unconfirmed.
|
|
192
|
+
if (state.pendingRequest?.messageId === messageId) Object.assign(state, { status: "ready", pendingRequest: null });
|
|
193
|
+
return json({ status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." });
|
|
194
|
+
}
|
|
175
195
|
return json(receipt ? { status: "accepted", messageId, turnId: receipt.turnId } : { status: "unknown", messageId });
|
|
176
196
|
}
|
|
177
197
|
return json({ error: "Unknown fixture operation" }, 404);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -13,19 +13,19 @@
|
|
|
13
13
|
"./locales/en.json": "./locales/en.json"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
16
|
+
"@jskit-ai/assistant-core": "0.2.3",
|
|
17
17
|
"json-rest-schema": "^1.0.17"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
21
|
-
"@jskit-ai/http-web": "0.2.
|
|
22
|
-
"@jskit-ai/kernel": "0.2.
|
|
23
|
-
"@jskit-ai/realtime": "0.2.
|
|
24
|
-
"@jskit-ai/shell-web": "0.2.
|
|
20
|
+
"@jskit-ai/http-runtime": "0.2.1",
|
|
21
|
+
"@jskit-ai/http-web": "0.2.2",
|
|
22
|
+
"@jskit-ai/kernel": "0.2.1",
|
|
23
|
+
"@jskit-ai/realtime": "0.2.1",
|
|
24
|
+
"@jskit-ai/shell-web": "0.2.1",
|
|
25
25
|
"@tanstack/vue-query": "^5.90.5",
|
|
26
26
|
"vue": "^3.5.13",
|
|
27
27
|
"vuetify": "^4.0.0",
|
|
28
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
28
|
+
"@jskit-ai/database-runtime": "0.1.250"
|
|
29
29
|
},
|
|
30
30
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
31
31
|
"jskit": {
|
|
@@ -42,7 +42,8 @@ function configureAssistantConversations(app, { actorKey, api = null, request, c
|
|
|
42
42
|
// occur after dispatch and must retain an uncertain receipt.
|
|
43
43
|
const SEND_REJECTIONS = new Set([
|
|
44
44
|
"ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
|
|
45
|
-
"conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed"
|
|
45
|
+
"conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed",
|
|
46
|
+
"conversation_message_cancelled"
|
|
46
47
|
]);
|
|
47
48
|
|
|
48
49
|
function isQuestionConfiguration(value) {
|
|
@@ -116,6 +117,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
116
117
|
const accessDenied = ref(false);
|
|
117
118
|
const delivery = createAssistantMessageDelivery();
|
|
118
119
|
const pendingMessages = new Map();
|
|
120
|
+
const deliveryChecks = new Map();
|
|
119
121
|
const olderPages = ref([]);
|
|
120
122
|
let historyRevision = 0;
|
|
121
123
|
let deniedHistoryRevision = 0;
|
|
@@ -158,10 +160,14 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
158
160
|
typeof message.payload?.message === "string") : [];
|
|
159
161
|
const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
|
|
160
162
|
const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
|
|
161
|
-
return { ...message, status, checking: false, error: message.error || (status === "accepted" ? "" :
|
|
163
|
+
return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
|
|
162
164
|
t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
|
|
163
165
|
});
|
|
164
166
|
delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
|
|
167
|
+
// A cancel the server had not agreed to before this page closed is retried.
|
|
168
|
+
for (const message of messages) {
|
|
169
|
+
if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
|
|
170
|
+
}
|
|
165
171
|
} catch {
|
|
166
172
|
draft.value = "";
|
|
167
173
|
draftAttachments.value = [];
|
|
@@ -239,6 +245,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
239
245
|
accessDenied.value = false;
|
|
240
246
|
delivery.reconcile(state.turns);
|
|
241
247
|
if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
|
|
248
|
+
retryCancels();
|
|
242
249
|
if (state.segmentId !== goalSegment) {
|
|
243
250
|
// Keep the open goal menu while its first native identity arrives. The
|
|
244
251
|
// pending read disables commands until it supplies the new exact target.
|
|
@@ -374,6 +381,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
374
381
|
stopPlacement();
|
|
375
382
|
for (const pending of pendingMessages.values()) pending.controller.abort();
|
|
376
383
|
pendingMessages.clear();
|
|
384
|
+
for (const check of deliveryChecks.values()) check.abort();
|
|
377
385
|
goalReadController?.abort();
|
|
378
386
|
delivery.reset();
|
|
379
387
|
});
|
|
@@ -561,11 +569,32 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
561
569
|
if (current.value) subscription?.reload();
|
|
562
570
|
}
|
|
563
571
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
572
|
+
// Cancel means cancel, and the server decides. Cancel ends the message's
|
|
573
|
+
// unsent request and running delivery check, then asks the server, which
|
|
574
|
+
// always records the cancel. The message stays, shown as cancelling, until
|
|
575
|
+
// the server agrees; when the server cannot be reached it stays with Cancel
|
|
576
|
+
// again, and the next snapshot after reconnecting retries it.
|
|
577
|
+
async function cancelMessage(messageId) {
|
|
578
|
+
pendingMessages.get(messageId)?.controller.abort();
|
|
579
|
+
deliveryChecks.get(messageId)?.abort();
|
|
580
|
+
if (!available.value || !delivery.cancel(messageId)) return false;
|
|
581
|
+
try {
|
|
582
|
+
const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
|
|
583
|
+
if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
|
|
584
|
+
if (!current.value) return false;
|
|
585
|
+
delivery.remove(messageId);
|
|
586
|
+
subscription?.reload();
|
|
587
|
+
return true;
|
|
588
|
+
} catch (failure) {
|
|
589
|
+
// The server's own refusal names itself; anything else did not reach it.
|
|
590
|
+
if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function retryCancels() {
|
|
595
|
+
for (const message of delivery.state.messages) {
|
|
596
|
+
if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
|
|
597
|
+
}
|
|
569
598
|
}
|
|
570
599
|
|
|
571
600
|
// The original history control keeps older pages separate from the live page.
|
|
@@ -653,14 +682,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
653
682
|
const view = goalView.value;
|
|
654
683
|
const target = view?.target;
|
|
655
684
|
const command = target?.capabilities?.goalCommands?.[action];
|
|
685
|
+
// Pause and Cancel never depend on chat delivery: each is pinned to the goal
|
|
686
|
+
// it stops and goes straight to the goal operation, even while a message is
|
|
687
|
+
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
688
|
+
const stopping = action === "pause" || action === "cancel";
|
|
656
689
|
if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
|
|
657
690
|
!["message", "control"].includes(command?.delivery) ||
|
|
658
|
-
delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
691
|
+
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
659
692
|
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
660
693
|
goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
|
|
661
694
|
return false;
|
|
662
695
|
}
|
|
663
|
-
const retry = delivery.find(messageId);
|
|
696
|
+
const retry = stopping ? null : delivery.find(messageId);
|
|
664
697
|
if (retry && retry.status !== "failed") return false;
|
|
665
698
|
const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
|
|
666
699
|
expectedGoalId: view.goal?.id || null,
|
|
@@ -669,7 +702,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
669
702
|
goalAction.value = action;
|
|
670
703
|
goalError.value = "";
|
|
671
704
|
try {
|
|
672
|
-
if (retry || command.delivery === "message") {
|
|
705
|
+
if (retry || !stopping && command.delivery === "message") {
|
|
673
706
|
const result = await submitRequest(retry?.payload || {
|
|
674
707
|
message: action === "set" ? request.objective
|
|
675
708
|
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
@@ -683,7 +716,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
683
716
|
}
|
|
684
717
|
return result;
|
|
685
718
|
}
|
|
686
|
-
const result = await api.updateConversationGoal(identity.conversationId,
|
|
719
|
+
const result = await api.updateConversationGoal(identity.conversationId,
|
|
720
|
+
command.delivery === "message" ? { ...request, messageId } : request);
|
|
687
721
|
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
688
722
|
return result;
|
|
689
723
|
} catch (failure) {
|
|
@@ -719,6 +753,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
719
753
|
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
720
754
|
pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
|
|
721
755
|
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
756
|
+
stopPending: Boolean(goalAction.value) || !goalAvailable || !available.value,
|
|
722
757
|
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
723
758
|
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
724
759
|
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
@@ -740,14 +775,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
740
775
|
if (!available.value) return false;
|
|
741
776
|
const message = delivery.find(messageId);
|
|
742
777
|
if (message?.checking) return false;
|
|
778
|
+
const check = new AbortController();
|
|
779
|
+
deliveryChecks.set(messageId, check);
|
|
743
780
|
if (message) message.checking = true;
|
|
744
781
|
try {
|
|
745
|
-
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
|
|
746
|
-
if (!current.value) return false;
|
|
782
|
+
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
|
|
783
|
+
if (!current.value || check.signal.aborted) return false;
|
|
747
784
|
if (receipt.status === "accepted") {
|
|
748
785
|
delivery.accept(messageId);
|
|
749
786
|
if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
|
|
750
787
|
}
|
|
788
|
+
// The server recorded this message as cancelled, perhaps from another tab.
|
|
789
|
+
else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
|
|
751
790
|
else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
|
|
752
791
|
else if (receipt.status === "unknown" && message?.status === "uncertain") {
|
|
753
792
|
message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
|
|
@@ -755,11 +794,15 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
755
794
|
subscription?.reload();
|
|
756
795
|
return receipt;
|
|
757
796
|
} catch (failure) {
|
|
797
|
+
if (check.signal.aborted) return false;
|
|
758
798
|
if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
|
|
759
799
|
receiveError(failure);
|
|
760
800
|
return false;
|
|
761
801
|
}
|
|
762
|
-
finally {
|
|
802
|
+
finally {
|
|
803
|
+
if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
|
|
804
|
+
if (message) message.checking = false;
|
|
805
|
+
}
|
|
763
806
|
}
|
|
764
807
|
|
|
765
808
|
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
@@ -1054,6 +1097,8 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1054
1097
|
hasMoreBefore: current?.hasMoreBefore.value || false,
|
|
1055
1098
|
loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
|
|
1056
1099
|
loading: current?.loading.value || false, working: !current?.error.value && state?.status === "working" && !current.delivery.state.sending,
|
|
1100
|
+
// The server cancels any unconfirmed message, so its Cancel is offered.
|
|
1101
|
+
cancellableDelivery: true,
|
|
1057
1102
|
assistantLabel: display.assistantLabel || t("jskit.assistant.runtime.conversation.assistantLabel"),
|
|
1058
1103
|
welcomeMessage: display.welcomeMessage ?? t("jskit.assistant.runtime.conversation.welcomeMessage"),
|
|
1059
1104
|
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
|
|
@@ -1075,6 +1120,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1075
1120
|
else if (canDraftWhileLoading.value) initialDraft.value = value;
|
|
1076
1121
|
}, submit,
|
|
1077
1122
|
resend: messageId => submit({ retryMessageId: messageId }), stop: () => current?.cancel(),
|
|
1123
|
+
cancel: messageId => current?.cancelMessage(messageId),
|
|
1078
1124
|
checkDelivery: messageId => current?.inspectDelivery(messageId), loadMore: input => current?.loadMore(input), reload: () => current?.reload() }
|
|
1079
1125
|
};
|
|
1080
1126
|
});
|
package/src/server/actionIds.js
CHANGED
|
@@ -5,6 +5,7 @@ const actionIds = Object.freeze({
|
|
|
5
5
|
conversationCancel: "assistant.conversation.cancel",
|
|
6
6
|
conversationSubscribe: "assistant.conversation.subscribe",
|
|
7
7
|
conversationInspectDelivery: "assistant.conversation.delivery.inspect",
|
|
8
|
+
conversationCancelDelivery: "assistant.conversation.delivery.cancel",
|
|
8
9
|
conversationGoalRead: "assistant.conversation.goal.read",
|
|
9
10
|
conversationGoalUpdate: "assistant.conversation.goal.update",
|
|
10
11
|
conversationConfigure: "assistant.conversation.configure",
|
package/src/server/actions.js
CHANGED
|
@@ -225,6 +225,21 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
|
|
|
225
225
|
return conversation.inspectDelivery({ messageId: input.messageId });
|
|
226
226
|
}
|
|
227
227
|
},
|
|
228
|
+
{
|
|
229
|
+
id: actionIds.conversationCancelDelivery,
|
|
230
|
+
version: 1,
|
|
231
|
+
kind: "command",
|
|
232
|
+
channels: ["api", "internal"],
|
|
233
|
+
surfaces: runtimeSurfaces,
|
|
234
|
+
permission: { require: "authenticated" },
|
|
235
|
+
input: conversationInspectInputValidator,
|
|
236
|
+
output: null,
|
|
237
|
+
idempotency: "none",
|
|
238
|
+
async execute(input, context) {
|
|
239
|
+
const conversation = await openConversation(input, context);
|
|
240
|
+
return conversation.cancelDelivery({ messageId: input.messageId });
|
|
241
|
+
}
|
|
242
|
+
},
|
|
228
243
|
{
|
|
229
244
|
id: actionIds.conversationGoalRead,
|
|
230
245
|
version: 1,
|
|
@@ -385,6 +385,25 @@ function registerConversationRoutes(
|
|
|
385
385
|
reply.code(200).send(response);
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
router.register("POST", `${routeBase}/deliveries/:messageId/cancel`, {
|
|
389
|
+
auth: "required", visibility,
|
|
390
|
+
params: composeSchemaDefinitions([params, assistantConversationMessageIdInputValidator], {
|
|
391
|
+
mode: "patch", context: "assistant-runtime conversation delivery cancel route params"
|
|
392
|
+
}),
|
|
393
|
+
meta: { tags: ["assistant"], summary: "Cancel one unsent or unconfirmed assistant message." }
|
|
394
|
+
}, async function assistantConversationCancelDeliveryRoute(request, reply) {
|
|
395
|
+
const routeState = resolveRouteRequestState(request, {
|
|
396
|
+
resolveCurrentAppConfig, requiresWorkspace, workspaceScopeSupport
|
|
397
|
+
});
|
|
398
|
+
const response = await request.executeAction({
|
|
399
|
+
actionId: actionIds.conversationCancelDelivery,
|
|
400
|
+
surface: routeState.hostSurfaceId,
|
|
401
|
+
input: { ...routeState.actionInput, conversationId: request.input.params.conversationId,
|
|
402
|
+
messageId: request.input.params.messageId }
|
|
403
|
+
});
|
|
404
|
+
reply.code(200).send(response);
|
|
405
|
+
});
|
|
406
|
+
|
|
388
407
|
for (const method of ["GET", "POST"]) {
|
|
389
408
|
router.register(method, `${routeBase}/goal`, {
|
|
390
409
|
auth: "required", visibility, params,
|
|
@@ -215,6 +215,34 @@ function createChatService({
|
|
|
215
215
|
return { conversationId: response.meta?.conversationId, messageId: request.messageId, status: record.status };
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// Turns on one conversation share its runtime entry. The last of them to
|
|
219
|
+
// settle disposes it, so a finished chat keeps no runtime state; a turn that
|
|
220
|
+
// starts while that disposal runs waits for it and opens the entry again.
|
|
221
|
+
const conversationTurns = new Map();
|
|
222
|
+
async function openConversationTurn(conversationId, open) {
|
|
223
|
+
while (conversationTurns.get(conversationId)?.releasing) await conversationTurns.get(conversationId).releasing;
|
|
224
|
+
const turns = conversationTurns.get(conversationId) || { count: 0, handle: null, releasing: null };
|
|
225
|
+
conversationTurns.set(conversationId, turns);
|
|
226
|
+
turns.count += 1;
|
|
227
|
+
try {
|
|
228
|
+
turns.handle = await open();
|
|
229
|
+
return turns.handle;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
await releaseConversationTurn(conversationId);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function releaseConversationTurn(conversationId) {
|
|
236
|
+
const turns = conversationTurns.get(conversationId);
|
|
237
|
+
turns.count -= 1;
|
|
238
|
+
if (turns.count > 0) return;
|
|
239
|
+
// A failed disposal keeps the entry, as before: the next turn reuses it and
|
|
240
|
+
// runtime close retries its save.
|
|
241
|
+
turns.releasing = Promise.resolve(turns.handle?.dispose()).catch(() => {})
|
|
242
|
+
.finally(() => conversationTurns.delete(conversationId));
|
|
243
|
+
await turns.releasing;
|
|
244
|
+
}
|
|
245
|
+
|
|
218
246
|
async function streamChat(payload = {}, options = {}) {
|
|
219
247
|
const assistantSurface = requireAssistantSurface(appConfig, payload?.targetSurfaceId);
|
|
220
248
|
const context = normalizeObject(options.context);
|
|
@@ -320,85 +348,91 @@ function createChatService({
|
|
|
320
348
|
const runtimeContext = { ...assistantContext, assistantRequest: {
|
|
321
349
|
conversation, workspace, history: source.history, resolvedAttachments
|
|
322
350
|
} };
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
let turnId = null;
|
|
331
|
-
let displayedId = null;
|
|
332
|
-
let displayedText = "";
|
|
333
|
-
const release = await handle.subscribe(event => {
|
|
334
|
-
if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
|
|
335
|
-
if (!turnId || event.turnId !== turnId) return;
|
|
336
|
-
if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
|
|
337
|
-
if (displayedId !== event.messageId) {
|
|
338
|
-
if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
|
|
339
|
-
displayedId = event.messageId;
|
|
340
|
-
displayedText = "";
|
|
341
|
-
}
|
|
342
|
-
if (event.text === displayedText) return;
|
|
343
|
-
if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
|
|
344
|
-
else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
|
|
345
|
-
displayedText = event.text;
|
|
346
|
-
} else if (event.type === "tool") {
|
|
347
|
-
const call = event.call;
|
|
348
|
-
if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
|
|
349
|
-
toolCallId: call.id, name: call.name, arguments: call.arguments });
|
|
350
|
-
else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
|
|
351
|
-
toolCallId: call.id, name: call.name, ok: call.result.ok === true,
|
|
352
|
-
...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
|
|
351
|
+
const handle = await openConversationTurn(conversationId, async () => {
|
|
352
|
+
try { return await conversationRuntime.open({ id: conversationId, context: runtimeContext, configuration }); }
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (error.code !== "conversation_configuration_mismatch") throw error;
|
|
355
|
+
const opened = await conversationRuntime.open({ id: conversationId, context: runtimeContext });
|
|
356
|
+
await opened.configure(configuration);
|
|
357
|
+
return opened;
|
|
353
358
|
}
|
|
354
359
|
});
|
|
355
|
-
let cancellation;
|
|
356
|
-
const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
|
|
357
|
-
let streamed = false;
|
|
358
360
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
361
|
+
let turnId = null;
|
|
362
|
+
let displayedId = null;
|
|
363
|
+
let displayedText = "";
|
|
364
|
+
const release = await handle.subscribe(event => {
|
|
365
|
+
if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
|
|
366
|
+
if (!turnId || event.turnId !== turnId) return;
|
|
367
|
+
if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
|
|
368
|
+
if (displayedId !== event.messageId) {
|
|
369
|
+
if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
|
|
370
|
+
displayedId = event.messageId;
|
|
371
|
+
displayedText = "";
|
|
372
|
+
}
|
|
373
|
+
if (event.text === displayedText) return;
|
|
374
|
+
if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
|
|
375
|
+
else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
|
|
376
|
+
displayedText = event.text;
|
|
377
|
+
} else if (event.type === "tool") {
|
|
378
|
+
const call = event.call;
|
|
379
|
+
if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
|
|
380
|
+
toolCallId: call.id, name: call.name, arguments: call.arguments });
|
|
381
|
+
else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
|
|
382
|
+
toolCallId: call.id, name: call.name, ok: call.result.ok === true,
|
|
383
|
+
...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
let cancellation;
|
|
387
|
+
const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
|
|
388
|
+
let streamed = false;
|
|
389
|
+
try {
|
|
390
|
+
const meta = { type: ASSISTANT_STREAM_EVENT_TYPES.META, messageId: source.messageId, conversationId,
|
|
391
|
+
provider: aiClient.provider, model: aiClient.defaultModel };
|
|
392
|
+
await turnRequests.update(claim, { meta });
|
|
393
|
+
streamWriter.sendMeta(meta);
|
|
394
|
+
streamed = true;
|
|
395
|
+
options.abortSignal?.addEventListener("abort", cancel, { once: true });
|
|
396
|
+
options.abortSignal?.throwIfAborted();
|
|
397
|
+
const receipt = await handle.send({ messageId: source.messageId, text: source.input,
|
|
398
|
+
...(source.attachmentIds !== undefined ? { attachmentIds: source.attachmentIds } : {}) });
|
|
399
|
+
turnId = receipt.turnId;
|
|
400
|
+
const snapshot = await handle.wait();
|
|
401
|
+
const turn = snapshot.conversationLog.find(entry => entry.turnId === turnId);
|
|
402
|
+
const status = turn?.metadata?.runtime?.status;
|
|
403
|
+
if (status !== "complete") {
|
|
404
|
+
const reason = turn?.metadata?.runtime?.error || snapshot.error;
|
|
405
|
+
const error = reason ? new Error(reason) : Object.assign(new Error("Assistant request failed."), {
|
|
406
|
+
messageCode: "assistant.runtime.request.failed", messageParams: {} });
|
|
407
|
+
if (status === "cancelled") error.name = "AbortError";
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
const text = normalizeText(turn.assistant?.text);
|
|
411
|
+
if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
|
|
412
|
+
await transcriptService.completeConversation(assistantSurface, conversationId,
|
|
413
|
+
{ status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
|
|
414
|
+
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
|
|
415
|
+
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
|
|
416
|
+
return { conversationId, messageId: source.messageId, status: "completed" };
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const aborted = isAbortError(error) || options.abortSignal?.aborted;
|
|
419
|
+
const status = aborted ? "aborted" : "failed";
|
|
420
|
+
if (!streamed) throw error;
|
|
421
|
+
await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
|
|
422
|
+
streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
|
|
423
|
+
code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
|
|
424
|
+
message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
|
|
425
|
+
...streamErrorMessageFields(error, aborted),
|
|
426
|
+
status: aborted ? 499 : errorStatus(error) });
|
|
427
|
+
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
|
|
428
|
+
return { conversationId, messageId: source.messageId, status };
|
|
429
|
+
} finally {
|
|
430
|
+
options.abortSignal?.removeEventListener("abort", cancel);
|
|
431
|
+
release();
|
|
432
|
+
await cancellation;
|
|
378
433
|
}
|
|
379
|
-
const text = normalizeText(turn.assistant?.text);
|
|
380
|
-
if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
|
|
381
|
-
await transcriptService.completeConversation(assistantSurface, conversationId,
|
|
382
|
-
{ status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
|
|
383
|
-
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
|
|
384
|
-
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
|
|
385
|
-
return { conversationId, messageId: source.messageId, status: "completed" };
|
|
386
|
-
} catch (error) {
|
|
387
|
-
const aborted = isAbortError(error) || options.abortSignal?.aborted;
|
|
388
|
-
const status = aborted ? "aborted" : "failed";
|
|
389
|
-
if (!streamed) throw error;
|
|
390
|
-
await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
|
|
391
|
-
streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
|
|
392
|
-
code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
|
|
393
|
-
message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
|
|
394
|
-
...streamErrorMessageFields(error, aborted),
|
|
395
|
-
status: aborted ? 499 : errorStatus(error) });
|
|
396
|
-
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
|
|
397
|
-
return { conversationId, messageId: source.messageId, status };
|
|
398
434
|
} finally {
|
|
399
|
-
|
|
400
|
-
release();
|
|
401
|
-
await cancellation;
|
|
435
|
+
await releaseConversationTurn(conversationId);
|
|
402
436
|
}
|
|
403
437
|
}
|
|
404
438
|
}
|
|
@@ -117,7 +117,8 @@ test("host access wraps only canonical conversation definitions and receives the
|
|
|
117
117
|
});
|
|
118
118
|
assert.deepEqual(wrapped.map(({ id }) => id), [
|
|
119
119
|
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, subscribeActionId,
|
|
120
|
-
actionIds.conversationInspectDelivery, actionIds.
|
|
120
|
+
actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead,
|
|
121
|
+
actionIds.conversationGoalUpdate
|
|
121
122
|
]);
|
|
122
123
|
assert.equal(findDefinition(definitions, actionIds.conversationSubscribe), undefined);
|
|
123
124
|
for (const definition of wrapped) {
|
|
@@ -33,7 +33,14 @@ async function fixture(t, width = 1280, { acceptedDraft = false, draftWhileLoadi
|
|
|
33
33
|
const state = async () => (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
|
|
34
34
|
const sent = async () => (await state()).requests.filter(request => request.suffix === "/messages");
|
|
35
35
|
const notify = id => page.evaluate(id => window.conversationFixture.socket.notify(id), id);
|
|
36
|
-
|
|
36
|
+
// An unknown delivery check rereads the conversation. A receipt confirmed
|
|
37
|
+
// before the server serves that read would settle it from canonical history.
|
|
38
|
+
const inspectionReread = id => expect.poll(async () => {
|
|
39
|
+
const { requests } = await state();
|
|
40
|
+
const inspection = requests.findLastIndex(request => request.id === id && request.suffix.endsWith("/inspect"));
|
|
41
|
+
return inspection >= 0 && requests.slice(inspection + 1).some(request => request.method === "GET" && request.id === id && !request.suffix);
|
|
42
|
+
}).toBe(true);
|
|
43
|
+
return { page, primary, input, command, state, sent, notify, inspectionReread };
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
test("inspecting a restored no-admission request releases Send while preserving newer draft and uploads", options, async t => {
|
|
@@ -180,6 +187,142 @@ test("optional goal controls follow declared command semantics and budgets witho
|
|
|
180
187
|
assert.equal((await f.sent()).length, 0, "Goal commands use the existing goal operation, not ordinary send");
|
|
181
188
|
});
|
|
182
189
|
|
|
190
|
+
test("a paused goal stays cancellable while a message is sending or its delivery is unknown", options, async t => {
|
|
191
|
+
const f = await fixture(t);
|
|
192
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
193
|
+
set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
|
|
194
|
+
pause: { delivery: "control", interruptsTurn: true }, cancel: { delivery: "message", interruptsTurn: true }
|
|
195
|
+
} });
|
|
196
|
+
await f.notify("chat:1");
|
|
197
|
+
const goals = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST").map(request => request.input);
|
|
198
|
+
const pausedGoal = async objective => {
|
|
199
|
+
await f.primary.getByRole("button", { name: "Set goal", exact: true }).click();
|
|
200
|
+
await f.page.getByRole("textbox", { name: "Goal objective" }).fill(objective);
|
|
201
|
+
await f.page.getByRole("button", { name: "Start goal", exact: true }).click();
|
|
202
|
+
await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
|
|
203
|
+
await f.page.getByRole("button", { name: "Pause goal", exact: true }).click();
|
|
204
|
+
await expect(f.primary.getByRole("button", { name: "Goal paused", exact: true })).toBeVisible();
|
|
205
|
+
await f.page.keyboard.press("Escape");
|
|
206
|
+
await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
|
|
207
|
+
};
|
|
208
|
+
const cancelPausedGoal = async () => {
|
|
209
|
+
await f.primary.getByRole("button", { name: "Goal paused", exact: true }).click();
|
|
210
|
+
await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toBeDisabled();
|
|
211
|
+
const cancel = f.page.getByRole("button", { name: "Cancel goal", exact: true });
|
|
212
|
+
await expect(cancel).toBeEnabled();
|
|
213
|
+
await cancel.click();
|
|
214
|
+
await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toBeVisible();
|
|
215
|
+
await f.page.keyboard.press("Escape");
|
|
216
|
+
await expect(f.page.getByRole("textbox", { name: "Goal objective" })).toHaveCount(0);
|
|
217
|
+
const sent = await goals();
|
|
218
|
+
assert.equal(sent.at(-1).action, "cancel");
|
|
219
|
+
assert.ok(sent.at(-1).messageId, "A message-delivered Cancel keeps its receipt identity");
|
|
220
|
+
assert.equal(sent.at(-1).expectedGoalId, sent.at(-2).expectedGoalId);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await pausedGoal("Finish while a message is sending");
|
|
224
|
+
await f.command("mode", { mode: "hold" });
|
|
225
|
+
await f.input.fill("Still sending");
|
|
226
|
+
await f.input.press("Enter");
|
|
227
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
228
|
+
await f.command("mode", { mode: "accepted" });
|
|
229
|
+
await cancelPausedGoal();
|
|
230
|
+
assert.equal((await f.state()).held, 1, "Cancel did not wait for the message still sending");
|
|
231
|
+
await f.command("accept");
|
|
232
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.sending)).toBe(false);
|
|
233
|
+
|
|
234
|
+
await pausedGoal("Finish while a delivery is unknown");
|
|
235
|
+
await f.command("mode", { mode: "uncertain" });
|
|
236
|
+
await f.input.fill("Deslop");
|
|
237
|
+
await f.input.press("Enter");
|
|
238
|
+
await expect(f.primary.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
|
|
239
|
+
await f.command("mode", { mode: "accepted" });
|
|
240
|
+
await cancelPausedGoal();
|
|
241
|
+
assert.deepEqual((await goals()).map(command => command.action), ["set", "pause", "cancel", "set", "pause", "cancel"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("Cancel ends a running delivery check, keeps the message until the server agrees and retries after the server was unreachable", options, async t => {
|
|
245
|
+
const f = await fixture(t);
|
|
246
|
+
await f.command("mode", { mode: "uncertain" });
|
|
247
|
+
await f.input.fill("Say OK and just that");
|
|
248
|
+
await f.input.press("Enter");
|
|
249
|
+
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
250
|
+
await expect(check).toBeVisible();
|
|
251
|
+
const [original] = await f.sent();
|
|
252
|
+
const message = f.primary.getByText("Say OK and just that", { exact: true });
|
|
253
|
+
const cancelling = f.primary.getByRole("status").filter({ hasText: "Cancelling…" });
|
|
254
|
+
const cancel = f.primary.getByRole("button", { name: "Cancel", exact: true });
|
|
255
|
+
const cancels = async () => (await f.state()).requests.filter(request => request.suffix.endsWith("/cancel")).length;
|
|
256
|
+
|
|
257
|
+
// The server cannot be reached: the message stays, with Cancel again.
|
|
258
|
+
await f.page.route("**/deliveries/*/cancel", route => route.abort("internetdisconnected"));
|
|
259
|
+
const result = await f.page.evaluate(async messageId => {
|
|
260
|
+
const runtime = window.conversationFixture.current();
|
|
261
|
+
const checking = runtime.inspectDelivery(messageId);
|
|
262
|
+
const wasChecking = runtime.delivery.find(messageId)?.checking === true;
|
|
263
|
+
const cancelled = runtime.cancelMessage(messageId);
|
|
264
|
+
const whileWaiting = runtime.delivery.find(messageId)?.cancelling === true;
|
|
265
|
+
return { wasChecking, whileWaiting, receipt: await checking, cancelled: await cancelled };
|
|
266
|
+
}, original.input.messageId);
|
|
267
|
+
assert.deepEqual(result, { wasChecking: true, whileWaiting: true, receipt: false, cancelled: false });
|
|
268
|
+
await expect(f.primary.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
|
|
269
|
+
await expect(message).toBeVisible();
|
|
270
|
+
await expect(cancel).toBeEnabled();
|
|
271
|
+
await f.page.unroute("**/deliveries/*/cancel");
|
|
272
|
+
|
|
273
|
+
// Reconnecting retries the cancel; the message stays, cancelling, until the
|
|
274
|
+
// server answers, and an unconfirmed snapshot meanwhile does not change that.
|
|
275
|
+
await f.command("hold-cancels", { hold: true });
|
|
276
|
+
await f.notify("chat:1");
|
|
277
|
+
await expect(cancelling).toBeVisible();
|
|
278
|
+
await expect.poll(cancels).toBe(1);
|
|
279
|
+
await expect(message).toBeVisible();
|
|
280
|
+
assert.equal((await f.state()).states["chat:1"].pendingRequest.messageId, original.input.messageId);
|
|
281
|
+
await f.command("answer-cancels");
|
|
282
|
+
await expect(message).toHaveCount(0);
|
|
283
|
+
await expect(check).toHaveCount(0);
|
|
284
|
+
|
|
285
|
+
// The server dropped the request, so neither a snapshot nor a reload brings it back.
|
|
286
|
+
assert.equal((await f.state()).states["chat:1"].pendingRequest, null);
|
|
287
|
+
await f.notify("chat:1");
|
|
288
|
+
await f.page.reload();
|
|
289
|
+
await expect(f.input).toBeEnabled();
|
|
290
|
+
await expect(message).toHaveCount(0);
|
|
291
|
+
assert.equal(await cancels(), 1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a Cancel the server never received is retried after a page reload", options, async t => {
|
|
295
|
+
const f = await fixture(t);
|
|
296
|
+
const attach = async () => {
|
|
297
|
+
await f.page.evaluate(() => {
|
|
298
|
+
window.savedBinding = window.conversationFixture.acquireConversation({
|
|
299
|
+
conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "unfinished-cancel" }
|
|
300
|
+
});
|
|
301
|
+
window.conversationFixture.target("chat:2");
|
|
302
|
+
});
|
|
303
|
+
await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
|
|
304
|
+
};
|
|
305
|
+
await attach();
|
|
306
|
+
await f.command("mode", { mode: "uncertain" });
|
|
307
|
+
await f.input.fill("Cancel me across a reload");
|
|
308
|
+
await f.input.press("Enter");
|
|
309
|
+
await expect(f.primary.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
|
|
310
|
+
await f.page.route("**/deliveries/*/cancel", route => route.abort("internetdisconnected"));
|
|
311
|
+
await f.primary.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
312
|
+
await expect(f.primary.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
|
|
313
|
+
await f.page.unroute("**/deliveries/*/cancel");
|
|
314
|
+
await f.command("hold-cancels", { hold: true });
|
|
315
|
+
await f.page.reload();
|
|
316
|
+
await attach();
|
|
317
|
+
const message = f.primary.getByText("Cancel me across a reload", { exact: true });
|
|
318
|
+
await expect(f.primary.getByRole("status").filter({ hasText: "Cancelling…" })).toBeVisible();
|
|
319
|
+
await expect(message).toBeVisible();
|
|
320
|
+
await f.command("answer-cancels");
|
|
321
|
+
await expect(message).toHaveCount(0);
|
|
322
|
+
const cancels = (await f.state()).requests.filter(request => request.suffix.endsWith("/cancel"));
|
|
323
|
+
assert.deepEqual(cancels.map(request => request.id), ["chat:2"]);
|
|
324
|
+
});
|
|
325
|
+
|
|
183
326
|
test("the first native goal preserves absent identity and later controls capture its actual thread", options, async t => {
|
|
184
327
|
const f = await fixture(t);
|
|
185
328
|
await f.input.fill("Keep this ordinary draft");
|
|
@@ -413,6 +556,7 @@ test("explicit delivery inspection acknowledges uncertain attachments without cl
|
|
|
413
556
|
await check.click();
|
|
414
557
|
await expect(check).toBeVisible();
|
|
415
558
|
assert.deepEqual((await attachments()).acknowledged, [], "An unknown receipt does not accept the upload");
|
|
559
|
+
await f.inspectionReread("chat:1");
|
|
416
560
|
await f.command("confirm", { id: "chat:1" });
|
|
417
561
|
await check.click();
|
|
418
562
|
await expect(check).toHaveCount(0);
|
|
@@ -639,7 +783,9 @@ test("canonical receipts settle a held response and uncertain delivery stays ins
|
|
|
639
783
|
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
640
784
|
await expect(check).toBeVisible();
|
|
641
785
|
await expect(f.primary.getByText("Keep this exact uncertain request", { exact: true })).toHaveCount(1);
|
|
642
|
-
|
|
786
|
+
// Never resent or edited while unknown; only the server's Cancel is offered.
|
|
787
|
+
await expect(f.primary.getByRole("button", { name: /^(Retry|Edit)$/u })).toHaveCount(0);
|
|
788
|
+
await expect(f.primary.getByRole("button", { name: "Cancel", exact: true })).toBeEnabled();
|
|
643
789
|
const checkBox = await check.boundingBox();
|
|
644
790
|
assert.ok(checkBox.width >= 48 && checkBox.height >= 48);
|
|
645
791
|
await f.input.fill("Preserve the newer draft");
|
|
@@ -731,7 +877,17 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
|
|
|
731
877
|
assert.equal(restored.id, saved.messages[0].id);
|
|
732
878
|
assert.deepEqual(restored.payload, saved.messages[0].payload);
|
|
733
879
|
assert.equal((await f.sent()).length, 1, "restoration never dispatches a saved request");
|
|
734
|
-
|
|
880
|
+
// The server is ready, so a different authored draft may proceed; only the
|
|
881
|
+
// restored uncertain words themselves stay blocked until inspection settles them.
|
|
882
|
+
assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), true);
|
|
883
|
+
if (unconfirmed) {
|
|
884
|
+
assert.equal(await f.page.evaluate(() => {
|
|
885
|
+
const runtime = window.savedBinding.runtime.value;
|
|
886
|
+
runtime.draft.value = "Exact original saved words";
|
|
887
|
+
return runtime.canSubmit.value;
|
|
888
|
+
}), false, "the uncertain request's own words cannot be sent again");
|
|
889
|
+
await f.page.evaluate(draft => { window.savedBinding.runtime.value.draft.value = draft; }, saved.draft);
|
|
890
|
+
}
|
|
735
891
|
if (unconfirmed) {
|
|
736
892
|
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
737
893
|
try { await expect(check).toBeEnabled(); }
|
|
@@ -746,9 +902,9 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
|
|
|
746
902
|
await f.page.screenshot({ path: `/tmp/jskit-saved-delivery-reload-${status}-20261006.png` });
|
|
747
903
|
throw failure;
|
|
748
904
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
905
|
+
// Never resent or edited while unknown; only the server's Cancel is offered.
|
|
906
|
+
await expect(f.primary.getByRole("button", { name: /^(Retry|Edit)$/u })).toHaveCount(0);
|
|
907
|
+
await expect(f.primary.getByRole("button", { name: "Cancel", exact: true })).toBeEnabled();
|
|
752
908
|
// The fixture's original inspector matches any same-ID row. Keep the
|
|
753
909
|
// nonreceipt projection proof above, then inspect genuine absence.
|
|
754
910
|
await f.command("history", { id: "chat:2", turns: [], limit: 20 });
|
|
@@ -833,7 +989,7 @@ test("a finished-turn error without a definite rejection status remains uncertai
|
|
|
833
989
|
assert.equal((await f.sent()).length, 1);
|
|
834
990
|
});
|
|
835
991
|
|
|
836
|
-
for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
|
|
992
|
+
for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable", "conversation_message_cancelled"]) {
|
|
837
993
|
test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
|
|
838
994
|
const f = await fixture(t);
|
|
839
995
|
await f.command("mode", { mode: "rejected" });
|
|
@@ -842,6 +998,8 @@ test(`an explicit pre-admission rejection retains original retry identity and th
|
|
|
842
998
|
await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
|
|
843
999
|
await f.notify("chat:1");
|
|
844
1000
|
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
1001
|
+
}
|
|
1002
|
+
if (code !== "ACTION_VALIDATION_FAILED") {
|
|
845
1003
|
await f.page.route("**/messages", async route => {
|
|
846
1004
|
const response = await route.fetch();
|
|
847
1005
|
await route.fulfill(response.status() === 400
|
|
@@ -887,7 +1045,8 @@ test(`an explicit pre-admission rejection retains original retry identity and th
|
|
|
887
1045
|
await f.input.fill("Keep my newer draft");
|
|
888
1046
|
await f.primary.getByRole("button", { name: action, exact: true }).click();
|
|
889
1047
|
await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toHaveCount(0);
|
|
890
|
-
|
|
1048
|
+
// Cancel removes the message once the server agrees.
|
|
1049
|
+
await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId)).toBe(null);
|
|
891
1050
|
await expect(f.input).toHaveValue(action === "Edit" ? `${text}\n\nKeep my newer draft` : "Keep my newer draft");
|
|
892
1051
|
}
|
|
893
1052
|
assert.equal((await f.sent()).length, 4, "Cancel and Edit do not dispatch another prompt");
|
|
@@ -1557,13 +1716,18 @@ test("a voice preview cannot acknowledge sending or uncertain delivery and dedup
|
|
|
1557
1716
|
const second = (await f.sent())[1].input.messageId;
|
|
1558
1717
|
await preview(second, "An uncertain preview is not an acknowledgement.");
|
|
1559
1718
|
await expect(f.primary.getByText("An uncertain preview is not an acknowledgement.", { exact: true })).toHaveCount(0);
|
|
1560
|
-
|
|
1719
|
+
// The uncertain notice stays attached to the user's own bubble, not a system row.
|
|
1720
|
+
const uncertainBubble = f.primary.locator(".assistant-transcript__message--user").filter({ hasText: "Keep this uncertain request." });
|
|
1721
|
+
await expect(uncertainBubble.locator(".assistant-transcript__optimistic-failure")).toBeVisible();
|
|
1722
|
+
await expect(uncertainBubble.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
|
|
1723
|
+
await expect(f.primary.locator(".assistant-transcript__system")).toHaveCount(0);
|
|
1561
1724
|
assert.deepEqual((await delivery()).messages, [{ id: second, status: "uncertain" }]);
|
|
1562
1725
|
assert.equal((await delivery()).history.length, 1);
|
|
1563
1726
|
await check.click();
|
|
1564
1727
|
await expect(check).toBeEnabled();
|
|
1565
1728
|
assert.deepEqual((await delivery()).messages, [{ id: second, status: "uncertain" }]);
|
|
1566
1729
|
await expect(f.input).toHaveValue("Keep this uncertain request.");
|
|
1730
|
+
await f.inspectionReread("chat:1");
|
|
1567
1731
|
await f.command("confirm", { id: "chat:1" });
|
|
1568
1732
|
await check.click();
|
|
1569
1733
|
await expect(check).toHaveCount(0);
|
|
@@ -2329,9 +2493,25 @@ test("prepared Retry sends an uncertain message again only after the server prov
|
|
|
2329
2493
|
assert.equal(unknown.status, "uncertain");
|
|
2330
2494
|
assert.equal((await f.sent()).length, 1);
|
|
2331
2495
|
await f.command("mode", { mode: "not-sent" });
|
|
2332
|
-
|
|
2496
|
+
// Hold the resend until a read after the not-sent proof has arrived: the
|
|
2497
|
+
// proven request is saved as unattempted, so it no longer reads as unconfirmed.
|
|
2498
|
+
const sendGate = Promise.withResolvers();
|
|
2499
|
+
t.after(() => sendGate.resolve());
|
|
2500
|
+
let sendHeld = false;
|
|
2501
|
+
await f.page.route("**/messages", async route => {
|
|
2502
|
+
sendHeld = true;
|
|
2503
|
+
await sendGate.promise;
|
|
2504
|
+
await route.continue().catch(() => {});
|
|
2505
|
+
});
|
|
2506
|
+
const retrying = f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
|
|
2333
2507
|
{ retryMessageId: "proven-retry", prepare: captured => captured.request }));
|
|
2334
|
-
|
|
2508
|
+
await expect.poll(() => sendHeld).toBe(true);
|
|
2509
|
+
await f.page.evaluate(() => window.provenBinding.runtime.value.reload());
|
|
2510
|
+
sendGate.resolve();
|
|
2511
|
+
const retried = await retrying;
|
|
2512
|
+
await f.page.unroute("**/messages");
|
|
2513
|
+
// Acceptance is the HTTP receipt or the canonical row, whichever arrives first.
|
|
2514
|
+
assert.ok(retried && retried.ok !== false, JSON.stringify(retried));
|
|
2335
2515
|
const sent = await f.sent();
|
|
2336
2516
|
assert.equal(sent.length, 2);
|
|
2337
2517
|
assert.equal(sent[1].input.messageId, "proven-retry");
|
|
@@ -2394,7 +2574,8 @@ test("transient subscription errors keep loaded history and recover through the
|
|
|
2394
2574
|
user: { messageId: `question-${index}`, role: "user", text: `Private saved question ${index}. ` + "Keep the original discussion. ".repeat(8) },
|
|
2395
2575
|
assistant: { messageId: `answer-${index}`, role: "assistant", text: `Private saved answer ${index}. ` + "Read the saved answer without losing your place. ".repeat(8) }
|
|
2396
2576
|
}));
|
|
2397
|
-
|
|
2577
|
+
// History pages count real messages: load all 30 turns' questions and answers.
|
|
2578
|
+
await f.command("history", { id: "chat:1", turns, limit: turns.length * 2 });
|
|
2398
2579
|
await f.notify("chat:1");
|
|
2399
2580
|
const body = f.primary.locator(".assistant-transcript__body");
|
|
2400
2581
|
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
|
|
@@ -2500,7 +2681,25 @@ for (const width of [390, 768, 1280]) {
|
|
|
2500
2681
|
await body.hover();
|
|
2501
2682
|
await f.page.mouse.wheel(0, -300);
|
|
2502
2683
|
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
|
|
2503
|
-
|
|
2684
|
+
// The reader looks at question 13. The refresh removes saved turns 7 and 10
|
|
2685
|
+
// and shortens answer 11 above it, so the reader's rows must stay where they
|
|
2686
|
+
// are on screen, whatever scrollTop that takes at this width.
|
|
2687
|
+
const reader = body.locator(".assistant-transcript__turn").filter({ hasText: "Atomic saved question 13." });
|
|
2688
|
+
await reader.evaluate(row => {
|
|
2689
|
+
const scroller = row.closest(".assistant-transcript__body");
|
|
2690
|
+
window.atomicHistoryBody = scroller;
|
|
2691
|
+
scroller.scrollTop += row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
2692
|
+
});
|
|
2693
|
+
// Rows near the new position replace their size estimates on later frames.
|
|
2694
|
+
const readerTop = () => reader.evaluate(async row => {
|
|
2695
|
+
const scroller = row.closest(".assistant-transcript__body");
|
|
2696
|
+
const top = () => row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
2697
|
+
const before = [top(), scroller.scrollTop];
|
|
2698
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
2699
|
+
return before[0] === top() && before[1] === scroller.scrollTop ? Math.round(top()) : NaN;
|
|
2700
|
+
});
|
|
2701
|
+
await expect.poll(readerTop).not.toBeNaN();
|
|
2702
|
+
const readerOffset = await readerTop();
|
|
2504
2703
|
await f.input.fill("Unsent while history refreshes");
|
|
2505
2704
|
await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(3, 8); });
|
|
2506
2705
|
const previousIds = turns.slice(6).map(turn => turn.turnId);
|
|
@@ -2522,7 +2721,7 @@ for (const width of [390, 768, 1280]) {
|
|
|
2522
2721
|
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2523
2722
|
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), previousIds);
|
|
2524
2723
|
assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
|
|
2525
|
-
await expect.poll(() =>
|
|
2724
|
+
await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
|
|
2526
2725
|
await expect(f.input).toHaveValue("Unsent while history refreshes");
|
|
2527
2726
|
await expect(f.input).toBeFocused();
|
|
2528
2727
|
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
|
|
@@ -2533,7 +2732,7 @@ for (const width of [390, 768, 1280]) {
|
|
|
2533
2732
|
await expect(f.primary.getByText("Authoritatively replaced saved answer", { exact: true })).toHaveCount(1);
|
|
2534
2733
|
await expect(f.primary.getByText(/Atomic saved question (7|10)\./)).toHaveCount(0);
|
|
2535
2734
|
assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
|
|
2536
|
-
await expect.poll(() =>
|
|
2735
|
+
await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
|
|
2537
2736
|
await expect(f.input).toHaveValue("Unsent while history refreshes");
|
|
2538
2737
|
await expect(f.input).toBeFocused();
|
|
2539
2738
|
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
|
|
@@ -36,12 +36,18 @@ async function fixture(t, { workspace = false, conversationDataSchema = null, ap
|
|
|
36
36
|
const dispatched = [Promise.withResolvers(), Promise.withResolvers()];
|
|
37
37
|
const scopeInputs = [];
|
|
38
38
|
const knownIds = new Set(["chat:one", "chat:two"]);
|
|
39
|
-
const access = { allowed: true };
|
|
39
|
+
const access = { allowed: true, authorized: [], hold: null };
|
|
40
40
|
const storage = createFileConversationStorage({ directory });
|
|
41
41
|
const runtime = createConversationRuntime({
|
|
42
42
|
storage,
|
|
43
|
-
authorize
|
|
44
|
-
|
|
43
|
+
async authorize({ context: actor, conversationId, operation }) {
|
|
44
|
+
access.authorized.push(operation);
|
|
45
|
+
// A test can hold one operation's authorization, and with it the conversation queue.
|
|
46
|
+
const hold = access.hold?.operation === operation && access.hold;
|
|
47
|
+
if (hold) { access.hold = null; hold.entered.resolve(); await hold.release; }
|
|
48
|
+
return access.allowed && actor?.actor?.id === "owner" && knownIds.has(conversationId) &&
|
|
49
|
+
(!workspace || actor.workspace?.id === trustedWorkspace.id);
|
|
50
|
+
},
|
|
45
51
|
connections: {
|
|
46
52
|
async resolve({ context: actor }) {
|
|
47
53
|
assert.equal(actor.actor.id, "owner");
|
|
@@ -215,9 +221,9 @@ test("file-backed conversation routes return admission receipts and cancel one c
|
|
|
215
221
|
const f = await fixture(t);
|
|
216
222
|
assert.deepEqual(f.catalogue.listDefinitions().map(({ id }) => id), [
|
|
217
223
|
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
|
|
218
|
-
actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
|
|
224
|
+
actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
|
|
219
225
|
]);
|
|
220
|
-
assert.equal(f.routes.length,
|
|
226
|
+
assert.equal(f.routes.length, 7, "A supplied runtime does not register the old chat or optional settings routes");
|
|
221
227
|
const initial = await f.invoke("GET");
|
|
222
228
|
assert.equal(initial.status, 200);
|
|
223
229
|
assert.equal(initial.payload.id, "chat:one");
|
|
@@ -255,11 +261,62 @@ test("file-backed conversation routes return admission receipts and cancel one c
|
|
|
255
261
|
assert.equal((await readdir(f.directory)).length, 2, "Only the host's two conversation records are persisted");
|
|
256
262
|
});
|
|
257
263
|
|
|
264
|
+
test("Cancel of one message stops its own submission and always answers", async t => {
|
|
265
|
+
const f = await fixture(t);
|
|
266
|
+
const first = await f.invoke("POST", "/messages", { body: { messageId: "first", text: "First question" } });
|
|
267
|
+
assert.equal(first.payload.status, "accepted");
|
|
268
|
+
await f.dispatched[0].promise;
|
|
269
|
+
const cancelled = await f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "first" });
|
|
270
|
+
assert.equal(cancelled.status, 200);
|
|
271
|
+
assert.deepEqual(cancelled.payload, { ok: true, cancelled: true, messageId: "first", interrupted: true });
|
|
272
|
+
assert.equal(f.requests[0].signal.aborted, true, "the turn this message started stops");
|
|
273
|
+
const never = await f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "never-sent" });
|
|
274
|
+
assert.deepEqual(never.payload, { ok: true, cancelled: true, messageId: "never-sent", interrupted: false });
|
|
275
|
+
assert.equal((await f.invoke("GET")).payload.status, "ready");
|
|
276
|
+
assert.equal(f.requests.length, 1, "Cancel never submits a model request");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("Cancel answers while earlier work holds the conversation queue and the queued send is refused as not sent", async t => {
|
|
280
|
+
const f = await fixture(t);
|
|
281
|
+
const entered = Promise.withResolvers();
|
|
282
|
+
const release = Promise.withResolvers();
|
|
283
|
+
f.access.hold = { operation: "inspectDelivery", entered, release: release.promise };
|
|
284
|
+
const inspecting = f.invoke("POST", "/deliveries/:messageId/inspect", { messageId: "earlier" });
|
|
285
|
+
inspecting.catch(() => {});
|
|
286
|
+
let sending;
|
|
287
|
+
let cancelling;
|
|
288
|
+
let timer;
|
|
289
|
+
try {
|
|
290
|
+
await entered.promise;
|
|
291
|
+
sending = f.invoke("POST", "/messages", { body: { messageId: "queued", text: "Queued question" } });
|
|
292
|
+
sending.catch(() => {});
|
|
293
|
+
// The send is authorized and waits in the queue behind the held inspection.
|
|
294
|
+
while (!f.access.authorized.includes("send")) await new Promise(resolve => setImmediate(resolve));
|
|
295
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
296
|
+
cancelling = f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "queued" });
|
|
297
|
+
const answer = await Promise.race([cancelling,
|
|
298
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(null), 2_000); })]);
|
|
299
|
+
assert.notEqual(answer, null, "Cancel answered while earlier work held the conversation queue");
|
|
300
|
+
assert.deepEqual(answer.payload, { ok: true, cancelled: true, messageId: "queued", interrupted: false });
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
release.resolve();
|
|
304
|
+
}
|
|
305
|
+
await inspecting.catch(() => {});
|
|
306
|
+
await cancelling;
|
|
307
|
+
await assert.rejects(sending, { code: "conversation_message_cancelled", statusCode: 409 });
|
|
308
|
+
await assert.rejects(f.invoke("POST", "/messages", { body: { messageId: "queued", text: "Queued question" } }),
|
|
309
|
+
{ code: "conversation_message_cancelled" }, "a later send of the cancelled message is refused too");
|
|
310
|
+
assert.equal(f.requests.length, 0, "the cancelled message never reaches the model");
|
|
311
|
+
const next = await f.invoke("POST", "/messages", { body: { messageId: "next", text: "Next question" } });
|
|
312
|
+
assert.equal(next.payload.status, "accepted", "the conversation stays usable");
|
|
313
|
+
});
|
|
314
|
+
|
|
258
315
|
test("conversation routes keep authenticated ownership and reject unknown identities", async t => {
|
|
259
316
|
const f = await fixture(t);
|
|
260
317
|
const other = { ...f.context, actor: { id: "other-user" } };
|
|
261
318
|
for (const [method, suffix] of [["GET", ""], ["POST", "/messages"], ["POST", "/cancel"],
|
|
262
|
-
["POST", "/deliveries/:messageId/inspect"], ["GET", "/goal"], ["POST", "/goal"]]) {
|
|
319
|
+
["POST", "/deliveries/:messageId/inspect"], ["POST", "/deliveries/:messageId/cancel"], ["GET", "/goal"], ["POST", "/goal"]]) {
|
|
263
320
|
await assert.rejects(f.invoke(method, suffix, { actor: other, body: {
|
|
264
321
|
action: "set", expectedSegmentId: "segment", objective: "A goal", messageId: "forged", text: "A forged request", actor: f.context.actor, context: f.context,
|
|
265
322
|
host: { nativeTools: true }, configuration: { systemPrompt: "Override" }, engine: "codex"
|
|
@@ -296,7 +353,7 @@ test("workspace conversation actions use the host's resolved workspace and optio
|
|
|
296
353
|
const options = { ...f.options, assistantConfigService: { getSettings() {}, updateSettings() {} } };
|
|
297
354
|
assert.deepEqual(createAssistantActions(options).map(({ id }) => id), [
|
|
298
355
|
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
|
|
299
|
-
actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
|
|
356
|
+
actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
|
|
300
357
|
actionIds.settingsRead, actionIds.settingsUpdate
|
|
301
358
|
]);
|
|
302
359
|
const router = createRouter();
|
|
@@ -414,10 +471,10 @@ test("the supplied runtime route/action path imports without SQL or the old chat
|
|
|
414
471
|
const { registerRoutes } = await import(${JSON.stringify(new URL("../src/server/registerRoutes.js", import.meta.url).href)});
|
|
415
472
|
const conversationRuntime = { async open() { throw new Error("Registration cannot open conversations."); } };
|
|
416
473
|
const options = { config: ${JSON.stringify(config)}, conversationRuntime };
|
|
417
|
-
assert.equal(createAssistantActions(options).length,
|
|
474
|
+
assert.equal(createAssistantActions(options).length, 8);
|
|
418
475
|
const routes = [];
|
|
419
476
|
registerRoutes({ register(...args) { routes.push(args); } }, options);
|
|
420
|
-
assert.equal(routes.length,
|
|
477
|
+
assert.equal(routes.length, 7);
|
|
421
478
|
`], { encoding: "utf8" });
|
|
422
479
|
assert.equal(result.status, 0, result.stderr);
|
|
423
480
|
});
|
|
@@ -162,6 +162,9 @@ async function fixture(t, { diagnostics = false, onSetup } = {}) {
|
|
|
162
162
|
stopSubscriptions();
|
|
163
163
|
stopProbe();
|
|
164
164
|
await transport.shutdown();
|
|
165
|
+
// Realtime closes only Socket.IO; the fixture owns its HTTP server.
|
|
166
|
+
http.closeAllConnections();
|
|
167
|
+
await new Promise((resolve) => http.close(resolve));
|
|
165
168
|
await runtime.close();
|
|
166
169
|
await rm(directory, { recursive: true, force: true });
|
|
167
170
|
});
|
|
@@ -908,6 +911,8 @@ async function hostSubscriptionFixture(t, authService = null, conversationRuntim
|
|
|
908
911
|
for (const client of clients) client.disconnect();
|
|
909
912
|
release();
|
|
910
913
|
await transport.shutdown();
|
|
914
|
+
http.closeAllConnections();
|
|
915
|
+
await new Promise((resolve) => http.close(resolve));
|
|
911
916
|
});
|
|
912
917
|
http.listen(0, "127.0.0.1");
|
|
913
918
|
await once(http, "listening");
|
|
@@ -73,7 +73,7 @@ async function* completionStream(completion = {}) {
|
|
|
73
73
|
yield { choices: [{ delta: {}, finish_reason: completion.toolCall ? "tool_calls" : "stop" }] };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true } = {}) {
|
|
76
|
+
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime } = {}) {
|
|
77
77
|
const pendingCompletions = [...completions];
|
|
78
78
|
const completionRequests = [];
|
|
79
79
|
const transcriptMessages = [];
|
|
@@ -165,7 +165,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
165
165
|
...createMemoryAssistantRows({ messages: transcriptMessages }),
|
|
166
166
|
aiClientFactory: dependencies.aiClientFactory, toolCatalog: dependencies.serviceToolCatalog, attachments
|
|
167
167
|
});
|
|
168
|
-
const chatService = createChatService({ ...dependencies, conversationRuntime });
|
|
168
|
+
const chatService = createChatService({ ...dependencies, conversationRuntime: wrapRuntime(conversationRuntime) });
|
|
169
169
|
|
|
170
170
|
const streamWriter = {};
|
|
171
171
|
for (const method of [
|
|
@@ -555,3 +555,49 @@ test("attachment identifiers use the conversation runtime's shape before any fil
|
|
|
555
555
|
}
|
|
556
556
|
assert.deepEqual(calls, []);
|
|
557
557
|
});
|
|
558
|
+
|
|
559
|
+
function countDisposals(runtime) {
|
|
560
|
+
const counts = { opened: 0, disposed: 0 };
|
|
561
|
+
return { counts, runtime: { ...runtime, async open(input) {
|
|
562
|
+
const handle = await runtime.open(input);
|
|
563
|
+
counts.opened += 1;
|
|
564
|
+
return { ...handle, async dispose(options) { counts.disposed += 1; return handle.dispose(options); } };
|
|
565
|
+
} } };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
test("each settled turn releases its conversation, and a later turn opens it again", async () => {
|
|
569
|
+
const counted = {};
|
|
570
|
+
const harness = createHarness([textCompletion("First answer."), textCompletion("Second answer.")], {
|
|
571
|
+
wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
assert.equal((await harness.run("First question", { messageId: "message_1" })).status, "completed");
|
|
575
|
+
assert.deepEqual(counted.counts, { opened: 1, disposed: 1 }, "a finished turn keeps no runtime conversation open");
|
|
576
|
+
assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "completed");
|
|
577
|
+
assert.deepEqual(counted.counts, { opened: 2, disposed: 2 });
|
|
578
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["First answer.", "Second answer."]);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("a conversation stays open until the last of its overlapping turns settles", async () => {
|
|
582
|
+
const counted = {};
|
|
583
|
+
const firstChunk = Promise.withResolvers();
|
|
584
|
+
const continueResponse = Promise.withResolvers();
|
|
585
|
+
async function* held() {
|
|
586
|
+
yield { choices: [{ delta: { content: "Held answer." } }] };
|
|
587
|
+
firstChunk.resolve();
|
|
588
|
+
await continueResponse.promise;
|
|
589
|
+
yield { choices: [{ delta: {}, finish_reason: "stop" }] };
|
|
590
|
+
}
|
|
591
|
+
const harness = createHarness([held()], {
|
|
592
|
+
wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const first = harness.run("First question", { messageId: "message_1" });
|
|
596
|
+
await firstChunk.promise;
|
|
597
|
+
// A second tab sends while the first answer is still running; the runtime refuses it.
|
|
598
|
+
assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "failed");
|
|
599
|
+
assert.equal(counted.counts.disposed, 0, "the refused turn must not close the conversation the first turn still uses");
|
|
600
|
+
continueResponse.resolve();
|
|
601
|
+
assert.equal((await first).status, "completed");
|
|
602
|
+
assert.deepEqual(counted.counts, { opened: 2, disposed: 1 });
|
|
603
|
+
});
|
|
@@ -229,11 +229,11 @@ test("one AssistantFeature composes host access and declared data while settings
|
|
|
229
229
|
await host.start();
|
|
230
230
|
try {
|
|
231
231
|
assert.equal(registrations, 1);
|
|
232
|
-
assert.equal(wrapped.length,
|
|
232
|
+
assert.equal(wrapped.length, 11);
|
|
233
233
|
assert.ok(wrapped.includes(access.subscribeActionId));
|
|
234
234
|
assert.ok(!wrapped.includes(actionIds.conversationSubscribe));
|
|
235
|
-
assert.equal(actions.listDefinitions().length,
|
|
236
|
-
assert.equal(routes.length,
|
|
235
|
+
assert.equal(actions.listDefinitions().length, 13);
|
|
236
|
+
assert.equal(routes.length, 12);
|
|
237
237
|
assert.ok(routes.filter(route => route.path.endsWith("/settings")).every(route => route.contract.auth === "required"));
|
|
238
238
|
assert.ok(routes.filter(route => !route.path.endsWith("/settings")).every(route => route.contract.auth === "public"));
|
|
239
239
|
assert.equal(actions.getDefinition(actionIds.settingsRead).permission.require, "authenticated");
|