@frockbot/plugin-shell 0.3.1 → 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.
Files changed (44) hide show
  1. package/package.json +32 -29
  2. package/src/agent.test.ts +15 -3
  3. package/src/backend-applets.test.ts +581 -0
  4. package/src/backend-applets.ts +959 -0
  5. package/src/backend-authoring.test.ts +61 -19
  6. package/src/backend-authoring.ts +66 -27
  7. package/src/backend-completion.ts +4 -2
  8. package/src/backend-composition.ts +64 -0
  9. package/src/backend-computer.test.ts +128 -0
  10. package/src/backend-computer.ts +81 -0
  11. package/src/backend-configuration.test.ts +8 -1
  12. package/src/backend-iframe-ui.test.ts +29 -12
  13. package/src/backend-isolate.ts +31 -5
  14. package/src/backend-package-catalog.test.ts +13 -8
  15. package/src/backend-package-catalog.ts +8 -6
  16. package/src/backend-recovery-integration.test.ts +23 -0
  17. package/src/backend-recovery.ts +20 -12
  18. package/src/backend-runner-iframe.test.ts +10 -1
  19. package/src/backend-runner.ts +9 -2
  20. package/src/backend-stop.test.ts +6 -6
  21. package/src/backend-supersede.test.ts +377 -0
  22. package/src/backend.ts +567 -13
  23. package/src/client/AppletCanvas.vue +679 -0
  24. package/src/client/FrockBotApp.vue +195 -21
  25. package/src/client/PackageEntryTrigger.vue +77 -0
  26. package/src/client/PackageIframeHost.vue +148 -47
  27. package/src/client/PackageIframeSettings.vue +8 -6
  28. package/src/client/PackageSurfacePage.vue +39 -0
  29. package/src/client/applets-client.test.ts +204 -0
  30. package/src/client/applets-client.ts +139 -0
  31. package/src/client/applets-state.ts +64 -0
  32. package/src/client/index.test.ts +221 -7
  33. package/src/client/index.ts +398 -6
  34. package/src/client/package-iframe-entries.test.ts +122 -0
  35. package/src/client/package-iframe-entries.ts +112 -0
  36. package/src/client/package-iframe-host-message.test.ts +3 -3
  37. package/src/client/package-iframe-host-message.ts +3 -3
  38. package/src/client/styles.css +118 -1
  39. package/src/composition-views.ts +31 -6
  40. package/src/run-protocol.test.ts +92 -0
  41. package/src/run-protocol.ts +193 -17
  42. package/src/shared.ts +70 -0
  43. package/src/terminal-records.test.ts +52 -1
  44. package/src/terminal-records.ts +48 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The client half of the Applet routes.
