@rebasepro/plugin-ai 0.13.0 → 0.13.1-canary.g06dbe5b

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.
@@ -7,11 +7,12 @@ import {
7
7
  InputProperty,
8
8
  ProposedField
9
9
  } from "../types/data_enhancement_controller";
10
- import { CollectionConfig } from "@rebasepro/types";
10
+ import { CollectionConfig, User } from "@rebasepro/types";
11
11
  import { PluginFormActionProps } from "@rebasepro/admin-types";
12
- import { autofillStream, fetchAiStatus, fetchPromptSuggestions } from "../api";
12
+ import { useAuthController } from "@rebasepro/app";
13
+ import { autofillStream, fetchAiStatusCached, fetchPromptSuggestions } from "../api";
13
14
  import { getSimplifiedProperties } from "../utils/properties";
14
- import { flatMapEntityValues } from "../utils/values";
15
+ import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
15
16
  import { useEditorAIController } from "../editor/useEditorAIController";
16
17
  import { getValueInPath } from "@rebasepro/utils";
17
18
 
@@ -19,9 +20,16 @@ const DataEnhancementControllerContext = React.createContext<DataEnhancementCont
19
20
 
20
21
  type DataEnhancementControllerProviderProps = {
21
22
 
23
+ /**
24
+ * Kept in step with `DataEnhancementPluginProps.getConfigForPath`, which is
25
+ * the signature the host app actually writes against: the plugin hands this
26
+ * component through as `ComponentType<any>`, so nothing but agreement here
27
+ * makes the two match.
28
+ */
22
29
  getConfigForPath?: (props: {
23
30
  path: string,
24
- collection: CollectionConfig
31
+ collection: CollectionConfig,
32
+ user: User | null
25
33
  }) => boolean;
26
34
 
27
35
  endpoint?: string;
@@ -84,15 +92,26 @@ export function DataEnhancementControllerProvider({
84
92
  const propertiesRef = useRef(properties);
85
93
  propertiesRef.current = properties;
86
94
 
87
- /** The host app's own opt-out. */
95
+ /**
96
+ * The host app's own opt-out.
97
+ *
98
+ * `user` is part of the documented signature and was never passed, so
99
+ * `getConfigForPath: ({ user }) => user?.roles?.includes("editor")` was
100
+ * `Boolean(undefined)` for everyone — an access rule that silently decided
101
+ * nothing, in whichever direction the host had written it.
102
+ */
103
+ const authController = useAuthController();
104
+ const user: User | null = authController?.user ?? null;
105
+
88
106
  useEffect(() => {
89
107
  if (!getConfigForPath) {
90
108
  setAllowedHere(true);
91
109
  return;
92
110
  }
93
111
  setAllowedHere(Boolean(getConfigForPath({ path,
94
- collection })));
95
- }, [getConfigForPath, path, collection]);
112
+ collection,
113
+ user })));
114
+ }, [getConfigForPath, path, collection, user]);
96
115
 
97
116
  /**
98
117
  * The service's own availability.
@@ -101,15 +120,23 @@ collection })));
101
120
  * unconfigured provider key or an exhausted daily quota all land here, and
102
121
  * all of them mean the same thing to the operator: no Autofill button,
103
122
  * rather than a button that fails when clicked.
123
+ *
124
+ * Asked through the session cache: this provider is form-scoped, so an
125
+ * uncached probe is one request to the host per record opened, by an install
126
+ * that may never use the feature. The probe is shared rather than aborted on
127
+ * unmount — cancelling it would cancel it for whatever else is waiting on the
128
+ * same answer — so unmounting only stops this component from reading it.
104
129
  */
105
130
  useEffect(() => {
106
131
  if (!allowedHere) return;
107
- const abort = new AbortController();
108
- fetchAiStatus({ endpoint,
109
- signal: abort.signal })
110
- .then((status) => setServiceAvailable(status.available))
111
- .catch(() => setServiceAvailable(false));
112
- return () => abort.abort();
132
+ let cancelled = false;
133
+ fetchAiStatusCached({ endpoint })
134
+ .then((status) => {
135
+ if (!cancelled) setServiceAvailable(status.available);
136
+ });
137
+ return () => {
138
+ cancelled = true;
139
+ };
113
140
  }, [allowedHere, endpoint]);
