@frockbot/plugin-shell 0.3.2 → 0.3.3

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.
@@ -974,6 +974,52 @@ describe("Bot selection", () => {
974
974
  });
975
975
 
976
976
  describe("detached Turn projection", () => {
977
+ test("shows a dynamic call as its namespace/tool with inner arguments", () => {
978
+ const messages: Parameters<typeof projectCompletedRuns>[0] = [];
979
+ projectCompletedRuns(
980
+ messages,
981
+ [],
982
+ [
983
+ {
984
+ runId: "run-dynamic",
985
+ input: "Find open issues",
986
+ status: "completed",
987
+ responseText: "Done",
988
+ events: [
989
+ {
990
+ type: "tool/call",
991
+ call: {
992
+ id: "tool-1",
993
+ name: "call_dynamic_tool",
994
+ input: {
995
+ namespace: "user-Github--acme",
996
+ toolName: "search_issues",
997
+ argumentsJson: '{"query":"is:open"}',
998
+ },
999
+ },
1000
+ },
1001
+ {
1002
+ type: "tool/result",
1003
+ callId: "tool-1",
1004
+ content: "[]",
1005
+ isError: false,
1006
+ },
1007
+ ],
1008
+ },
1009
+ ],
1010
+ );
1011
+
1012
+ expect(messages[1]?.tools).toEqual([
1013
+ {
1014
+ id: "tool-1",
1015
+ name: "user-Github--acme/search_issues",
1016
+ input: { query: "is:open" },
1017
+ status: "completed",
1018
+ text: "[]",
1019
+ },
1020
+ ]);
1021
+ });
1022
+
977
1023
  test("projects a completed run before it can be acknowledged", () => {
978
1024
  const messages: Parameters<typeof projectCompletedRuns>[0] = [];
979
1025
  const projected = projectCompletedRuns(
@@ -2651,3 +2697,165 @@ describe("Connection operation reconciliation", () => {
2651
2697
  expect(commandIds[1]).not.toBe(commandIds[0]);
2652
2698
  });
2653
2699
  });
2700
+
2701
+ describe("a message sent while a Turn is running", () => {
2702
+ test("is accepted, and carries the intent to supersede what is running", async () => {
2703
+ Object.defineProperty(globalThis, "window", {
2704
+ configurable: true,
2705
+ value: {
2706
+ location: { href: "https://app.example/?bot=primary" },
2707
+ history: { replaceState: () => undefined },
2708
+ },
2709
+ });
2710
+ let provided: Ref<FrockBotWebData> | undefined;
2711
+ const sent: {
2712
+ text: string;
2713
+ supersedes?: { runId?: string };
2714
+ }[] = [];
2715
+ let releaseFirst!: () => void;
2716
+ const firstTurn = new Promise<void>((resolve) => {
2717
+ releaseFirst = resolve;
2718
+ });
2719
+ await shellClientPlugin({
2720
+ transport: {
2721
+ turn: async (_botId, text, _signal, commandId, _skills, supersedes) => {
2722
+ sent.push({ text, ...(supersedes ? { supersedes } : {}) });
2723
+ if (sent.length === 1) await firstTurn;
2724
+ return { runId: commandId, text: `answer to ${text}`, events: [] };
2725
+ },
2726
+ readConfiguration: () =>
2727
+ Promise.resolve(initializeBotSettingsV1("primary")),
2728
+ listRuns: () => Promise.resolve([]),
2729
+ listNotifications: () => Promise.resolve([]),
2730
+ },
2731
+ slot: () => () => {},
2732
+ inject: () => {
2733
+ throw new Error("unexpected client provider injection");
2734
+ },
2735
+ provide: (_key, value) => {
2736
+ provided = value as Ref<FrockBotWebData>;
2737
+ return () => {};
2738
+ },
2739
+ });
2740
+ if (!provided) throw new Error("shell data was not provided");
2741
+ provided.value.activeBotId = "primary";
2742
+ provided.value.composerContext = "primary";
2743
+
2744
+ const first = provided.value.sendPrompt("first");
2745
+ await Promise.resolve();
2746
+ const runningRunId = provided.value.activeRunId;
2747
+ expect(runningRunId).toBeString();
2748
+
2749
+ // The composer is open while a Turn runs, and what it sends replaces it.
2750
+ const second = provided.value.sendPrompt("second");
2751
+ releaseFirst();
2752
+ expect(await first).toMatchObject({ accepted: true });
2753
+ expect(await second).toMatchObject({ accepted: true });
2754
+
2755
+ expect(sent.map((entry) => entry.text).sort()).toEqual(["first", "second"]);
2756
+ // Every send carries the intent. The first had observed no run, so it
2757
+ // carries no provenance — and that is exactly the send that used to be
2758
+ // refused when a person typed faster than the client could observe the
2759
+ // Turn it had just started.
2760
+ expect(sent.find((entry) => entry.text === "first")?.supersedes).toEqual(
2761
+ {},
2762
+ );
2763
+ expect(sent.find((entry) => entry.text === "second")?.supersedes).toEqual({
2764
+ runId: runningRunId!,
2765
+ });
2766
+ });
2767
+
2768
+ test("greys the queued Turn, and un-greys it when it starts", () => {
2769
+ const state: Pick<
2770
+ FrockBotWebData,
2771
+ "messages" | "activeRunId" | "runningRunId" | "activeRun"
2772
+ > = { messages: [] };
2773
+
2774
+ projectDurableRuns(
2775
+ state,
2776
+ [],
2777
+ [
2778
+ { runId: "run-1", input: "first", events: [], status: "running" },
2779
+ {
2780
+ runId: "run-2",
2781
+ input: "second",
2782
+ events: [],
2783
+ status: "running",
2784
+ queued: true,
2785
+ },
2786
+ ],
2787
+ );
2788
+
2789
+ // Ordinary messages; the waiting one is simply held back.
2790
+ expect(state.messages.map((message) => message.pending)).toEqual([
2791
+ undefined,
2792
+ undefined,
2793
+ true,
2794
+ true,
2795
+ ]);
2796
+ // A new message supersedes the newest unsettled Turn; Stop cancels the one
2797
+ // that is actually executing.
2798
+ expect(state.activeRunId).toBe("run-2");
2799
+ expect(state.runningRunId).toBe("run-1");
2800
+
2801
+ projectDurableRuns(
2802
+ state,
2803
+ [],
2804
+ [
2805
+ {
2806
+ runId: "run-1",
2807
+ input: "first",
2808
+ events: [],
2809
+ status: "superseded",
2810
+ failure: "Interrupted by your next message.",
2811
+ },
2812
+ { runId: "run-2", input: "second", events: [], status: "running" },
2813
+ ],
2814
+ );
2815
+
2816
+ expect(state.messages.every((message) => !message.pending)).toBe(true);
2817
+ expect(state.activeRunId).toBe("run-2");
2818
+ expect(state.runningRunId).toBe("run-2");
2819
+ // The superseded Turn keeps the quiet treatment a stopped one gets.
2820
+ expect(state.messages[1]).toMatchObject({
2821
+ status: "aborted",
2822
+ text: "Interrupted by your next message.",
2823
+ });
2824
+ });
2825
+
2826
+ test("a reload reconstructs the greyed state from durable runs alone", () => {
2827
+ const reloaded: Pick<
2828
+ FrockBotWebData,
2829
+ "messages" | "activeRunId" | "runningRunId" | "activeRun"
2830
+ > = { messages: [] };
2831
+
2832
+ projectDurableRuns(
2833
+ reloaded,
2834
+ [],
2835
+ [
2836
+ {
2837
+ runId: "run-1",
2838
+ input: "first",
2839
+ events: [],
2840
+ status: "superseded",
2841
+ failure: "Interrupted by your next message.",
2842
+ },
2843
+ {
2844
+ runId: "run-2",
2845
+ input: "second",
2846
+ events: [],
2847
+ status: "running",
2848
+ queued: true,
2849
+ },
2850
+ ],
2851
+ );
2852
+
2853
+ expect(reloaded.messages[2]).toMatchObject({
2854
+ role: "user",
2855
+ text: "second",
2856
+ pending: true,
2857
+ });
2858
+ expect(reloaded.runningRunId).toBeUndefined();
2859
+ expect(reloaded.activeRunId).toBe("run-2");
2860
+ });
2861
+ });
@@ -98,13 +98,49 @@ import "@frockbot/client-core/fonts.css";
98
98
  import "./styles.css";
99
99
  import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
100
100
 
101
+ function presentedToolCall(call: NonNullable<ClientTurnEvent["call"]>): {
102
+ name: string;
103
+ input?: unknown;
104
+ } {
105
+ if (
106
+ call.name === "call_dynamic_tool" &&
107
+ typeof call.input === "object" &&
108
+ call.input !== null &&
109
+ !Array.isArray(call.input)
110
+ ) {
111
+ const input = call.input as Record<string, unknown>;
112
+ if (
113
+ typeof input.namespace === "string" &&
114
+ typeof input.toolName === "string"
115
+ ) {
116
+ let innerArguments: unknown = {};
117
+ if (typeof input.argumentsJson === "string") {
118
+ try {
119
+ innerArguments = JSON.parse(input.argumentsJson) as unknown;
120
+ } catch {
121
+ innerArguments = {};
122
+ }
123
+ }
124
+ return {
125
+ name: `${input.namespace}/${input.toolName}`,
126
+ input: innerArguments,
127
+ };
128
+ }
129
+ }
130
+ return {
131
+ name: call.name,
132
+ ...(call.input === undefined ? {} : { input: call.input }),
133
+ };
134
+ }
135
+
101
136
  function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
102
137
  const tools = new Map<string, WebToolActivity>();
103
138
  for (const event of events) {
104
139
  if (event.type === "tool/call" && event.call) {
140
+ const presented = presentedToolCall(event.call);
105
141
  tools.set(event.call.id, {
106
142
  id: event.call.id,
107
- name: event.call.name,
143
+ ...presented,
108
144
  status: "running",
109
145
  });
110
146
  }
@@ -179,7 +215,7 @@ function tasksFrom(events: ClientTurnEvent[]): WebTaskChip[] {
179
215
 
180
216
  type DurableRunProjectionState = Pick<
181
217
  FrockBotWebData,
182
- "messages" | "activeRunId" | "activeRun" | "error"
218
+ "messages" | "activeRunId" | "runningRunId" | "activeRun" | "error"
183
219
  >;
184
220
 
185
221
  /**
@@ -218,7 +254,8 @@ function isTerminalRun(run: ClientRun): boolean {
218
254
  return (
219
255
  run.status === "completed" ||
220
256
  run.status === "failed" ||
221
- run.status === "cancelled"
257
+ run.status === "cancelled" ||
258
+ run.status === "superseded"
222
259
  );
223
260
  }
224
261
 
@@ -235,6 +272,23 @@ function assistantMessage(
235
272
  role: "assistant",
236
273
  text: run.responseText ?? "",
237
274
  status: "streaming",
275
+ // A Turn that has not started shows nothing of its own: the greyed user
276
+ // message is the whole of what the thread says about it.
277
+ ...(run.queued ? { pending: true } : {}),
278
+ tools: toolsFrom(run.events),
279
+ sends: sendsFrom(run.events),
280
+ tasks: tasksFrom(run.events),
281
+ };
282
+ }
283
+ if (run.status === "superseded") {
284
+ // The same quiet treatment a stopped Turn gets. It keeps everything it
285
+ // already sent; the line only says why it ends where it does.
286
+ return {
287
+ id: `${run.runId}:assistant`,
288
+ runId: run.runId,
289
+ role: "assistant",
290
+ text: run.failure ?? "Interrupted by your next message.",
291
+ status: "aborted",
238
292
  tools: toolsFrom(run.events),
239
293
  sends: sendsFrom(run.events),
240
294
  tasks: tasksFrom(run.events),
@@ -321,6 +375,9 @@ export function projectDurableRuns(
321
375
  // Busy state and the banner are separate: a running Turn keeps the composer
322
376
  // busy without producing a banner of its own.
323
377
  let busyRunId: string | undefined;
378
+ // The Turn Stop targets: the one that is executing, never the one waiting
379
+ // behind it.
380
+ let runningRunId: string | undefined;
324
381
  for (const run of runs) {
325
382
  const notification = notifications.find(
326
383
  (candidate) => candidate.runId === run.runId,
@@ -340,6 +397,9 @@ export function projectDurableRuns(
340
397
  ? { at: existingUser.at }
341
398
  : {}),
342
399
  status: "completed",
400
+ // Greyed while its Turn waits, ordinary the moment it is running. The
401
+ // flag comes from durable run state, so a reload draws the same thing.
402
+ ...(run.queued ? { pending: true } : {}),
343
403
  tools: [],
344
404
  sends: [],
345
405
  };
@@ -360,6 +420,7 @@ export function projectDurableRuns(
360
420
  activeRun = activeRunView(run) ?? activeRun;
361
421
  if (run.status === "running" || run.status === "reconciliation-required") {
362
422
  busyRunId = run.runId;
423
+ if (!run.queued) runningRunId = run.runId;
363
424
  }
364
425
  if (notification && isTerminalRun(run)) {
365
426
  projected.add(notification.notificationId);
@@ -377,6 +438,10 @@ export function projectDurableRuns(
377
438
  else if (state.activeRunId && terminalRunIds.has(state.activeRunId)) {
378
439
  state.activeRunId = undefined;
379
440
  }
441
+ if (runningRunId) state.runningRunId = runningRunId;
442
+ else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
443
+ state.runningRunId = undefined;
444
+ }
380
445
  if (activeRun) state.activeRun = activeRun;
381
446
  else if (
382
447
  state.activeRun &&
@@ -1156,6 +1221,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1156
1221
  web.value.messages = [];
1157
1222
  web.value.activeRun = undefined;
1158
1223
  web.value.activeRunId = undefined;
1224
+ web.value.runningRunId = undefined;
1159
1225
  web.value.skillCatalog = [];
1160
1226
  web.value.approvals = [];
1161
1227
  web.value.tasks = [];
@@ -2211,12 +2277,20 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2211
2277
  text: string,
2212
2278
  skills?: readonly SkillRefV1[],
2213
2279
  ): Promise<SendPromptResult> {
2214
- if (web.value.activeRunId) return { accepted: false, error: "busy" };
2215
2280
  const botId = web.value.activeBotId;
2216
2281
  if (!botId) return { accepted: false, error: "no-bot" };
2217
2282
  const generation = selectionGeneration;
2218
2283
  const pendingRunId = crypto.randomUUID();
2219
2284
  const optimisticAt = new Date().toISOString();
2285
+ // Every send carries the intent, because "do this instead" is what a
2286
+ // person means by pressing send and it does not depend on what this
2287
+ // client had managed to observe first. Whether a run was showing as
2288
+ // active is a race — the composer unlocks the instant a Turn settles,
2289
+ // and a fast typist beats the next poll — so gating the intent on
2290
+ // `activeRunId` made the Bot refuse a message the User had every right
2291
+ // to send. The observed run rides along as provenance when there is one.
2292
+ const observed = web.value.activeRunId;
2293
+ const supersedes = observed ? { runId: observed } : {};
2220
2294
  web.value.activeRunId = pendingRunId;
2221
2295
  web.value.error = undefined;
2222
2296
  web.value.messages.push(
@@ -2227,6 +2301,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2227
2301
  text,
2228
2302
  at: optimisticAt,
2229
2303
  status: "completed",
2304
+ // Greyed until its own Turn is admitted and running. Optimistic
2305
+ // only: the durable projection replaces it by run id.
2306
+ ...(observed ? { pending: true } : {}),
2230
2307
  tools: [],
2231
2308
  sends: [],
2232
2309
  },
@@ -2237,6 +2314,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2237
2314
  text: "",
2238
2315
  at: optimisticAt,
2239
2316
  status: "streaming",
2317
+ ...(observed ? { pending: true } : {}),
2240
2318
  tools: [],
2241
2319
  sends: [],
2242
2320
  },
@@ -2257,6 +2335,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2257
2335
  requestController.signal,
2258
2336
  pendingRunId,
2259
2337
  skills,
2338
+ supersedes,
2260
2339
  );
2261
2340
  if (
2262
2341
  generation !== selectionGeneration ||
@@ -2385,7 +2464,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2385
2464
  },
2386
2465
  async stopRun(): Promise<void> {
2387
2466
  const botId = web.value.activeBotId;
2388
- const runId = web.value.activeRun?.runId ?? web.value.activeRunId;
2467
+ // The Turn that is executing, never the message waiting behind it: Stop
2468
+ // cancels what the Bot is doing and does not discard what the User just
2469
+ // sent.
2470
+ const runId =
2471
+ web.value.activeRun?.runId ??
2472
+ web.value.runningRunId ??
2473
+ web.value.activeRunId;
2389
2474
  if (!botId || !runId) return;
2390
2475
  if (!ctx.transport.stopRun) {
2391
2476
  web.value.settingsError = "Stop is unavailable";
@@ -263,6 +263,17 @@
263
263
  margin-top: 18px;
264
264
  }
265
265
 
266
+ /*
267
+ * A message the User sent while the Bot was still working. It is an ordinary
268
+ * message the Bot has not reached yet, so it is drawn as one and simply held
269
+ * back — no label, no icon, no word for the User to learn. It goes to full
270
+ * strength the moment its own Turn starts.
271
+ */
272
+ .message-pending {
273
+ opacity: 0.55;
274
+ transition: opacity var(--frock-motion-enter);
275
+ }
276
+
266
277
  /* A Session announcement: the conversation narrating itself, not a party. */
267
278
  .message-system {
268
279
  align-items: center;
@@ -416,6 +416,46 @@ describe("client run protocol v1", () => {
416
416
  }),
417
417
  ).toThrow("turn command.schemaVersion is invalid");
418
418
 
419
+ // Supersede intent is carried by the field's presence. The composer sends
420
+ // it on every send, and names a run only when it observed one.
421
+ expect(
422
+ decodeClientTurnCommandV1({
423
+ schemaVersion: 1,
424
+ commandId: "command-1",
425
+ text: "hello",
426
+ supersedes: {},
427
+ }),
428
+ ).toEqual({
429
+ schemaVersion: 1,
430
+ commandId: "command-1",
431
+ text: "hello",
432
+ supersedes: {},
433
+ });
434
+ expect(
435
+ decodeClientTurnCommandV1({
436
+ schemaVersion: 1,
437
+ commandId: "command-1",
438
+ text: "hello",
439
+ supersedes: { runId: "run-1" },
440
+ }).supersedes,
441
+ ).toEqual({ runId: "run-1" });
442
+ expect(() =>
443
+ decodeClientTurnCommandV1({
444
+ schemaVersion: 1,
445
+ commandId: "command-1",
446
+ text: "hello",
447
+ supersedes: { runId: "not a run id" },
448
+ }),
449
+ ).toThrow("turn command.supersedes.runId is invalid");
450
+ expect(() =>
451
+ decodeClientTurnCommandV1({
452
+ schemaVersion: 1,
453
+ commandId: "command-1",
454
+ text: "hello",
455
+ supersedes: { runId: "run-1", extra: 1 },
456
+ }),
457
+ ).toThrow();
458
+
419
459
  expect(
420
460
  decodeClientNotificationAcknowledgementCommandV1({
421
461
  schemaVersion: 1,
@@ -571,6 +611,58 @@ describe("client run protocol v1", () => {
571
611
  }
572
612
  });
573
613
 
614
+ test("projects dynamic call identity so the client can show the inner tool", () => {
615
+ const projected = projectClientTurnV1({
616
+ runId: "run-dynamic-tool",
617
+ text: "",
618
+ events: [
619
+ event({
620
+ type: "tool/call",
621
+ seq: 0,
622
+ timestamp,
623
+ turn: 1,
624
+ step: 1,
625
+ occurrenceId: "tool:1:1:0",
626
+ name: "call_dynamic_tool",
627
+ input: {
628
+ namespace: "user-Github--acme",
629
+ toolName: "search_issues",
630
+ arguments: { query: "is:open" },
631
+ mcpDetails: { description: "Find open issues." },
632
+ },
633
+ }),
634
+ event({
635
+ type: "tool/result",
636
+ seq: 1,
637
+ timestamp,
638
+ turn: 1,
639
+ step: 1,
640
+ occurrenceId: "tool:1:1:0",
641
+ name: "call_dynamic_tool",
642
+ content: "[]",
643
+ isError: false,
644
+ status: "completed",
645
+ }),
646
+ ],
647
+ });
648
+
649
+ expect(projected.events[0]).toEqual({
650
+ type: "tool/call",
651
+ call: {
652
+ id: "tool-1",
653
+ name: "call_dynamic_tool",
654
+ input: {
655
+ namespace: "user-Github--acme",
656
+ toolName: "search_issues",
657
+ argumentsJson: '{"query":"is:open"}',
658
+ },
659
+ },
660
+ });
661
+ expect(decodeClientTurnV1(structuredClone(projected)).events[0]).toEqual(
662
+ projected.events[0],
663
+ );
664
+ });
665
+
574
666
  test("projects only bounded user-visible run state", () => {
575
667
  const stored = {
576
668
  runId: "run-1",