@iloveagents/foundry-agent 0.1.0 → 0.1.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # @iloveagents/foundry-agent
2
+
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 30a4346: fix(agent): retry protected fetch with `forceRefresh` on 401
8
+
9
+ Closes the long-lived-tab 401 loop where the only recovery the user had was logging out or clearing localStorage.
10
+
11
+ The token-fetch wrapper used to ask MSAL for a cached token and never retry. If the resource server rejected that token (claims challenge, conditional-access re-eval, audience drift, or staleness MSAL's clock-offset check missed), every subsequent request kept re-sending the same token.
12
+
13
+ Fix: on 401 from a protected API, retry once with `acquireTokenSilent({ forceRefresh: true })`. If that also fails, the existing `InteractionRequiredAuthError` path bounces the user through Entra ID. Single-retry cap so we never loop. Request bodies are cloned per-attempt (ReadableStream is single-consume) so POST/PUT payloads survive the retry intact.
14
+
15
+ The `acquireToken` callback type now accepts an optional `{ forceRefresh? }` argument — non-breaking; existing callbacks continue to work, but they should forward the option to their token source so the retry path actually reaches the auth backend.
16
+
17
+ Pair: `lastspace#205` (forwards the option through `spacesFetch.acquireToken`).
18
+
19
+ ## 0.1.1
20
+
21
+ ### Patch Changes
22
+
23
+ - 689d3e9: feat(sidebar): distinct file-drop affordance for nav containers
24
+
25
+ `useNavItemDnd` now exposes `isFileDragOver` separately from `isDragOver`
26
+ so consuming nav items can render a prominent file-drop visual (dashed
27
+ primary outline + soft primary background + Upload icon) when the user
28
+ is dragging native files over the container. Previously file drags
29
+ shared the same subtle ring as entity-move drags, so users couldn't
30
+ tell that a folder accepted external files. Applies to all five nav-
31
+ item shapes (action-row leaf, button leaf, `NestedFolderItem`,
32
+ `CollapsibleNavItem` with children/actions, `NavLink` fallthrough) and
33
+ updates `ContainerDropZone` for visual parity.
34
+
35
+ Also fixes a related regression: the capture-phase
36
+ `onDragEnterCapture` / `onDragOverCapture` handlers were calling
37
+ `e.stopPropagation()`, which short-circuits React's synthetic dispatch
38
+ and prevented the bubble-phase `onDragEnter` (where state actually
39
+ mutates) from running. Removed — `preventDefault()` alone is enough to
40
+ mark the element as droppable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -6,7 +6,7 @@ import type { AgentSubscriber } from "@ag-ui/client";
6
6
  // runAgent fires them in order, synchronously yielding the loop between
7
7
  // each one so the runner's queue can drain.
8
8
 
9
- type ScriptedRun = (subscriber: AgentSubscriber) => Promise<void>;
9
+ type ScriptedRun = (subscriber: AgentSubscriber, agent: { messages: unknown[] }) => Promise<void>;
10
10
 
11
11
  let nextScript: ScriptedRun[] = [];
12
12
  let runCount = 0;
@@ -38,7 +38,7 @@ vi.mock("@ag-ui/client", () => {
38
38
  const turnIdx = runCount;
39
39
  runCount++;
40
40
  const script = nextScript[turnIdx];
41
- if (script) await script(subscriber);
41
+ if (script) await script(subscriber, this);
42
42
  }
43
43
 
44
44
  abortRun() {
@@ -52,7 +52,10 @@ import { AGUIRunner } from "../client/agui-runner.ts";
52
52
  import { clientToolRegistry } from "../tools/registry.ts";
53
53
  import type { RunnerEvent } from "../client/runner-events.ts";
54
54
 
55
- function makeRegistry(client: { isRegistered: (name: string) => boolean; executeTool?: (name: string, args: string) => Promise<string> }) {
55
+ function makeRegistry(client: {
56
+ isRegistered: (name: string) => boolean;
57
+ executeTool?: (name: string, args: string) => Promise<string>;
58
+ }) {
56
59
  return {
57
60
  isRegistered: client.isRegistered,
58
61
  executeTool: client.executeTool ?? (async () => JSON.stringify({})),
@@ -60,7 +63,10 @@ function makeRegistry(client: { isRegistered: (name: string) => boolean; execute
60
63
  };
61
64
  }
62
65
 
63
- async function collect(runner: AGUIRunner, registry: ReturnType<typeof makeRegistry>): Promise<RunnerEvent[]> {
66
+ async function collect(
67
+ runner: AGUIRunner,
68
+ registry: ReturnType<typeof makeRegistry>,
69
+ ): Promise<RunnerEvent[]> {
64
70
  const out: RunnerEvent[] = [];
65
71
  for await (const evt of runner.run({ messages: [], registry })) {
66
72
  out.push(evt);
@@ -68,6 +74,18 @@ async function collect(runner: AGUIRunner, registry: ReturnType<typeof makeRegis
68
74
  return out;
69
75
  }
70
76
 
77
+ async function collectWithMessages(
78
+ runner: AGUIRunner,
79
+ messages: Parameters<AGUIRunner["run"]>[0]["messages"],
80
+ registry: ReturnType<typeof makeRegistry>,
81
+ ): Promise<RunnerEvent[]> {
82
+ const out: RunnerEvent[] = [];
83
+ for await (const evt of runner.run({ messages, registry })) {
84
+ out.push(evt);
85
+ }
86
+ return out;
87
+ }
88
+
71
89
  describe("AGUIRunner", () => {
72
90
  beforeEach(() => {
73
91
  nextScript = [];
@@ -103,8 +121,8 @@ describe("AGUIRunner", () => {
103
121
  expect(types).toContain("text-message-end");
104
122
  expect(types).toContain("run-finished");
105
123
 
106
- const deltas = events.filter((e): e is Extract<RunnerEvent, { type: "text-delta" }> =>
107
- e.type === "text-delta",
124
+ const deltas = events.filter(
125
+ (e): e is Extract<RunnerEvent, { type: "text-delta" }> => e.type === "text-delta",
108
126
  );
109
127
  expect(deltas.map((d) => d.delta).join("")).toBe("Hello world");
110
128
  });
@@ -121,7 +139,7 @@ describe("AGUIRunner", () => {
121
139
  } as never,
122
140
  } as never);
123
141
  await s.onToolCallArgsEvent?.({
124
- event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta: "{\"q\":\"x\"}" } as never,
142
+ event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta: '{"q":"x"}' } as never,
125
143
  } as never);
126
144
  await s.onToolCallEndEvent?.({
127
145
  event: { type: "TOOL_CALL_END", toolCallId: "tc-1" } as never,
@@ -140,8 +158,8 @@ describe("AGUIRunner", () => {
140
158
  const runner = new AGUIRunner();
141
159
  const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
142
160
 
143
- const start = events.find((e): e is Extract<RunnerEvent, { type: "tool-call-start" }> =>
144
- e.type === "tool-call-start",
161
+ const start = events.find(
162
+ (e): e is Extract<RunnerEvent, { type: "tool-call-start" }> => e.type === "tool-call-start",
145
163
  );
146
164
  expect(start).toBeDefined();
147
165
  expect(start!.isClientSide).toBe(false);
@@ -212,8 +230,8 @@ describe("AGUIRunner", () => {
212
230
  expect(lastAssistantMsg?.toolCalls?.[0]?.id).toBe("tc-1");
213
231
 
214
232
  // tool-call-start should have isClientSide=true
215
- const start = events.find((e): e is Extract<RunnerEvent, { type: "tool-call-start" }> =>
216
- e.type === "tool-call-start",
233
+ const start = events.find(
234
+ (e): e is Extract<RunnerEvent, { type: "tool-call-start" }> => e.type === "tool-call-start",
217
235
  );
218
236
  expect(start!.isClientSide).toBe(true);
219
237
  });
@@ -276,6 +294,63 @@ describe("AGUIRunner", () => {
276
294
  expect(lastAssistant?.content).toBe("Navigating...");
277
295
  });
278
296
 
297
+ it("preserves hidden protocol messages from AG-UI snapshots across visible UI turns", async () => {
298
+ const registry = makeRegistry({ isRegistered: () => false });
299
+ const runner = new AGUIRunner();
300
+
301
+ nextScript = [
302
+ async (s, agent) => {
303
+ agent.messages = [
304
+ {
305
+ id: "spaces-global-guidance:abc",
306
+ role: "system",
307
+ content: "Tenant guidance",
308
+ },
309
+ { id: "u-1", role: "user", content: "What is your name?" },
310
+ { id: "a-1", role: "assistant", content: "Magic Luna." },
311
+ ];
312
+ await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
313
+ },
314
+ ];
315
+
316
+ await collectWithMessages(
317
+ runner,
318
+ [{ id: "u-1", role: "user", content: "What is your name?" }],
319
+ registry,
320
+ );
321
+
322
+ nextScript = [
323
+ async (s) => {
324
+ await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
325
+ },
326
+ ];
327
+
328
+ const events = await collectWithMessages(
329
+ runner,
330
+ [
331
+ { id: "u-1", role: "user", content: "What is your name?" },
332
+ { id: "a-1", role: "assistant", content: "Magic Luna." },
333
+ { id: "u-2", role: "user", content: "Say your name again." },
334
+ ],
335
+ registry,
336
+ );
337
+
338
+ const request = events.find(
339
+ (event): event is Extract<RunnerEvent, { type: "request-sent" }> =>
340
+ event.type === "request-sent",
341
+ );
342
+ expect(request?.input.messages).toEqual([
343
+ {
344
+ id: "spaces-global-guidance:abc",
345
+ role: "system",
346
+ content: "Tenant guidance",
347
+ },
348
+ { id: "u-1", role: "user", content: "What is your name?" },
349
+ { id: "a-1", role: "assistant", content: "Magic Luna." },
350
+ { id: "u-2", role: "user", content: "Say your name again." },
351
+ ]);
352
+ });
353
+
279
354
  it("yields turn-started exactly once per run iteration", async () => {
280
355
  nextScript = [
281
356
  async (s) => {
@@ -320,8 +395,8 @@ describe("AGUIRunner", () => {
320
395
  const runner = new AGUIRunner();
321
396
  const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
322
397
 
323
- const err = events.find((e): e is Extract<RunnerEvent, { type: "run-error" }> =>
324
- e.type === "run-error",
398
+ const err = events.find(
399
+ (e): e is Extract<RunnerEvent, { type: "run-error" }> => e.type === "run-error",
325
400
  );
326
401
  expect(err).toBeDefined();
327
402
  expect(err!.message).toBe("boom");
@@ -1,5 +1,6 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
2
- import { authStore } from "../msal/auth-store.ts";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AuthInteractionRequiredError, authStore } from "../msal/auth-store.ts";
3
+ import * as authConfig from "../msal/auth-config.ts";
3
4
 
4
5
  const store = () => authStore.getState();
5
6
 
@@ -35,3 +36,231 @@ describe("authStore", () => {
35
36
  expect(store().user?.avatar).toBe("https://example.com/bob.png");
36
37
  });
37
38
  });
39
+
40
+ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
41
+ // Reference: https://learn.microsoft.com/entra/msal/javascript/browser/errors
42
+ const config = {
43
+ clientId: "test-client",
44
+ authority: "https://login.microsoftonline.com/test-tenant",
45
+ redirectUri: "http://localhost:8010",
46
+ apiScope: "api://test-api/access_as_user",
47
+ };
48
+
49
+ function mockMsal(overrides: Record<string, unknown> = {}) {
50
+ const account = { username: "alice@example.com", localAccountId: "alice-oid" };
51
+ const mock = {
52
+ initialize: vi.fn().mockResolvedValue(undefined),
53
+ handleRedirectPromise: vi.fn().mockResolvedValue(null),
54
+ clearCache: vi.fn().mockResolvedValue(undefined),
55
+ getAllAccounts: vi.fn().mockReturnValue([account]),
56
+ loginRedirect: vi.fn().mockResolvedValue(undefined),
57
+ logoutRedirect: vi.fn().mockResolvedValue(undefined),
58
+ setActiveAccount: vi.fn(),
59
+ acquireTokenSilent: vi.fn().mockResolvedValue({ accessToken: "fresh-token" }),
60
+ acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
61
+ acquireTokenPopup: vi.fn().mockResolvedValue({ accessToken: "popup-token" }),
62
+ ...overrides,
63
+ };
64
+ vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(mock);
65
+ vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
66
+ return mock;
67
+ }
68
+
69
+ async function expectInteractiveRecoveryError(
70
+ promise: Promise<unknown>,
71
+ expected: {
72
+ code?: string;
73
+ cause: unknown;
74
+ messageIncludes: string[];
75
+ },
76
+ ) {
77
+ let thrown: unknown;
78
+ try {
79
+ await promise;
80
+ } catch (err) {
81
+ thrown = err;
82
+ }
83
+
84
+ expect(thrown).toBeInstanceOf(AuthInteractionRequiredError);
85
+ const authError = thrown as AuthInteractionRequiredError;
86
+ expect(authError.code).toBe(expected.code);
87
+ expect(authError.cause).toBe(expected.cause);
88
+ for (const value of expected.messageIncludes) {
89
+ expect(authError.message).toContain(value);
90
+ }
91
+ }
92
+
93
+ afterEach(() => {
94
+ vi.restoreAllMocks();
95
+ });
96
+
97
+ it("returns null without navigating when MSAL isn't configured", async () => {
98
+ vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(null);
99
+ vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(null);
100
+ expect(await store().getAccessToken()).toBeNull();
101
+ });
102
+
103
+ it("returns the access token on a normal silent acquire", async () => {
104
+ const msal = mockMsal();
105
+ expect(await store().getAccessToken()).toBe("fresh-token");
106
+ expect(msal.acquireTokenSilent).toHaveBeenCalledOnce();
107
+ expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
108
+ expect.objectContaining({ forceRefresh: false }),
109
+ );
110
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
111
+ expect(msal.loginRedirect).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("forwards forceRefresh to acquireTokenSilent on the 401-retry path", async () => {
115
+ // The fetch interceptor passes ``{ forceRefresh: true }`` after a
116
+ // protected API returns 401. MSAL must skip its local cache and round-
117
+ // trip the token endpoint so we stop sending the same stale-but-cached
118
+ // access token.
119
+ const msal = mockMsal();
120
+ expect(await store().getAccessToken("api", { forceRefresh: true })).toBe(
121
+ "fresh-token",
122
+ );
123
+ expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
124
+ expect.objectContaining({ forceRefresh: true }),
125
+ );
126
+ });
127
+
128
+ it("returns null without redirecting when no account is cached", async () => {
129
+ // AuthGuard calls loginRedirect when accounts.length === 0; double-
130
+ // redirecting from getAccessToken would race with AuthGuard.
131
+ const msal = mockMsal({ getAllAccounts: vi.fn().mockReturnValue([]) });
132
+ expect(await store().getAccessToken()).toBeNull();
133
+ expect(msal.acquireTokenSilent).not.toHaveBeenCalled();
134
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
135
+ expect(msal.loginRedirect).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("clears stale cache and redirects on InteractionRequiredAuthError", async () => {
139
+ const err = Object.assign(new Error("MFA required"), {
140
+ name: "InteractionRequiredAuthError",
141
+ errorCode: "interaction_required",
142
+ });
143
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
144
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
145
+ code: "interaction_required",
146
+ cause: err,
147
+ messageIncludes: [
148
+ "Authentication interaction required",
149
+ "InteractionRequiredAuthError",
150
+ "interaction_required",
151
+ "MFA required",
152
+ ],
153
+ });
154
+ expect(msal.clearCache).toHaveBeenCalledWith({
155
+ account: { username: "alice@example.com", localAccountId: "alice-oid" },
156
+ });
157
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
158
+ expect(msal.loginRedirect).toHaveBeenCalledWith({
159
+ scopes: [config.apiScope],
160
+ });
161
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("clears stale cache and redirects on consent_required", async () => {
165
+ const err = Object.assign(new Error("consent required"), {
166
+ name: "InteractionRequiredAuthError",
167
+ errorCode: "consent_required",
168
+ });
169
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
170
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
171
+ code: "consent_required",
172
+ cause: err,
173
+ messageIncludes: ["Authentication interaction required", "consent_required"],
174
+ });
175
+ expect(msal.clearCache).toHaveBeenCalledOnce();
176
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
177
+ });
178
+
179
+ it("clears stale cache and redirects on monitor_window_timeout", async () => {
180
+ // Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
181
+ // → documented remedy includes "Invoke an interactive API".
182
+ const err = Object.assign(new Error("monitor_window_timeout"), {
183
+ name: "BrowserAuthError",
184
+ errorCode: "monitor_window_timeout",
185
+ });
186
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
187
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
188
+ code: "monitor_window_timeout",
189
+ cause: err,
190
+ messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
191
+ });
192
+ expect(msal.clearCache).toHaveBeenCalledOnce();
193
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
194
+ });
195
+
196
+ it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
197
+ const err = Object.assign(new Error("interaction_in_progress"), {
198
+ name: "BrowserAuthError",
199
+ errorCode: "interaction_in_progress",
200
+ });
201
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
202
+ expect(await store().getAccessToken()).toBeNull();
203
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it("does NOT redirect on hash_empty_error (config bug, redirect won't fix)", async () => {
207
+ const err = Object.assign(new Error("hash_empty_error"), {
208
+ name: "BrowserAuthError",
209
+ errorCode: "hash_empty_error",
210
+ });
211
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
212
+ expect(await store().getAccessToken()).toBeNull();
213
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it("does NOT redirect on a transient/unknown error", async () => {
217
+ // A network blip shouldn't bounce the user through a login flow.
218
+ const err = new Error("connection reset");
219
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
220
+ expect(await store().getAccessToken()).toBeNull();
221
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
222
+ expect(msal.loginRedirect).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("matches InteractionRequiredAuthError by class name when no errorCode is set", async () => {
226
+ const err = Object.assign(new Error("interaction"), {
227
+ name: "InteractionRequiredAuthError",
228
+ });
229
+ const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
230
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
231
+ cause: err,
232
+ messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
233
+ });
234
+ expect(msal.clearCache).toHaveBeenCalledOnce();
235
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
236
+ });
237
+
238
+ it("preserves redirect failure details when interactive recovery cannot start", async () => {
239
+ const tokenErr = Object.assign(new Error("MFA required"), {
240
+ name: "InteractionRequiredAuthError",
241
+ errorCode: "interaction_required",
242
+ });
243
+ const redirectErr = Object.assign(new Error("redirect blocked"), {
244
+ name: "BrowserAuthError",
245
+ errorCode: "redirect_failed",
246
+ });
247
+ const msal = mockMsal({
248
+ acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
249
+ loginRedirect: vi.fn().mockRejectedValue(redirectErr),
250
+ });
251
+
252
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
253
+ code: "redirect_failed",
254
+ cause: redirectErr,
255
+ messageIncludes: [
256
+ "Authentication interaction required",
257
+ "login redirect failed",
258
+ "redirect_failed",
259
+ "Original token error",
260
+ "interaction_required",
261
+ ],
262
+ });
263
+ expect(msal.clearCache).toHaveBeenCalledOnce();
264
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
265
+ });
266
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it, beforeEach, vi } from "vitest";
2
+ import { linkStore, resolveLinkHandler, type LinkHandler } from "../store/link-store.ts";
3
+
4
+ describe("linkStore", () => {
5
+ beforeEach(() => {
6
+ linkStore.getState().clear();
7
+ });
8
+
9
+ it("registers and clears a markdown link handler", () => {
10
+ const handler: LinkHandler = {
11
+ canHandle: (href) => href.startsWith("/spaces/"),
12
+ openLink: vi.fn(),
13
+ };
14
+
15
+ linkStore.getState().setHandler(handler);
16
+ expect(linkStore.getState().handler).toBe(handler);
17
+ expect(linkStore.getState().handlers).toEqual([handler]);
18
+
19
+ linkStore.getState().clear();
20
+ expect(linkStore.getState().handler).toBeNull();
21
+ expect(linkStore.getState().handlers).toEqual([]);
22
+ });
23
+
24
+ it("keeps multiple handlers and resolves the matching namespace", () => {
25
+ const spacesHandler: LinkHandler = {
26
+ canHandle: (href) => href.startsWith("/spaces/"),
27
+ openLink: vi.fn(),
28
+ };
29
+ const docsHandler: LinkHandler = {
30
+ normalizeHref: (href) => (href.startsWith("docs:") ? `/docs/${href.slice(5)}` : href),
31
+ canHandle: (href) => href.startsWith("/docs/"),
32
+ openLink: vi.fn(),
33
+ };
34
+
35
+ linkStore.getState().setHandler(spacesHandler);
36
+ linkStore.getState().setHandler(docsHandler);
37
+
38
+ expect(resolveLinkHandler("/spaces/page-1")).toEqual({
39
+ handler: spacesHandler,
40
+ href: "/spaces/page-1",
41
+ });
42
+ expect(resolveLinkHandler("docs:setup")).toEqual({
43
+ handler: docsHandler,
44
+ href: "/docs/setup",
45
+ });
46
+ expect(resolveLinkHandler("/unknown")).toBeNull();
47
+ });
48
+ });
@@ -172,15 +172,133 @@ describe("createServiceFetch", () => {
172
172
  expect(merged.get("X-Init-Only")).toBe("init");
173
173
  });
174
174
 
175
- it("recovers when acquireToken throws", async () => {
175
+ it("does not downgrade token failures to anonymous protected calls", async () => {
176
176
  const acquireToken = vi.fn().mockRejectedValue(new Error("token boom"));
177
177
  const serviceFetch = createServiceFetch({ acquireToken });
178
178
 
179
- await serviceFetch("/api/spaces/entities");
179
+ await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow("token boom");
180
180
 
181
- expect(fetchSpy).toHaveBeenCalledTimes(1);
182
- const [, init] = fetchSpy.mock.calls[0]!;
183
- const headers = new Headers(init?.headers);
184
- expect(headers.get("Authorization")).toBeNull();
181
+ expect(fetchSpy).not.toHaveBeenCalled();
182
+ });
183
+
184
+ describe("401 retry with forceRefresh", () => {
185
+ it("retries once with forceRefresh when first attempt returns 401", async () => {
186
+ // Resource server rejects the first (stale-cached) token, then accepts
187
+ // the freshly-acquired one. This is the long-lived-tab recovery path.
188
+ fetchSpy.mockReset();
189
+ fetchSpy
190
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
191
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
192
+ const acquireToken = vi
193
+ .fn()
194
+ .mockResolvedValueOnce("stale-token")
195
+ .mockResolvedValueOnce("fresh-token");
196
+ const serviceFetch = createServiceFetch({ acquireToken });
197
+
198
+ const res = await serviceFetch("/api/spaces/entities");
199
+
200
+ expect(res.status).toBe(200);
201
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
202
+ expect(acquireToken).toHaveBeenCalledTimes(2);
203
+ // First call: no forceRefresh (use cached token)
204
+ expect(acquireToken).toHaveBeenNthCalledWith(1, undefined);
205
+ // Second call: forceRefresh: true (skip MSAL cache, hit token endpoint)
206
+ expect(acquireToken).toHaveBeenNthCalledWith(2, { forceRefresh: true });
207
+
208
+ const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
209
+ "Authorization",
210
+ );
211
+ const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
212
+ "Authorization",
213
+ );
214
+ expect(firstAuth).toBe("Bearer stale-token");
215
+ expect(secondAuth).toBe("Bearer fresh-token");
216
+ });
217
+
218
+ it("does not retry on non-401 responses", async () => {
219
+ fetchSpy.mockReset();
220
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500 }));
221
+ const acquireToken = vi.fn().mockResolvedValue("token");
222
+ const serviceFetch = createServiceFetch({ acquireToken });
223
+
224
+ const res = await serviceFetch("/api/spaces/entities");
225
+
226
+ expect(res.status).toBe(500);
227
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
228
+ expect(acquireToken).toHaveBeenCalledTimes(1);
229
+ });
230
+
231
+ it("does not retry when caller supplied their own Authorization header", async () => {
232
+ // The caller owns the token in this case; retrying would let our auth
233
+ // layer overwrite their explicit header on the second attempt.
234
+ fetchSpy.mockReset();
235
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }));
236
+ const acquireToken = vi.fn();
237
+ const serviceFetch = createServiceFetch({ acquireToken });
238
+
239
+ const res = await serviceFetch("/api/spaces/entities", {
240
+ headers: { Authorization: "Bearer caller-supplied" },
241
+ });
242
+
243
+ expect(res.status).toBe(401);
244
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
245
+ expect(acquireToken).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("preserves a POST body across the 401 retry", async () => {
249
+ // Regression for the body-stream-consumed bug: ReadableStream bodies
250
+ // are single-consume, so the retry would otherwise see an empty body
251
+ // and a server-side validation failure that masks the real auth
252
+ // recovery. We assert the second attempt receives the same body.
253
+ fetchSpy.mockReset();
254
+ fetchSpy
255
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
256
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
257
+ const acquireToken = vi
258
+ .fn()
259
+ .mockResolvedValueOnce("stale-token")
260
+ .mockResolvedValueOnce("fresh-token");
261
+ const serviceFetch = createServiceFetch({ acquireToken });
262
+
263
+ const payload = JSON.stringify({ name: "create-this", id: 42 });
264
+ const request = new Request("https://example.com/api/items", {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: payload,
268
+ });
269
+
270
+ const res = await serviceFetch(request);
271
+
272
+ expect(res.status).toBe(200);
273
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
274
+
275
+ // Each fetch call's body must be readable and equal to the original.
276
+ // Awaiting both confirms neither call's body stream was already
277
+ // drained by the time fetch received it.
278
+ const firstAttempt = fetchSpy.mock.calls[0]![1];
279
+ const secondAttempt = fetchSpy.mock.calls[1]![1];
280
+ const firstBody = await new Response(firstAttempt?.body as BodyInit).text();
281
+ const secondBody = await new Response(secondAttempt?.body as BodyInit).text();
282
+ expect(firstBody).toBe(payload);
283
+ expect(secondBody).toBe(payload);
284
+ });
285
+
286
+ it("only retries once even if the second attempt also returns 401", async () => {
287
+ // Hard auth failure (refresh token expired). The second 401 propagates
288
+ // to the caller; the auth layer's interaction-required path is what
289
+ // kicks the user to loginRedirect, not an infinite retry loop here.
290
+ fetchSpy.mockReset();
291
+ fetchSpy
292
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
293
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
294
+ const acquireToken = vi.fn().mockResolvedValue("token");
295
+ const serviceFetch = createServiceFetch({ acquireToken });
296
+
297
+ const res = await serviceFetch("/api/spaces/entities");
298
+
299
+ expect(res.status).toBe(401);
300
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
301
+ expect(acquireToken).toHaveBeenCalledTimes(2);
302
+ });
185
303
  });
186
304
  });