114
141
 
115
142
  const enabled = allowedHere && serviceAvailable;
@@ -131,7 +158,10 @@ fields };
131
158
  const generate = useCallback(async (params: GenerateParams<Record<string, unknown>>): Promise<void> => {
132
159
 
133
160
  const currentProperties = propertiesRef.current;
134
- const flatValues = flatMapEntityValues(params.values ?? {}) as Record<string, unknown>;
161
+ const flatValues = omitDisabledValues(
162
+ flatMapEntityValues(params.values ?? {}),
163
+ currentProperties
164
+ );
135
165
 
136
166
  setReview({
137
167
  status: "generating",
@@ -150,6 +180,12 @@ fields };
150
180
  // Flattened to dotted paths so the keys line up with the
151
181
  // property map: the service is told about `seo.title`, so it
152
182
  // has to be told the value of `seo.title` too, not of `seo`.
183
+ // Exactly the same rule in both directions — an array or a
184
+ // date is one value under one key here because it is one
185
+ // property under one key there. Where they disagreed, the
186
+ // service read a filled field as empty and offered to
187
+ // rewrite it. Values of properties nobody may edit do not
188
+ // travel at all.
153
189
  values: flatValues,
154
190
  properties: currentProperties,
155
191
  propertyKey: params.propertyKey,
@@ -1,7 +1,6 @@
1
1
  import React, { useCallback, useEffect, useRef } from "react";
2
2
 
3
3
  import {
4
- Button,
5
4
  CircularProgress,
6
5
  cls,
7
6
  fieldBackgroundMixin,
@@ -117,14 +116,18 @@ export function FormEnhanceAction({
117
116
  // Never full width: this used to stretch to fill the form's
118
117
  // `w-80 2xl:w-96` side rail in full screen. That rail is gone, and
119
118
  // in the footer a stretched button reads as the primary action.
120
- trigger={<Button variant={"filled"}
121
- color={"neutral"}
119
+ // Icon only. The label is carried by `aria-label`/`title` — a
120
+ // `Tooltip` here would swallow the menu: both it and
121
+ // `DropdownMenu.Trigger` render `asChild`, and `Tooltip` drops
122
+ // the props Radix clones onto it, so the menu never opens.
123
+ trigger={<IconButton variant={"filled"}
122
124
  size={"small"}
125
+ aria-label={"Autofill"}
126
+ title={"Autofill"}
123
127
  disabled={loading}>
124
128
  {!loading && <AIIcon size={"small"}/>}
125
129
  {loading && <CircularProgress size={"small"}/>}
126
- Autofill
127
- </Button>}>
130
+ </IconButton>}>
128
131
 
129
132
  <MenuItem className={"py-4"}
130
133
  onClick={() => {
@@ -6,7 +6,9 @@ import {
6
6
  DEFAULT_AI_ENDPOINT,
7
7
  autocompleteStream,
8
8
  autofillStream,
9
+ clearAiStatusCache,
9
10
  fetchAiStatus,
11
+ fetchAiStatusCached,
10
12
  fetchPromptSuggestions
11
13
  } from "../api";
12
14
  import { AutofillRequest } from "../types/data_enhancement_controller";
@@ -144,7 +146,8 @@ stock: 42 });
144
146
  });
145
147
 
146
148
  it("joins a multi-line data field", async () => {
147
- const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n';
149
+ const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n'
150
+ + "event: done\ndata: {}\n\n";
148
151
  const { values } = await runAutofill([body]);
149
152
  expect(values).toEqual([["body", "two lines"]]);
150
153
  });
@@ -185,6 +188,58 @@ onValue: () => undefined })
185
188
  ).rejects.toThrow(/quota for today/);
186
189
  });