3
+ *
4
+ * The backend is the authority for every one of these reads; this module is
5
+ * only the typed seam between the hosted transport and the shell's projection,
6
+ * so a route that is absent from a deployment reads as "no Applets" rather
7
+ * than as an error over the User's conversation. Every response crosses a
8
+ * strict decoder before it reaches the shell.
9
+ */
10
+ import {
11
+ decodeAppletBuildViewV1,
12
+ decodeAppletFocusViewV1,
13
+ decodeAppletListViewV1,
14
+ decodeAppletSourceViewV1,
15
+ decodeAppletUiViewV1,
16
+ decodeAppletViewerTokenV1,
17
+ type AppletBuildViewV1,
18
+ type AppletSourceViewV1,
19
+ type AppletSummaryV1,
20
+ type AppletUiViewV1,
21
+ type AppletViewerTokenV1,
22
+ } from "@frockbot/kernel-contracts";
23
+
24
+ /** Exactly what the shell's hosted transport offers, and nothing more. */
25
+ export type AppletsHostedRequest = (
26
+ path: string,
27
+ method?: "GET" | "POST",
28
+ body?: string,
29
+ ) => Promise<unknown>;
30
+
31
+ const applet = (appletId: string) => encodeURIComponent(appletId);
32
+
33
+ export async function readAppletList(
34
+ request: AppletsHostedRequest,
35
+ ): Promise<AppletSummaryV1[]> {
36
+ return decodeAppletListViewV1(await request("/api/applets")).applets;
37
+ }
38
+
39
+ export async function readAppletViewerToken(
40
+ request: AppletsHostedRequest,
41
+ appletId: string,
42
+ ): Promise<AppletViewerTokenV1> {
43
+ return decodeAppletViewerTokenV1(
44
+ await request(`/api/applets/${applet(appletId)}/token`),
45
+ );
46
+ }
47
+
48
+ export async function readAppletUi(
49
+ request: AppletsHostedRequest,
50
+ appletId: string,
51
+ ): Promise<AppletUiViewV1> {
52
+ return decodeAppletUiViewV1(
53
+ await request(`/api/applets/${applet(appletId)}/ui`),
54
+ );
55
+ }
56
+
57
+ /*
58
+ * The canvas's two Workspace-backed reads are Bot-scoped in the URL and
59
+ * User-scoped in what they answer: the Applets root belongs to the User, and
60
+ * the Bot in the path only names the Durable Object that holds the Workspace
61
+ * binding. Reading them wakes no Computer.
62
+ */
63
+ export async function readAppletSource(
64
+ request: AppletsHostedRequest,
65
+ botId: string,
66
+ appletId: string,
67
+ ): Promise<AppletSourceViewV1> {
68
+ return decodeAppletSourceViewV1(
69
+ await request(
70
+ `/api/bots/${encodeURIComponent(botId)}/applets/${applet(appletId)}/source`,
71
+ ),
72
+ );
73
+ }
74
+
75
+ export async function readAppletBuild(
76
+ request: AppletsHostedRequest,
77
+ botId: string,
78
+ appletId: string,
79
+ ): Promise<AppletBuildViewV1> {
80
+ return decodeAppletBuildViewV1(
81
+ await request(
82
+ `/api/bots/${encodeURIComponent(botId)}/applets/${applet(appletId)}/build`,
83
+ ),
84
+ );
85
+ }
86
+
87
+ export async function readFocusedAppletId(
88
+ request: AppletsHostedRequest,
89
+ botId: string,
90
+ ): Promise<string | null> {
91
+ return decodeAppletFocusViewV1(
92
+ await request(`/api/bots/${encodeURIComponent(botId)}/applets/focus`),
93
+ ).appletId;
94
+ }
95
+
96
+ /**
97
+ * Records the Session's focused Applet and returns what the backend recorded,
98
+ * never what the click asked for: a focus the backend refused reads back as
99
+ * the focus it kept.
100
+ */
101
+ export async function writeFocusedAppletId(
102
+ request: AppletsHostedRequest,
103
+ botId: string,
104
+ appletId: string | null,
105
+ ): Promise<string | null> {
106
+ return decodeAppletFocusViewV1(
107
+ await request(
108
+ `/api/bots/${encodeURIComponent(botId)}/applets/focus`,
109
+ "POST",
110
+ JSON.stringify({ schemaVersion: 1, appletId }),
111
+ ),
112
+ ).appletId;
113
+ }
114
+
115
+ /**
116
+ * The file the canvas shows while a Bot is writing an Applet: the one that
117
+ * changed most recently, falling back to the first path in sorted order so a
118
+ * store with no timestamps still opens on something.
119
+ */
120
+ export function mostRecentlyChangedFileV1(
121
+ source: AppletSourceViewV1 | undefined,
122
+ ): string | undefined {
123
+ if (!source || source.files.length === 0) return undefined;
124
+ // A tie on time — a fresh scaffold, one sync — opens on the file a Bot edits
125
+ // first, not on the README the alphabet would pick.
126
+ const preferred = ["server.ts", "ui.tsx"];
127
+ const ordered = source.files.toSorted((left, right) => {
128
+ const leftAt = left.changedAt ?? "";
129
+ const rightAt = right.changedAt ?? "";
130
+ if (leftAt !== rightAt) return rightAt.localeCompare(leftAt);
131
+ const leftRank = preferred.indexOf(left.path);
132
+ const rightRank = preferred.indexOf(right.path);
133
+ const rank = (value: number) => (value < 0 ? preferred.length : value);
134
+ return (
135
+ rank(leftRank) - rank(rightRank) || left.path.localeCompare(right.path)
136
+ );
137
+ });
138
+ return ordered[0]?.path;
139
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The `applets` feed a bridge v2 page receives.
3
+ *
4
+ * This is a projection of what the shell has already read, never a second
5
+ * source of truth: the backend is the authority for the list, the focus, and
6
+ * the viewer credential, and a page that is handed a stale generation simply
7
+ * reconnects when the next feed arrives.
8
+ */
9
+ import {
10
+ PACKAGE_IFRAME_FOCUS_TOOL_V2,
11
+ type PackageIframeAppletsStateV2,
12
+ type PackageIframeCatalogV1,
13
+ type AppletSummaryV1,
14
+ } from "@frockbot/kernel-contracts";
15
+ import type { FrockBotWebData } from "../shared.js";
16
+
17
+ /**
18
+ * Whether this Bot's Composition has Applets in it at all.
19
+ *
20
+ * Derived from manifest facts — a Package declaring the Applet focus tool —
21
+ * never from a Package id. A deployment or a User without the Applets Package
22
+ * has no Applet routes, and the shell must not ask for them: an absent
23
+ * capability is silence, not a failed request.
24
+ */
25
+ export function appletsAvailableV1(
26
+ catalog: PackageIframeCatalogV1 | undefined,
27
+ ): boolean {
28
+ return (catalog?.contributions ?? []).some((contribution) =>
29
+ contribution.declaredTools.includes(PACKAGE_IFRAME_FOCUS_TOOL_V2),
30
+ );
31
+ }
32
+
33
+ export function focusedAppletSummaryV1(
34
+ web: Pick<FrockBotWebData, "applets" | "focusedAppletId">,
35
+ ): AppletSummaryV1 | null {
36
+ const appletId = web.focusedAppletId;
37
+ if (!appletId) return null;
38
+ return web.applets.find((applet) => applet.appletId === appletId) ?? null;
39
+ }
40
+
41
+ export function appletsBridgeStateV2(
42
+ web: Pick<
43
+ FrockBotWebData,
44
+ "applets" | "focusedAppletId" | "appletViewer" | "appletBuild"
45
+ >,
46
+ ): PackageIframeAppletsStateV2 {
47
+ const viewer = web.appletViewer;
48
+ return {
49
+ focused: focusedAppletSummaryV1(web),
50
+ list: web.applets,
51
+ viewer:
52
+ viewer && viewer.appletId === web.focusedAppletId
53
+ ? {
54
+ token: viewer.token,
55
+ socketUrl: viewer.socketUrl,
56
+ uiUrl: viewer.uiUrl,
57
+ generationId: viewer.generationId,
58
+ }
59
+ : null,
60
+ // The source stays out of the feed: the shell draws the code view itself,
61
+ // and a source tree would overflow the bridge's 64 KB message bound.
62
+ ...(web.appletBuild === undefined ? {} : { build: web.appletBuild }),
63
+ };
64
+ }
@@ -408,13 +408,19 @@ describe("Bot selection", () => {
408
408
  packageId: "weather-page",
409
409
  displayName: "Sydney Weather",
410
410
  provenance: "Bot-authored" as const,
411
- artifact: {
412
- contentHash: "a".repeat(64),
413
- size: 1,
414
- mediaType: "text/html" as const,
415
- bundlerVersion: "frockbot-inline-html@1",
416
- },
417
- mounts: [{ slot: "frockbot.bot-settings-sections", order: 20 }],
411
+ pages: [
412
+ {
413
+ id: "main",
414
+ artifact: {
415
+ contentHash: "a".repeat(64),
416
+ size: 1,
417
+ mediaType: "text/html" as const,
418
+ bundlerVersion: "frockbot-inline-html@1",
419
+ },
420
+ mounts: [{ slot: "frockbot.bot-settings-sections", order: 20 }],
421
+ },
422
+ ],
423
+ entries: [],
418
424
  declaredTools: ["weather_lookup"],
419
425
  };
420
426
  const requests: Array<{
@@ -968,6 +974,52 @@ describe("Bot selection", () => {
968
974
  });
969
975
 
970
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
+
971
1023
  test("projects a completed run before it can be acknowledged", () => {
972
1024
  const messages: Parameters<typeof projectCompletedRuns>[0] = [];
973
1025
  const projected = projectCompletedRuns(
@@ -2645,3 +2697,165 @@ describe("Connection operation reconciliation", () => {
2645
2697
  expect(commandIds[1]).not.toBe(commandIds[0]);
2646
2698
  });
2647
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
+ });