@rebasepro/plugin-ai 0.12.0 → 0.12.1-canary.g009ed95

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.
@@ -1,5 +1,4 @@
1
-
2
- import React, { useCallback, useDeferredValue, useEffect, useRef } from "react";
1
+ import React, { useCallback, useEffect, useRef } from "react";
3
2
 
4
3
  import {
5
4
  Button,
@@ -16,29 +15,24 @@ import {
16
15
  XIcon
17
16
  } from "@rebasepro/ui";
18
17
  import {
19
- AIIcon,
20
- useLargeLayout
18
+ AIIcon
21
19
  } from "@rebasepro/app";
22
20
  import { EntityStatus, Properties, Property } from "@rebasepro/types";
23
21
  import { PluginFormActionProps } from "@rebasepro/admin-types";
24
22
  import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
25
23
  import { useDataEnhancementController } from "./DataEnhancementControllerProvider";
24
+ import { AutofillReviewDialog } from "./AutofillReviewDialog";
26
25
  import { SamplePrompt } from "../types/data_enhancement_controller";
27
26
 
28
27
  export function FormEnhanceAction({
29
- entityId,
30
28
  path,
31
29
  status,
32
30
  collection,
33
- formContext,
34
- openEntityMode
31
+ formContext
35
32
  }: PluginFormActionProps) {
36
33
 
37
- const largeLayout = useLargeLayout();
38
-
39
34
  const storageKey = createLocalStorageKey(path, status);
40
35
 
41
- const [loading, setLoading] = React.useState(false);
42
36
  const dataEnhancementController = useDataEnhancementController();
43
37
 
44
38
  const [samplePrompts, setSamplePrompts] = React.useState<SamplePrompt[] | undefined>(undefined);
@@ -46,6 +40,15 @@ export function FormEnhanceAction({
46
40
 
47
41
  const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
48
42
 
43
+ /**
44
+ * Driven by the controller rather than by local state.
45
+ *
46
+ * There is exactly one run at a time, and the review owns it — a second
47
+ * `loading` flag here could disagree with the dialog about whether the
48
+ * model is still writing.
49
+ */
50
+ const loading = dataEnhancementController?.review?.status === "generating";
51
+
49
52
  const loadingPrompts = useRef(false);
50
53
  const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions?: string) {
51
54
  if (!getSamplePrompts) return;
@@ -62,9 +65,6 @@ export function FormEnhanceAction({
62
65
  },
63
66
  [collection.name, collection.singularName, getSamplePrompts, status]);
64
67
 
65
- const deferredValues = useDeferredValue(formContext?.values);
66
- // const enoughData = countStringCharacters(deferredValues, collection.properties) > 20;
67
-
68
68
  useEffect(() => {
69
69
  if (!dataEnhancementController) return;
70
70
  if (!samplePrompts) {
@@ -78,9 +78,12 @@ export function FormEnhanceAction({
78
78
  updateSuggestedPrompts().then();
79
79
  }, [dataEnhancementController, status]);
80
80
 
81
- const enhance = (prompt?: string) => {
81
+ /**
82
+ * Starts a run and opens the review. Nothing is written to the form here —
83
+ * see {@link AutofillReviewDialog}.
84
+ */
85
+ const generate = (prompt?: string) => {
82
86
  if (!dataEnhancementController || !formContext?.values) return;
83
- setLoading(true);
84
87
  if (prompt) {
85
88
  addRecentPrompt(storageKey, prompt);
86
89
  setSamplePrompts([{
@@ -88,136 +91,133 @@ export function FormEnhanceAction({
88
91
  type: "recent"
89
92
  }, ...(samplePrompts ?? []).slice(0, 5)]);
90
93
  }
91
- return dataEnhancementController.enhance({
92
- entityId,
93
- values: formContext!.values,
94
- instructions: prompt,
95
- replaceValues: true
96
- }).finally(() => {
97
- setLoading(false);
98
- });
94
+ // The controller records a failure in the review itself, so there is
95
+ // nothing to catch here — but the promise is still explicitly handled
96
+ // so a rejection can never surface as an unhandled one.
97
+ dataEnhancementController.generate({
98
+ values: formContext.values,
99
+ instructions: prompt
100
+ }).catch(() => undefined);
99
101
  };
100
102
 
101
103
  if (!dataEnhancementController?.enabled)
102
104
  return null;
103
105
 
104
- const suggestions = dataEnhancementController.suggestions;
105
- const hasSuggestions = Object.values(suggestions).filter(Boolean).length > 0;
106
-
107
- const disabledSuggestionActions = !hasSuggestions;
108
- const promptSuggestionsEnabled = (samplePrompts ?? []).length > 0 && instructions.length === 0;
109
-
110
- // const noIdSet = !formContext?.entityId;
111
-
112
106
  function submit() {
113
- enhance(instructions);
107
+ generate(instructions);
114
108
  }
115
109
 
116
110
  return (
117
- <Menu
118
- align={"end"}
119
- sideOffset={8}
120
- className={"max-w-[100vw]"}
121
- trigger={<Button variant={"filled"}
122
- color={"neutral"}
123
- fullWidth={largeLayout && openEntityMode === "full_screen"}
124
- size={"small"}
125
- disabled={loading}>
126
- {!loading && <AIIcon size={"small"}/>}
127
- {loading && <CircularProgress size={"small"}/>}
128
- Autofill
129
- </Button>}>
130
-
131
- <MenuItem className={"py-4"}
132
- onClick={() => {
133
- enhance();
134
- }}>
135
- <AIIcon size={"small"}/>
136
- Autofill based on the current content
137
- </MenuItem>
138
-
139
- <Separator orientation={"horizontal"} className={"mt-2"}/>
140
-
141
- {samplePrompts?.map((samplePrompt, index) => {
142
- return <MenuItem
143
- key={index + "_" + samplePrompt.prompt}
111
+ <>
112
+ <Menu
113
+ align={"end"}
114
+ sideOffset={8}
115
+ className={"max-w-[100vw]"}
116
+ // Never full width: this used to stretch to fill the form's
117
+ // `w-80 2xl:w-96` side rail in full screen. That rail is gone, and
118
+ // in the footer a stretched button reads as the primary action.
119
+ trigger={<Button variant={"filled"}
120
+ color={"neutral"}
121
+ size={"small"}
122
+ disabled={loading}>
123
+ {!loading && <AIIcon size={"small"}/>}
124
+ {loading && <CircularProgress size={"small"}/>}
125
+ Autofill
126
+ </Button>}>
127
+
128
+ <MenuItem className={"py-4"}
144
129
  onClick={() => {
145
- setInstructions(samplePrompt.prompt);
146
- enhance(samplePrompt.prompt);
147
- }}
148
- >
149
- <div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
150
- {samplePrompt.prompt}
151
- </div>
152
-
153
- {samplePrompt.type === "recent" && <IconButton
154
- onClick={(e) => {
155
- e.preventDefault();
156
- e.stopPropagation();
157
- removeRecentPrompt(storageKey, samplePrompt.prompt);
158
- setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
130
+ generate();
131
+ }}>
132
+ <AIIcon size={"small"}/>
133
+ Autofill based on the current content
134
+ </MenuItem>
135
+
136
+ <Separator orientation={"horizontal"} className={"mt-2"}/>
137
+
138
+ {samplePrompts?.map((samplePrompt, index) => {
139
+ return <MenuItem
140
+ key={index + "_" + samplePrompt.prompt}
141
+ onClick={() => {
142
+ setInstructions(samplePrompt.prompt);
143
+ generate(samplePrompt.prompt);
159
144
  }}
160
- size={"smallest"}
161
145
  >
162
- <XIcon size={iconSize.smallest}/>
163
- </IconButton>
164
- }
165
- </MenuItem>;
166
- })}
167
-
168
- <Separator orientation={"horizontal"}/>
169
-
170
- <div
171
- className={cls(
172
- "my-2 w-[500px] max-w-full flex items-start text-surface-700 dark:text-surface-200"
173
- )}>
174
-
175
- <TextareaAutosize
176
- className={cls("p-4 rounded-lg resize-none bg-surface-100 dark:bg-surface-950 mx-2 w-full grow outline-hidden max-h-[300px] overflow-auto", focusedDisabled)}
177
- value={instructions}
178
- autoFocus={status === "new"}
179
- disabled={loading}
180
- onFocus={(event) => {
181
- event.stopPropagation();
182
- }}
183
- placeholder={"...or provide instructions"}
184
- onKeyDown={(e) => {
185
- e.stopPropagation();
186
- if (e.key === "Enter" && !e.shiftKey) {
187
- e.preventDefault();
188
- submit();
146
+ <div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
147
+ {samplePrompt.prompt}
148
+ </div>
149
+
150
+ {samplePrompt.type === "recent" && <IconButton
151
+ onClick={(e) => {
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ removeRecentPrompt(storageKey, samplePrompt.prompt);
155
+ setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
156
+ }}
157
+ size={"smallest"}
158
+ >
159
+ <XIcon size={iconSize.smallest}/>
160
+ </IconButton>
189
161
  }
162
+ </MenuItem>;
163
+ })}
164
+
165
+ <Separator orientation={"horizontal"}/>
166
+
167
+ <div
168
+ className={cls(
169
+ "my-2 w-[500px] max-w-full flex items-start text-surface-700 dark:text-surface-200"
170
+ )}>
171
+
172
+ <TextareaAutosize
173
+ className={cls("p-4 rounded-lg resize-none bg-surface-100 dark:bg-surface-950 mx-2 w-full grow outline-hidden max-h-[300px] overflow-auto", focusedDisabled)}
174
+ value={instructions}
175
+ autoFocus={status === "new"}
176
+ disabled={loading}
177
+ onFocus={(event) => {
178
+ event.stopPropagation();
179
+ }}
180
+ placeholder={"...or provide instructions"}
181
+ onKeyDown={(e) => {
182
+ e.stopPropagation();
183
+ if (e.key === "Enter" && !e.shiftKey) {
184
+ e.preventDefault();
185
+ submit();
186
+ }
190
187
 
191
- }}
192
- onChange={(e) => {
193
- setInstructions(e.target.value);
194
- }}
195
- />
188
+ }}
189
+ onChange={(e) => {
190
+ setInstructions(e.target.value);
191
+ }}
192
+ />
196
193
 
197
- <IconButton
198
- size={"small"}
199
- onClick={() => {
200
- setInstructions("");
201
- }}
202
- color={!instructions ? "primary" : undefined}
203
- disabled={loading || !instructions}>
204
- <XIcon size={iconSize.small}/>
205
- </IconButton>
206
-
207
- <IconButton
208
- onClick={() => enhance(instructions)}
209
- size={"small"}
210
- color={!instructions ? "primary" : undefined}
211
- disabled={loading || !instructions}>
212
- {loading &&
213
- <CircularProgress size={"smallest"}/>}
214
- {!loading &&
215
- <SendIcon color={"primary"}/>}
216
- </IconButton>
194
+ <IconButton
195
+ size={"small"}
196
+ onClick={() => {
197
+ setInstructions("");
198
+ }}
199
+ color={!instructions ? "primary" : undefined}
200
+ disabled={loading || !instructions}>
201
+ <XIcon size={iconSize.small}/>
202
+ </IconButton>
203
+
204
+ <IconButton
205
+ onClick={() => generate(instructions)}
206
+ size={"small"}
207
+ color={!instructions ? "primary" : undefined}
208
+ disabled={loading || !instructions}>
209
+ {loading &&
210
+ <CircularProgress size={"smallest"}/>}
211
+ {!loading &&
212
+ <SendIcon color={"primary"}/>}
213
+ </IconButton>
214
+
215
+ </div>
217
216
 
218
- </div>
217
+ </Menu>
219
218
 
220
- </Menu>
219
+ <AutofillReviewDialog/>
220
+ </>
221
221
  );
222
222
  }
223
223
 
@@ -1,37 +1,24 @@
1
+ import React from "react";
1
2
  import { autocompleteStream } from "../api";
2
3
  import { EditorAIController } from "@rebasepro/admin";
3
4
 
4
- export function useEditorAIController({ getAuthToken }: { getAuthToken?: () => Promise<string> }): EditorAIController {
5
- const autocomplete = async (textBefore: string, textAfter: string, onUpdate: (delta: string) => void) => {
6
- if (!getAuthToken) {
7
- throw new Error("Firebase token is required");
8
- }
9
- const firebaseToken = await getAuthToken();
10
- return autocompleteStream({
11
- firebaseToken,
12
- textBefore,
13
- textAfter,
14
- onUpdate
15
- });
16
- }
17
-
18
- return {
19
- autocomplete
20
- };
5
+ /**
6
+ * Inline continuation for the rich-text editor's slash command.
7
+ *
8
+ * No token is threaded through any more. The previous version demanded a
9
+ * Firebase ID token and threw `"Firebase token is required"` when it could not
10
+ * get one — in a Rebase app there is no such thing, and the token it actually
11
+ * sent was a Rebase JWT the receiving service had no way to verify. The hosted
12
+ * service authenticates nobody; see `src/api.ts`.
13
+ */
14
+ export function useEditorAIController({ endpoint }: { endpoint?: string } = {}): EditorAIController {
15
+ return React.useMemo(() => ({
16
+ autocomplete: (textBefore: string, textAfter: string, onUpdate: (delta: string) => void) =>
17
+ autocompleteStream({
18
+ endpoint,
19
+ textBefore,
20
+ textAfter,
21
+ onDelta: onUpdate
22
+ })
23
+ }), [endpoint]);
21
24
  }
22
-
23
- // async function * generateLoremIpsum(): AsyncGenerator<string> {
24
- // const loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n# Heading\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
25
- //
26
- // const words = loremIpsum.split(" ");
27
- //
28
- // for (const word of words) {
29
- // yield word;
30
- // await new Promise(resolve => setTimeout(resolve, 100));
31
- // }
32
- // }
33
- //
34
- // const generator = generateLoremIpsum();
35
- // for await (const word of generator) {
36
- //
37
- // }
@@ -0,0 +1,283 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ Object.assign(global, { TextEncoder,
3
+ TextDecoder });
4
+
5
+ import {
6
+ DEFAULT_AI_ENDPOINT,
7
+ autocompleteStream,
8
+ autofillStream,
9
+ fetchAiStatus,
10
+ fetchPromptSuggestions
11
+ } from "../api";
12
+ import { AutofillRequest } from "../types/data_enhancement_controller";
13
+
14
+ /**
15
+ * The transport.
16
+ *
17
+ * This exists because of what it replaced. The old client split each network
18
+ * chunk on the literal `"&$# "` and `JSON.parse`d the pieces — so a delimiter
19
+ * landing across two reads corrupted the parse, and reads land wherever the
20
+ * network puts them. The central test below therefore re-delivers the same
21
+ * response one byte at a time and asserts the result is identical to
22
+ * delivering it whole. Anything that only ever feeds a complete body would
23
+ * have passed against the old code too.
24
+ */
25
+
26
+ /** A `Response` whose body yields exactly the chunks given, in order. */
27
+ function streamingResponse(chunks: string[], ok = true): any {
28
+ const encoder = new TextEncoder();
29
+ let i = 0;
30
+ return {
31
+ ok,
32
+ status: ok ? 200 : 500,
33
+ body: {
34
+ getReader: () => ({
35
+ read: async () =>
36
+ i < chunks.length
37
+ ? { done: false,
38
+ value: encoder.encode(chunks[i++]) }
39
+ : { done: true,
40
+ value: undefined }
41
+ })
42
+ }
43
+ };
44
+ }
45
+
46
+ function jsonResponse(body: unknown, ok = true, status = 200): any {
47
+ return { ok,
48
+ status,
49
+ json: async () => body };
50
+ }
51
+
52
+ /** Split a string into fixed-size pieces, to force boundaries anywhere. */
53
+ function chunked(body: string, size: number): string[] {
54
+ const out: string[] = [];
55
+ for (let i = 0; i < body.length; i += size) out.push(body.slice(i, i + size));
56
+ return out;
57
+ }
58
+
59
+ const REQUEST: AutofillRequest = {
60
+ entityName: "Product",
61
+ values: {},
62
+ properties: { title: { type: "string",
63
+ fieldConfigId: "text_field" } }
64
+ };
65
+
66
+ const BODY = [
67
+ "event: suggestion_delta",
68
+ 'data: {"key":"title","text":"Blue "}',
69
+ "",
70
+ "event: suggestion_delta",
71
+ 'data: {"key":"title","text":"widget"}',
72
+ "",
73
+ "event: suggestion",
74
+ 'data: {"key":"title","value":"Blue widget"}',
75
+ "",
76
+ "event: suggestion",
77
+ 'data: {"key":"stock","value":42}',
78
+ "",
79
+ "event: done",
80
+ 'data: {"suggestions":{"title":"Blue widget","stock":42},"usage":{"outputTokens":9}}',
81
+ "",
82
+ ""
83
+ ].join("\n");
84
+
85
+ async function runAutofill(chunks: string[]) {
86
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunks));
87
+ const deltas: [string, string][] = [];
88
+ const values: [string, unknown][] = [];
89
+ const result = await autofillStream({
90
+ request: REQUEST,
91
+ onDelta: (k, t) => deltas.push([k, t]),
92
+ onValue: (k, v) => values.push([k, v])
93
+ });
94
+ return { deltas,
95
+ values,
96
+ result };
97
+ }
98
+
99
+ afterEach(() => {
100
+ jest.restoreAllMocks();
101
+ });
102
+
103
+ describe("autofillStream", () => {
104
+ it("reads deltas, values and the final result", async () => {
105
+ const { deltas, values, result } = await runAutofill([BODY]);
106
+ expect(deltas).toEqual([
107
+ ["title", "Blue "],
108
+ ["title", "widget"]
109
+ ]);
110
+ expect(values).toEqual([
111
+ ["title", "Blue widget"],
112
+ ["stock", 42]
113
+ ]);
114
+ expect(result.suggestions).toEqual({ title: "Blue widget",
115
+ stock: 42 });
116
+ expect(result.usage).toEqual({ outputTokens: 9 });
117
+ });
118
+
119
+ it("produces identical results at every chunk boundary", async () => {
120
+ const whole = await runAutofill([BODY]);
121
+ for (let size = 1; size <= BODY.length; size++) {
122
+ const split = await runAutofill(chunked(BODY, size));
123
+ expect(split.deltas).toEqual(whole.deltas);
124
+ expect(split.values).toEqual(whole.values);
125
+ expect(split.result).toEqual(whole.result);
126
+ }
127
+ });
128
+
129
+ it("handles CRLF record separators", async () => {
130
+ // Proxies rewrite line endings, and a four-character separator sliced as
131
+ // if it were two leaves a stray newline that eats the next `event:`.
132
+ const { values } = await runAutofill([BODY.replace(/\n/g, "\r\n")]);
133
+ expect(values).toEqual([
134
+ ["title", "Blue widget"],
135
+ ["stock", 42]
136
+ ]);
137
+ });
138
+
139
+ it("ignores keep-alive comments", async () => {
140
+ const withComments = ":keep-alive\n\n" + BODY;
141
+ const { result } = await runAutofill([withComments]);
142
+ expect(result.suggestions).toEqual({ title: "Blue widget",
143
+ stock: 42 });
144
+ });
145
+
146
+ it("joins a multi-line data field", async () => {
147
+ const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n';
148
+ const { values } = await runAutofill([body]);
149
+ expect(values).toEqual([["body", "two lines"]]);
150
+ });
151
+
152
+ it("keeps going past one malformed record", async () => {
153
+ // A single bad frame must not discard fields that arrived correctly.
154
+ const body = "event: suggestion\ndata: {not json\n\n" + BODY;
155
+ const { values } = await runAutofill([body]);
156
+ expect(values).toEqual([
157
+ ["title", "Blue widget"],
158
+ ["stock", 42]
159
+ ]);
160
+ });
161
+
162
+ it("throws the message carried on an error event", async () => {
163
+ (global as any).fetch = jest.fn().mockResolvedValue(
164
+ streamingResponse(['event: error\ndata: {"message":"quota exhausted"}\n\n'])
165
+ );
166
+ await expect(
167
+ autofillStream({ request: REQUEST,
168
+ onDelta: () => undefined,
169
+ onValue: () => undefined })
170
+ ).rejects.toThrow("quota exhausted");
171
+ });
172
+
173
+ it("surfaces the server's error envelope on a non-2xx", async () => {
174
+ // `{ error: { message } }` is the control plane's contract. Reading it
175
+ // is the difference between telling the operator the quota reset time
176
+ // and telling them "Request failed with status 429".
177
+ (global as any).fetch = jest.fn().mockResolvedValue(
178
+ jsonResponse({ error: { message: "The free AI quota for today has been used up.",
179
+ code: "upstream_error" } }, false, 429)
180
+ );
181
+ await expect(
182
+ autofillStream({ request: REQUEST,
183
+ onDelta: () => undefined,
184
+ onValue: () => undefined })
185
+ ).rejects.toThrow(/quota for today/);
186
+ });
187
+
188
+ it("sends no credentials of any kind", async () => {
189
+ // The FireCMS-era client sent the tenant's JWT as `Authorization: Basic`
190
+ // plus a hardcoded `fcms-…` key. No external service could verify the
191
+ // former, and the latter shipped in the published package.
192
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
193
+ (global as any).fetch = fetchMock;
194
+ await autofillStream({ request: REQUEST,
195
+ onDelta: () => undefined,
196
+ onValue: () => undefined });
197
+ const [, init] = fetchMock.mock.calls[0];
198
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
199
+ expect(JSON.stringify(init)).not.toMatch(/fcms-|Bearer|Basic/);
200
+ });
201
+
202
+ it("posts to the hosted endpoint by default and to an override when given", async () => {
203
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
204
+ (global as any).fetch = fetchMock;
205
+
206
+ await autofillStream({ request: REQUEST,
207
+ onDelta: () => undefined,
208
+ onValue: () => undefined });
209
+ expect(fetchMock.mock.calls[0][0]).toBe(`${DEFAULT_AI_ENDPOINT}/autofill`);
210
+
211
+ await autofillStream({
212
+ request: REQUEST,
213
+ endpoint: "https://ai.example.com/",
214
+ onDelta: () => undefined,
215
+ onValue: () => undefined
216
+ });
217
+ // Trailing slash trimmed — otherwise the override 404s on `//autofill`.
218
+ expect(fetchMock.mock.calls[1][0]).toBe("https://ai.example.com/autofill");
219
+ });
220
+ });
221
+
222
+ describe("autocompleteStream", () => {
223
+ it("concatenates deltas and returns the full continuation", async () => {
224
+ const body = [
225
+ 'event: delta\ndata: {"text":"the quick "}',
226
+ 'event: delta\ndata: {"text":"brown fox"}',
227
+ "event: done\ndata: {}",
228
+ ""
229
+ ].join("\n\n");
230
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunked(body, 7)));
231
+
232
+ const seen: string[] = [];
233
+ const text = await autocompleteStream({
234
+ textBefore: "I saw ",
235
+ textAfter: "",
236
+ onDelta: (t) => seen.push(t)
237
+ });
238
+
239
+ expect(seen.join("")).toBe("the quick brown fox");
240
+ expect(text).toBe("the quick brown fox");
241
+ });
242
+ });
243
+
244
+ describe("fetchAiStatus", () => {
245
+ it("reports available when the service says so", async () => {
246
+ (global as any).fetch = jest.fn().mockResolvedValue(
247
+ jsonResponse({ available: true,
248
+ model: "claude-opus-5",
249
+ features: ["autofill"] })
250
+ );
251
+ await expect(fetchAiStatus({})).resolves.toEqual({
252
+ available: true,
253
+ model: "claude-opus-5",
254
+ features: ["autofill"]
255
+ });
256
+ });
257
+
258
+ it("reports unavailable rather than throwing when the service errors", async () => {
259
+ // This value decides whether a button renders. Any doubt must resolve to
260
+ // "no button" — that is the whole fix for the 404-on-click failure.
261
+ (global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({}, false, 503));
262
+ await expect(fetchAiStatus({})).resolves.toEqual({ available: false });
263
+ });
264
+ });
265
+
266
+ describe("fetchPromptSuggestions", () => {
267
+ it("maps the service's prompts", async () => {
268
+ (global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({ prompts: ["A blue widget", "A red one"] }));
269
+ await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({
270
+ prompts: [
271
+ { prompt: "A blue widget",
272
+ type: "sample" },
273
+ { prompt: "A red one",
274
+ type: "sample" }
275
+ ]
276
+ });
277
+ });
278
+
279
+ it("degrades to no suggestions instead of failing the menu", async () => {
280
+ (global as any).fetch = jest.fn().mockRejectedValue(new Error("offline"));
281
+ await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({ prompts: [] });
282
+ });
283
+ });