@frankieai/react 0.2.9 → 0.2.11

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.
@@ -5,6 +5,7 @@ import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
5
5
  import { ChatPanel } from "./components/chat/ChatPanel.js";
6
6
  import { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
7
7
  import { Markdown } from "./components/chat/Markdown.js";
8
+ import { sessionIdentity } from "./lib/identity.js";
8
9
  import { FrankieClient, readEventStream } from "./lib/client.js";
9
10
  import { applyEvent, newAssistantMessage } from "./lib/messages.js";
10
11
  import { themeToCssVars } from "./lib/theme.js";
@@ -157,6 +158,75 @@ describe("FrankieClient", () => {
157
158
  expect(headers["x-frankie-key"]).toBe("pk_1");
158
159
  });
159
160
 
161
+ /** A JWT with the given claims; the signature is irrelevant to the client. */
162
+ const jwt = (claims: Record<string, unknown>) =>
163
+ `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
164
+
165
+ it("keeps the same session when the same user's token is re-minted between turns", async () => {
166
+ // Hosts mint with setIssuedAt(), so every getUserToken() call returns a
167
+ // different string for the same person. That must not start a new,
168
+ // empty conversation — the agent would never see the previous turn.
169
+ let iat = 1_700_000_000;
170
+ const getUserToken = () => jwt({ sub: "user_42", iat: iat++ });
171
+ let sessionCalls = 0;
172
+ const bearers: string[] = [];
173
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
174
+ const href = String(url);
175
+ if (href.endsWith("/v1/session")) {
176
+ sessionCalls += 1;
177
+ return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `bearer-${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "user_42", anonymous: false } });
178
+ }
179
+ bearers.push((init?.headers as Record<string, string>)["authorization"] ?? "");
180
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
181
+ }) as unknown as typeof fetch;
182
+
183
+ const client = new FrankieClient({ publishableKey: "pk_rotate", getUserToken, fetchImpl });
184
+ await client.send({ message: "first" }, () => {});
185
+ await client.send({ message: "second" }, () => {});
186
+
187
+ expect(sessionCalls).toBe(1);
188
+ expect(bearers).toEqual(["Bearer bearer-1", "Bearer bearer-1"]);
189
+ expect(client.sessionId).toBe("s1");
190
+ });
191
+
192
+ it("still starts a new session when a different user signs in", async () => {
193
+ let sub = "user_42";
194
+ const getUserToken = () => jwt({ sub, iat: 1 });
195
+ let sessionCalls = 0;
196
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
197
+ if (String(url).endsWith("/v1/session")) {
198
+ sessionCalls += 1;
199
+ return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `bearer-${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: sub, anonymous: false } });
200
+ }
201
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
202
+ }) as unknown as typeof fetch;
203
+
204
+ const client = new FrankieClient({ publishableKey: "pk_switch", getUserToken, fetchImpl });
205
+ await client.send({ message: "as 42" }, () => {});
206
+ sub = "user_99"; // account change on the host
207
+ await client.send({ message: "as 99" }, () => {});
208
+
209
+ expect(sessionCalls).toBe(2);
210
+ expect(client.sessionId).toBe("s2");
211
+ });
212
+
213
+ it("falls back to comparing an opaque token, so a changed one still resets", async () => {
214
+ let token = "opaque-token-a";
215
+ let sessionCalls = 0;
216
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
217
+ if (String(url).endsWith("/v1/session")) {
218
+ sessionCalls += 1;
219
+ return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `b${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: false } });
220
+ }
221
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
222
+ }) as unknown as typeof fetch;
223
+ const client = new FrankieClient({ publishableKey: "pk_opaque", getUserToken: () => token, fetchImpl });
224
+ await client.send({ message: "a" }, () => {});
225
+ token = "opaque-token-b";
226
+ await client.send({ message: "b" }, () => {});
227
+ expect(sessionCalls).toBe(2);
228
+ });
229
+
160
230
  it("drops a rejected session so the next send re-authenticates", async () => {
161
231
  let sessionCalls = 0;
162
232
  const fetchImpl = vi.fn(async (url: string | URL | Request) => {
@@ -333,6 +403,49 @@ describe("ChatPanel", () => {
333
403
  expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(255, 0, 128)");
334
404
  expect(root.classList.contains("dark")).toBe(true);
335
405
  });
406
+
407
+ it("inherits the host page's own colors once sampling succeeds", async () => {
408
+ document.body.innerHTML = '<button data-stub="host-accent">Buy</button>';
409
+ const real = window.getComputedStyle.bind(window);
410
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
411
+ if (el === document.body) return { backgroundColor: "rgb(250, 250, 250)", color: "rgb(20, 20, 20)" } as CSSStyleDeclaration;
412
+ if ((el as HTMLElement).dataset?.stub === "host-accent") return { backgroundColor: "rgb(16, 185, 129)", color: "rgb(255, 255, 255)" } as CSSStyleDeclaration;
413
+ return real(el);
414
+ });
415
+ const { container } = render(
416
+ <FrankieProvider
417
+ baseUrl="https://agent.acme.com"
418
+ publishableKey="pk_1"
419
+ config={{ ...config, theme: { mode: "light", source: "inherit", primary: "#configured-fallback" } }}
420
+ >
421
+ <ChatPanel />
422
+ </FrankieProvider>,
423
+ );
424
+ const root = () => container.querySelector(".frankie-root") as HTMLElement;
425
+ // Sampling runs post-mount, so the first paint still shows the configured
426
+ // fallback — never a blank/undefined value — until inference resolves.
427
+ await waitFor(() => expect(root().style.getPropertyValue("--frankie-primary")).toBe("rgb(16, 185, 129)"));
428
+ expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(250, 250, 250)");
429
+ vi.restoreAllMocks();
430
+ });
431
+
432
+ it("keeps the configured fallback when the host page can't be read reliably", async () => {
433
+ vi.spyOn(window, "getComputedStyle").mockImplementation(() => ({ backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration);
434
+ const { container } = render(
435
+ <FrankieProvider
436
+ baseUrl="https://agent.acme.com"
437
+ publishableKey="pk_1"
438
+ config={{ ...config, theme: { mode: "light", source: "inherit", primary: "rgb(1, 2, 3)" } }}
439
+ >
440
+ <ChatPanel />
441
+ </FrankieProvider>,
442
+ );
443
+ const root = container.querySelector(".frankie-root") as HTMLElement;
444
+ // A transparent body background means inference declines to guess, so
445
+ // the configured value is what the widget shows — not left blank.
446
+ await waitFor(() => expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(1, 2, 3)"));
447
+ vi.restoreAllMocks();
448
+ });
336
449
  });
337
450
 
338
451
  describe("FrankieChatPage", () => {
@@ -366,3 +479,108 @@ describe("FrankieChatPage", () => {
366
479
  cleanup();
367
480
  });
368
481
  });
482
+
483
+ describe("sessionIdentity", () => {
484
+ const jwt = (claims: Record<string, unknown>) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
485
+ it("reads the JWT subject", () => expect(sessionIdentity(jwt({ sub: "user_42", iat: 1 }))).toBe("user_42"));
486
+ it("is stable across re-minted tokens for the same subject", () =>
487
+ expect(sessionIdentity(jwt({ sub: "u", iat: 1 }))).toBe(sessionIdentity(jwt({ sub: "u", iat: 2 }))));
488
+ it("falls back to the raw token when there is no readable subject", () => {
489
+ expect(sessionIdentity("opaque")).toBe("opaque");
490
+ expect(sessionIdentity("a.b")).toBe("a.b");
491
+ expect(sessionIdentity(jwt({ iat: 1 }))).toBe(jwt({ iat: 1 }));
492
+ expect(sessionIdentity("h.%%%notbase64%%%.s")).toBe("h.%%%notbase64%%%.s");
493
+ });
494
+ it("maps a missing token to undefined", () => expect(sessionIdentity(undefined)).toBeUndefined());
495
+ });
496
+
497
+ describe("client-side tool execution", () => {
498
+ it("joinHostUrl joins base and relative path, and refuses an absolute one", async () => {
499
+ const { joinHostUrl } = await import("./lib/client.js");
500
+ expect(joinHostUrl("https://app.acme.com/api", "restaurants/1?x=2")).toBe("https://app.acme.com/api/restaurants/1?x=2");
501
+ expect(joinHostUrl("https://app.acme.com/api/", "/restaurants")).toBe("https://app.acme.com/api/restaurants");
502
+ expect(() => joinHostUrl("https://app.acme.com", "https://evil.com/steal")).toThrow(/relative/);
503
+ expect(() => joinHostUrl("https://app.acme.com", "//evil.com/steal")).toThrow(/relative/);
504
+ });
505
+
506
+ it("runs a tool_call_request against the host API and posts the outcome back", async () => {
507
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
508
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
509
+ const href = String(url);
510
+ calls.push({ url: href, ...(init ? { init } : {}) });
511
+ if (href.endsWith("/v1/session")) {
512
+ return Response.json({ sessionId: "s1", sessionToken: "bearer-1", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: true } });
513
+ }
514
+ if (href.endsWith("/v1/chat")) {
515
+ return new Response(
516
+ sseStream([
517
+ { type: "tool_call_request", callId: "c-1", toolUseId: "c-1", name: "api__acme__getRestaurant", request: { source: "acme", method: "GET", path: "restaurants/9" } },
518
+ { type: "text_delta", text: "done" },
519
+ { type: "turn_end", stopReason: "end_turn" },
520
+ ]),
521
+ { status: 200 },
522
+ );
523
+ }
524
+ if (href.endsWith("/v1/chat/tool-result")) return Response.json({ ok: true });
525
+ // The host API call itself.
526
+ return new Response(JSON.stringify({ name: "Bistro" }), { status: 200 });
527
+ }) as unknown as typeof fetch;
528
+
529
+ const client = new FrankieClient({
530
+ baseUrl: "https://agent.acme.com",
531
+ publishableKey: "pk_client_exec",
532
+ api: { baseUrl: "https://app.acme.com/api", credentials: "include" },
533
+ fetchImpl,
534
+ persist: false,
535
+ });
536
+
537
+ await client.send({ message: "show me restaurant 9" }, () => {});
538
+
539
+ // The host API was called from the browser, at the configured base, with the
540
+ // app's own credentials — Frankie added no Authorization of its own.
541
+ await waitFor(() => expect(calls.some((c) => c.url === "https://app.acme.com/api/restaurants/9")).toBe(true));
542
+ const hostCall = calls.find((c) => c.url === "https://app.acme.com/api/restaurants/9")!;
543
+ expect(hostCall.init?.method).toBe("GET");
544
+ expect(hostCall.init?.credentials).toBe("include");
545
+ expect((hostCall.init?.headers as Record<string, string> | undefined)?.["authorization"]).toBeUndefined();
546
+
547
+ // The outcome was posted back with the matching callId and the host status.
548
+ await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
549
+ const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
550
+ const body = JSON.parse(String(post.init?.body)) as { callId: string; result: { status: number; body: string } };
551
+ expect(body.callId).toBe("c-1");
552
+ expect(body.result.status).toBe(200);
553
+ expect(body.result.body).toContain("Bistro");
554
+ });
555
+
556
+ it("reports a transport failure when no host api client is configured", async () => {
557
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
558
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
559
+ const href = String(url);
560
+ calls.push({ url: href, ...(init ? { init } : {}) });
561
+ if (href.endsWith("/v1/session")) {
562
+ return Response.json({ sessionId: "s1", sessionToken: "b1", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: true } });
563
+ }
564
+ if (href.endsWith("/v1/chat")) {
565
+ return new Response(
566
+ sseStream([
567
+ { type: "tool_call_request", callId: "c-2", toolUseId: "c-2", name: "api__acme__x", request: { source: "acme", method: "GET", path: "x" } },
568
+ { type: "turn_end", stopReason: "end_turn" },
569
+ ]),
570
+ { status: 200 },
571
+ );
572
+ }
573
+ return Response.json({ ok: true });
574
+ }) as unknown as typeof fetch;
575
+
576
+ // No `api` option configured.
577
+ const client = new FrankieClient({ baseUrl: "https://agent.acme.com", publishableKey: "pk_no_api", fetchImpl, persist: false });
578
+ await client.send({ message: "hi" }, () => {});
579
+
580
+ await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
581
+ const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
582
+ const body = JSON.parse(String(post.init?.body)) as { result: { status: number; error?: string } };
583
+ expect(body.result.status).toBe(0);
584
+ expect(body.result.error).toMatch(/no host API client/i);
585
+ });
586
+ });