@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
@@ -1,20 +1,35 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- decodePackageIframePageMessageV1,
3
+ decodePackageIframePageMessageV2,
4
+ packageIframeExternalUrlAllowedV2,
5
+ packageIframeFocusAllowedV2,
4
6
  packageIframeToolAllowedV1,
7
+ type PackageIframeBridgeVersionV2,
5
8
  type PackageIframeContributionViewV1,
6
- type PackageIframeHostMessageV1,
9
+ type PackageIframeHostMessageV2,
10
+ type PackageIframePageViewV1,
7
11
  } from "@frockbot/kernel-contracts";
8
12
  import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
9
13
  import { frockBotWebDataKey } from "../shared.js";
10
14
  import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
11
15
 
12
- const props = defineProps<{
13
- contribution: PackageIframeContributionViewV1;
14
- slot: string;
15
- stateName: string;
16
- stateValue: unknown;
17
- }>();
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ contribution: PackageIframeContributionViewV1;
19
+ page: PackageIframePageViewV1;
20
+ slot: string;
21
+ /** The named state feeds this frame receives, by state name. */
22
+ states: Record<string, unknown>;
23
+ /**
24
+ * `flow` gives the frame the height the page asks for; `fill` gives it the
25
+ * height of its container, for a page that owns a whole panel.
26
+ */
27
+ layout?: "flow" | "fill";
28
+ /** A page hosted in a surface has its attribution drawn by the surface. */
29
+ attribution?: boolean;
30
+ }>(),
31
+ { layout: "flow", attribution: true },
32
+ );
18
33
  const providedWeb = inject(frockBotWebDataKey);
19
34
  if (!providedWeb) throw new Error("Package iframe host data was not provided");
20
35
  const web = providedWeb;
@@ -22,37 +37,86 @@ const frame = ref<HTMLIFrameElement>();
22
37
  const height = ref(240);
23
38
  const failure = ref<string>();
24
39
  const lastStateWireByName = new Map<string, string>();
40
+ /*
41
+ * Which bridge this page reads. A page announces version 2 with `hello`; one
42
+ * that never announces is a version 1 page and is only ever sent version 1
43
+ * messages, so a page published before the bump keeps working unchanged.
44
+ */
45
+ const bridgeVersion = ref<PackageIframeBridgeVersionV2>(1);
25
46
  const catalog = computed(() => web.value.packageUi);
26
47
  const source = computed(() => {
27
48
  const origin = catalog.value?.artifactOrigin;
28
49
  return origin
29
- ? `${origin}/packages/${props.contribution.artifact.contentHash}.html`
50
+ ? `${origin}/packages/${props.page.artifact.contentHash}.html`
30
51
  : "about:blank";
31
52
  });
32
53
 
