@jskit-ai/assistant-runtime 0.2.8 → 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.
|
@@ -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,7 +13,7 @@
|
|
|
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": {
|
|
@@ -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);
|
|
@@ -220,6 +222,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
220
222
|
let goalSegment = null;
|
|
221
223
|
let goalReadController = null;
|
|
222
224
|
let goalReadQueued = false;
|
|
225
|
+
let goalCommandSequence = 0;
|
|
223
226
|
const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
|
|
224
227
|
[...readers.values()].some(reader => toValue(reader.goal) === true));
|
|
225
228
|
watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
|
|
@@ -377,6 +380,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
377
380
|
for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
|
|
378
381
|
clearConversationView();
|
|
379
382
|
goalAction.value = "";
|
|
383
|
+
goalSettling.value = false;
|
|
380
384
|
goalReadQueued = false;
|
|
381
385
|
questions.reset();
|
|
382
386
|
}, { flush: "sync" });
|
|
@@ -489,8 +493,10 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
489
493
|
if (deferred) deferredWaits.value += 1;
|
|
490
494
|
try {
|
|
491
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.
|
|
492
498
|
messageId, queue, isCurrent: () => current.value, onAccepted,
|
|
493
|
-
receiptTurns: turns, uncertainOnError: true,
|
|
499
|
+
receiptTurns: payload.goalRequest ? null : turns, uncertainOnError: true,
|
|
494
500
|
async deliver(submission) {
|
|
495
501
|
pending.started = true;
|
|
496
502
|
let admission = null;
|
|
@@ -696,7 +702,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
696
702
|
// it stops and goes straight to the goal operation, even while a message is
|
|
697
703
|
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
698
704
|
const stopping = action === "pause" || action === "cancel";
|
|
699
|
-
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 ||
|
|
700
707
|
!["message", "control"].includes(command?.delivery) ||
|
|
701
708
|
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
702
709
|
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
@@ -709,11 +716,16 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
709
716
|
expectedGoalId: view.goal?.id || null,
|
|
710
717
|
...(action === "set" ? { objective: String(input.objective || "").trim(),
|
|
711
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;
|
|
712
722
|
goalAction.value = action;
|
|
723
|
+
goalSettling.value = false;
|
|
713
724
|
goalError.value = "";
|
|
714
725
|
try {
|
|
726
|
+
let result;
|
|
715
727
|
if (retry || !stopping && command.delivery === "message") {
|
|
716
|
-
|
|
728
|
+
result = await submitRequest(retry?.payload || {
|
|
717
729
|
message: action === "set" ? request.objective
|
|
718
730
|
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
719
731
|
goalRequest: request
|
|
@@ -724,11 +736,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
724
736
|
}
|
|
725
737
|
return false;
|
|
726
738
|
}
|
|
727
|
-
|
|
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"));
|
|
728
743
|
}
|
|
729
|
-
|
|
730
|
-
command.delivery === "message" ? { ...request, messageId } : request);
|
|
731
|
-
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
744
|
+
accepted = true;
|
|
732
745
|
return result;
|
|
733
746
|
} catch (failure) {
|
|
734
747
|
if (current.value) {
|
|
@@ -737,13 +750,43 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
737
750
|
}
|
|
738
751
|
return false;
|
|
739
752
|
} finally {
|
|
740
|
-
|
|
741
|
-
|
|
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) {
|
|
742
758
|
await goalRead;
|
|
743
759
|
await refreshGoal();
|
|
760
|
+
if (accepted && !stopping) await awaitGoalChange(before, sequence);
|
|
744
761
|
subscription?.reload();
|
|
745
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
|
+
});
|
|
746
785
|
}
|
|
786
|
+
if (settled()) return;
|
|
787
|
+
await goalRead;
|
|
788
|
+
await refreshGoal();
|
|
789
|
+
if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
|
|
747
790
|
}
|
|
748
791
|
const goalState = computed(() => {
|
|
749
792
|
const view = goalView.value;
|
|
@@ -752,7 +795,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
752
795
|
const commands = capabilities.goalCommands || {};
|
|
753
796
|
const loading = !goalTargetCurrent.value && !goalLoadError.value;
|
|
754
797
|
const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
|
|
755
|
-
const stopPending = Boolean(goalAction.value) || !goalAvailable || !available.value;
|
|
798
|
+
const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
|
|
756
799
|
return {
|
|
757
800
|
enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
|
|
758
801
|
loading,
|
|
@@ -762,7 +805,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
762
805
|
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
763
806
|
goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
764
807
|
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
765
|
-
pending: stopPending || delivery.state.sending ||
|
|
808
|
+
pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
|
|
809
|
+
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
766
810
|
stopPending,
|
|
767
811
|
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
768
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",
|
|
@@ -3372,10 +3483,13 @@ test("a background goal read keeps current pinned controls available and seriali
|
|
|
3372
3483
|
await expect(f.page.getByRole("button", { name: "Cancel goal", exact: true })).toBeDisabled();
|
|
3373
3484
|
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("cancel")), false);
|
|
3374
3485
|
releaseCommand();
|
|
3375
|
-
|
|
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();
|
|
3376
3489
|
assert.equal(commands.length, 1, "Only the actual Pause command was admitted");
|
|
3377
3490
|
holdRead = false;
|
|
3378
3491
|
releaseRead();
|
|
3492
|
+
await expect(pause).toBeEnabled();
|
|
3379
3493
|
await f.page.evaluate(() => window.fixtureBackgroundGoalRead);
|
|
3380
3494
|
assert.deepEqual(await f.sent(), []);
|
|
3381
3495
|
} finally {
|