@jskit-ai/assistant-runtime 0.1.216 → 0.1.218

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.216",
3
+ "version": "0.1.218",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -12,7 +12,7 @@
12
12
  "./server": "./src/server/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/assistant-core": "0.1.223",
15
+ "@jskit-ai/assistant-core": "0.1.225",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
@@ -84,7 +84,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
84
84
  const error = ref("");
85
85
  const loading = ref(true);
86
86
  const stopping = ref(false);
87
- const goalPending = ref(false);
87
+ const goalAction = ref("");
88
88
  const goalTargetCurrent = ref(false);
89
89
  const goalError = ref("");
90
90
  const goalView = shallowRef(null);
@@ -115,7 +115,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
115
115
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
116
116
  const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
117
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") &&
118
+ const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
119
119
  (queueWhileSending !== false || !delivery.state.sending) &&
120
120
  !delivery.state.messages.some(message => message.status === "uncertain" &&
121
121
  (snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
@@ -221,6 +221,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
221
221
  // Keep the open goal menu while its first native identity arrives. The
222
222
  // pending read disables commands until it supplies the new exact target.
223
223
  goalTargetCurrent.value = false;
224
+ goalLoadError.value = "";
224
225
  goalReadController?.abort();
225
226
  }
226
227
  if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
@@ -338,7 +339,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
338
339
  draftAttachments.value = [];
339
340
  draftRetry.value = null;
340
341
  goalError.value = "";
341
- goalPending.value = false;
342
+ goalAction.value = "";
342
343
  goalView.value = null;
343
344
  goalLoadError.value = "";
344
345
  goalReadQueued = false;
@@ -441,7 +442,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
441
442
  if (pendingMessages.has(messageId)) return false;
442
443
  // Register before the original serial tail so Stop/retirement also reaches
443
444
  // a local follower whose deliver callback has not started yet.
444
- const pending = { controller: new AbortController(), started: false, dispatched: false, deferred, appAdmission };
445
+ const pending = { controller: new AbortController(), started: false, dispatched: false };
445
446
  const { controller } = pending;
446
447
  pendingMessages.set(messageId, pending);
447
448
  try {
@@ -592,7 +593,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
592
593
  const view = goalView.value;
593
594
  const target = view?.target;
594
595
  const command = target?.capabilities?.goalCommands?.[action];
595
- if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value || !goalTargetCurrent.value ||
596
+ if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
596
597
  !["message", "control"].includes(command?.delivery) ||
597
598
  delivery.state.messages.some(message => message.status === "uncertain")) return false;
598
599
  if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
@@ -605,7 +606,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
605
606
  expectedGoalId: view.goal?.id || null,
606
607
  ...(action === "set" ? { objective: String(input.objective || "").trim(),
607
608
  ...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
608
- goalPending.value = true;
609
+ goalAction.value = action;
609
610
  goalError.value = "";
610
611
  try {
611
612
  if (retry || command.delivery === "message") {
@@ -630,7 +631,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
630
631
  return false;
631
632
  } finally {
632
633
  if (current.value) {
633
- goalPending.value = false;
634
+ goalAction.value = "";
634
635
  await goalRead;
635
636
  await refreshGoal();
636
637
  subscription?.reload();
@@ -642,14 +643,19 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
642
643
  if (!readsGoals.value) return null;
643
644
  const capabilities = view?.target?.capabilities || {};
644
645
  const commands = capabilities.goalCommands || {};
646
+ const loading = !goalTargetCurrent.value && !goalLoadError.value;
647
+ const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
645
648
  return {
646
- enabled: active.value && Boolean((view?.status === "available" && !goalLoadError.value) || goalError.value),
649
+ enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
650
+ loading,
651
+ available: Boolean(goalAvailable),
652
+ action: goalAction.value,
647
653
  tokenBudgetSupported: capabilities.goalBudgets === true,
648
- goal: view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
654
+ goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
649
655
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
650
- pending: goalPending.value || !goalTargetCurrent.value || !available.value || delivery.state.sending ||
656
+ pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
651
657
  delivery.state.messages.some(message => message.status === "uncertain"),
652
- error: goalError.value,
658
+ error: goalError.value || goalLoadError.value,
653
659
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
654
660
  cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
655
661
  ...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
@@ -660,7 +666,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
660
666
  if (!editable.value || stopping.value) return false;
661
667
  stopping.value = true;
662
668
  for (const pending of pendingMessages.values()) {
663
- if ((pending.deferred || pending.appAdmission) && !pending.dispatched) pending.controller.abort();
669
+ if (!pending.dispatched) pending.controller.abort();
664
670
  }
665
671
  try { return await api.cancelConversation(identity.conversationId); }
666
672
  catch (failure) { receiveError(failure); return false; }
@@ -295,6 +295,12 @@ test("a supplied goal view keeps its pinned target and refreshes through the ret
295
295
  failedRead = true;
296
296
  await f.page.evaluate(() => window.conversationFixture.current().refreshGoal());
297
297
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalLoadError.value)).toBe("Goal status is unavailable.");
298
+ await expect(f.primary.getByRole("button", { name: "Goal unavailable", exact: true })).toBeVisible();
299
+ assert.deepEqual(await f.page.evaluate(() => {
300
+ const state = window.conversationFixture.current().goalState.value;
301
+ return { available: state.available, goal: state.goal, pending: state.pending };
302
+ }), { available: false, goal: null, pending: true });
303
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
298
304
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().error.value), "",
299
305
  "Passive goal failure does not enter conversation or app-wide request recovery");
300
306
  await expect(f.input).toBeEnabled();
@@ -877,6 +883,41 @@ test("opted-in nonsteerable follow-ups use the original serial queue and dispatc
877
883
  assert.equal((await f.sent()).at(-1).input.steer, true);
878
884
  });
879
885
 
886
+ for (const steer of [false, true]) {
887
+ test(`Stop cancels locally queued native ${steer ? "steering" : "startup"} follow-ups`, options, async t => {
888
+ const f = await fixture(t);
889
+ await f.command("steering", { id: "chat:1", enabled: true });
890
+ await f.command("mode", { mode: "hold" });
891
+ await f.page.evaluate(() => {
892
+ window.firstNative = window.conversationFixture.current().send({ message: "First held instruction" });
893
+ });
894
+ await expect.poll(async () => (await f.state()).held).toBe(1);
895
+ await f.command("question", { id: "chat:1", text: "Starting native work", pending: true });
896
+ await f.notify("chat:1");
897
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
898
+ await f.page.evaluate(steer => {
899
+ window.queuedNative = window.conversationFixture.current().send({ message: "Queued native follow-up",
900
+ request: { text: "Queued native follow-up", steer } });
901
+ }, steer);
902
+ assert.equal((await f.sent()).length, 1);
903
+ const stopGate = Promise.withResolvers();
904
+ t.after(() => stopGate.resolve());
905
+ await f.page.route("**/cancel", async route => { await stopGate.promise; await route.continue(); });
906
+ await f.page.evaluate(() => { window.stoppingNative = window.conversationFixture.current().cancel(); });
907
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().stopping.value)).toBe(true);
908
+ const canSendDuringStop = await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value);
909
+ stopGate.resolve();
910
+ await f.page.evaluate(() => window.stoppingNative);
911
+ await f.command("mode", { mode: "accepted" });
912
+ await f.command("accept");
913
+ await f.page.evaluate(() => window.firstNative);
914
+ const queuedResult = await f.page.evaluate(() => window.queuedNative);
915
+ assert.equal((await f.sent()).length, 1, "Stop must prevent a local follower from dispatching after the first receipt");
916
+ assert.equal(queuedResult, false);
917
+ assert.equal(canSendDuringStop, false, "new sends wait for Stop confirmation");
918
+ });
919
+ }
920
+
880
921
  for (const action of ["stop", "actor", "denied"]) {
881
922
  test(`opted-in local followers cannot dispatch after ${action}`, options, async t => {
882
923
  const f = await fixture(t);
@@ -2912,6 +2953,11 @@ test("changing the observed native segment blocks cached goal controls until the
2912
2953
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.segmentId)).toBe("replacement-native-observation");
2913
2954
  await expect.poll(() => Boolean(releaseRead)).toBe(true);
2914
2955
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.pending), true);
2956
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.loading), true);
2957
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.goal), null);
2958
+ await expect(f.primary.getByRole("button", { name: "Loading goal…", exact: true })).toBeVisible();
2959
+ await expect(f.page.getByRole("button", { name: "Pause goal", exact: true })).toHaveCount(0);
2960
+ await expect(f.page.getByRole("button", { name: "Resuming…", exact: true })).toHaveCount(0);
2915
2961
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("pause")), false);
2916
2962
  assert.deepEqual(commands, [], "A previous observation cannot authorize a command for the replacement thread");