33
- const THEME_TOKEN_NAMES = [
34
- "surface",
35
- "surface-raised",
36
- "surface-subtle",
37
- "text",
38
- "text-muted",
39
- "border",
40
- "accent-surface",
41
- "accent-text",
42
- "radius-card",
54
+ /*
55
+ * The theme a page is given.
56
+ *
57
+ * Design tokens are the contract between the shell and a Package's page (ADR
58
+ * 0007): a page is handed semantic names, never the shell's stylesheet, and
59
+ * never a colour to hard-code. The list is what an Applet kit needs to build a
60
+ * whole screen — surfaces, text, borders, the accent, the three status
61
+ * colours, the focus ring, geometry, and the type scale — under names that say
62
+ * what a value is for rather than which shell control it came from.
63
+ */
64
+ const THEME_TOKENS: ReadonlyArray<readonly [string, string]> = [
65
+ ["surface", "surface"],
66
+ ["surface-raised", "surface-raised"],
67
+ ["surface-subtle", "surface-subtle"],
68
+ ["surface-window", "surface-window"],
69
+ ["text", "text"],
70
+ ["text-muted", "text-muted"],
71
+ ["text-subtle", "text-subtle"],
72
+ ["border", "border"],
73
+ ["border-strong", "border-strong"],
74
+ ["accent", "action-primary"],
75
+ ["accent-hover", "action-primary-hover"],
76
+ ["accent-pressed", "action-primary-pressed"],
77
+ ["accent-surface", "accent-surface"],
78
+ ["accent-text", "accent-text"],
79
+ ["on-accent", "on-accent"],
80
+ ["danger", "danger-text"],
81
+ ["danger-surface", "danger-surface"],
82
+ ["danger-border", "danger-border"],
83
+ ["success", "success"],
84
+ ["success-surface", "success-surface"],
85
+ ["success-border", "success-border"],
86
+ ["warning", "warning"],
87
+ ["warning-surface", "warning-surface"],
88
+ ["warning-border", "warning-border"],
89
+ ["focus-ring", "focus-ring"],
90
+ ["fill-hover", "fill-hover"],
91
+ ["fill-pressed", "fill-pressed"],
92
+ ["radius-control", "radius-control"],
93
+ ["radius-card", "radius-card"],
94
+ ["control-sm", "control-sm"],
95
+ ["control-md", "control-md"],
96
+ ["control-lg", "control-lg"],
97
+ ["font-sans", "font-sans"],
98
+ ["font-mono", "font-mono"],
99
+ ["text-xs", "text-xs"],
100
+ ["text-sm", "text-sm"],
101
+ ["text-base", "text-base"],
102
+ ["text-md", "text-md"],
103
+ ["text-lg", "text-lg"],
104
+ ["text-xl", "text-xl"],
105
+ ["leading-normal", "leading-normal"],
106
+ ["motion-fast", "motion-fast"],
43
107
  ] as const;
44
108
 
45
109
  function themeTokens(): Record<string, string> {
46
110
  const styles = getComputedStyle(document.documentElement);
47
111
  return Object.fromEntries(
48
- THEME_TOKEN_NAMES.map((name) => [
112
+ THEME_TOKENS.map(([name, token]) => [
49
113
  name,
50
- styles.getPropertyValue(`--frock-${name}`).trim(),
51
- ]),
114
+ styles.getPropertyValue(`--frock-${token}`).trim(),
115
+ ]).filter(([, value]) => value !== ""),
52
116
  );
53
117
  }
54
118
 
55
- function post(message: PackageIframeHostMessageV1): void {
119
+ function post(message: PackageIframeHostMessageV2): void {
56
120
  const target = frame.value?.contentWindow;
57
121
  if (!target) return;
58
122
  try {
@@ -63,51 +127,75 @@ function post(message: PackageIframeHostMessageV1): void {
63
127
  }
64
128
  }
65
129
 
130
+ function postStates(): void {
131
+ for (const [name, value] of Object.entries(props.states)) {
132
+ post({
133
+ schemaVersion: bridgeVersion.value,
134
+ type: "state",
135
+ name,
136
+ value,
137
+ });
138
+ }
139
+ }
140
+
66
141
  function initialize(): void {
67
142
  const botId = web.value.activeBotId;
68
143
  if (!botId) return;
69
144
  lastStateWireByName.clear();
70
145
  post({
71
- schemaVersion: 1,
146
+ schemaVersion: bridgeVersion.value,
72
147
  type: "init",
73
148
  themeTokens: themeTokens(),
74
149
  packageId: props.contribution.packageId,
75
150
  botId,
76
151
  slot: props.slot,
152
+ pageId: props.page.id,
77
153
  });
78
- post({
79
- schemaVersion: 1,
80
- type: "state",
81
- name: props.stateName,
82
- value: props.stateValue,
83
- });
154
+ postStates();
84
155
  }
85
156
 
86
- watch(
87
- () => [props.stateName, props.stateValue] as const,
88
- () =>
89
- post({
90
- schemaVersion: 1,
91
- type: "state",
92
- name: props.stateName,
93
- value: props.stateValue,
94
- }),
95
- { deep: true },
96
- );
157
+ watch(() => props.states, postStates, { deep: true });
97
158
 
98
159
  async function onMessage(event: MessageEvent): Promise<void> {
99
160
  if (!frame.value?.contentWindow || event.source !== frame.value.contentWindow)
100
161
  return;
101
162
  let message;
102
163
  try {
103
- message = decodePackageIframePageMessageV1(event.data);
164
+ message = decodePackageIframePageMessageV2(event.data);
104
165
  } catch {
105
166
  return;
106
167
  }
168
+ if (message.type === "hello") {
169
+ // The announcement can land before or after the frame's load event, so the
170
+ // handshake is re-sent at the announced version either way.
171
+ if (bridgeVersion.value === message.bridgeVersion) return;
172
+ bridgeVersion.value = message.bridgeVersion;
173
+ initialize();
174
+ return;
175
+ }
107
176
  if (message.type === "resize") {
108
177
  height.value = Math.min(1_200, Math.max(96, Math.round(message.height)));
109
178
  return;
110
179
  }
180
+ if (message.type === "focus") {
181
+ if (!packageIframeFocusAllowedV2(props.contribution)) {
182
+ failure.value = "This Package cannot change the focused Applet.";
183
+ return;
184
+ }
185
+ failure.value = undefined;
186
+ await web.value.setFocusedApplet(message.appletId);
187
+ return;
188
+ }
189
+ if (message.type === "openExternal") {
190
+ const origin = catalog.value?.artifactOrigin;
191
+ if (!origin || !packageIframeExternalUrlAllowedV2(message.url, origin)) {
192
+ failure.value = "This Package page can only open its own pages.";
193
+ return;
194
+ }
195
+ failure.value = undefined;
196
+ window.open(message.url, "_blank", "noopener,noreferrer");
197
+ return;
198
+ }
111
199
  if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
112
200
  failure.value = `This Package did not declare ${message.name}.`;
113
201
  return;
@@ -120,7 +208,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
120
208
  message.input,
121
209
  );
122
210
  post({
123
- schemaVersion: 1,
211
+ schemaVersion: bridgeVersion.value,
124
212
  type: "state",
125
213
  name: `tool:${message.name}`,
126
214
  value: result,
@@ -128,7 +216,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
128
216
  } catch (error) {
129
217
  failure.value = error instanceof Error ? error.message : "Tool call failed";
130
218
  post({
131
- schemaVersion: 1,
219
+ schemaVersion: bridgeVersion.value,
132
220
  type: "state",
133
221
  name: `tool:${message.name}`,
134
222
  value: { isError: true, content: failure.value },
@@ -141,8 +229,8 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
141
229
  </script>
142
230
 
143
231
  <template>
144
- <section class="package-iframe-frame">
145
- <header class="package-iframe-attribution">
232
+ <section class="package-iframe-frame" :class="`package-iframe-${layout}`">
233
+ <header v-if="attribution" class="package-iframe-attribution">
146
234
  <strong>{{ contribution.displayName }}</strong>
147
235
  <span>{{ contribution.provenance }} Package</span>
148
236
  </header>
@@ -151,7 +239,7 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
151
239
  ref="frame"
152
240
  :title="`${contribution.displayName} Package page`"
153
241
  :src="source"
154
- :style="{ height: `${height}px` }"
242
+ :style="layout === 'fill' ? undefined : { height: `${height}px` }"
155
243
  sandbox="allow-scripts"
156
244
  credentialless
157
245
  referrerpolicy="no-referrer"
@@ -172,6 +260,19 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
172
260
  background: var(--frock-surface);
173
261
  }
174
262
 
263
+ .package-iframe-fill {
264
+ display: flex;
265
+ height: 100%;
266
+ flex-direction: column;
267
+ border: 0;
268
+ border-radius: 0;
269
+ }
270
+
271
+ .package-iframe-fill iframe {
272
+ min-height: 0;
273
+ flex: 1;
274
+ }
275
+
175
276
  .package-iframe-attribution {
176
277
  position: relative;
177
278
  z-index: 1;
@@ -11,9 +11,11 @@ const slot = "frockbot.bot-settings-sections";
11
11
  const contributions = computed(() =>
12
12
  (web.value.packageUi?.contributions ?? [])
13
13
  .flatMap((contribution) =>
14
- contribution.mounts
15
- .filter((mount) => mount.slot === slot)
16
- .map((mount) => ({ contribution, order: mount.order ?? 0 })),
14
+ contribution.pages.flatMap((page) =>
15
+ page.mounts
16
+ .filter((mount) => mount.slot === slot)
17
+ .map((mount) => ({ contribution, page, order: mount.order ?? 0 })),
18
+ ),
17
19
  )
18
20
  .sort(
19
21
  (left, right) =>
@@ -34,11 +36,11 @@ function settingsFor(packageId: string): Record<string, unknown> {
34
36
  <div v-if="contributions.length > 0" class="package-iframe-settings">
35
37
  <PackageIframeHost
36
38
  v-for="entry in contributions"
37
- :key="entry.contribution.packageId"
39
+ :key="`${entry.contribution.packageId}:${entry.page.id}`"
38
40
  :contribution="entry.contribution"
41
+ :page="entry.page"
39
42
  :slot="slot"
40
- state-name="settings"
41
- :state-value="settingsFor(entry.contribution.packageId)"
43
+ :states="{ settings: settingsFor(entry.contribution.packageId) }"
42
44
  />
43
45
  </div>
44
46
  </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A Package page hosted as a surface.
4
+ *
5
+ * The surface's chrome — its title and the way out of it — belongs to the
6
+ * shell; the page fills the body and is attributed to the Package that ships
7
+ * it, so a User always knows whose screen they are looking at.
8
+ */
9
+ import { computed, inject } from "vue";
10
+ import { frockBotWebDataKey } from "../shared.js";
11
+ import { appletsBridgeStateV2 } from "./applets-state.js";
12
+ import PackageIframeHost from "./PackageIframeHost.vue";
13
+ import type { PackageIframeEntryV1 } from "./package-iframe-entries.js";
14
+
15
+ const props = defineProps<{ entry: PackageIframeEntryV1 }>();
16
+ const providedWeb = inject(frockBotWebDataKey);
17
+ if (!providedWeb) throw new Error("Package surface data was not provided");
18
+ const web = providedWeb;
19
+ const states = computed(() => ({ applets: appletsBridgeStateV2(web.value) }));
20
+ </script>
21
+
22
+ <template>
23
+ <div class="package-surface-page">
24
+ <PackageIframeHost
25
+ :contribution="props.entry.contribution"
26
+ :page="props.entry.page"
27
+ :slot="props.entry.slot"
28
+ :states="states"
29
+ />
30
+ </div>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .package-surface-page {
35
+ display: grid;
36
+ min-width: 0;
37
+ gap: 16px;
38
+ }
39
+ </style>
@@ -0,0 +1,204 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ mostRecentlyChangedFileV1,
4
+ readAppletList,
5
+ readAppletSource,
6
+ readFocusedAppletId,
7
+ writeFocusedAppletId,
8
+ type AppletsHostedRequest,
9
+ } from "./applets-client.js";
10
+ import { appletsAvailableV1, appletsBridgeStateV2 } from "./applets-state.js";
11
+ import type { FrockBotWebData } from "../shared.js";
12
+
13
+ const summary = {
14
+ appletId: "u1abc.todo",
15
+ displayName: "Todo",
16
+ status: "published" as const,
17
+ currentGenerationId: "generation-2",
18
+ tools: ["add_todo"],
19
+ createdAt: "2026-09-03T00:00:00.000Z",
20
+ };
21
+
22
+ /** A fake transport, so the client is proven without a backend on the branch. */
23
+ function transportOf(
24
+ answers: Record<string, unknown>,
25
+ recorded: Array<{ path: string; method?: string; body?: string }> = [],
26
+ ): AppletsHostedRequest {
27
+ return (path, method, body) => {
28
+ recorded.push({
29
+ path,
30
+ ...(method ? { method } : {}),
31
+ ...(body ? { body } : {}),
32
+ });
33
+ if (!(path in answers))
34
+ return Promise.reject(new Error(`no route ${path}`));
35
+ return Promise.resolve(answers[path]);
36
+ };
37
+ }
38
+
39
+ describe("Applet routes", () => {
40
+ test("reads the User's Applets through the strict decoder", async () => {
41
+ const request = transportOf({
42
+ "/api/applets": { schemaVersion: 1, applets: [summary] },
43
+ });
44
+ expect(await readAppletList(request)).toEqual([summary]);
45
+ });
46
+
47
+ test("refuses a list the decoder does not accept", () => {
48
+ const request = transportOf({ "/api/applets": { applets: [summary] } });
49
+ expect(readAppletList(request)).rejects.toThrow();
50
+ });
51
+
52
+ test("the focus write reads back what the backend recorded", async () => {
53
+ const recorded: Array<{ path: string; method?: string; body?: string }> =
54
+ [];
55
+ const request = transportOf(
56
+ { "/api/bots/bot-1/applets/focus": { appletId: "u1abc.todo" } },
57
+ recorded,
58
+ );
59
+ // The click asked for one Applet; the answer is the one that was recorded.
60
+ expect(await writeFocusedAppletId(request, "bot-1", "u1abc.other")).toBe(
61
+ "u1abc.todo",
62
+ );
63
+ expect(recorded[0]).toEqual({
64
+ path: "/api/bots/bot-1/applets/focus",
65
+ method: "POST",
66
+ body: JSON.stringify({ schemaVersion: 1, appletId: "u1abc.other" }),
67
+ });
68
+ expect(await readFocusedAppletId(request, "bot-1")).toBe("u1abc.todo");
69
+ });
70
+
71
+ test("no focus is a value, not a missing read", async () => {
72
+ const request = transportOf({
73
+ "/api/bots/bot-1/applets/focus": { appletId: null },
74
+ });
75
+ expect(await readFocusedAppletId(request, "bot-1")).toBeNull();
76
+ });
77
+
78
+ test("the source read is bot-scoped and decoded", async () => {
79
+ const view = {
80
+ appletId: "u1abc.todo",
81
+ files: [
82
+ {
83
+ path: "server.ts",
84
+ text: "export class A {}",
85
+ generationId: "w-1",
86
+ changedAt: "2026-09-03T00:01:00.000Z",
87
+ },
88
+ ],
89
+ truncated: false,
90
+ };
91
+ const request = transportOf({
92
+ "/api/bots/bot-1/applets/u1abc.todo/source": view,
93
+ });
94
+ expect(await readAppletSource(request, "bot-1", "u1abc.todo")).toEqual(
95
+ view,
96
+ );
97
+ });
98
+
99
+ test("the code view follows the most recently changed file", () => {
100
+ expect(mostRecentlyChangedFileV1(undefined)).toBeUndefined();
101
+ expect(
102
+ mostRecentlyChangedFileV1({
103
+ appletId: "u1abc.todo",
104
+ truncated: false,
105
+ files: [
106
+ {
107
+ path: "server.ts",
108
+ text: "",
109
+ generationId: "w-1",
110
+ changedAt: "2026-09-03T00:01:00.000Z",
111
+ },
112
+ {
113
+ path: "ui.tsx",
114
+ text: "",
115
+ generationId: "w-2",
116
+ changedAt: "2026-09-03T00:05:00.000Z",
117
+ },
118
+ ],
119
+ }),
120
+ ).toBe("ui.tsx");
121
+ });
122
+ });
123
+
124
+ describe("the applets feed a page receives", () => {
125
+ const web = {
126
+ applets: [summary],
127
+ focusedAppletId: "u1abc.todo",
128
+ appletViewer: {
129
+ appletId: "u1abc.todo",
130
+ token: "signed",
131
+ expiresAt: "2026-09-03T00:15:00.000Z",
132
+ socketUrl: "wss://bot.example.com/api/applets/u1abc.todo/socket",
133
+ uiUrl: "https://ui.example.com/packages/aa.html",
134
+ generationId: "generation-2",
135
+ },
136
+ appletSource: undefined,
137
+ appletBuild: undefined,
138
+ } satisfies Pick<
139
+ FrockBotWebData,
140
+ | "applets"
141
+ | "focusedAppletId"
142
+ | "appletViewer"
143
+ | "appletSource"
144
+ | "appletBuild"
145
+ >;
146
+
147
+ test("carries the focused Applet, the list, and the viewer", () => {
148
+ const state = appletsBridgeStateV2(web);
149
+ expect(state.focused).toEqual(summary);
150
+ expect(state.list).toEqual([summary]);
151
+ expect(state.viewer?.generationId).toBe("generation-2");
152
+ expect(state.viewer).not.toHaveProperty("expiresAt");
153
+ });
154
+
155
+ test("a viewer credential minted for another Applet is not handed over", () => {
156
+ const state = appletsBridgeStateV2({
157
+ ...web,
158
+ focusedAppletId: "u1abc.other",
159
+ });
160
+ expect(state.viewer).toBeNull();
161
+ expect(state.focused).toBeNull();
162
+ });
163
+
164
+ test("Applets are available only when a Package declares the focus tool", () => {
165
+ const page = {
166
+ id: "canvas",
167
+ artifact: {
168
+ contentHash: "a".repeat(64),
169
+ size: 1,
170
+ mediaType: "text/html" as const,
171
+ bundlerVersion: "frockbot-inline-html@1",
172
+ },
173
+ mounts: [{ slot: "frockbot.right-panel" }],
174
+ };
175
+ const catalog = (declaredTools: string[]) => ({
176
+ schemaVersion: 1 as const,
177
+ botId: "bot-1",
178
+ generationId: "generation-1",
179
+ artifactOrigin: "https://ui.example.com",
180
+ contributions: [
181
+ {
182
+ packageId: "applets",
183
+ displayName: "Applets",
184
+ provenance: "Bot-authored" as const,
185
+ pages: [page],
186
+ entries: [],
187
+ declaredTools,
188
+ },
189
+ ],
190
+ });
191
+ // Derived from a manifest fact, never from a Package id: a deployment
192
+ // without Applets asks for no Applet route at all.
193
+ expect(appletsAvailableV1(catalog(["applet_focus"]))).toBe(true);
194
+ expect(appletsAvailableV1(catalog(["weather_lookup"]))).toBe(false);
195
+ expect(appletsAvailableV1(undefined)).toBe(false);
196
+ });
197
+
198
+ test("no focus is null rather than an absent field", () => {
199
+ const state = appletsBridgeStateV2({ ...web, focusedAppletId: null });
200
+ expect(state.focused).toBeNull();
201
+ expect(state.viewer).toBeNull();
202
+ expect(state.list).toEqual([summary]);
203
+ });
204
+ });