@rebasepro/plugin-ai 0.17.3-canary.gdd23447 → 0.18.0
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.
- package/LICENSE +0 -1
- package/README.md +4 -0
- package/dist/api.d.ts +8 -0
- package/dist/index.es.js.map +1 -1
- package/package.json +35 -21
- package/src/api.ts +0 -320
- package/src/components/AutofillReviewDialog.tsx +0 -209
- package/src/components/DataEnhancementControllerProvider.tsx +0 -320
- package/src/components/FormEnhanceAction.tsx +0 -307
- package/src/editor/useEditorAIController.tsx +0 -24
- package/src/index.ts +0 -9
- package/src/tests/AutofillReviewDialog.test.tsx +0 -340
- package/src/tests/api.test.ts +0 -382
- package/src/tests/properties.test.ts +0 -420
- package/src/tests/request_agreement.test.ts +0 -240
- package/src/tests/review.test.tsx +0 -596
- package/src/tests/useDataEnhancementPlugin.test.tsx +0 -72
- package/src/tests/useEditorAIController.test.ts +0 -98
- package/src/tests/values.test.ts +0 -87
- package/src/types/data_enhancement_controller.tsx +0 -142
- package/src/useDataEnhancementPlugin.tsx +0 -68
- package/src/utils/properties.ts +0 -168
- package/src/utils/values.ts +0 -72
- package/src/vite-env.d.ts +0 -1
package/src/tests/api.test.ts
DELETED
|
@@ -1,382 +0,0 @@
|
|
|
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
|
-
clearAiStatusCache,
|
|
10
|
-
fetchAiStatus,
|
|
11
|
-
fetchAiStatusCached,
|
|
12
|
-
fetchPromptSuggestions
|
|
13
|
-
} from "../api";
|
|
14
|
-
import { AutofillRequest } from "../types/data_enhancement_controller";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The transport.
|
|
18
|
-
*
|
|
19
|
-
* This exists because of what it replaced. The old client split each network
|
|
20
|
-
* chunk on the literal `"&$# "` and `JSON.parse`d the pieces — so a delimiter
|
|
21
|
-
* landing across two reads corrupted the parse, and reads land wherever the
|
|
22
|
-
* network puts them. The central test below therefore re-delivers the same
|
|
23
|
-
* response one byte at a time and asserts the result is identical to
|
|
24
|
-
* delivering it whole. Anything that only ever feeds a complete body would
|
|
25
|
-
* have passed against the old code too.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** A `Response` whose body yields exactly the chunks given, in order. */
|
|
29
|
-
function streamingResponse(chunks: string[], ok = true): any {
|
|
30
|
-
const encoder = new TextEncoder();
|
|
31
|
-
let i = 0;
|
|
32
|
-
return {
|
|
33
|
-
ok,
|
|
34
|
-
status: ok ? 200 : 500,
|
|
35
|
-
body: {
|
|
36
|
-
getReader: () => ({
|
|
37
|
-
read: async () =>
|
|
38
|
-
i < chunks.length
|
|
39
|
-
? { done: false,
|
|
40
|
-
value: encoder.encode(chunks[i++]) }
|
|
41
|
-
: { done: true,
|
|
42
|
-
value: undefined }
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function jsonResponse(body: unknown, ok = true, status = 200): any {
|
|
49
|
-
return { ok,
|
|
50
|
-
status,
|
|
51
|
-
json: async () => body };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Split a string into fixed-size pieces, to force boundaries anywhere. */
|
|
55
|
-
function chunked(body: string, size: number): string[] {
|
|
56
|
-
const out: string[] = [];
|
|
57
|
-
for (let i = 0; i < body.length; i += size) out.push(body.slice(i, i + size));
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const REQUEST: AutofillRequest = {
|
|
62
|
-
entityName: "Product",
|
|
63
|
-
values: {},
|
|
64
|
-
properties: { title: { type: "string",
|
|
65
|
-
fieldConfigId: "text_field" } }
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const BODY = [
|
|
69
|
-
"event: suggestion_delta",
|
|
70
|
-
'data: {"key":"title","text":"Blue "}',
|
|
71
|
-
"",
|
|
72
|
-
"event: suggestion_delta",
|
|
73
|
-
'data: {"key":"title","text":"widget"}',
|
|
74
|
-
"",
|
|
75
|
-
"event: suggestion",
|
|
76
|
-
'data: {"key":"title","value":"Blue widget"}',
|
|
77
|
-
"",
|
|
78
|
-
"event: suggestion",
|
|
79
|
-
'data: {"key":"stock","value":42}',
|
|
80
|
-
"",
|
|
81
|
-
"event: done",
|
|
82
|
-
'data: {"suggestions":{"title":"Blue widget","stock":42},"usage":{"outputTokens":9}}',
|
|
83
|
-
"",
|
|
84
|
-
""
|
|
85
|
-
].join("\n");
|
|
86
|
-
|
|
87
|
-
async function runAutofill(chunks: string[]) {
|
|
88
|
-
(global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunks));
|
|
89
|
-
const deltas: [string, string][] = [];
|
|
90
|
-
const values: [string, unknown][] = [];
|
|
91
|
-
const result = await autofillStream({
|
|
92
|
-
request: REQUEST,
|
|
93
|
-
onDelta: (k, t) => deltas.push([k, t]),
|
|
94
|
-
onValue: (k, v) => values.push([k, v])
|
|
95
|
-
});
|
|
96
|
-
return { deltas,
|
|
97
|
-
values,
|
|
98
|
-
result };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
afterEach(() => {
|
|
102
|
-
jest.restoreAllMocks();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("autofillStream", () => {
|
|
106
|
-
it("reads deltas, values and the final result", async () => {
|
|
107
|
-
const { deltas, values, result } = await runAutofill([BODY]);
|
|
108
|
-
expect(deltas).toEqual([
|
|
109
|
-
["title", "Blue "],
|
|
110
|
-
["title", "widget"]
|
|
111
|
-
]);
|
|
112
|
-
expect(values).toEqual([
|
|
113
|
-
["title", "Blue widget"],
|
|
114
|
-
["stock", 42]
|
|
115
|
-
]);
|
|
116
|
-
expect(result.suggestions).toEqual({ title: "Blue widget",
|
|
117
|
-
stock: 42 });
|
|
118
|
-
expect(result.usage).toEqual({ outputTokens: 9 });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("produces identical results at every chunk boundary", async () => {
|
|
122
|
-
const whole = await runAutofill([BODY]);
|
|
123
|
-
for (let size = 1; size <= BODY.length; size++) {
|
|
124
|
-
const split = await runAutofill(chunked(BODY, size));
|
|
125
|
-
expect(split.deltas).toEqual(whole.deltas);
|
|
126
|
-
expect(split.values).toEqual(whole.values);
|
|
127
|
-
expect(split.result).toEqual(whole.result);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("handles CRLF record separators", async () => {
|
|
132
|
-
// Proxies rewrite line endings, and a four-character separator sliced as
|
|
133
|
-
// if it were two leaves a stray newline that eats the next `event:`.
|
|
134
|
-
const { values } = await runAutofill([BODY.replace(/\n/g, "\r\n")]);
|
|
135
|
-
expect(values).toEqual([
|
|
136
|
-
["title", "Blue widget"],
|
|
137
|
-
["stock", 42]
|
|
138
|
-
]);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("ignores keep-alive comments", async () => {
|
|
142
|
-
const withComments = ":keep-alive\n\n" + BODY;
|
|
143
|
-
const { result } = await runAutofill([withComments]);
|
|
144
|
-
expect(result.suggestions).toEqual({ title: "Blue widget",
|
|
145
|
-
stock: 42 });
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("joins a multi-line data field", async () => {
|
|
149
|
-
const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n'
|
|
150
|
-
+ "event: done\ndata: {}\n\n";
|
|
151
|
-
const { values } = await runAutofill([body]);
|
|
152
|
-
expect(values).toEqual([["body", "two lines"]]);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("keeps going past one malformed record", async () => {
|
|
156
|
-
// A single bad frame must not discard fields that arrived correctly.
|
|
157
|
-
const body = "event: suggestion\ndata: {not json\n\n" + BODY;
|
|
158
|
-
const { values } = await runAutofill([body]);
|
|
159
|
-
expect(values).toEqual([
|
|
160
|
-
["title", "Blue widget"],
|
|
161
|
-
["stock", 42]
|
|
162
|
-
]);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("throws the message carried on an error event", async () => {
|
|
166
|
-
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
167
|
-
streamingResponse(['event: error\ndata: {"message":"quota exhausted"}\n\n'])
|
|
168
|
-
);
|
|
169
|
-
await expect(
|
|
170
|
-
autofillStream({ request: REQUEST,
|
|
171
|
-
onDelta: () => undefined,
|
|
172
|
-
onValue: () => undefined })
|
|
173
|
-
).rejects.toThrow("quota exhausted");
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("surfaces the server's error envelope on a non-2xx", async () => {
|
|
177
|
-
// `{ error: { message } }` is the control plane's contract. Reading it
|
|
178
|
-
// is the difference between telling the operator the quota reset time
|
|
179
|
-
// and telling them "Request failed with status 429".
|
|
180
|
-
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
181
|
-
jsonResponse({ error: { message: "The free AI quota for today has been used up.",
|
|
182
|
-
code: "upstream_error" } }, false, 429)
|
|
183
|
-
);
|
|
184
|
-
await expect(
|
|
185
|
-
autofillStream({ request: REQUEST,
|
|
186
|
-
onDelta: () => undefined,
|
|
187
|
-
onValue: () => undefined })
|
|
188
|
-
).rejects.toThrow(/quota for today/);
|
|
189
|
-
});
|
|
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
|
-
|
|
243
|
-
it("sends no credentials of any kind", async () => {
|
|
244
|
-
// The FireCMS-era client sent the tenant's JWT as `Authorization: Basic`
|
|
245
|
-
// plus a hardcoded `fcms-…` key. No external service could verify the
|
|
246
|
-
// former, and the latter shipped in the published package.
|
|
247
|
-
const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
|
|
248
|
-
(global as any).fetch = fetchMock;
|
|
249
|
-
await autofillStream({ request: REQUEST,
|
|
250
|
-
onDelta: () => undefined,
|
|
251
|
-
onValue: () => undefined });
|
|
252
|
-
const [, init] = fetchMock.mock.calls[0];
|
|
253
|
-
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
|
254
|
-
expect(JSON.stringify(init)).not.toMatch(/fcms-|Bearer|Basic/);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it("posts to the hosted endpoint by default and to an override when given", async () => {
|
|
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]));
|
|
262
|
-
(global as any).fetch = fetchMock;
|
|
263
|
-
|
|
264
|
-
await autofillStream({ request: REQUEST,
|
|
265
|
-
onDelta: () => undefined,
|
|
266
|
-
onValue: () => undefined });
|
|
267
|
-
expect(fetchMock.mock.calls[0][0]).toBe(`${DEFAULT_AI_ENDPOINT}/autofill`);
|
|
268
|
-
|
|
269
|
-
await autofillStream({
|
|
270
|
-
request: REQUEST,
|
|
271
|
-
endpoint: "https://ai.example.com/",
|
|
272
|
-
onDelta: () => undefined,
|
|
273
|
-
onValue: () => undefined
|
|
274
|
-
});
|
|
275
|
-
// Trailing slash trimmed — otherwise the override 404s on `//autofill`.
|
|
276
|
-
expect(fetchMock.mock.calls[1][0]).toBe("https://ai.example.com/autofill");
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe("autocompleteStream", () => {
|
|
281
|
-
it("concatenates deltas and returns the full continuation", async () => {
|
|
282
|
-
const body = [
|
|
283
|
-
'event: delta\ndata: {"text":"the quick "}',
|
|
284
|
-
'event: delta\ndata: {"text":"brown fox"}',
|
|
285
|
-
"event: done\ndata: {}",
|
|
286
|
-
""
|
|
287
|
-
].join("\n\n");
|
|
288
|
-
(global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunked(body, 7)));
|
|
289
|
-
|
|
290
|
-
const seen: string[] = [];
|
|
291
|
-
const text = await autocompleteStream({
|
|
292
|
-
textBefore: "I saw ",
|
|
293
|
-
textAfter: "",
|
|
294
|
-
onDelta: (t) => seen.push(t)
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
expect(seen.join("")).toBe("the quick brown fox");
|
|
298
|
-
expect(text).toBe("the quick brown fox");
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
describe("fetchAiStatus", () => {
|
|
303
|
-
it("reports available when the service says so", async () => {
|
|
304
|
-
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
305
|
-
jsonResponse({ available: true,
|
|
306
|
-
model: "claude-opus-5",
|
|
307
|
-
features: ["autofill"] })
|
|
308
|
-
);
|
|
309
|
-
await expect(fetchAiStatus({})).resolves.toEqual({
|
|
310
|
-
available: true,
|
|
311
|
-
model: "claude-opus-5",
|
|
312
|
-
features: ["autofill"]
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it("reports unavailable rather than throwing when the service errors", async () => {
|
|
317
|
-
// This value decides whether a button renders. Any doubt must resolve to
|
|
318
|
-
// "no button" — that is the whole fix for the 404-on-click failure.
|
|
319
|
-
(global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({}, false, 503));
|
|
320
|
-
await expect(fetchAiStatus({})).resolves.toEqual({ available: false });
|
|
321
|
-
});
|
|
322
|
-
});
|
|
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
|
-
|
|
365
|
-
describe("fetchPromptSuggestions", () => {
|
|
366
|
-
it("maps the service's prompts", async () => {
|
|
367
|
-
(global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({ prompts: ["A blue widget", "A red one"] }));
|
|
368
|
-
await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({
|
|
369
|
-
prompts: [
|
|
370
|
-
{ prompt: "A blue widget",
|
|
371
|
-
type: "sample" },
|
|
372
|
-
{ prompt: "A red one",
|
|
373
|
-
type: "sample" }
|
|
374
|
-
]
|
|
375
|
-
});
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it("degrades to no suggestions instead of failing the menu", async () => {
|
|
379
|
-
(global as any).fetch = jest.fn().mockRejectedValue(new Error("offline"));
|
|
380
|
-
await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({ prompts: [] });
|
|
381
|
-
});
|
|
382
|
-
});
|