@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.8",
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.6",
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 || goalAction.value || !goalTargetCurrent.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
- const result = await submitRequest(retry?.payload || {
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
- return result;
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
- const result = await api.updateConversationGoal(identity.conversationId,
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
- if (current.value) {
741
- goalAction.value = "";
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 || delivery.state.messages.some(message => message.status === "uncertain"),
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
- await expect(pause).toBeEnabled();
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 {