187
190
 
191
+ it("fails a stream that ends without a `done` record", async () => {
192
+ // A rolled pod, a proxy timeout, a dropped connection. The body simply
193
+ // stops. Returning `{ suggestions: {} }` for that is indistinguishable
194
+ // from the service saying there was nothing to fill — and the operator
195
+ // is then told, in a confident sentence, that their empty fields are
196
+ // fields the model would not improve on.
197
+ const truncated = [
198
+ "event: suggestion",
199
+ 'data: {"key":"title","value":"Blue widget"}',
200
+ "",
201
+ "event: suggestion_delta",
202
+ 'data: {"key":"summary","text":"half a sen'
203
+ ].join("\n");
204
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse([truncated]));
205
+
206
+ const values: [string, unknown][] = [];
207
+ await expect(autofillStream({
208
+ request: REQUEST,
209
+ onDelta: () => undefined,
210
+ onValue: (k, v) => values.push([k, v])
211
+ })).rejects.toThrow(/ended before it finished/);
212
+
213
+ // Whatever did arrive was still delivered — the caller keeps the fields
214
+ // that completed and reports the run as failed.
215
+ expect(values).toEqual([["title", "Blue widget"]]);
216
+ });
217
+
218
+ it("fails a stream whose every record was unreadable", async () => {
219
+ // Zero good fields plus n discarded records is not "nothing to fill".
220
+ const garbage = "event: suggestion\ndata: {not json\n\nevent: suggestion\ndata: {also not\n\n";
221
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse([garbage]));
222
+ await expect(
223
+ autofillStream({ request: REQUEST,
224
+ onDelta: () => undefined,
225
+ onValue: () => undefined })
226
+ ).rejects.toThrow(/could not be read|ended before it finished/);
227
+ });
228
+
229
+ it("accepts the empty run the service sends when there is nothing to fill", async () => {
230
+ // That case is a `done` with no suggestions, not an empty body, so it
231
+ // must stay distinguishable from a truncation.
232
+ (global as any).fetch = jest.fn().mockResolvedValue(
233
+ streamingResponse(['event: done\ndata: {"suggestions":{},"usage":{}}\n\n'])
234
+ );
235
+ await expect(
236
+ autofillStream({ request: REQUEST,
237
+ onDelta: () => undefined,
238
+ onValue: () => undefined })
239
+ ).resolves.toEqual({ suggestions: {},
240
+ usage: {} });
241
+ });
242
+
188
243
  it("sends no credentials of any kind", async () => {
189
244
  // The FireCMS-era client sent the tenant's JWT as `Authorization: Basic`
190
245
  // plus a hardcoded `fcms-…` key. No external service could verify the
@@ -200,7 +255,10 @@ onValue: () => undefined });
200
255
  });
201
256
 
202
257
  it("posts to the hosted endpoint by default and to an override when given", async () => {
203
- const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
258
+ // A fresh response per call: one `streamingResponse` is a single reader,
259
+ // and handing the same exhausted one to the second call makes it read an
260
+ // empty body — which is now, correctly, a truncated stream.
261
+ const fetchMock = jest.fn().mockImplementation(async () => streamingResponse([BODY]));
204
262
  (global as any).fetch = fetchMock;
205
263
 
206
264
  await autofillStream({ request: REQUEST,
@@ -263,6 +321,47 @@ features: ["autofill"] })
263
321
  });
264
322
  });
265
323
 