2917
2963
  view.goal.id = "replacement-goal";
@@ -3011,3 +3057,31 @@ for (const width of [1280, 390]) test(`ten real messages per history load across
3011
3057
  await expect(f.primary.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
3012
3058
  assert.deepEqual(await f.sent(), []);
3013
3059
  });
3060
+
3061
+
3062
+ test("initial goal loading shows no goal commands until the empty goal is confirmed", options, async t => {
3063
+ const f = await fixture(t);
3064
+ const waiting = Promise.withResolvers();
3065
+ const entered = Promise.withResolvers();
3066
+ await f.page.route("**/conversations/*/goal", async route => {
3067
+ entered.resolve();
3068
+ await waiting.promise;
3069
+ return route.fulfill({ json: { status: "available", goal: null, target: { segmentId: "empty-goal",
3070
+ capabilities: { goals: true, goalBudgets: false, goalCommands: {
3071
+ set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false }
3072
+ } } } } });
3073
+ });
3074
+ try {
3075
+ await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
3076
+ await entered.promise;
3077
+ const loading = f.primary.getByRole("button", { name: "Loading goal…", exact: true });
3078
+ await expect(loading).toBeVisible();
3079
+ await loading.click();
3080
+ await expect(f.page.getByRole("button", { name: "Start goal", exact: true })).toHaveCount(0);
3081
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
3082
+ waiting.resolve();
3083
+ await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toBeVisible();
3084
+ await expect(f.page.getByRole("button", { name: "Start goal", exact: true })).toBeVisible();
3085
+ await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
3086
+ } finally { waiting.resolve(); }
3087
+ });