@rebasepro/plugin-ai 0.17.3 → 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.
@@ -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
- });