324
+ describe("fetchAiStatusCached", () => {
325
+
326
+ beforeEach(() => clearAiStatusCache());
327
+ afterEach(() => clearAiStatusCache());
328
+
329
+ it("asks the host once per endpoint, however many callers there are", async () => {
330
+ // The provider is form-scoped, so an uncached probe is one request to
331
+ // the host every time any record is opened — a beacon from an install
332
+ // that may never use the feature, and enough traffic from one office
333
+ // behind one address to spend the host's per-IP limit on nothing.
334
+ const fetchMock = jest.fn().mockResolvedValue(jsonResponse({ available: true }));
335
+ (global as any).fetch = fetchMock;
336
+
337
+ const answers = await Promise.all([
338
+ fetchAiStatusCached({}),
339
+ fetchAiStatusCached({}),
340
+ fetchAiStatusCached({})
341
+ ]);
342
+ await fetchAiStatusCached({});
343
+
344
+ expect(fetchMock).toHaveBeenCalledTimes(1);
345
+ expect(answers.every(a => a.available)).toBe(true);
346
+ });
347
+
348
+ it("keeps one answer per endpoint", async () => {
349
+ const fetchMock = jest.fn().mockResolvedValue(jsonResponse({ available: true }));
350
+ (global as any).fetch = fetchMock;
351
+ await fetchAiStatusCached({});
352
+ await fetchAiStatusCached({ endpoint: "https://ai.example.com" });
353
+ expect(fetchMock).toHaveBeenCalledTimes(2);
354
+ });
355
+
356
+ it("answers unavailable, once, when the host cannot be reached", async () => {
357
+ const fetchMock = jest.fn().mockRejectedValue(new Error("offline"));
358
+ (global as any).fetch = fetchMock;
359
+ await expect(fetchAiStatusCached({})).resolves.toEqual({ available: false });
360
+ await expect(fetchAiStatusCached({})).resolves.toEqual({ available: false });
361
+ expect(fetchMock).toHaveBeenCalledTimes(1);
362
+ });
363
+ });
364
+
266
365
  describe("fetchPromptSuggestions", () => {
267
366
  it("maps the service's prompts", async () => {
268
367
  (global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({ prompts: ["A blue widget", "A red one"] }));
@@ -0,0 +1,240 @@
1
+ import { Properties } from "@rebasepro/types";
2
+ import { getValueInPath } from "@rebasepro/utils";
3
+
4
+ import { getSimplifiedProperties } from "../utils/properties";
5
+ import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
6
+ import { InputProperty } from "../types/data_enhancement_controller";
7
+
8
+ /**
9
+ * The two halves of an autofill request have to be keyed the same way.
10
+ *
11
+ * "Autofill only fills blanks" is not enforced anywhere in this package. It is
12
+ * enforced on the service, by omitting already-filled fields from the JSON
13
+ * schema the model answers into — and the service decides a field is filled by
14
+ * looking up `values[key]` for each `key` of `properties`. So the guard is only
15
+ * as true as the agreement between the two maps this file checks.
16
+ *
17
+ * That is why these tests build *both* maps from one record and then replay the
18
+ * service's own decision over them, rather than testing either map alone. Every
19
+ * assertion here passed against a client that flattened `tags: ["a","b"]` into
20
+ * `tags.0`/`tags.1` and dropped `Date` values entirely — as long as it was the
21
+ * flattener, or the property map, that was looked at in isolation.
22
+ *
23
+ * `getFieldId` is mocked deterministically; the real one resolves against the
24
+ * admin field registry, which is not what is under test.
25
+ */
26
+ jest.mock("@rebasepro/admin", () => ({
27
+ getFieldId: (property: { type?: string }) => {
28
+ if (!property?.type) return undefined;
29
+ if (property.type === "string") return "text_field";
30
+ if (property.type === "number") return "number_field";
31
+ if (property.type === "boolean") return "switch";
32
+ if (property.type === "date") return "date_time";
33
+ return undefined;
34
+ }
35
+ }));
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // The service's side of the contract, reproduced.
39
+ //
40
+ // Copied from `saas/backend/functions/ai.ts` (`alreadyFilled`, `scalarSchema`,
41
+ // `propertySchema`, `planFill`). It lives in another repository and cannot be
42
+ // imported, so it is mirrored — and mirrored *including* the JSON round trip,
43
+ // because the difference between a `Date` object and the ISO string a `Date`
44
+ // serialises to is exactly the difference between "this field is empty" and
45
+ // "this field is filled" over there.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function alreadyFilled(value: unknown): boolean {
49
+ if (value === null || value === undefined) return false;
50
+ if (typeof value === "string") return value.trim().length > 0;
51
+ if (Array.isArray(value)) return value.length > 0;
52
+ if (typeof value === "object") return Object.keys(value as object).length > 0;
53
+ return true;
54
+ }
55
+
56
+ function scalarSchema(property: InputProperty): object | undefined {
57
+ if (property.enum && property.enum.length > 0) return { type: "string",
58
+ enum: property.enum };
59
+ switch (property.type) {
60
+ case "string":
61
+ return { type: "string" };
62
+ case "number":
63
+ return { type: "number" };
64
+ case "boolean":
65
+ return { type: "boolean" };
66
+ case "date":
67
+ return { type: "string",
68
+ format: "date-time" };
69
+ default:
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ function propertySchema(property: InputProperty): object | undefined {
75
+ if (property.disabled) return undefined;
76
+ if (property.type === "array") {
77
+ const items = property.of ? scalarSchema(property.of) : undefined;
78
+ return items ? { type: "array",
79
+ items } : undefined;
80
+ }
81
+ return scalarSchema(property);
82
+ }
83
+
84
+ /** The keys the service would offer the model, given one request body. */
85
+ function plannedFillKeys(request: { properties: Record<string, InputProperty>, values: Record<string, unknown> }): string[] {
86
+ // As the service receives it: JSON, not the client's live objects.
87
+ const values = JSON.parse(JSON.stringify(request.values)) as Record<string, unknown>;
88
+ return Object.entries(request.properties)
89
+ .filter(([key, property]) => {
90
+ if (!property || typeof property !== "object") return false;
91
+ if (alreadyFilled(values[key])) return false;
92
+ return Boolean(propertySchema(property));
93
+ })
94
+ .map(([key]) => key);
95
+ }
96
+
97
+ /** What the client puts on the wire, built the way the provider builds it. */
98
+ function requestFor(properties: Properties, record: Record<string, unknown>) {
99
+ const simplified = getSimplifiedProperties(properties, record);
100
+ return {
101
+ properties: simplified,
102
+ values: omitDisabledValues(flatMapEntityValues(record), simplified)
103
+ };
104
+ }
105
+
106
+ const POST_PROPERTIES: Properties = {
107
+ title: { name: "Title",
108
+ type: "string" },
109
+ summary: { name: "Summary",
110
+ type: "string" },
111
+ tags: {
112
+ name: "Tags",
113
+ type: "array",
114
+ of: { name: "Tag",
115
+ type: "string" }
116
+ },
117
+ published_at: { name: "Published at",
118
+ type: "date" },
119
+ seo: {
120
+ name: "SEO",
121
+ type: "map",
122
+ properties: {
123
+ title: { name: "SEO title",
124
+ type: "string" },
125
+ description: { name: "SEO description",
126
+ type: "string" }
127
+ }
128
+ },
129
+ internal_notes: {
130
+ name: "Internal notes",
131
+ type: "string",
132
+ admin: { readOnly: true }
133
+ }
134
+ } as Properties;
135
+
136
+ /** A published post: five tags, a publication date, one genuinely empty field. */
137
+ const PUBLISHED_POST = {
138
+ title: "Hello",
139
+ tags: ["news", "launch"],
140
+ published_at: new Date("2026-01-01T00:00:00.000Z"),
141
+ seo: { title: "Hello — SEO" },
142
+ internal_notes: "written by a backend hook"
143
+ };
144
+
145
+ describe("the request the client sends", () => {
146
+
147
+ it("keys an array and a date exactly as the property map names them", () => {
148
+ const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
149
+
150
+ expect(Object.keys(properties)).toEqual(expect.arrayContaining(["tags", "published_at"]));
151
+ expect(values.tags).toEqual(["news", "launch"]);
152
+ expect(values.published_at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
153
+ // The whole key set, because the old shapes — `tags.0`, `tags.1`, and no
154
+ // `published_at` at all — are absences a per-key assertion would miss.
155
+ expect(Object.keys(values).sort()).toEqual(["published_at", "seo.title", "tags", "title"]);
156
+ });
157
+
158
+ it("still descends into a map, which is the one container with its own fields", () => {
159
+ const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
160
+ expect(Object.keys(properties)).toContain("seo.title");
161
+ expect(values["seo.title"]).toBe("Hello — SEO");
162
+ });
163
+
164
+ it("offers the model only the fields that are actually empty", () => {
165
+ // The whole invariant, end to end. `tags` and `published_at` used to be
166
+ // in this list, so a populated tag list and a set publication date
167
+ // arrived in the review pre-ticked for replacement.
168
+ expect(plannedFillKeys(requestFor(POST_PROPERTIES, PUBLISHED_POST)))
169
+ .toEqual(["summary", "seo.description"]);
170
+ });
171
+
172
+ it("offers every field of an empty record", () => {
173
+ // The other direction: the agreement must not be bought by hiding
174
+ // fillable fields. `internal_notes` is absent because it is read-only,
175
+ // and `seo` because a map is a container, not a field.
176
+ expect(plannedFillKeys(requestFor(POST_PROPERTIES, {})))
177
+ .toEqual(["title", "summary", "tags", "published_at", "seo.title", "seo.description"]);
178
+ });
179
+
180
+ it("carries a value for every fillable property the record has filled", () => {
181
+ // The drift check, stated as a rule rather than a list: whatever a
182
+ // future property type does to either map, a value the operator can see
183
+ // in the form must reach the service under the key the service will
184
+ // look it up by.
185
+ const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
186
+ const onTheWire = JSON.parse(JSON.stringify(values)) as Record<string, unknown>;
187
+
188
+ for (const [key, property] of Object.entries(properties)) {
189
+ if (!propertySchema(property)) continue; // not a field the service fills
190
+ if (!alreadyFilled(getValueInPath(PUBLISHED_POST, key))) continue;
191
+ expect({ key,
192
+ filled: alreadyFilled(onTheWire[key]) }).toEqual({ key,
193
+ filled: true });
194
+ }
195
+ });
196
+
197
+ it("does not transmit the value of a read-only property", () => {
198
+ // `disabled` already means "the service may not fill this". It has to
199
+ // mean "and it is not context either": the prompt includes every value
200
+ // it is given, so a field owned by a backend hook was being pasted into
201
+ // it verbatim.
202
+ const { values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
203
+ expect(values).not.toHaveProperty("internal_notes");
204
+ expect(JSON.stringify(values)).not.toContain("backend hook");
205
+ });
206
+
207
+ it("takes the children of a disabled map with it", () => {
208
+ const properties = {
209
+ audit: {
210
+ name: "Audit",
211
+ type: "map",
212
+ admin: { disabled: true },
213
+ properties: {
214
+ author: { name: "Author",
215
+ type: "string" }
216
+ }
217
+ }
218
+ } as Properties;
219
+ const { values } = requestFor(properties, { audit: { author: "root" } });
220
+ expect(values).toEqual({});
221
+ });
222
+ });
223
+
224
+ describe("omitDisabledValues", () => {
225
+
226
+ it("leaves a record alone when nothing is disabled", () => {
227
+ const values = { a: 1,
228
+ b: 2 };
229
+ expect(omitDisabledValues(values, { a: { type: "number",
230
+ fieldConfigId: "number_field" } })).toBe(values);
231
+ });
232
+
233
+ it("survives a property map carrying a non-object at a path", () => {
234
+ // `getSimplifiedProperty` writes a raw type string at
235
+ // `${path}.${i}.${typeField}` for `oneOf` arrays, and `"disabled" in
236
+ // aString` throws.
237
+ const properties = { "blocks.0.type": "images" } as unknown as Record<string, InputProperty>;
238
+ expect(() => omitDisabledValues({ "blocks.0.type": "images" }, properties)).not.toThrow();
239
+ });
240
+ });