@jskit-ai/assistant-runtime 0.1.206 → 0.1.207
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/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +45 -20
- package/src/client/support/subscribeAssistantConversation.js +5 -4
- package/src/server/services/chatService.js +6 -2
- package/test/assistantConversationBinding.browser.test.js +413 -0
- package/test/assistantConversationSubscriptions.test.js +114 -2
- package/test/sqlConversationStorage.test.js +219 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.207",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -12,19 +12,19 @@
|
|
|
12
12
|
"./server": "./src/server/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
15
|
+
"@jskit-ai/assistant-core": "0.1.214",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/realtime": "0.1.
|
|
23
|
-
"@jskit-ai/shell-web": "0.1.
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.234",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.81",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.236",
|
|
22
|
+
"@jskit-ai/realtime": "0.1.233",
|
|
23
|
+
"@jskit-ai/shell-web": "0.1.240",
|
|
24
24
|
"@tanstack/vue-query": "^5.90.5",
|
|
25
25
|
"vue": "^3.5.13",
|
|
26
26
|
"vuetify": "^4.0.0",
|
|
27
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
27
|
+
"@jskit-ai/database-runtime": "0.1.236"
|
|
28
28
|
},
|
|
29
29
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
30
30
|
"jskit": {
|
|
@@ -47,7 +47,19 @@ function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
|
|
|
47
47
|
return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function
|
|
50
|
+
function resolveWorkingPolicy(snapshot, { deferWhileWorking, admitWhileWorking }) {
|
|
51
|
+
const resolve = (name, option) => {
|
|
52
|
+
const value = typeof option === "function" ? option(snapshot) : option;
|
|
53
|
+
if (typeof value !== "boolean") throw new TypeError(`${name} must return a boolean.`);
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
const deferred = resolve("deferWhileWorking", deferWhileWorking);
|
|
57
|
+
const admitted = resolve("admitWhileWorking", admitWhileWorking);
|
|
58
|
+
if (deferred && admitted) throw new TypeError("Choose either admitWhileWorking or deferWhileWorking.");
|
|
59
|
+
return { deferWhileWorking: deferred, admitWhileWorking: admitted };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled }) {
|
|
51
63
|
const disposed = ref(false);
|
|
52
64
|
const placementRevision = ref(0);
|
|
53
65
|
const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
|
|
@@ -73,7 +85,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
73
85
|
const loading = ref(true);
|
|
74
86
|
const stopping = ref(false);
|
|
75
87
|
const goalPending = ref(false);
|
|
76
|
-
const
|
|
88
|
+
const goalTargetCurrent = ref(false);
|
|
77
89
|
const goalError = ref("");
|
|
78
90
|
const goalView = shallowRef(null);
|
|
79
91
|
const goalLoadError = ref("");
|
|
@@ -101,8 +113,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
101
113
|
const editable = computed(() => active.value && !accessDenied.value);
|
|
102
114
|
const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
|
|
103
115
|
const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
|
|
104
|
-
const
|
|
105
|
-
const
|
|
116
|
+
const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
|
|
117
|
+
const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
|
|
118
|
+
const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
|
|
106
119
|
(queueWhileSending !== false || !delivery.state.sending) &&
|
|
107
120
|
!delivery.state.messages.some(message => message.status === "uncertain"));
|
|
108
121
|
const savedDraft = toValue(draftStorage);
|
|
@@ -206,6 +219,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
206
219
|
if (state.segmentId !== goalSegment) {
|
|
207
220
|
// Keep the open goal menu while its first native identity arrives. The
|
|
208
221
|
// pending read disables commands until it supplies the new exact target.
|
|
222
|
+
goalTargetCurrent.value = false;
|
|
209
223
|
goalReadController?.abort();
|
|
210
224
|
}
|
|
211
225
|
if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
|
|
@@ -228,6 +242,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
228
242
|
delivery.reset();
|
|
229
243
|
goalError.value = "";
|
|
230
244
|
goalView.value = null;
|
|
245
|
+
goalTargetCurrent.value = false;
|
|
231
246
|
goalLoadError.value = "";
|
|
232
247
|
goalReadController?.abort();
|
|
233
248
|
}
|
|
@@ -339,8 +354,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
339
354
|
const requestedSteering = request?.steer === true;
|
|
340
355
|
if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
|
|
341
356
|
const steering = requestedSteering || steerable.value;
|
|
342
|
-
const
|
|
343
|
-
|
|
357
|
+
const policy = workingPolicy.value;
|
|
358
|
+
const deferred = policy.deferWhileWorking && !retry && !steering;
|
|
359
|
+
// An application queue can admit ordinary messages without native steering
|
|
360
|
+
// or waiting for ready. It still owns admission, permissions and receipts.
|
|
361
|
+
const appAdmission = policy.admitWhileWorking && !steering;
|
|
362
|
+
if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
|
|
344
363
|
if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
|
|
345
364
|
const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request || {
|
|
346
365
|
text: String(payload.message || ""),
|
|
@@ -348,7 +367,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
348
367
|
...(payload.displayAttachments?.length ? { attachmentIds: payload.displayAttachments.map(file => file.attachmentId) } : {}),
|
|
349
368
|
...(steerable.value ? { steer: true } : {})
|
|
350
369
|
} };
|
|
351
|
-
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred), deferred });
|
|
370
|
+
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
|
|
352
371
|
}
|
|
353
372
|
function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
|
|
354
373
|
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
@@ -406,11 +425,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
406
425
|
});
|
|
407
426
|
}
|
|
408
427
|
|
|
409
|
-
async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false }) {
|
|
428
|
+
async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
|
|
410
429
|
if (pendingMessages.has(messageId)) return false;
|
|
411
430
|
// Register before the original serial tail so Stop/retirement also reaches
|
|
412
431
|
// a local follower whose deliver callback has not started yet.
|
|
413
|
-
const pending = { controller: new AbortController(), started: false, dispatched: false, deferred };
|
|
432
|
+
const pending = { controller: new AbortController(), started: false, dispatched: false, deferred, appAdmission };
|
|
414
433
|
const { controller } = pending;
|
|
415
434
|
pendingMessages.set(messageId, pending);
|
|
416
435
|
try {
|
|
@@ -420,7 +439,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
420
439
|
async deliver(submission) {
|
|
421
440
|
pending.started = true;
|
|
422
441
|
try {
|
|
423
|
-
if (controller.signal.aborted) return false;
|
|
442
|
+
if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
|
|
424
443
|
if (deferred) {
|
|
425
444
|
// Refresh through the same subscription after a queued predecessor;
|
|
426
445
|
// its receipt may have arrived before the working snapshot.
|
|
@@ -447,7 +466,10 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
447
466
|
if (controller.signal.aborted) return false;
|
|
448
467
|
if (prepare && !pending.dispatched) return { ok: false, error: failure.message,
|
|
449
468
|
...(failure.code ? { code: failure.code } : {}) };
|
|
450
|
-
|
|
469
|
+
const rejection = failure.details?.delivery;
|
|
470
|
+
const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
|
|
471
|
+
rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
|
|
472
|
+
if (notSent || SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
|
|
451
473
|
...(prepare && failure.code ? { code: failure.code } : {}) };
|
|
452
474
|
throw failure;
|
|
453
475
|
} finally {
|
|
@@ -519,7 +541,6 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
519
541
|
const capabilities = snapshot.value.capabilities;
|
|
520
542
|
const controller = new AbortController();
|
|
521
543
|
goalReadController = controller;
|
|
522
|
-
goalLoading.value = true;
|
|
523
544
|
const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
|
|
524
545
|
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
|
525
546
|
})).then(result => {
|
|
@@ -529,6 +550,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
529
550
|
goalView.value = result && Object.hasOwn(result, "target") ? result : {
|
|
530
551
|
status: "available", goal: result, target: { segmentId: segment, capabilities }
|
|
531
552
|
};
|
|
553
|
+
// A background read does not invalidate this observation. Its supplied
|
|
554
|
+
// goal target may intentionally differ from the visible chat segment.
|
|
555
|
+
goalTargetCurrent.value = true;
|
|
532
556
|
goalLoadError.value = "";
|
|
533
557
|
return true;
|
|
534
558
|
}).catch(failure => {
|
|
@@ -540,7 +564,6 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
540
564
|
if (goalRead !== job) return;
|
|
541
565
|
goalRead = null;
|
|
542
566
|
goalReadController = null;
|
|
543
|
-
goalLoading.value = false;
|
|
544
567
|
if (goalReadQueued) {
|
|
545
568
|
goalReadQueued = false;
|
|
546
569
|
void refreshGoal();
|
|
@@ -558,7 +581,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
558
581
|
const view = goalView.value;
|
|
559
582
|
const target = view?.target;
|
|
560
583
|
const command = target?.capabilities?.goalCommands?.[action];
|
|
561
|
-
if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value ||
|
|
584
|
+
if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value || !goalTargetCurrent.value ||
|
|
562
585
|
!["message", "control"].includes(command?.delivery) ||
|
|
563
586
|
delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
564
587
|
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
@@ -613,7 +636,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
613
636
|
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
614
637
|
goal: view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
615
638
|
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
616
|
-
pending: goalPending.value ||
|
|
639
|
+
pending: goalPending.value || !goalTargetCurrent.value || !available.value || delivery.state.sending ||
|
|
617
640
|
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
618
641
|
error: goalError.value,
|
|
619
642
|
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
@@ -626,7 +649,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
626
649
|
if (!editable.value || stopping.value) return false;
|
|
627
650
|
stopping.value = true;
|
|
628
651
|
for (const pending of pendingMessages.values()) {
|
|
629
|
-
if (pending.deferred && !pending.dispatched) pending.controller.abort();
|
|
652
|
+
if ((pending.deferred || pending.appAdmission) && !pending.dispatched) pending.controller.abort();
|
|
630
653
|
}
|
|
631
654
|
try { return await api.cancelConversation(identity.conversationId); }
|
|
632
655
|
catch (failure) { receiveError(failure); return false; }
|
|
@@ -653,7 +676,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
653
676
|
error, accessDenied, loading, stopping, delivery, turns,
|
|
654
677
|
hasMoreBefore, loadingMore, loadMoreError, loadMore,
|
|
655
678
|
send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
|
|
656
|
-
reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
|
|
679
|
+
reload(options) { const job = subscription?.reload(options); void refreshGoal(); return job; } };
|
|
657
680
|
runtime.application = application ? application(runtime) : null;
|
|
658
681
|
return runtime;
|
|
659
682
|
}
|
|
@@ -724,7 +747,7 @@ function conversationBindingSetup({ socket, boundedTask = null } = {}) {
|
|
|
724
747
|
|
|
725
748
|
function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
|
|
726
749
|
actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
|
|
727
|
-
clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, draftWhileLoading = false,
|
|
750
|
+
clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, admitWhileWorking = false, draftWhileLoading = false,
|
|
728
751
|
draftStorage = null, application = null, boundedTask = null,
|
|
729
752
|
data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
|
|
730
753
|
const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
|
|
@@ -736,7 +759,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
736
759
|
});
|
|
737
760
|
if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
|
|
738
761
|
if (queueWhileSending !== undefined && typeof queueWhileSending !== "boolean") throw new TypeError("queueWhileSending must be a boolean.");
|
|
739
|
-
if (typeof deferWhileWorking
|
|
762
|
+
if (!["boolean", "function"].includes(typeof deferWhileWorking)) throw new TypeError("deferWhileWorking must be a boolean.");
|
|
763
|
+
if (!["boolean", "function"].includes(typeof admitWhileWorking)) throw new TypeError("admitWhileWorking must be a boolean.");
|
|
764
|
+
resolveWorkingPolicy(null, { deferWhileWorking, admitWhileWorking });
|
|
740
765
|
if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
|
|
741
766
|
if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
|
|
742
767
|
if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
|
|
@@ -794,7 +819,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
794
819
|
const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
|
|
795
820
|
target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
|
|
796
821
|
retained = retainAssistantConversation(app, key, readers => createConversation(target, {
|
|
797
|
-
readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
|
|
822
|
+
readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
|
|
798
823
|
api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
|
|
799
824
|
resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
|
|
800
825
|
}), { active, questions, goal, onEvent });
|
|
@@ -65,9 +65,10 @@ function subscribeAssistantConversation({
|
|
|
65
65
|
function receiveState(snapshot, options) {
|
|
66
66
|
state = snapshot;
|
|
67
67
|
receiveStreaming(snapshot.streaming);
|
|
68
|
-
publishState({
|
|
68
|
+
publishState({ canonical: true, ...options });
|
|
69
69
|
}
|
|
70
|
-
function reload() {
|
|
70
|
+
function reload({ resubscribe = false } = {}) {
|
|
71
|
+
if (resubscribe) return subscribe({ preserveHistory: true });
|
|
71
72
|
if (!listening) return subscribe();
|
|
72
73
|
if (reloadInFlight) {
|
|
73
74
|
reloadQueued = true;
|
|
@@ -179,7 +180,7 @@ function subscribeAssistantConversation({
|
|
|
179
180
|
if (epoch === null) pending.push(payload);
|
|
180
181
|
else deliver(payload);
|
|
181
182
|
}
|
|
182
|
-
function subscribe() {
|
|
183
|
+
function subscribe({ preserveHistory = false } = {}) {
|
|
183
184
|
if (disposed || !socket.connected) return;
|
|
184
185
|
const attempt = ++generation;
|
|
185
186
|
epoch = null;
|
|
@@ -206,7 +207,7 @@ function subscribeAssistantConversation({
|
|
|
206
207
|
return;
|
|
207
208
|
}
|
|
208
209
|
epoch = response.streamEpoch;
|
|
209
|
-
receiveState(response.state, { initial:
|
|
210
|
+
receiveState(response.state, { initial: !preserveHistory, canonical: !preserveHistory });
|
|
210
211
|
for (const payload of pending.sort((left, right) => left.streamRevision - right.streamRevision)) deliver(payload);
|
|
211
212
|
pending = [];
|
|
212
213
|
});
|
|
@@ -218,7 +218,9 @@ function createChatService({
|
|
|
218
218
|
writer.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: request.messageId, status: "unconfirmed" });
|
|
219
219
|
return { conversationId: response.meta?.conversationId, messageId: request.messageId, status: "unconfirmed" };
|
|
220
220
|
}
|
|
221
|
-
if (response.failure && !response.meta) throw new AppError(response.failure.status, response.failure.message
|
|
221
|
+
if (response.failure && !response.meta) throw new AppError(response.failure.status, response.failure.message, {
|
|
222
|
+
code: response.failure.code, details: response.failure.details
|
|
223
|
+
});
|
|
222
224
|
if (response.meta) writer.sendMeta(response.meta);
|
|
223
225
|
if (response.answer) writer.sendAssistantMessage(response.answer);
|
|
224
226
|
if (response.error) writer.sendError(response.error);
|
|
@@ -268,7 +270,9 @@ function createChatService({
|
|
|
268
270
|
result = await executeTurn();
|
|
269
271
|
} catch (error) {
|
|
270
272
|
response.failure = { message: String(error?.message || "Assistant request failed."),
|
|
271
|
-
status: Number(error?.status || error?.statusCode || 500)
|
|
273
|
+
status: Number(error?.status || error?.statusCode || 500),
|
|
274
|
+
...(error instanceof AppError ? { code: error.code,
|
|
275
|
+
...(error.details && String(error.code).trim() !== "ACTION_PERMISSION_DENIED" ? { details: error.details } : {}) } : {}) };
|
|
272
276
|
await turnRequests.update(claim, response, "failed");
|
|
273
277
|
throw error;
|
|
274
278
|
}
|
|
@@ -2479,3 +2479,416 @@ test("canonical loaded-history replay retains older completed output and its aut
|
|
|
2479
2479
|
assert.equal(display.assistant.text, "Live older completed reply");
|
|
2480
2480
|
assert.deepEqual(await f.sent(), []);
|
|
2481
2481
|
});
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
test("an exact host pre-admission receipt preserves one manual retry and actor and draft guards", options, async t => {
|
|
2485
|
+
const f = await fixture(t);
|
|
2486
|
+
await f.command("mode", { mode: "rejected" });
|
|
2487
|
+
await f.page.route("**/messages", async route => {
|
|
2488
|
+
const response = await route.fetch();
|
|
2489
|
+
const input = route.request().postDataJSON();
|
|
2490
|
+
await route.fulfill({ response, status: 409, json: { code: "host_capture_rejected",
|
|
2491
|
+
error: "The host refused this message before native admission.",
|
|
2492
|
+
details: { delivery: { status: "not-sent", messageId: input.messageId } } } });
|
|
2493
|
+
});
|
|
2494
|
+
await f.page.evaluate(() => window.conversationFixture.data({ originId: "captured-tab", choice: "original" }));
|
|
2495
|
+
await f.input.fill("Retain this exact original request");
|
|
2496
|
+
await f.input.press("Enter");
|
|
2497
|
+
await expect(f.primary.getByText("Failed: The host refused this message before native admission.", { exact: true })).toBeVisible();
|
|
2498
|
+
const [original] = await f.sent();
|
|
2499
|
+
assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, original.input.messageId), "failed");
|
|
2500
|
+
await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
|
|
2501
|
+
await f.input.fill("Keep my newer draft");
|
|
2502
|
+
await f.page.evaluate(() => window.conversationFixture.data({ originId: "newer-tab", choice: "newer" }));
|
|
2503
|
+
assert.equal((await f.sent()).length, 1, "a rejection never retries automatically");
|
|
2504
|
+
await f.page.unroute("**/messages");
|
|
2505
|
+
await f.command("mode", { mode: "accepted" });
|
|
2506
|
+
await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
|
|
2507
|
+
await expect.poll(async () => (await f.sent()).length).toBe(2);
|
|
2508
|
+
const requests = await f.sent();
|
|
2509
|
+
assert.deepEqual(requests[1].input, original.input, "manual Retry retains the ID, words and captured selection/data");
|
|
2510
|
+
await expect(f.input).toHaveValue("Keep my newer draft");
|
|
2511
|
+
await f.page.evaluate(() => {
|
|
2512
|
+
window.rejectedActorRuntime = window.conversationFixture.current();
|
|
2513
|
+
window.conversationFixture.actor("another-actor");
|
|
2514
|
+
});
|
|
2515
|
+
assert.equal(await f.page.evaluate(() => window.rejectedActorRuntime.send({ message: "Must not dispatch" },
|
|
2516
|
+
{ messageId: "foreign-actor" })), false);
|
|
2517
|
+
assert.equal((await f.sent()).length, 2, "receipt metadata grants no authority to a retired actor");
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
for (const kind of ["mismatched-id", "malformed-status", "missing-marker", "post-routing-failure"]) {
|
|
2521
|
+
test(`unconfirmed host delivery metadata remains inspection-only: ${kind}`, options, async t => {
|
|
2522
|
+
const f = await fixture(t);
|
|
2523
|
+
await f.command("mode", { mode: "rejected" });
|
|
2524
|
+
await f.page.route("**/messages", async route => {
|
|
2525
|
+
const response = await route.fetch();
|
|
2526
|
+
const input = route.request().postDataJSON();
|
|
2527
|
+
await route.fulfill({ response, status: kind === "post-routing-failure" ? 500 : 409,
|
|
2528
|
+
json: { code: "host_dispatch_failed", error: "Delivery cannot be confirmed.", details: { delivery: kind === "missing-marker" ? null : {
|
|
2529
|
+
status: kind === "malformed-status" ? "rejected" : "not-sent",
|
|
2530
|
+
messageId: kind === "mismatched-id" ? "another-message" : input.messageId
|
|
2531
|
+
} } } });
|
|
2532
|
+
});
|
|
2533
|
+
await f.input.fill("Do not resubmit unknown work");
|
|
2534
|
+
await f.input.press("Enter");
|
|
2535
|
+
const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
|
|
2536
|
+
await expect(check).toBeVisible();
|
|
2537
|
+
const [original] = await f.sent();
|
|
2538
|
+
await check.click();
|
|
2539
|
+
await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).checking,
|
|
2540
|
+
original.input.messageId)).toBe(false);
|
|
2541
|
+
assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status,
|
|
2542
|
+
original.input.messageId), "uncertain");
|
|
2543
|
+
await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
|
2544
|
+
assert.equal((await f.sent()).length, 1, "missing native/canonical history never proves not-sent or resubmits");
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
for (const width of [390, 768, 1280]) {
|
|
2549
|
+
test(`application admission accepts typed and spoken follow-ups during nonsteerable work at ${width}px`, options, async t => {
|
|
2550
|
+
const f = await fixture(t, width);
|
|
2551
|
+
await f.page.evaluate(() => {
|
|
2552
|
+
window.admissionBinding = window.conversationFixture.acquireConversation({
|
|
2553
|
+
conversationId: "chat:2", admitWhileWorking: true, queueWhileSending: true
|
|
2554
|
+
});
|
|
2555
|
+
window.conversationFixture.target("chat:2");
|
|
2556
|
+
});
|
|
2557
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
|
|
2558
|
+
await f.input.fill("Initial request remains working");
|
|
2559
|
+
await f.input.press("Enter");
|
|
2560
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
|
|
2561
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().steerable.value), false);
|
|
2562
|
+
await f.command("mode", { mode: "hold" });
|
|
2563
|
+
await f.page.evaluate(() => window.conversationFixture.data({ clientId: "typed", focus: { project: "captured" } }));
|
|
2564
|
+
await f.input.fill("Typed clarification while answering");
|
|
2565
|
+
await f.input.press("Enter");
|
|
2566
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
2567
|
+
const typed = (await f.sent())[1].input;
|
|
2568
|
+
await f.page.evaluate(() => {
|
|
2569
|
+
window.conversationFixture.retainVoice();
|
|
2570
|
+
window.admittedSpeech = window.conversationFixture.sendVoice("Spoken clarification while answering", {
|
|
2571
|
+
clientId: "voice", focus: { project: "voice-captured" }
|
|
2572
|
+
});
|
|
2573
|
+
window.conversationFixture.changeFocus({ project: "later" });
|
|
2574
|
+
});
|
|
2575
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(2);
|
|
2576
|
+
const spoken = (await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages))[1];
|
|
2577
|
+
assert.equal((await f.sent()).length, 2, "the voice follower waits for the preceding admission receipt");
|
|
2578
|
+
await f.input.fill("Keep this newer draft");
|
|
2579
|
+
await f.command("mode", { mode: "accepted" });
|
|
2580
|
+
await f.command("accept", { holdResponse: true });
|
|
2581
|
+
await f.notify("chat:2");
|
|
2582
|
+
await expect.poll(async () => (await f.sent()).length).toBe(3);
|
|
2583
|
+
assert.deepEqual(typed, {
|
|
2584
|
+
messageId: typed.messageId, text: "Typed clarification while answering",
|
|
2585
|
+
data: { clientId: "typed", focus: { project: "captured" } }
|
|
2586
|
+
});
|
|
2587
|
+
assert.deepEqual((await f.sent())[2].input, {
|
|
2588
|
+
messageId: spoken.id, text: spoken.text, data: { clientId: "voice", focus: { project: "voice-captured" } }
|
|
2589
|
+
});
|
|
2590
|
+
assert.notEqual(typed.messageId, spoken.id);
|
|
2591
|
+
assert.equal((await f.page.evaluate(() => window.admittedSpeech)).status, "accepted");
|
|
2592
|
+
assert.equal((await f.state()).held, 1, "a real canonical receipt releases the queue before the old HTTP response");
|
|
2593
|
+
await f.command("release-response");
|
|
2594
|
+
await expect(f.input).toHaveValue("Keep this newer draft");
|
|
2595
|
+
assert.equal((await f.state()).states["chat:2"].status, "working", "no ready transition is needed for app admission");
|
|
2596
|
+
assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false);
|
|
2597
|
+
assert.equal((await f.sent()).some(request => Object.hasOwn(request.input, "steer")), false);
|
|
2598
|
+
const layout = await f.primary.evaluate(element => {
|
|
2599
|
+
const rect = element.getBoundingClientRect();
|
|
2600
|
+
return { right: rect.right, width: innerWidth, overflow: document.documentElement.scrollWidth > innerWidth };
|
|
2601
|
+
});
|
|
2602
|
+
assert.ok(layout.right <= layout.width + 1);
|
|
2603
|
+
assert.equal(layout.overflow, false);
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
for (const action of ["stop", "actor", "denied"]) {
|
|
2608
|
+
test(`application admission retains local-follower fences after ${action}`, options, async t => {
|
|
2609
|
+
const f = await fixture(t);
|
|
2610
|
+
await f.page.evaluate(() => {
|
|
2611
|
+
window.admissionBinding = window.conversationFixture.acquireConversation({
|
|
2612
|
+
conversationId: "chat:2", admitWhileWorking: true, queueWhileSending: true
|
|
2613
|
+
});
|
|
2614
|
+
window.conversationFixture.target("chat:2");
|
|
2615
|
+
});
|
|
2616
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
|
|
2617
|
+
await f.command("mode", { mode: "hold" });
|
|
2618
|
+
await f.page.evaluate(() => {
|
|
2619
|
+
window.firstAdmission = window.admissionBinding.runtime.value.send({ message: "First held admission" }, { messageId: "first-app-held" });
|
|
2620
|
+
});
|
|
2621
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
2622
|
+
await f.page.evaluate(() => {
|
|
2623
|
+
window.localAdmission = window.admissionBinding.runtime.value.send({ message: "Undispatched follower" }, { messageId: "local-app-follower" });
|
|
2624
|
+
});
|
|
2625
|
+
assert.equal((await f.sent()).length, 1);
|
|
2626
|
+
if (action === "stop") await f.page.evaluate(() => window.admissionBinding.runtime.value.cancel());
|
|
2627
|
+
else if (action === "actor") await f.page.evaluate(() => window.conversationFixture.actor("different-actor"));
|
|
2628
|
+
else {
|
|
2629
|
+
await f.command("deny", { denied: true });
|
|
2630
|
+
await f.notify("chat:2");
|
|
2631
|
+
await expect.poll(() => f.page.evaluate(() => window.admissionBinding.runtime.value.available.value)).toBe(false);
|
|
2632
|
+
}
|
|
2633
|
+
await f.command("accept");
|
|
2634
|
+
assert.equal(await f.page.evaluate(() => window.localAdmission), false);
|
|
2635
|
+
assert.equal((await f.sent()).length, 1, "local followers never dispatch after their scope is stopped or retired");
|
|
2636
|
+
assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, action === "stop" ? 1 : 0);
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
test("application admission keeps unknown delivery inspection-only and rejects conflicting policies", options, async t => {
|
|
2641
|
+
const f = await fixture(t);
|
|
2642
|
+
const invalid = await f.page.evaluate(() => [
|
|
2643
|
+
{ admitWhileWorking: "yes" }, { admitWhileWorking: true, deferWhileWorking: true }
|
|
2644
|
+
].map(options => {
|
|
2645
|
+
try { window.conversationFixture.acquireConversation({ conversationId: "chat:2", ...options }); }
|
|
2646
|
+
catch (failure) { return { name: failure.name, message: failure.message }; }
|
|
2647
|
+
return null;
|
|
2648
|
+
}));
|
|
2649
|
+
assert.deepEqual(invalid, [
|
|
2650
|
+
{ name: "TypeError", message: "admitWhileWorking must be a boolean." },
|
|
2651
|
+
{ name: "TypeError", message: "Choose either admitWhileWorking or deferWhileWorking." }
|
|
2652
|
+
]);
|
|
2653
|
+
await f.page.evaluate(() => {
|
|
2654
|
+
window.admissionBinding = window.conversationFixture.acquireConversation({
|
|
2655
|
+
conversationId: "chat:2", admitWhileWorking: true, queueWhileSending: true
|
|
2656
|
+
});
|
|
2657
|
+
window.conversationFixture.target("chat:2");
|
|
2658
|
+
});
|
|
2659
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
|
|
2660
|
+
await f.command("question", { id: "chat:2", text: "Still working", pending: true });
|
|
2661
|
+
await f.notify("chat:2");
|
|
2662
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
|
|
2663
|
+
await f.command("mode", { mode: "uncertain" });
|
|
2664
|
+
await f.input.fill("Do not repeat unknown work");
|
|
2665
|
+
await f.input.press("Enter");
|
|
2666
|
+
const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
|
|
2667
|
+
await expect(check).toBeVisible();
|
|
2668
|
+
const [original] = await f.sent();
|
|
2669
|
+
await f.input.fill("Keep my newer draft");
|
|
2670
|
+
assert.equal(await f.page.evaluate(() => window.admissionBinding.runtime.value.send({ message: "Cannot bypass uncertainty" })), false);
|
|
2671
|
+
await check.click();
|
|
2672
|
+
await expect.poll(() => f.page.evaluate(id => window.admissionBinding.runtime.value.delivery.find(id).checking,
|
|
2673
|
+
original.input.messageId)).toBe(false);
|
|
2674
|
+
assert.equal((await f.sent()).length, 1);
|
|
2675
|
+
await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
|
2676
|
+
await expect(f.input).toHaveValue("Keep my newer draft");
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
// Keep the original HTTP/browser fixture; its canonical read carries the real
|
|
2680
|
+
// engine independently of a native-looking product model-selection label.
|
|
2681
|
+
async function modeAwareAdmissionFixture(t) {
|
|
2682
|
+
const f = await fixture(t, 390);
|
|
2683
|
+
let engine;
|
|
2684
|
+
await f.page.route(/\/api\/assistant\/home\/conversations\/chat%3A2(?:\?.*)?$/u, async route => {
|
|
2685
|
+
const response = await route.fetch();
|
|
2686
|
+
const state = await response.json();
|
|
2687
|
+
await route.fulfill({ response, json: { ...state,
|
|
2688
|
+
engine, assistantSelection: { engineId: "codex" } } });
|
|
2689
|
+
});
|
|
2690
|
+
await f.page.evaluate(() => {
|
|
2691
|
+
const native = snapshot => ["codex", "claude", "opencode"].includes(snapshot?.engine);
|
|
2692
|
+
window.modeBinding = window.conversationFixture.acquireConversation({ conversationId: "chat:2",
|
|
2693
|
+
admitWhileWorking: native, deferWhileWorking: snapshot => !native(snapshot), queueWhileSending: true });
|
|
2694
|
+
window.conversationFixture.target("chat:2");
|
|
2695
|
+
window.modeRuntime = window.modeBinding.runtime.value;
|
|
2696
|
+
});
|
|
2697
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
|
|
2698
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.available.value)).toBe(true);
|
|
2699
|
+
return { ...f, async engine(value) {
|
|
2700
|
+
engine = value;
|
|
2701
|
+
await f.notify("chat:2");
|
|
2702
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value?.engine || null)).toBe(value || null);
|
|
2703
|
+
} };
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
test("mode-aware application admission retains typed and spoken native and API policies at one identity", options, async t => {
|
|
2707
|
+
const f = await modeAwareAdmissionFixture(t);
|
|
2708
|
+
await f.command("question", { id: "chat:2", text: "Unresolved backend is working", pending: true });
|
|
2709
|
+
await f.notify("chat:2");
|
|
2710
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
|
|
2711
|
+
await f.page.evaluate(() => { window.unresolvedAdmission = window.modeRuntime.send({ message: "Wait for original readiness" }); });
|
|
2712
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(1);
|
|
2713
|
+
assert.equal((await f.sent()).length, 0, "An unknown backend retains existing deferral");
|
|
2714
|
+
await f.engine("codex");
|
|
2715
|
+
assert.equal((await f.sent()).length, 0, "A captured deferred request is never promoted to native admission");
|
|
2716
|
+
await f.command("question", { id: "chat:2", text: "Unresolved backend is working", pending: false });
|
|
2717
|
+
await f.notify("chat:2");
|
|
2718
|
+
await expect.poll(async () => (await f.sent()).length).toBe(1);
|
|
2719
|
+
assert.equal((await f.page.evaluate(() => window.unresolvedAdmission)).status, "accepted");
|
|
2720
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
|
|
2721
|
+
await f.command("mode", { mode: "hold" });
|
|
2722
|
+
await f.input.fill("Native typed clarification");
|
|
2723
|
+
await f.input.press("Enter");
|
|
2724
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
2725
|
+
await f.page.evaluate(() => {
|
|
2726
|
+
window.conversationFixture.retainVoice();
|
|
2727
|
+
window.nativeVoiceAdmission = window.conversationFixture.sendVoice("Native spoken clarification");
|
|
2728
|
+
});
|
|
2729
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(2);
|
|
2730
|
+
assert.equal((await f.sent()).length, 2, "Voice waits for the same real predecessor receipt");
|
|
2731
|
+
await f.input.fill("Retain my draft across backend selection");
|
|
2732
|
+
await f.command("mode", { mode: "accepted" });
|
|
2733
|
+
await f.command("accept", { holdResponse: true });
|
|
2734
|
+
await f.notify("chat:2");
|
|
2735
|
+
await expect.poll(async () => (await f.sent()).length).toBe(3);
|
|
2736
|
+
assert.equal((await f.page.evaluate(() => window.nativeVoiceAdmission)).status, "accepted");
|
|
2737
|
+
assert.equal((await f.state()).states["chat:2"].status, "working");
|
|
2738
|
+
await f.command("release-response");
|
|
2739
|
+
await f.command("finish", { id: "chat:2" });
|
|
2740
|
+
await f.engine("api");
|
|
2741
|
+
await expect(f.input).toHaveValue("Retain my draft across backend selection");
|
|
2742
|
+
await f.command("question", { id: "chat:2", text: "API is still answering", pending: true });
|
|
2743
|
+
await f.notify("chat:2");
|
|
2744
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
|
|
2745
|
+
await f.input.fill("API typed clarification");
|
|
2746
|
+
await f.input.press("Enter");
|
|
2747
|
+
await f.page.evaluate(() => { window.apiVoiceAdmission = window.conversationFixture.sendVoice("API spoken clarification"); });
|
|
2748
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(2);
|
|
2749
|
+
assert.equal((await f.sent()).length, 3, "A native-looking label cannot bypass the real API mode's ready wait");
|
|
2750
|
+
await f.command("question", { id: "chat:2", text: "API is still answering", pending: false });
|
|
2751
|
+
await f.notify("chat:2");
|
|
2752
|
+
await expect.poll(async () => (await f.sent()).length).toBe(4);
|
|
2753
|
+
assert.equal((await f.sent())[3].input.text, "API typed clarification");
|
|
2754
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
|
|
2755
|
+
assert.equal((await f.sent()).length, 4, "The second API follower retains the preceding-turn barrier");
|
|
2756
|
+
await f.command("finish", { id: "chat:2" });
|
|
2757
|
+
await f.notify("chat:2");
|
|
2758
|
+
await expect.poll(async () => (await f.sent()).length).toBe(5);
|
|
2759
|
+
assert.equal((await f.page.evaluate(() => window.apiVoiceAdmission)).status, "accepted");
|
|
2760
|
+
await f.command("finish", { id: "chat:2" });
|
|
2761
|
+
await f.engine("opencode");
|
|
2762
|
+
await f.command("question", { id: "chat:2", text: "Native work resumes", pending: true });
|
|
2763
|
+
await f.notify("chat:2");
|
|
2764
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
|
|
2765
|
+
assert.equal((await f.page.evaluate(() => window.conversationFixture.sendVoice("Native admission after returning"))).status, "accepted");
|
|
2766
|
+
assert.equal((await f.sent()).length, 6);
|
|
2767
|
+
assert.equal(await f.page.evaluate(() => window.modeRuntime === window.modeBinding.runtime.value &&
|
|
2768
|
+
window.modeRuntime === window.conversationFixture.current()), true);
|
|
2769
|
+
assert.equal((await f.page.evaluate(() => window.conversationFixture.voiceState())).id, "chat:2");
|
|
2770
|
+
assert.equal((await f.sent()).some(request => Object.hasOwn(request.input, "steer")), false);
|
|
2771
|
+
assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false);
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
test("mode-aware application admission refuses a captured follower after the actual backend changes", options, async t => {
|
|
2775
|
+
const f = await modeAwareAdmissionFixture(t);
|
|
2776
|
+
await f.engine("claude");
|
|
2777
|
+
await f.command("mode", { mode: "hold" });
|
|
2778
|
+
await f.page.evaluate(() => { window.modeFirst = window.modeRuntime.send({ message: "Original in-flight admission" }); });
|
|
2779
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
2780
|
+
await f.page.evaluate(() => { window.modeFollower = window.modeRuntime.send({ message: "Local native follower" }); });
|
|
2781
|
+
await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(2);
|
|
2782
|
+
await f.engine("api");
|
|
2783
|
+
await f.command("accept");
|
|
2784
|
+
assert.equal(await f.page.evaluate(() => window.modeFollower), false);
|
|
2785
|
+
assert.equal((await f.sent()).length, 1, "A captured native policy cannot dispatch into a changed API owner");
|
|
2786
|
+
assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false);
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
test("mode-aware application admission rejects nonboolean asynchronous and conflicting predicates", options, async t => {
|
|
2790
|
+
const f = await fixture(t);
|
|
2791
|
+
const failures = await f.page.evaluate(() => [
|
|
2792
|
+
{ admitWhileWorking: () => "yes" },
|
|
2793
|
+
{ deferWhileWorking: () => Promise.resolve(true) },
|
|
2794
|
+
{ admitWhileWorking: () => true, deferWhileWorking: () => true }
|
|
2795
|
+
].map(policy => {
|
|
2796
|
+
try { window.conversationFixture.acquireConversation({ conversationId: "chat:2", ...policy }); }
|
|
2797
|
+
catch (error) { return { name: error.name, message: error.message }; }
|
|
2798
|
+
return null;
|
|
2799
|
+
}));
|
|
2800
|
+
assert.deepEqual(failures, [
|
|
2801
|
+
{ name: "TypeError", message: "admitWhileWorking must return a boolean." },
|
|
2802
|
+
{ name: "TypeError", message: "deferWhileWorking must return a boolean." },
|
|
2803
|
+
{ name: "TypeError", message: "Choose either admitWhileWorking or deferWhileWorking." }
|
|
2804
|
+
]);
|
|
2805
|
+
assert.deepEqual(await f.sent(), []);
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
test("a background goal read keeps current pinned controls available and serializes actual commands", options, async t => {
|
|
2809
|
+
const f = await fixture(t);
|
|
2810
|
+
const view = { status: "available", goal: { id: "current-goal", objective: "Keep working", status: "active" },
|
|
2811
|
+
target: { segmentId: "pinned-native-goal", capabilities: { goals: true,
|
|
2812
|
+
goalCommands: { pause: { delivery: "control", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false } } } } };
|
|
2813
|
+
const commands = [];
|
|
2814
|
+
let holdRead = false;
|
|
2815
|
+
let releaseRead;
|
|
2816
|
+
let releaseCommand;
|
|
2817
|
+
await f.page.route("**/conversations/*/goal", async route => {
|
|
2818
|
+
if (route.request().method() === "GET") {
|
|
2819
|
+
if (holdRead) await new Promise(resolve => { releaseRead = resolve; });
|
|
2820
|
+
return route.fulfill({ json: view });
|
|
2821
|
+
}
|
|
2822
|
+
commands.push(route.request().postDataJSON());
|
|
2823
|
+
await new Promise(resolve => { releaseCommand = resolve; });
|
|
2824
|
+
return route.fulfill({ json: { ok: true } });
|
|
2825
|
+
});
|
|
2826
|
+
try {
|
|
2827
|
+
await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
|
|
2828
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
|
|
2829
|
+
await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
|
|
2830
|
+
holdRead = true;
|
|
2831
|
+
await f.page.evaluate(() => { window.fixtureBackgroundGoalRead = window.conversationFixture.current().refreshGoal(); });
|
|
2832
|
+
await expect.poll(() => Boolean(releaseRead)).toBe(true);
|
|
2833
|
+
const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
|
|
2834
|
+
await expect(pause).toBeEnabled();
|
|
2835
|
+
await pause.click();
|
|
2836
|
+
await expect.poll(() => commands.length).toBe(1);
|
|
2837
|
+
assert.deepEqual(commands[0], { action: "pause", expectedSegmentId: "pinned-native-goal", expectedGoalId: "current-goal" });
|
|
2838
|
+
await expect(f.page.getByRole("button", { name: "Cancel goal", exact: true })).toBeDisabled();
|
|
2839
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("cancel")), false);
|
|
2840
|
+
releaseCommand();
|
|
2841
|
+
await expect(pause).toBeEnabled();
|
|
2842
|
+
assert.equal(commands.length, 1, "Only the actual Pause command was admitted");
|
|
2843
|
+
holdRead = false;
|
|
2844
|
+
releaseRead();
|
|
2845
|
+
await f.page.evaluate(() => window.fixtureBackgroundGoalRead);
|
|
2846
|
+
assert.deepEqual(await f.sent(), []);
|
|
2847
|
+
} finally {
|
|
2848
|
+
holdRead = false;
|
|
2849
|
+
releaseCommand?.();
|
|
2850
|
+
releaseRead?.();
|
|
2851
|
+
}
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
test("changing the observed native segment blocks cached goal controls until the replacement read completes", options, async t => {
|
|
2855
|
+
const f = await fixture(t);
|
|
2856
|
+
const commands = [];
|
|
2857
|
+
const view = { status: "available", goal: { id: "old-goal", objective: "Old goal", status: "active" },
|
|
2858
|
+
target: { segmentId: "old-pinned-target", capabilities: { goals: true,
|
|
2859
|
+
goalCommands: { pause: { delivery: "control", interruptsTurn: false } } } } };
|
|
2860
|
+
let held = false;
|
|
2861
|
+
let releaseRead;
|
|
2862
|
+
await f.page.route("**/conversations/*/goal", async route => {
|
|
2863
|
+
if (route.request().method() === "GET") {
|
|
2864
|
+
if (held) await new Promise(resolve => { releaseRead = resolve; });
|
|
2865
|
+
return route.fulfill({ json: view });
|
|
2866
|
+
}
|
|
2867
|
+
commands.push(route.request().postDataJSON());
|
|
2868
|
+
return route.fulfill({ json: { ok: true } });
|
|
2869
|
+
});
|
|
2870
|
+
try {
|
|
2871
|
+
await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
|
|
2872
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
|
|
2873
|
+
held = true;
|
|
2874
|
+
await f.command("goals", { id: "chat:1", segmentId: "replacement-native-observation", budgets: false,
|
|
2875
|
+
commands: view.target.capabilities.goalCommands });
|
|
2876
|
+
await f.notify("chat:1");
|
|
2877
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.segmentId)).toBe("replacement-native-observation");
|
|
2878
|
+
await expect.poll(() => Boolean(releaseRead)).toBe(true);
|
|
2879
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.pending), true);
|
|
2880
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("pause")), false);
|
|
2881
|
+
assert.deepEqual(commands, [], "A previous observation cannot authorize a command for the replacement thread");
|
|
2882
|
+
view.goal.id = "replacement-goal";
|
|
2883
|
+
view.target.segmentId = "replacement-pinned-target";
|
|
2884
|
+
held = false;
|
|
2885
|
+
releaseRead();
|
|
2886
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.pending)).toBe(false);
|
|
2887
|
+
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("pause")), { ok: true });
|
|
2888
|
+
assert.deepEqual(commands, [{ action: "pause", expectedSegmentId: "replacement-pinned-target", expectedGoalId: "replacement-goal" }]);
|
|
2889
|
+
assert.deepEqual(await f.sent(), []);
|
|
2890
|
+
} finally {
|
|
2891
|
+
held = false;
|
|
2892
|
+
releaseRead?.();
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
@@ -736,7 +736,7 @@ test("removing the integration releases every observer and callback while the sh
|
|
|
736
736
|
assert.equal(f.serverSockets.size, 0, "Provider shutdown also runs unrelated connection cleanup");
|
|
737
737
|
});
|
|
738
738
|
|
|
739
|
-
async function hostSubscriptionFixture(t, authService = null) {
|
|
739
|
+
async function hostSubscriptionFixture(t, authService = null, conversationRuntime = null) {
|
|
740
740
|
const changes = new EventEmitter();
|
|
741
741
|
const notify = () => changes.emit("change");
|
|
742
742
|
const listeners = new Set();
|
|
@@ -753,7 +753,7 @@ async function hostSubscriptionFixture(t, authService = null) {
|
|
|
753
753
|
notify();
|
|
754
754
|
return () => { listeners.delete(listener); notify(); };
|
|
755
755
|
} };
|
|
756
|
-
const definitions = createAssistantActions({ config, conversationRuntime: { async open() { return conversation; } } });
|
|
756
|
+
const definitions = createAssistantActions({ config, conversationRuntime: conversationRuntime || { async open() { return conversation; } } });
|
|
757
757
|
const original = definitions.find(definition => definition.id === actionIds.conversationSubscribe);
|
|
758
758
|
const hostActionId = "test.host.conversation.subscribe";
|
|
759
759
|
actions.register({ contributorId: "test.host", domain: "assistant", actions: [...definitions, {
|
|
@@ -990,3 +990,115 @@ test("denied Reload before the initial acknowledgement retires only that pending
|
|
|
990
990
|
assert.equal(reads, 1, "Recovery uses fresh subscription admission rather than repeating the old read or inference");
|
|
991
991
|
assert.deepEqual(errors, [failure]);
|
|
992
992
|
});
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
test("retargeting a retained logical observer uses the current engine namespace and original same-ID subscription owner", { timeout: 20_000 }, async t => {
|
|
996
|
+
let selected = "opencode";
|
|
997
|
+
let allowed = true;
|
|
998
|
+
const states = new Map(["opencode", "codex"].map(engine => [engine, {
|
|
999
|
+
engine, configuration: {}, status: "ready", conversationLog: [], threadId: `${engine}-thread`
|
|
1000
|
+
}]));
|
|
1001
|
+
const oldMessage = { messageId: "old-overlay", role: "assistant", turnId: "old-turn", status: "inProgress", text: "Old engine overlay" };
|
|
1002
|
+
const streams = new Map([["opencode", { revision: 100, messages: [oldMessage] }], ["codex", { revision: 0, messages: [] }]]);
|
|
1003
|
+
const originalListeners = new Map();
|
|
1004
|
+
let sends = 0;
|
|
1005
|
+
let active = 0;
|
|
1006
|
+
let maximum = 0;
|
|
1007
|
+
const commands = { async send() { sends += 1; }, async cancel() {}, async select() {}, async inspectDelivery() {} };
|
|
1008
|
+
const runtime = createConversationRuntime({ authorize: () => allowed, host: {
|
|
1009
|
+
async conversation({ id, context }) {
|
|
1010
|
+
const engine = context.selected;
|
|
1011
|
+
return { namespace: `${engine}:${id}`, sessionId: id, engine, commands,
|
|
1012
|
+
read: () => states.get(engine), readStream: () => streams.get(engine) };
|
|
1013
|
+
}
|
|
1014
|
+
} });
|
|
1015
|
+
t.after(() => runtime.close());
|
|
1016
|
+
async function current() { return runtime.open({ id: "host-conversation", context: { selected } }); }
|
|
1017
|
+
const f = await hostSubscriptionFixture(t, null, { async open() {
|
|
1018
|
+
return { read: async () => (await current()).read(), async subscribe(listener) {
|
|
1019
|
+
const engine = selected;
|
|
1020
|
+
const release = await (await current()).subscribe(listener);
|
|
1021
|
+
originalListeners.set(engine, listener);
|
|
1022
|
+
active += 1;
|
|
1023
|
+
maximum = Math.max(maximum, active);
|
|
1024
|
+
return () => { release(); active -= 1; };
|
|
1025
|
+
} };
|
|
1026
|
+
} });
|
|
1027
|
+
f.registerHost();
|
|
1028
|
+
const socket = await f.connect();
|
|
1029
|
+
const requests = [];
|
|
1030
|
+
const timeout = socket.timeout.bind(socket);
|
|
1031
|
+
socket.timeout = milliseconds => {
|
|
1032
|
+
const target = timeout(milliseconds);
|
|
1033
|
+
return { emit(name, input, acknowledge) {
|
|
1034
|
+
requests.push({ name, input });
|
|
1035
|
+
return target.emit(name, input, acknowledge);
|
|
1036
|
+
} };
|
|
1037
|
+
};
|
|
1038
|
+
const loaded = [];
|
|
1039
|
+
const errors = [];
|
|
1040
|
+
const updates = [];
|
|
1041
|
+
const release = subscribeAssistantConversation({ socket, conversationId: "host-conversation",
|
|
1042
|
+
targetSurfaceId: "home", hostSurfaceId: "home", read: async () => (await current()).read(),
|
|
1043
|
+
onState(state, options) { loaded.push({ state, options }); f.changes.emit("change"); },
|
|
1044
|
+
onEvent(event) { updates.push(event); f.changes.emit("change"); },
|
|
1045
|
+
onError(error) { errors.push(error); f.changes.emit("change"); } });
|
|
1046
|
+
t.after(release);
|
|
1047
|
+
await until(f.changes, () => loaded.length === 1);
|
|
1048
|
+
assert.equal(loaded[0].state.engine, "opencode");
|
|
1049
|
+
assert.equal(loaded[0].options.initial, true);
|
|
1050
|
+
assert.equal(loaded[0].state.streaming.revision, 100);
|
|
1051
|
+
assert.ok(loaded[0].state.turns.some(turn => turn.assistant?.text === oldMessage.text));
|
|
1052
|
+
selected = "codex";
|
|
1053
|
+
await release.reload();
|
|
1054
|
+
assert.equal(loaded.at(-1).state.engine, "codex", "A canonical read can already use the successor owner");
|
|
1055
|
+
assert.equal(f.calls.length, 1, "Ordinary Reload does not retarget the original observer");
|
|
1056
|
+
await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: { type: "phase", phase: "working" } });
|
|
1057
|
+
assert.equal(updates.length, 0, "The new engine namespace has no observer before retargeting");
|
|
1058
|
+
|
|
1059
|
+
release.reload({ resubscribe: true });
|
|
1060
|
+
await until(f.changes, () => loaded.length === 3);
|
|
1061
|
+
assert.equal(f.calls.length, 2);
|
|
1062
|
+
assert.equal(active, 1);
|
|
1063
|
+
assert.equal(maximum, 1, "The original server releases the predecessor before attaching its successor");
|
|
1064
|
+
assert.deepEqual(loaded.at(-1).options, { initial: false, canonical: false }, "Retarget snapshot preserves the mounted loaded history");
|
|
1065
|
+
assert.equal(loaded.at(-1).state.streaming.revision, 0, "The original epoch transition resets the predecessor's high stream revision");
|
|
1066
|
+
assert.equal(loaded.at(-1).state.turns.some(turn => turn.assistant?.text === oldMessage.text), false);
|
|
1067
|
+
originalListeners.get("opencode")({ type: "phase", phase: "obsolete" });
|
|
1068
|
+
originalListeners.get("opencode")({ type: "message", ...oldMessage, text: "Late foreign overlay",
|
|
1069
|
+
streaming: { revision: 200, messages: [{ ...oldMessage, text: "Late foreign overlay" }] } });
|
|
1070
|
+
const partial = { messageId: "answer", role: "assistant", turnId: "000001", status: "inProgress", text: "New engine partial" };
|
|
1071
|
+
streams.set("codex", { revision: 1, messages: [partial] });
|
|
1072
|
+
await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: {
|
|
1073
|
+
payload: { conversationStream: streams.get("codex") }
|
|
1074
|
+
} });
|
|
1075
|
+
await until(f.changes, () => loaded.at(-1).state.turns.some(turn => turn.assistant?.text === partial.text));
|
|
1076
|
+
assert.equal(loaded.at(-1).state.streaming.revision, 1);
|
|
1077
|
+
assert.equal(loaded.at(-1).state.turns.some(turn => turn.assistant?.text === "Late foreign overlay"), false);
|
|
1078
|
+
await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: { type: "phase", phase: "working" } });
|
|
1079
|
+
await until(f.changes, () => updates.some(event => event.phase === "working"));
|
|
1080
|
+
assert.equal(requests.length, 2);
|
|
1081
|
+
assert.ok(requests.every(request => request.name === ASSISTANT_CONVERSATION_SUBSCRIBE));
|
|
1082
|
+
assert.equal(requests[1].input.subscriptionId, requests[0].input.subscriptionId);
|
|
1083
|
+
assert.equal(f.published.at(-1).realtime.payload.subscriptionId, requests[0].input.subscriptionId);
|
|
1084
|
+
assert.equal(updates.some(event => event.phase === "obsolete"), false, "Late retired observer callbacks cannot publish");
|
|
1085
|
+
const answer = { messageId: "answer", role: "assistant", text: "Final from the selected engine" };
|
|
1086
|
+
states.set("codex", { ...states.get("codex"), status: "ready", conversationLog: [{ turnId: "000001", messages: [answer], assistant: answer }] });
|
|
1087
|
+
streams.set("codex", { revision: 2, messages: [] });
|
|
1088
|
+
await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: {
|
|
1089
|
+
payload: { conversationLogPatch: { type: "upsert-turn", turn: states.get("codex").conversationLog[0] },
|
|
1090
|
+
conversationStream: streams.get("codex"), agentSession: { turn: { active: false } } }
|
|
1091
|
+
} });
|
|
1092
|
+
await until(f.changes, () => loaded.at(-1).state.status === "ready" && loaded.at(-1).state.turns.some(turn => turn.assistant?.text === answer.text));
|
|
1093
|
+
assert.equal(sends, 0, "Observer refresh and final-state recovery never admit inference");
|
|
1094
|
+
assert.deepEqual(errors, []);
|
|
1095
|
+
allowed = false;
|
|
1096
|
+
release.reload({ resubscribe: true });
|
|
1097
|
+
await until(f.changes, () => errors.length === 1);
|
|
1098
|
+
assert.equal(active, 0, "Revoked access releases the predecessor and cannot attach another observer");
|
|
1099
|
+
assert.equal(errors[0].statusCode, 403);
|
|
1100
|
+
release();
|
|
1101
|
+
release.reload({ resubscribe: true });
|
|
1102
|
+
assert.equal(active, 0);
|
|
1103
|
+
assert.equal(sends, 0);
|
|
1104
|
+
});
|
|
@@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import knex from "knex";
|
|
7
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
7
8
|
import { createConversationTranscript } from "@jskit-ai/assistant-core/server/conversation";
|
|
8
9
|
import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
|
|
9
10
|
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
@@ -203,3 +204,221 @@ test("the SQL facade uses common inference and durable tool rows while supplied
|
|
|
203
204
|
{ code: "conversation_forbidden" });
|
|
204
205
|
}
|
|
205
206
|
});
|
|
207
|
+
|
|
208
|
+
test("the SQL facade replays historical inspection failures with their exact public code and row IDs without executing work", async t => {
|
|
209
|
+
const f = await fixture(t);
|
|
210
|
+
const conversation = await f.conversation();
|
|
211
|
+
const first = await f.messagesRepository.create({ conversationId: conversation.id, role: "user",
|
|
212
|
+
clientMessageSid: "first-legacy-question", contentText: "First" });
|
|
213
|
+
const next = await f.messagesRepository.create({ conversationId: conversation.id, role: "user",
|
|
214
|
+
clientMessageSid: "next-legacy-question", contentText: "Next" });
|
|
215
|
+
await f.messagesRepository.create({ conversationId: conversation.id, role: "assistant", contentText: "Unknown owner" });
|
|
216
|
+
const unchanged = await rows(f.db);
|
|
217
|
+
let inference = 0;
|
|
218
|
+
let effects = 0;
|
|
219
|
+
const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
|
|
220
|
+
createChatCompletionStream() { inference++; assert.fail("Historical inspection cannot invoke inference"); } }) };
|
|
221
|
+
const catalog = { resolveToolSet: () => ({ tools: [] }),
|
|
222
|
+
executeToolCall() { effects++; assert.fail("Historical inspection cannot invoke application effects"); } };
|
|
223
|
+
const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
|
|
224
|
+
t.after(() => runtime.close());
|
|
225
|
+
const turnRequests = createTurnRequestsRepository(f.db);
|
|
226
|
+
const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
|
|
227
|
+
transcriptService: createTranscriptService(f), turnRequests,
|
|
228
|
+
assistantConfigService: { resolveSystemPrompt: async () => "Read the supplied context" },
|
|
229
|
+
appConfig: { surfaceDefinitions: { assistant: { enabled: true, requiresWorkspace: false } },
|
|
230
|
+
assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
|
|
231
|
+
const input = { targetSurfaceId: "assistant", conversationId: conversation.id,
|
|
232
|
+
messageId: "inspect-legacy-history", input: "Continue", history: [] };
|
|
233
|
+
const events = [];
|
|
234
|
+
const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
|
|
235
|
+
["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
|
|
236
|
+
.map(name => [name, event => events.push({ name, event })])) };
|
|
237
|
+
const expectedDetails = { issues: [{ code: "overlapping_historical_turns", rowIds: [first.id, next.id] }] };
|
|
238
|
+
const requireInspection = error => {
|
|
239
|
+
assert.equal(error instanceof AppError, true);
|
|
240
|
+
assert.equal(error.status, 409);
|
|
241
|
+
assert.equal(error.code, "assistant_history_inspection_required");
|
|
242
|
+
assert.deepEqual(error.details, expectedDetails);
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
await assert.rejects(createChatService(dependencies).streamChat(input, options), requireInspection);
|
|
246
|
+
const receipt = await f.db("assistant_turn_requests").first();
|
|
247
|
+
assert.equal(receipt.status, "failed");
|
|
248
|
+
const restarted = createChatService({ ...dependencies, turnRequests: createTurnRequestsRepository(f.db) });
|
|
249
|
+
await assert.rejects(restarted.streamChat(input, options), requireInspection);
|
|
250
|
+
assert.deepEqual(JSON.parse(receipt.response_json).failure, {
|
|
251
|
+
message: "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
|
|
252
|
+
status: 409, code: "assistant_history_inspection_required", details: expectedDetails
|
|
253
|
+
});
|
|
254
|
+
assert.deepEqual(await f.db("assistant_turn_requests"), [receipt], "Replay preserves the original request claim");
|
|
255
|
+
assert.deepEqual(await rows(f.db), unchanged, "Inspection and replay leave historical rows and metadata untouched");
|
|
256
|
+
assert.deepEqual(events, [], "The refused request never starts a stream");
|
|
257
|
+
assert.equal(inference, 0);
|
|
258
|
+
assert.equal(effects, 0);
|
|
259
|
+
|
|
260
|
+
const oldFailure = { message: "Previously recorded failure", status: 409 };
|
|
261
|
+
const oldInput = { ...input, messageId: "old-failure-receipt" };
|
|
262
|
+
const oldClaim = await turnRequests.claim({ actorUserId: "1", surfaceId: "assistant", workspaceId: null },
|
|
263
|
+
{ conversationId: conversation.id, messageId: oldInput.messageId, input: oldInput.input, history: [], integrationId: "" });
|
|
264
|
+
await turnRequests.update(oldClaim, { failure: oldFailure }, "failed");
|
|
265
|
+
await assert.rejects(restarted.streamChat(oldInput, options), error => {
|
|
266
|
+
assert.equal(error.status, oldFailure.status);
|
|
267
|
+
assert.equal(error.message, oldFailure.message);
|
|
268
|
+
assert.equal(error.code, "APP_ERROR");
|
|
269
|
+
assert.equal(error.details, undefined);
|
|
270
|
+
return true;
|
|
271
|
+
});
|
|
272
|
+
const oldSaved = await turnRequests.find({ actorUserId: "1", surfaceId: "assistant", workspaceId: null }, oldInput.messageId);
|
|
273
|
+
assert.deepEqual(oldSaved.response, { failure: oldFailure }, "Replay does not backfill historical failure receipts");
|
|
274
|
+
|
|
275
|
+
for (const [index, failure] of [
|
|
276
|
+
new AppError(403, "Permission denied", { code: "ACTION_PERMISSION_DENIED", details: { privateRule: "private-permission-probe" } }),
|
|
277
|
+
Object.assign(new Error("Request failed"), { status: 503, code: "PRIVATE_ENGINE_ERROR", details: { privateRule: "private-permission-probe" } })
|
|
278
|
+
].entries()) {
|
|
279
|
+
const hiddenInput = { ...input, messageId: `private-failure-${index}` };
|
|
280
|
+
const denied = createChatService({ ...dependencies, assistantConfigService: {
|
|
281
|
+
resolveSystemPrompt() { throw failure; }
|
|
282
|
+
} });
|
|
283
|
+
await assert.rejects(denied.streamChat(hiddenInput, options), error => error === failure);
|
|
284
|
+
const saved = await turnRequests.find({ actorUserId: "1", surfaceId: "assistant", workspaceId: null }, hiddenInput.messageId);
|
|
285
|
+
assert.deepEqual(saved.response.failure, { message: failure.message, status: failure.status,
|
|
286
|
+
...(failure instanceof AppError ? { code: "ACTION_PERMISSION_DENIED" } : {}) });
|
|
287
|
+
assert.doesNotMatch(JSON.stringify(saved.response), /private-permission-probe|PRIVATE_ENGINE_ERROR|stack/u);
|
|
288
|
+
await assert.rejects(restarted.streamChat(hiddenInput, options), error => {
|
|
289
|
+
assert.equal(error.status, failure.status);
|
|
290
|
+
assert.equal(error.code, failure instanceof AppError ? "ACTION_PERMISSION_DENIED" : "APP_ERROR");
|
|
291
|
+
assert.equal(error.details, undefined);
|
|
292
|
+
return true;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
assert.equal(inference, 0);
|
|
296
|
+
assert.equal(effects, 0);
|
|
297
|
+
assert.deepEqual(await rows(f.db), unchanged);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"]) {
|
|
301
|
+
test(`the original SQL facade retains ${shape} history and admits only an authorized fresh conversation`, async t => {
|
|
302
|
+
const f = await fixture(t);
|
|
303
|
+
const conversation = await f.conversationsRepository.create({ createdByUserId: "1", surfaceId: "assistant",
|
|
304
|
+
workspaceId: "77", metadata: { retainedHistory: shape } });
|
|
305
|
+
const fields = { conversationId: conversation.id, workspaceId: "77", actorUserId: "1", role: "assistant", kind: "chat" };
|
|
306
|
+
if (shape !== "orphan-output") {
|
|
307
|
+
await f.messagesRepository.create({ ...fields, role: "user", clientMessageSid: "legacy-question", contentText: "Existing question" });
|
|
308
|
+
}
|
|
309
|
+
const offending = await f.messagesRepository.create({ ...fields,
|
|
310
|
+
...(shape === "orphan-output" ? { contentText: "Answer without an authored row" }
|
|
311
|
+
: { kind: shape === "orphan-tool-result" ? "tool_result" : "tool_call",
|
|
312
|
+
contentText: shape === "orphan-tool-result" ? '{"ok":true,"result":{"found":1}}' : "{}",
|
|
313
|
+
metadata: { toolCallId: "legacy-operation", tool: "inventory_lookup" } }) });
|
|
314
|
+
const issueCode = { "orphan-output": "orphan_output", "orphan-tool-result": "orphan_tool_result",
|
|
315
|
+
"unconfirmed-tool": "unconfirmed_historical_tool" }[shape];
|
|
316
|
+
const expectedDetails = { issues: [{ code: issueCode, rowIds: [offending.id] }] };
|
|
317
|
+
const unchanged = await rows(f.db);
|
|
318
|
+
const historical = await f.messagesRepository.listByConversationScope(conversation.id, { workspaceId: "77" });
|
|
319
|
+
let allowFreshInference = false;
|
|
320
|
+
const requests = [];
|
|
321
|
+
let effects = 0;
|
|
322
|
+
const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "fixture-model",
|
|
323
|
+
async createChatCompletionStream(request) {
|
|
324
|
+
assert.equal(allowFreshInference, true, "Historical refusal and foreign admission must not start inference");
|
|
325
|
+
requests.push(structuredClone(request.messages));
|
|
326
|
+
return (async function* () {
|
|
327
|
+
yield { choices: [{ delta: { content: "Fresh answer." }, finish_reason: "stop" }] };
|
|
328
|
+
})();
|
|
329
|
+
} }) };
|
|
330
|
+
const catalog = { limits: { maxToolArgumentBytes: 4096 }, resolveToolSet: () => ({ tools: [] }),
|
|
331
|
+
toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
|
|
332
|
+
executeToolCall() { effects++; assert.fail("Retained history cannot repeat an application effect"); } };
|
|
333
|
+
const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
|
|
334
|
+
t.after(() => runtime.close());
|
|
335
|
+
const turnRequests = createTurnRequestsRepository(f.db);
|
|
336
|
+
const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
|
|
337
|
+
transcriptService: createTranscriptService(f), turnRequests,
|
|
338
|
+
assistantConfigService: { resolveSystemPrompt: async () => "Read only the admitted request" },
|
|
339
|
+
workspaceScopeSupport: { resolveWorkspace: context => context.requestMeta.resolvedWorkspaceContext.workspace },
|
|
340
|
+
appConfig: { surfaceDefinitions: {
|
|
341
|
+
assistant: { enabled: true, requiresWorkspace: true }, other: { enabled: true, requiresWorkspace: true }
|
|
342
|
+
}, assistantSurfaces: {
|
|
343
|
+
assistant: { settingsSurfaceId: "assistant", configScope: "workspace" },
|
|
344
|
+
other: { settingsSurfaceId: "other", configScope: "workspace" }
|
|
345
|
+
} } };
|
|
346
|
+
const events = [];
|
|
347
|
+
const options = { input: { targetSurfaceId: "assistant" },
|
|
348
|
+
context: { actor: { id: "1" }, requestMeta: { resolvedWorkspaceContext: { workspace: { id: "77" } } } },
|
|
349
|
+
streamWriter: Object.fromEntries(
|
|
350
|
+
["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
|
|
351
|
+
.map(name => [name, event => events.push({ name, event })])) };
|
|
352
|
+
const input = { targetSurfaceId: "assistant", conversationId: conversation.id, messageId: "inspect-retained-history",
|
|
353
|
+
input: "Continue", history: [] };
|
|
354
|
+
const service = createChatService(dependencies);
|
|
355
|
+
await assert.rejects(service.streamChat(input, options), error => {
|
|
356
|
+
assert.equal(error instanceof AppError, true);
|
|
357
|
+
assert.equal(error.status, 409);
|
|
358
|
+
assert.equal(error.code, "assistant_history_inspection_required");
|
|
359
|
+
assert.deepEqual(error.details, expectedDetails);
|
|
360
|
+
return true;
|
|
361
|
+
});
|
|
362
|
+
const receipt = await f.db("assistant_turn_requests").first();
|
|
363
|
+
const restarted = createChatService({ ...dependencies, turnRequests: createTurnRequestsRepository(f.db) });
|
|
364
|
+
await assert.rejects(restarted.streamChat(input, options), error => {
|
|
365
|
+
assert.equal(error.status, 409);
|
|
366
|
+
assert.equal(error.code, "assistant_history_inspection_required");
|
|
367
|
+
assert.deepEqual(error.details, expectedDetails);
|
|
368
|
+
return true;
|
|
369
|
+
});
|
|
370
|
+
assert.deepEqual(await rows(f.db), unchanged, "Refusal and replay cannot repair or discard historical rows");
|
|
371
|
+
assert.deepEqual(await f.db("assistant_turn_requests"), [receipt], "Exact-ID replay preserves the failed claim");
|
|
372
|
+
assert.deepEqual(events, []);
|
|
373
|
+
assert.equal(requests.length, 0);
|
|
374
|
+
assert.equal(effects, 0);
|
|
375
|
+
const listed = await restarted.listConversations({}, options);
|
|
376
|
+
assert.equal(listed.items.some(entry => entry.id === conversation.id), true);
|
|
377
|
+
const loaded = await restarted.getConversationMessages(conversation.id, {}, options);
|
|
378
|
+
assert.deepEqual(loaded.entries, historical, "Authorized browsing reads original rows independently of common projection");
|
|
379
|
+
|
|
380
|
+
for (const [index, foreign] of [
|
|
381
|
+
{ surface: "assistant", actor: "2", workspace: "77" },
|
|
382
|
+
{ surface: "other", actor: "1", workspace: "77" },
|
|
383
|
+
{ surface: "assistant", actor: "1", workspace: "78" }
|
|
384
|
+
].entries()) {
|
|
385
|
+
const deniedOptions = { ...options, input: { targetSurfaceId: foreign.surface }, context: {
|
|
386
|
+
actor: { id: foreign.actor }, requestMeta: { resolvedWorkspaceContext: { workspace: { id: foreign.workspace } } }
|
|
387
|
+
} };
|
|
388
|
+
await assert.rejects(restarted.getConversationMessages(conversation.id, {}, deniedOptions), error => {
|
|
389
|
+
assert.equal(error.status, 404);
|
|
390
|
+
assert.equal(error.details, undefined, "Foreign readers cannot inspect row IDs");
|
|
391
|
+
return true;
|
|
392
|
+
});
|
|
393
|
+
assert.equal((await restarted.listConversations({}, deniedOptions)).items.some(entry => entry.id === conversation.id), false);
|
|
394
|
+
await assert.rejects(restarted.streamChat({ ...input, targetSurfaceId: foreign.surface,
|
|
395
|
+
messageId: `foreign-history-${index}` }, deniedOptions), error => {
|
|
396
|
+
assert.equal(error.status, 404);
|
|
397
|
+
assert.equal(error.details, undefined, "Foreign sends fail before historical inspection");
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
400
|
+
await assert.rejects(runtime.open({ id: conversation.id, context: { actor: { id: foreign.actor },
|
|
401
|
+
surface: foreign.surface, assistantRequest: { conversation, workspace: { id: foreign.workspace }, history: [] } } }),
|
|
402
|
+
{ code: "conversation_forbidden" });
|
|
403
|
+
}
|
|
404
|
+
assert.deepEqual(await rows(f.db), unchanged);
|
|
405
|
+
assert.deepEqual(events, []);
|
|
406
|
+
assert.equal(requests.length, 0);
|
|
407
|
+
assert.equal(effects, 0);
|
|
408
|
+
|
|
409
|
+
allowFreshInference = true;
|
|
410
|
+
const fresh = await restarted.streamChat({ targetSurfaceId: "assistant", messageId: "fresh-after-inspection",
|
|
411
|
+
input: "A new independent request", history: [] }, options);
|
|
412
|
+
assert.equal(fresh.status, "completed", JSON.stringify(events));
|
|
413
|
+
assert.notEqual(fresh.conversationId, conversation.id);
|
|
414
|
+
assert.equal(requests.length, 1, "A fresh conversation uses one admitted model response");
|
|
415
|
+
assert.equal(requests[0].some(message => message.content === "Existing question" ||
|
|
416
|
+
message.content === "Answer without an authored row"), false, "Fresh admission does not reseed ambiguous history");
|
|
417
|
+
assert.equal(effects, 0, "Fresh recovery cannot replay an unconfirmed legacy effect");
|
|
418
|
+
assert.equal(events.at(-1).name, "sendDone");
|
|
419
|
+
assert.deepEqual(await f.db("assistant_messages").where({ conversation_id: conversation.id }).orderBy("id"), unchanged.messages);
|
|
420
|
+
assert.deepEqual(await f.db("assistant_conversations").where({ id: conversation.id }).orderBy("id"), unchanged.conversations);
|
|
421
|
+
assert.deepEqual(await f.db("assistant_turn_requests").where({ id: receipt.id }), [receipt]);
|
|
422
|
+
assert.deepEqual((await restarted.getConversationMessages(conversation.id, {}, options)).entries, historical);
|
|
423
|
+
});
|
|
424
|
+
}
|