@jskit-ai/assistant-runtime 0.2.7 → 0.2.9
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/App.vue +2 -0
- package/fixtures/canonical-conversation/vite.config.mjs +11 -0
- package/locales/en.json +1 -0
- package/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +72 -18
- package/test/assistantConversationBinding.browser.test.js +194 -1
- package/test/assistantConversationSubscriptions.test.js +4 -0
|
@@ -121,6 +121,8 @@ onMounted(() => {
|
|
|
121
121
|
window.conversationFixture = {
|
|
122
122
|
socket,
|
|
123
123
|
acquireConversation,
|
|
124
|
+
// A reader's own visibility, as when an application hides a retained view.
|
|
125
|
+
activity(value) { return ref(value); },
|
|
124
126
|
releaseFactory() { factoryScope.stop(); },
|
|
125
127
|
target(id) { conversationId.value = id; },
|
|
126
128
|
show(value) { visible.value = value; },
|
|
@@ -110,6 +110,12 @@ export default defineConfig({
|
|
|
110
110
|
for (const pending of held.splice(0)) pending.respond(pending.receipt);
|
|
111
111
|
return json({ ok: true });
|
|
112
112
|
}
|
|
113
|
+
if (url.pathname === "/fixture/start-queued-goal") {
|
|
114
|
+
const state = states.get(input.id);
|
|
115
|
+
changeGoal(state, state.queuedGoal);
|
|
116
|
+
state.queuedGoal = null;
|
|
117
|
+
return json({ ok: true });
|
|
118
|
+
}
|
|
113
119
|
if (url.pathname === "/fixture/finish") {
|
|
114
120
|
const state = states.get(input.id);
|
|
115
121
|
state.status = "ready";
|
|
@@ -145,6 +151,11 @@ export default defineConfig({
|
|
|
145
151
|
}
|
|
146
152
|
const receipt = command.delivery === "message" ? accept(state, { messageId: input.messageId,
|
|
147
153
|
text: input.objective || (input.action === "resume" ? "Resume the goal." : "Cancel the goal.") }) : null;
|
|
154
|
+
// The command is accepted behind the running turn; its goal changes later.
|
|
155
|
+
if (receipt && mode === "queue-goal") {
|
|
156
|
+
state.queuedGoal = input;
|
|
157
|
+
return json(receipt);
|
|
158
|
+
}
|
|
148
159
|
changeGoal(state, input);
|
|
149
160
|
const result = receipt || state.goal || null;
|
|
150
161
|
return json(result, result === null ? 204 : 200);
|
package/locales/en.json
CHANGED
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"goal": {
|
|
75
75
|
"budgetsUnsupported": "This conversation does not support goal token budgets.",
|
|
76
76
|
"changeFailed": "Could not change the goal.",
|
|
77
|
+
"notStarted": "The goal did not start. Check the chat before starting it again.",
|
|
77
78
|
"unavailable": "Goal status is unavailable.",
|
|
78
79
|
"resumeMessage": "Resume the goal.",
|
|
79
80
|
"cancelMessage": "Cancel the goal."
|
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.9",
|
|
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.7",
|
|
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.3",
|
|
21
|
+
"@jskit-ai/http-web": "0.2.7",
|
|
22
|
+
"@jskit-ai/kernel": "0.2.3",
|
|
23
|
+
"@jskit-ai/realtime": "0.2.6",
|
|
24
|
+
"@jskit-ai/shell-web": "0.2.3",
|
|
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.252"
|
|
29
29
|
},
|
|
30
30
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
31
31
|
"jskit": {
|
|
@@ -110,6 +110,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
110
110
|
const loading = ref(true);
|
|
111
111
|
const stopping = ref(false);
|
|
112
112
|
const goalAction = ref("");
|
|
113
|
+
// An accepted Set or Resume waiting for its goal keeps Pause and Cancel usable.
|
|
114
|
+
const goalSettling = ref(false);
|
|
113
115
|
const goalTargetCurrent = ref(false);
|
|
114
116
|
const goalError = ref("");
|
|
115
117
|
const goalView = shallowRef(null);
|
|
@@ -136,6 +138,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
136
138
|
const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
|
|
137
139
|
const editable = computed(() => active.value && !accessDenied.value);
|
|
138
140
|
const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
|
|
141
|
+
// A deferred message keeps the subscription while its view is hidden, so it
|
|
142
|
+
// is admitted against live state. A failed read leaves that state stale.
|
|
143
|
+
const deferredWaits = ref(0);
|
|
144
|
+
const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
|
|
145
|
+
const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
|
|
139
146
|
const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
|
|
140
147
|
const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
|
|
141
148
|
const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
|
|
@@ -215,6 +222,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
215
222
|
let goalSegment = null;
|
|
216
223
|
let goalReadController = null;
|
|
217
224
|
let goalReadQueued = false;
|
|
225
|
+
let goalCommandSequence = 0;
|
|
218
226
|
const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
|
|
219
227
|
[...readers.values()].some(reader => toValue(reader.goal) === true));
|
|
220
228
|
watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
|
|
@@ -305,7 +313,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
305
313
|
if (denial !== deniedHistoryRevision) {
|
|
306
314
|
throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
|
|
307
315
|
}
|
|
308
|
-
return current.value &&
|
|
316
|
+
return current.value && listening.value && subscriptionCurrent();
|
|
309
317
|
};
|
|
310
318
|
// A concurrent Load older operation extends the desired span. Retry one
|
|
311
319
|
// refresh in this same read, rather than dropping that newly loaded page.
|
|
@@ -354,7 +362,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
354
362
|
}
|
|
355
363
|
return null;
|
|
356
364
|
}
|
|
357
|
-
watch(
|
|
365
|
+
watch(listening, enabled => {
|
|
358
366
|
subscription?.();
|
|
359
367
|
subscription = null;
|
|
360
368
|
historyRevision += 1;
|
|
@@ -372,6 +380,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
372
380
|
for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
|
|
373
381
|
clearConversationView();
|
|
374
382
|
goalAction.value = "";
|
|
383
|
+
goalSettling.value = false;
|
|
375
384
|
goalReadQueued = false;
|
|
376
385
|
questions.reset();
|
|
377
386
|
}, { flush: "sync" });
|
|
@@ -450,6 +459,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
450
459
|
}
|
|
451
460
|
// A deferred message waits only while the working turn cannot take it. It
|
|
452
461
|
// steers as soon as that turn accepts steering, or sends once it is ready.
|
|
462
|
+
// Its view may be hidden. While loading or after a failed read there is no
|
|
463
|
+
// current state to admit it against, so the unsent message keeps waiting;
|
|
464
|
+
// only Stop, Cancel, retirement or lost access end it.
|
|
453
465
|
function waitForAdmission({ controller }) {
|
|
454
466
|
return new Promise(resolve => {
|
|
455
467
|
let stop;
|
|
@@ -459,12 +471,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
459
471
|
resolve(admission);
|
|
460
472
|
};
|
|
461
473
|
const check = () => {
|
|
462
|
-
if (controller.signal.aborted ||
|
|
474
|
+
if (controller.signal.aborted || accessDenied.value) finish(false);
|
|
475
|
+
else if (!admissible.value) return;
|
|
463
476
|
else if (snapshot.value.status === "ready") finish("send");
|
|
464
477
|
else if (steerable.value) finish("steer");
|
|
465
478
|
else if (snapshot.value.status !== "working") finish(false);
|
|
466
479
|
};
|
|
467
|
-
stop = watch([
|
|
480
|
+
stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
|
|
468
481
|
controller.signal.addEventListener("abort", check, { once: true });
|
|
469
482
|
check();
|
|
470
483
|
});
|
|
@@ -477,10 +490,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
477
490
|
const pending = { controller: new AbortController(), started: false, dispatched: false };
|
|
478
491
|
const { controller } = pending;
|
|
479
492
|
pendingMessages.set(messageId, pending);
|
|
493
|
+
if (deferred) deferredWaits.value += 1;
|
|
480
494
|
try {
|
|
481
495
|
const result = await delivery.send(payload, {
|
|
496
|
+
// A goal command settles on the server's goal answer, not on its
|
|
497
|
+
// transcript turn, which can arrive while its goal is still changing.
|
|
482
498
|
messageId, queue, isCurrent: () => current.value, onAccepted,
|
|
483
|
-
receiptTurns: turns, uncertainOnError: true,
|
|
499
|
+
receiptTurns: payload.goalRequest ? null : turns, uncertainOnError: true,
|
|
484
500
|
async deliver(submission) {
|
|
485
501
|
pending.started = true;
|
|
486
502
|
let admission = null;
|
|
@@ -490,13 +506,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
490
506
|
// Refresh through the same subscription after a queued predecessor;
|
|
491
507
|
// its receipt may have arrived before the working snapshot.
|
|
492
508
|
await subscription?.reload();
|
|
493
|
-
if (
|
|
494
|
-
if (snapshot.value.status === "working" && !steerable.value) {
|
|
509
|
+
if (snapshot.value?.status === "working" && !steerable.value) {
|
|
495
510
|
delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
|
|
496
511
|
}
|
|
497
512
|
admission = await waitForAdmission(pending);
|
|
498
513
|
delivery.wait(messageId, "");
|
|
499
|
-
if (!admission || controller.signal.aborted || !
|
|
514
|
+
if (!admission || controller.signal.aborted || !admissible.value ||
|
|
500
515
|
snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
|
|
501
516
|
if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
|
|
502
517
|
return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
|
|
@@ -566,6 +581,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
566
581
|
return false;
|
|
567
582
|
} finally {
|
|
568
583
|
if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
584
|
+
if (deferred) deferredWaits.value -= 1;
|
|
569
585
|
if (current.value) subscription?.reload();
|
|
570
586
|
}
|
|
571
587
|
}
|
|
@@ -686,7 +702,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
686
702
|
// it stops and goes straight to the goal operation, even while a message is
|
|
687
703
|
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
688
704
|
const stopping = action === "pause" || action === "cancel";
|
|
689
|
-
if (!available.value || view?.status !== "available" || goalLoadError.value ||
|
|
705
|
+
if (!available.value || view?.status !== "available" || goalLoadError.value ||
|
|
706
|
+
goalAction.value && !(stopping && goalSettling.value) || !goalTargetCurrent.value ||
|
|
690
707
|
!["message", "control"].includes(command?.delivery) ||
|
|
691
708
|
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
692
709
|
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
@@ -699,11 +716,16 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
699
716
|
expectedGoalId: view.goal?.id || null,
|
|
700
717
|
...(action === "set" ? { objective: String(input.objective || "").trim(),
|
|
701
718
|
...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
|
|
719
|
+
const before = view.goal ? { id: view.goal.id, status: view.goal.status } : null;
|
|
720
|
+
const sequence = ++goalCommandSequence;
|
|
721
|
+
let accepted = false;
|
|
702
722
|
goalAction.value = action;
|
|
723
|
+
goalSettling.value = false;
|
|
703
724
|
goalError.value = "";
|
|
704
725
|
try {
|
|
726
|
+
let result;
|
|
705
727
|
if (retry || !stopping && command.delivery === "message") {
|
|
706
|
-
|
|
728
|
+
result = await submitRequest(retry?.payload || {
|
|
707
729
|
message: action === "set" ? request.objective
|
|
708
730
|
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
709
731
|
goalRequest: request
|
|
@@ -714,11 +736,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
714
736
|
}
|
|
715
737
|
return false;
|
|
716
738
|
}
|
|
717
|
-
|
|
739
|
+
} else {
|
|
740
|
+
result = await api.updateConversationGoal(identity.conversationId,
|
|
741
|
+
command.delivery === "message" ? { ...request, messageId } : request);
|
|
742
|
+
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
718
743
|
}
|
|
719
|
-
|
|
720
|
-
command.delivery === "message" ? { ...request, messageId } : request);
|
|
721
|
-
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
744
|
+
accepted = true;
|
|
722
745
|
return result;
|
|
723
746
|
} catch (failure) {
|
|
724
747
|
if (current.value) {
|
|
@@ -727,13 +750,43 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
727
750
|
}
|
|
728
751
|
return false;
|
|
729
752
|
} finally {
|
|
730
|
-
|
|
731
|
-
|
|
753
|
+
// The command stays Starting, Resuming, Pausing or Cancelling until a goal
|
|
754
|
+
// read made after the server's answer arrives; until then the control
|
|
755
|
+
// shows no stale goal or enabled form. An accepted Set or Resume also
|
|
756
|
+
// waits while its goal waits behind the running turn.
|
|
757
|
+
if (current.value && goalCommandSequence === sequence) {
|
|
732
758
|
await goalRead;
|
|
733
759
|
await refreshGoal();
|
|
760
|
+
if (accepted && !stopping) await awaitGoalChange(before, sequence);
|
|
734
761
|
subscription?.reload();
|
|
735
762
|
}
|
|
763
|
+
if (goalCommandSequence === sequence) {
|
|
764
|
+
goalAction.value = "";
|
|
765
|
+
goalSettling.value = false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// An accepted Set or Resume changes the server's goal, now or once the turn
|
|
770
|
+
// it waits behind has ended. When the conversation stops working without
|
|
771
|
+
// that change, a fresh read decides and the reason is shown. A later Pause
|
|
772
|
+
// or Cancel ends the wait.
|
|
773
|
+
const goalChangedFrom = before => {
|
|
774
|
+
const goal = goalView.value?.goal;
|
|
775
|
+
return Boolean(goal) && (goal.id !== before?.id || goal.status !== before?.status);
|
|
776
|
+
};
|
|
777
|
+
async function awaitGoalChange(before, sequence) {
|
|
778
|
+
const settled = () => !current.value || goalCommandSequence !== sequence || goalChangedFrom(before);
|
|
779
|
+
await subscription?.reload();
|
|
780
|
+
if (!settled()) goalSettling.value = true;
|
|
781
|
+
while (!settled() && snapshot.value?.status === "working") {
|
|
782
|
+
await new Promise(resolve => {
|
|
783
|
+
const stop = watch([goalView, () => snapshot.value?.status, current, goalAction], () => { stop(); resolve(); });
|
|
784
|
+
});
|
|
736
785
|
}
|
|
786
|
+
if (settled()) return;
|
|
787
|
+
await goalRead;
|
|
788
|
+
await refreshGoal();
|
|
789
|
+
if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
|
|
737
790
|
}
|
|
738
791
|
const goalState = computed(() => {
|
|
739
792
|
const view = goalView.value;
|
|
@@ -742,7 +795,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
742
795
|
const commands = capabilities.goalCommands || {};
|
|
743
796
|
const loading = !goalTargetCurrent.value && !goalLoadError.value;
|
|
744
797
|
const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
|
|
745
|
-
const stopPending = Boolean(goalAction.value) || !goalAvailable || !available.value;
|
|
798
|
+
const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
|
|
746
799
|
return {
|
|
747
800
|
enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
|
|
748
801
|
loading,
|
|
@@ -752,7 +805,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
752
805
|
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
753
806
|
goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
754
807
|
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
755
|
-
pending: stopPending || delivery.state.sending ||
|
|
808
|
+
pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
|
|
809
|
+
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
756
810
|
stopPending,
|
|
757
811
|
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
758
812
|
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
@@ -393,6 +393,117 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
|
|
|
393
393
|
assert.equal((await requests()).length, 1, "An undeclared goal command is not exposed or dispatched");
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
test("Start goal stays Starting… until the goal is read after the server's answer, and no second start is sent", options, async t => {
|
|
397
|
+
const f = await fixture(t);
|
|
398
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
399
|
+
set: { delivery: "message", interruptsTurn: false }, pause: { delivery: "control", interruptsTurn: true }
|
|
400
|
+
} });
|
|
401
|
+
await f.notify("chat:1");
|
|
402
|
+
const starts = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
|
|
403
|
+
await f.primary.getByRole("button", { name: "Set goal", exact: true }).click();
|
|
404
|
+
const objective = f.page.getByRole("textbox", { name: "Goal objective" });
|
|
405
|
+
const start = f.page.locator(".assistant-goal__form button[type=submit]");
|
|
406
|
+
await objective.fill("Finish while the goal read is slow");
|
|
407
|
+
// Goal reads made after Start wait, as a slow server read does.
|
|
408
|
+
const reads = Promise.withResolvers();
|
|
409
|
+
let heldReads = 0;
|
|
410
|
+
await f.page.route("**/conversations/*/goal", async route => {
|
|
411
|
+
if (route.request().method() === "GET") {
|
|
412
|
+
heldReads += 1;
|
|
413
|
+
await reads.promise;
|
|
414
|
+
}
|
|
415
|
+
await route.continue();
|
|
416
|
+
});
|
|
417
|
+
await start.click();
|
|
418
|
+
await expect.poll(async () => (await starts()).length).toBe(1);
|
|
419
|
+
await expect.poll(() => heldReads).toBeGreaterThan(0);
|
|
420
|
+
// The server has answered; the control has not yet read the goal it started.
|
|
421
|
+
await expect(start).toHaveText("Starting…");
|
|
422
|
+
await expect(start).toBeDisabled();
|
|
423
|
+
await expect(objective).toHaveValue("Finish while the goal read is slow");
|
|
424
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "A second start" })), false);
|
|
425
|
+
reads.resolve();
|
|
426
|
+
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
|
|
427
|
+
assert.equal((await starts()).length, 1, "One Start sends one goal command");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a Start accepted behind the running turn stays Starting… until its goal runs, and says so when it never starts", options, async t => {
|
|
431
|
+
const f = await fixture(t);
|
|
432
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
433
|
+
set: { delivery: "message", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false }
|
|
434
|
+
} });
|
|
435
|
+
await f.notify("chat:1");
|
|
436
|
+
const starts = async () => (await f.state()).requests.filter(request =>
|
|
437
|
+
request.suffix === "/goal" && request.method === "POST" && request.input.action === "set");
|
|
438
|
+
const control = name => f.primary.getByRole("button", { name, exact: true });
|
|
439
|
+
const objective = f.page.getByRole("textbox", { name: "Goal objective" });
|
|
440
|
+
const start = f.page.locator(".assistant-goal__form button[type=submit]");
|
|
441
|
+
await f.command("mode", { mode: "queue-goal" });
|
|
442
|
+
await control("Set goal").click();
|
|
443
|
+
await objective.fill("Start after the current turn");
|
|
444
|
+
await start.click();
|
|
445
|
+
await expect.poll(async () => (await starts()).length).toBe(1);
|
|
446
|
+
await f.notify("chat:1");
|
|
447
|
+
// Accepted, but its goal waits behind the running turn.
|
|
448
|
+
await expect(start).toHaveText("Starting…");
|
|
449
|
+
await expect(start).toBeDisabled();
|
|
450
|
+
await expect(objective).toHaveValue("Start after the current turn");
|
|
451
|
+
await f.page.keyboard.press("Escape");
|
|
452
|
+
await expect(control("Starting…")).toBeVisible();
|
|
453
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "A second start" })), false);
|
|
454
|
+
await f.command("start-queued-goal", { id: "chat:1" });
|
|
455
|
+
await f.notify("chat:1");
|
|
456
|
+
await expect(control("Goal running")).toBeVisible();
|
|
457
|
+
assert.equal((await starts()).length, 1);
|
|
458
|
+
|
|
459
|
+
// A Start the conversation finishes without says so and keeps its objective.
|
|
460
|
+
await control("Goal running").click();
|
|
461
|
+
await f.page.getByRole("button", { name: "Cancel goal", exact: true }).click();
|
|
462
|
+
await expect(control("Set goal")).toBeVisible();
|
|
463
|
+
await objective.fill("Never takes effect");
|
|
464
|
+
await start.click();
|
|
465
|
+
await expect.poll(async () => (await starts()).length).toBe(2);
|
|
466
|
+
await f.notify("chat:1");
|
|
467
|
+
await expect(start).toBeDisabled();
|
|
468
|
+
await f.command("finish", { id: "chat:1" });
|
|
469
|
+
await f.notify("chat:1");
|
|
470
|
+
await expect(f.page.getByRole("alert").filter({ hasText: "The goal did not start. Check the chat before starting it again." })).toBeVisible();
|
|
471
|
+
await expect(objective).toHaveValue("Never takes effect");
|
|
472
|
+
await expect(start).toBeEnabled();
|
|
473
|
+
assert.equal((await starts()).length, 2);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("Cancel stays available while a Resume waits behind the running turn and ends that wait", options, async t => {
|
|
477
|
+
const f = await fixture(t);
|
|
478
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
479
|
+
set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
|
|
480
|
+
pause: { delivery: "control", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false }
|
|
481
|
+
} });
|
|
482
|
+
await f.notify("chat:1");
|
|
483
|
+
const control = name => f.primary.getByRole("button", { name, exact: true });
|
|
484
|
+
const menu = name => f.page.getByRole("button", { name, exact: true });
|
|
485
|
+
await control("Set goal").click();
|
|
486
|
+
await f.page.getByRole("textbox", { name: "Goal objective" }).fill("Resume after the current turn");
|
|
487
|
+
await f.page.locator(".assistant-goal__form button[type=submit]").click();
|
|
488
|
+
await expect(control("Goal running")).toHaveAttribute("aria-expanded", "false");
|
|
489
|
+
await control("Goal running").click();
|
|
490
|
+
await menu("Pause goal").click();
|
|
491
|
+
await expect(control("Goal paused")).toBeVisible();
|
|
492
|
+
await f.command("mode", { mode: "queue-goal" });
|
|
493
|
+
await menu("Resume goal").click();
|
|
494
|
+
await expect(menu("Resuming…")).toBeDisabled();
|
|
495
|
+
await f.notify("chat:1");
|
|
496
|
+
await expect(menu("Cancel goal")).toBeEnabled();
|
|
497
|
+
await expect(menu("Resuming…")).toBeDisabled();
|
|
498
|
+
await menu("Cancel goal").click();
|
|
499
|
+
await expect(control("Set goal")).toBeVisible();
|
|
500
|
+
await f.command("finish", { id: "chat:1" });
|
|
501
|
+
await f.notify("chat:1");
|
|
502
|
+
await expect(f.page.getByRole("alert")).toHaveCount(0);
|
|
503
|
+
const commands = (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
|
|
504
|
+
assert.deepEqual(commands.map(request => request.input.action), ["set", "pause", "resume", "cancel"]);
|
|
505
|
+
});
|
|
506
|
+
|
|
396
507
|
test("a supplied goal view keeps its pinned target and refreshes through the retained binding without polling", options, async t => {
|
|
397
508
|
const f = await fixture(t);
|
|
398
509
|
const goal = { id: "retained-goal", objective: "The plain product objective", status: "paused",
|
|
@@ -1135,6 +1246,85 @@ test("a deferred follow-up explains its wait and steers once the working turn ac
|
|
|
1135
1246
|
await expect(f.primary.getByText(/^Pending/)).toHaveCount(0);
|
|
1136
1247
|
});
|
|
1137
1248
|
|
|
1249
|
+
// An application keeps a retained view mounted but inactive while another
|
|
1250
|
+
// conversation is shown. A message waiting for admission there was never sent:
|
|
1251
|
+
// hiding its view must not fail it as "Message could not be sent.", and the
|
|
1252
|
+
// person who moved on must not have to return before it is sent.
|
|
1253
|
+
test("a deferred message sends while its view is hidden once the session admits it", options, async t => {
|
|
1254
|
+
const f = await fixture(t);
|
|
1255
|
+
await f.page.evaluate(() => {
|
|
1256
|
+
window.readerActive = window.conversationFixture.activity(true);
|
|
1257
|
+
window.deferredBinding = window.conversationFixture.acquireConversation({
|
|
1258
|
+
conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true, active: window.readerActive
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
const deferred = () => f.page.evaluate(() => {
|
|
1262
|
+
const runtime = window.deferredBinding.runtime.value;
|
|
1263
|
+
const { id, status, error, waiting } = runtime.delivery.state.messages.find(message => message.text === "Queued while working") || {};
|
|
1264
|
+
return { available: runtime.available.value, status: runtime.snapshot.value?.status, message: { id, status, error, waiting },
|
|
1265
|
+
subscriptions: window.conversationFixture.socket.inspect().active };
|
|
1266
|
+
});
|
|
1267
|
+
await expect.poll(async () => (await deferred()).available).toBe(true);
|
|
1268
|
+
const { subscriptions } = await deferred();
|
|
1269
|
+
await f.page.evaluate(() => { window.original = window.deferredBinding.runtime.value.send({ message: "Original nonsteerable request" }); });
|
|
1270
|
+
await expect.poll(async () => (await deferred()).status).toBe("working");
|
|
1271
|
+
await f.page.evaluate(() => { window.queued = window.deferredBinding.runtime.value.send({ message: "Queued while working" }); });
|
|
1272
|
+
await expect.poll(async () => (await deferred()).message.waiting).toBe("Waiting for the assistant to finish this step");
|
|
1273
|
+
await f.page.evaluate(() => { window.readerActive.value = false; });
|
|
1274
|
+
await expect.poll(async () => (await deferred()).available).toBe(false);
|
|
1275
|
+
const hidden = await deferred();
|
|
1276
|
+
assert.equal(hidden.message.status, "pending", `hiding the view is not a failed delivery: ${hidden.message.error}`);
|
|
1277
|
+
assert.equal(hidden.subscriptions, subscriptions, "the waiting message keeps its live state");
|
|
1278
|
+
assert.equal((await f.sent()).length, 1, "the working turn still cannot take the message");
|
|
1279
|
+
await f.command("finish", { id: "chat:2" });
|
|
1280
|
+
await f.notify("chat:2");
|
|
1281
|
+
await expect.poll(async () => (await f.sent()).length).toBe(2);
|
|
1282
|
+
const [, followUp] = await f.sent();
|
|
1283
|
+
assert.deepEqual([followUp.input.messageId, followUp.input.text], [hidden.message.id, "Queued while working"]);
|
|
1284
|
+
assert.equal((await f.page.evaluate(() => window.queued)).status, "accepted");
|
|
1285
|
+
const sent = await deferred();
|
|
1286
|
+
assert.equal(sent.available, false, "the message was sent while its view stayed hidden");
|
|
1287
|
+
await expect.poll(async () => (await deferred()).subscriptions, { message: "an empty queue releases the hidden view's subscription" }).toBe(subscriptions - 1);
|
|
1288
|
+
await f.page.evaluate(() => { window.readerActive.value = true; });
|
|
1289
|
+
await expect.poll(async () => (await deferred()).available).toBe(true);
|
|
1290
|
+
assert.equal((await f.sent()).length, 2, "the queued message is sent once");
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
// A lost subscription leaves the last snapshot shown with its error while it
|
|
1294
|
+
// retries. Its refresh then reads nothing, so that stale error says nothing
|
|
1295
|
+
// about the unsent message: it waits for the recovered state and sends once.
|
|
1296
|
+
test("a deferred message waits through a failing resubscription instead of failing", options, async t => {
|
|
1297
|
+
const f = await fixture(t);
|
|
1298
|
+
await f.page.evaluate(() => {
|
|
1299
|
+
window.deferredBinding = window.conversationFixture.acquireConversation({
|
|
1300
|
+
conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
const deferred = () => f.page.evaluate(() => {
|
|
1304
|
+
const runtime = window.deferredBinding.runtime.value;
|
|
1305
|
+
const { id, status, error } = runtime.delivery.state.messages.find(message => message.text === "Sent while reconnecting") || {};
|
|
1306
|
+
return { available: runtime.available.value, error: runtime.error.value, message: { id, status, error } };
|
|
1307
|
+
});
|
|
1308
|
+
await expect.poll(async () => (await deferred()).available).toBe(true);
|
|
1309
|
+
let failing = true;
|
|
1310
|
+
await f.page.route(url => url.pathname.endsWith("/conversations/chat%3A2"), route => failing && route.request().method() === "GET"
|
|
1311
|
+
? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "Conversation temporarily unavailable." }) })
|
|
1312
|
+
: route.continue());
|
|
1313
|
+
await f.page.evaluate(() => window.conversationFixture.socket.reconnect());
|
|
1314
|
+
await expect.poll(async () => (await deferred()).error).toBe("Conversation temporarily unavailable.");
|
|
1315
|
+
assert.equal((await deferred()).available, true, "the last snapshot stays shown");
|
|
1316
|
+
await f.page.evaluate(() => { window.queued = window.deferredBinding.runtime.value.send({ message: "Sent while reconnecting" }); });
|
|
1317
|
+
await expect.poll(async () => (await deferred()).message.status).toBe("pending");
|
|
1318
|
+
const waiting = await deferred();
|
|
1319
|
+
assert.equal((await f.sent()).length, 0, "nothing is admitted against the stale snapshot");
|
|
1320
|
+
failing = false;
|
|
1321
|
+
// The subscription's own retry, not the message, recovers the state.
|
|
1322
|
+
await expect.poll(async () => (await f.sent()).length, { timeout: 15_000 }).toBe(1);
|
|
1323
|
+
const [sent] = await f.sent();
|
|
1324
|
+
assert.deepEqual([sent.input.messageId, sent.input.text], [waiting.message.id, "Sent while reconnecting"]);
|
|
1325
|
+
assert.equal((await f.page.evaluate(() => window.queued)).status, "accepted");
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1138
1328
|
for (const steer of [false, true]) {
|
|
1139
1329
|
test(`Stop cancels locally queued native ${steer ? "steering" : "startup"} follow-ups`, options, async t => {
|
|
1140
1330
|
const f = await fixture(t);
|
|
@@ -3293,10 +3483,13 @@ test("a background goal read keeps current pinned controls available and seriali
|
|
|
3293
3483
|
await expect(f.page.getByRole("button", { name: "Cancel goal", exact: true })).toBeDisabled();
|
|
3294
3484
|
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("cancel")), false);
|
|
3295
3485
|
releaseCommand();
|
|
3296
|
-
|
|
3486
|
+
// The answered Pause stays Pausing… until a goal read made after its answer
|
|
3487
|
+
// lands, so the stale goal never offers its controls again.
|
|
3488
|
+
await expect(f.page.getByRole("button", { name: "Pausing…", exact: true })).toBeDisabled();
|
|
3297
3489
|
assert.equal(commands.length, 1, "Only the actual Pause command was admitted");
|
|
3298
3490
|
holdRead = false;
|
|
3299
3491
|
releaseRead();
|
|
3492
|
+
await expect(pause).toBeEnabled();
|
|
3300
3493
|
await f.page.evaluate(() => window.fixtureBackgroundGoalRead);
|
|
3301
3494
|
assert.deepEqual(await f.sent(), []);
|
|
3302
3495
|
} finally {
|
|
@@ -342,6 +342,7 @@ test("a real subscription slower than ten seconds keeps its observer, says it is
|
|
|
342
342
|
const barrier = f.holdNextRead(id);
|
|
343
343
|
const view = f.view(socket, id);
|
|
344
344
|
await until(f.changes, () => barrier.started);
|
|
345
|
+
const readStartedAt = performance.now();
|
|
345
346
|
const subscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
|
|
346
347
|
while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
|
|
347
348
|
assert.equal(view.errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
|
|
@@ -350,6 +351,9 @@ test("a real subscription slower than ten seconds keeps its observer, says it is
|
|
|
350
351
|
assert.equal(socket.id, socketId);
|
|
351
352
|
assert.equal(socket.connected, true);
|
|
352
353
|
assert.equal(view.states.length, 0);
|
|
354
|
+
// The client's ten-second wait starts before the server's clock does, so the
|
|
355
|
+
// read is held until the server has also waited ten seconds.
|
|
356
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, 10_001 - (performance.now() - readStartedAt))));
|
|
353
357
|
barrier.resolve();
|
|
354
358
|
await until(f.changes, () => view.states.length === 1);
|
|
355
359
|
assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-wait");
|