@iloveagents/foundry-agent 0.1.1 → 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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @iloveagents/foundry-agent
2
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
+
3
19
  ## 0.1.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.1.1",
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,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { authStore } from "../msal/auth-store.ts";
2
+ import { AuthInteractionRequiredError, authStore } from "../msal/auth-store.ts";
3
3
  import * as authConfig from "../msal/auth-config.ts";
4
4
 
5
5
  const store = () => authStore.getState();
@@ -51,6 +51,7 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
51
51
  const mock = {
52
52
  initialize: vi.fn().mockResolvedValue(undefined),
53
53
  handleRedirectPromise: vi.fn().mockResolvedValue(null),
54
+ clearCache: vi.fn().mockResolvedValue(undefined),
54
55
  getAllAccounts: vi.fn().mockReturnValue([account]),
55
56
  loginRedirect: vi.fn().mockResolvedValue(undefined),
56
57
  logoutRedirect: vi.fn().mockResolvedValue(undefined),
@@ -65,6 +66,30 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
65
66
  return mock;
66
67
  }
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
+
68
93
  afterEach(() => {
69
94
  vi.restoreAllMocks();
70
95
  });
@@ -79,10 +104,27 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
79
104
  const msal = mockMsal();
80
105
  expect(await store().getAccessToken()).toBe("fresh-token");
81
106
  expect(msal.acquireTokenSilent).toHaveBeenCalledOnce();
107
+ expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
108
+ expect.objectContaining({ forceRefresh: false }),
109
+ );
82
110
  expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
83
111
  expect(msal.loginRedirect).not.toHaveBeenCalled();
84
112
  });
85
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
+
86
128
  it("returns null without redirecting when no account is cached", async () => {
87
129
  // AuthGuard calls loginRedirect when accounts.length === 0; double-
88
130
  // redirecting from getAccessToken would race with AuthGuard.
@@ -93,31 +135,48 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
93
135
  expect(msal.loginRedirect).not.toHaveBeenCalled();
94
136
  });
95
137
 
96
- it("triggers acquireTokenRedirect on InteractionRequiredAuthError (canonical pattern)", async () => {
138
+ it("clears stale cache and redirects on InteractionRequiredAuthError", async () => {
97
139
  const err = Object.assign(new Error("MFA required"), {
98
140
  name: "InteractionRequiredAuthError",
99
141
  errorCode: "interaction_required",
100
142
  });
101
143
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
102
- expect(await store().getAccessToken()).toBeNull();
103
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
104
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
105
- scopes: [config.apiScope],
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({
106
155
  account: { username: "alice@example.com", localAccountId: "alice-oid" },
107
156
  });
157
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
158
+ expect(msal.loginRedirect).toHaveBeenCalledWith({
159
+ scopes: [config.apiScope],
160
+ });
161
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
108
162
  });
109
163
 
110
- it("triggers acquireTokenRedirect on consent_required", async () => {
164
+ it("clears stale cache and redirects on consent_required", async () => {
111
165
  const err = Object.assign(new Error("consent required"), {
112
166
  name: "InteractionRequiredAuthError",
113
167
  errorCode: "consent_required",
114
168
  });
115
169
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
116
- expect(await store().getAccessToken()).toBeNull();
117
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
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();
118
177
  });
119
178
 
120
- it("triggers acquireTokenRedirect on monitor_window_timeout (third-party-iframe block)", async () => {
179
+ it("clears stale cache and redirects on monitor_window_timeout", async () => {
121
180
  // Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
122
181
  // → documented remedy includes "Invoke an interactive API".
123
182
  const err = Object.assign(new Error("monitor_window_timeout"), {
@@ -125,8 +184,13 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
125
184
  errorCode: "monitor_window_timeout",
126
185
  });
127
186
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
128
- expect(await store().getAccessToken()).toBeNull();
129
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
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();
130
194
  });
131
195
 
132
196
  it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
@@ -163,7 +227,40 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
163
227
  name: "InteractionRequiredAuthError",
164
228
  });
165
229
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
166
- expect(await store().getAccessToken()).toBeNull();
167
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
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();
168
265
  });
169
266
  });
@@ -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
  });
@@ -62,4 +62,60 @@ describe("tokenFetch", () => {
62
62
  const [, init] = fetchSpy.mock.calls[0]!;
63
63
  expect(init).toBeUndefined();
64
64
  });
65
+
66
+ it("retries once with forceRefresh when first attempt returns 401", async () => {
67
+ // Long-lived tab path: cached token is rejected by the API → MSAL is
68
+ // asked to round-trip the token endpoint with the refresh token.
69
+ getAccessToken
70
+ .mockResolvedValueOnce("stale-token")
71
+ .mockResolvedValueOnce("fresh-token");
72
+ const fetchSpy = vi
73
+ .spyOn(globalThis, "fetch")
74
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
75
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
76
+
77
+ const res = await tokenFetch("https://example.com/api/items");
78
+
79
+ expect(res.status).toBe(200);
80
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
81
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
82
+ expect(getAccessToken).toHaveBeenNthCalledWith(1, "api", undefined);
83
+ expect(getAccessToken).toHaveBeenNthCalledWith(2, "api", { forceRefresh: true });
84
+
85
+ const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
86
+ "Authorization",
87
+ );
88
+ const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
89
+ "Authorization",
90
+ );
91
+ expect(firstAuth).toBe("Bearer stale-token");
92
+ expect(secondAuth).toBe("Bearer fresh-token");
93
+ });
94
+
95
+ it("does not retry on non-401 responses", async () => {
96
+ getAccessToken.mockResolvedValue("token");
97
+ const fetchSpy = vi
98
+ .spyOn(globalThis, "fetch")
99
+ .mockResolvedValueOnce(new Response(null, { status: 500 }));
100
+
101
+ const res = await tokenFetch("https://example.com/api/items");
102
+
103
+ expect(res.status).toBe(500);
104
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
105
+ expect(getAccessToken).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("only retries once on a hard 401-then-401 path", async () => {
109
+ getAccessToken.mockResolvedValue("token");
110
+ const fetchSpy = vi
111
+ .spyOn(globalThis, "fetch")
112
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
113
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
114
+
115
+ const res = await tokenFetch("https://example.com/api/items");
116
+
117
+ expect(res.status).toBe(401);
118
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
119
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
120
+ });
65
121
  });
@@ -48,6 +48,51 @@ export interface AGUIRunInput {
48
48
  context?: Context[];
49
49
  }
50
50
 
51
+ function shouldPreserveAcrossVisibleHistory(message: Message): boolean {
52
+ return message.role === "system" || message.role === "developer" || message.role === "reasoning";
53
+ }
54
+
55
+ function mergeProtocolMessagesFromSnapshot(
56
+ previousMessages: Message[],
57
+ visibleMessages: Message[],
58
+ ): Message[] {
59
+ if (previousMessages.length === 0) return [...visibleMessages];
60
+
61
+ const visibleById = new Map(visibleMessages.map((message) => [message.id, message]));
62
+ const previousVisibleIds = new Set(
63
+ previousMessages
64
+ .filter((message) => !shouldPreserveAcrossVisibleHistory(message))
65
+ .map((message) => message.id),
66
+ );
67
+ const isSameVisibleThread = visibleMessages.some((message) => previousVisibleIds.has(message.id));
68
+ if (!isSameVisibleThread) return [...visibleMessages];
69
+
70
+ const merged: Message[] = [];
71
+ const emitted = new Set<string>();
72
+
73
+ for (const previous of previousMessages) {
74
+ const currentVisible = visibleById.get(previous.id);
75
+ if (currentVisible) {
76
+ merged.push(currentVisible);
77
+ emitted.add(currentVisible.id);
78
+ continue;
79
+ }
80
+
81
+ if (shouldPreserveAcrossVisibleHistory(previous)) {
82
+ merged.push(previous);
83
+ emitted.add(previous.id);
84
+ }
85
+ }
86
+
87
+ for (const message of visibleMessages) {
88
+ if (!emitted.has(message.id)) {
89
+ merged.push(message);
90
+ }
91
+ }
92
+
93
+ return merged;
94
+ }
95
+
51
96
  /**
52
97
  * Bridges callback-driven `AgentSubscriber` events into an async-iterable
53
98
  * queue the runner's generator drains. Single-producer / single-consumer.
@@ -126,10 +171,17 @@ export class AGUIRunner {
126
171
  async *run(input: AGUIRunInput): AsyncGenerator<RunnerEvent> {
127
172
  const { messages, state, registry, abortSignal, context } = input;
128
173
  const toolCalls = new Map<string, ToolCallState>();
129
- let currentMessages: Message[] = [...messages];
174
+ let currentMessages: Message[] = mergeProtocolMessagesFromSnapshot(
175
+ this.httpAgent.messages,
176
+ messages,
177
+ );
130
178
 
131
- // Replace once at the top: subsequent turns append to currentMessages
132
- // (per AG-UI convention see footgun in apps/web AGENTS.md cross-link).
179
+ // Replace once at the top with the reconciled AG-UI history. assistant-ui
180
+ // stores only visible chat turns, while AG-UI snapshots may contain
181
+ // model-visible but UI-hidden protocol messages such as system/developer
182
+ // guidance. Preserve those messages across turns when the visible history
183
+ // belongs to the same thread so the next request remains a complete AG-UI
184
+ // conversation without leaking system messages into the rendered chat.
133
185
  this.httpAgent.setMessages(currentMessages);
134
186
  if (state) this.httpAgent.setState(state);
135
187
 
@@ -17,8 +17,14 @@ export interface ServiceFetchOptions {
17
17
  * Acquire an access token for outgoing requests. Return `null` to skip
18
18
  * token attachment (callers without auth — e.g. local dev — pass through
19
19
  * to native fetch).
20
+ *
21
+ * The optional ``{ forceRefresh: true }`` argument is passed by the
22
+ * fetch interceptor on a 401 retry — the auth layer should bypass
23
+ * its local token cache and round-trip the token endpoint so we
24
+ * stop re-sending an access token the resource server has already
25
+ * rejected (canonical MSAL.js fix for tab-open-overnight 401 loops).
20
26
  */
21
- acquireToken: () => Promise<string | null>;
27
+ acquireToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>;
22
28
 
23
29
  /**
24
30
  * Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
@@ -66,47 +72,86 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
66
72
  // Request-input branch must preserve method / body / credentials /
67
73
  // signal / etc. — silently dropping them by reading only `.url` would
68
74
  // turn POST/PUT into GET and drop required headers.
69
- const headers = new Headers(input instanceof Request ? input.headers : undefined);
75
+ const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
70
76
  if (init?.headers) {
71
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
72
- }
73
- if (!headers.has("Authorization")) {
74
- try {
75
- const token = await options.acquireToken();
76
- if (token) {
77
- headers.set("Authorization", `Bearer ${token}`);
78
- }
79
- } catch {
80
- // Token acquisition failed — proceed without
81
- }
77
+ new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
82
78
  }
79
+ const callerSuppliedAuth = baseHeaders.has("Authorization");
83
80
 
84
- if (input instanceof Request) {
85
- // Build a fresh Request against the rewritten URL, copying every
86
- // field the runtime exposes on the originalexplicit field copy
87
- // is more portable than `new Request(url, originalRequest)`, which
88
- // some runtimes (older undici/JSDOM in particular) do not honor
89
- // for method/body. `init` then overrides anything the caller passed.
90
- const cloned = input.clone();
91
- const requestInit: RequestInit = {
92
- method: cloned.method,
93
- body:
94
- cloned.method === "GET" || cloned.method === "HEAD" ? undefined : cloned.body,
95
- headers,
96
- credentials: cloned.credentials,
97
- mode: cloned.mode,
98
- cache: cloned.cache,
99
- redirect: cloned.redirect,
100
- referrer: cloned.referrer,
101
- integrity: cloned.integrity,
102
- signal: cloned.signal,
81
+ function buildRequestInitFrom(req: Request): RequestInit {
82
+ // ReadableStream bodies are single-consume. ``req.clone()`` returns a
83
+ // fresh Request whose body stream is independentcall it once per
84
+ // dispatch attempt so the 401 retry can replay POST/PUT bodies
85
+ // intact.
86
+ const fresh = req.clone();
87
+ const ri: RequestInit = {
88
+ method: fresh.method,
89
+ body: fresh.method === "GET" || fresh.method === "HEAD" ? undefined : fresh.body,
90
+ credentials: fresh.credentials,
91
+ mode: fresh.mode,
92
+ cache: fresh.cache,
93
+ redirect: fresh.redirect,
94
+ referrer: fresh.referrer,
95
+ integrity: fresh.integrity,
96
+ signal: fresh.signal,
103
97
  };
104
98
  // Streaming bodies need duplex: "half"; harmless when there's no body.
105
- if (cloned.body !== null) {
106
- (requestInit as RequestInit & { duplex?: string }).duplex = "half";
99
+ if (fresh.body !== null) {
100
+ (ri as RequestInit & { duplex?: string }).duplex = "half";
107
101
  }
108
- return fetch(resolvedUrl, { ...requestInit, ...init, headers });
102
+ return ri;
103
+ }
104
+
105
+ async function dispatch(forceRefreshToken: boolean): Promise<Response> {
106
+ const headers = new Headers(baseHeaders);
107
+ if (!callerSuppliedAuth) {
108
+ try {
109
+ const token = await options.acquireToken(
110
+ forceRefreshToken ? { forceRefresh: true } : undefined,
111
+ );
112
+ if (token) {
113
+ headers.set("Authorization", `Bearer ${token}`);
114
+ }
115
+ } catch (error) {
116
+ // A thrown token acquisition error usually means the auth layer
117
+ // is starting an interactive recovery. Do not downgrade protected
118
+ // API calls to anonymous requests; that creates noisy 401s and
119
+ // stale UI.
120
+ throw error;
121
+ }
122
+ }
123
+ if (input instanceof Request) {
124
+ // Re-clone for THIS attempt — the body stream of the original is
125
+ // either still pristine (first attempt) or already consumed
126
+ // (second attempt); ``clone()`` always returns a fresh, replayable
127
+ // copy.
128
+ const requestInit = buildRequestInitFrom(input);
129
+ return fetch(resolvedUrl, { ...requestInit, ...init, headers });
130
+ }
131
+ return fetch(resolvedUrl, { ...init, headers });
132
+ }
133
+
134
+ const response = await dispatch(false);
135
+
136
+ // Long-lived tab recovery: when the resource server returns 401 the
137
+ // local MSAL cache may still hold a token MSAL itself thinks is valid
138
+ // (claims challenge, conditional-access re-eval, audience drift, or
139
+ // the user simply left the tab open past the cached access token's
140
+ // server-side validity). Force-refresh the token via the refresh
141
+ // token grant and replay the request once. If the refresh-token is
142
+ // also gone (24h SPA cap), ``acquireToken`` will throw an
143
+ // interaction-required error and the auth layer kicks off
144
+ // ``loginRedirect`` — that's the only correct UX for a hard expiry.
145
+ //
146
+ // Conditions for retry: 401, no caller-supplied auth header (we own
147
+ // the token), and the server didn't already see a fresh token (we
148
+ // only retry once).
149
+ if (response.status !== 401 || callerSuppliedAuth) {
150
+ return response;
109
151
  }
110
- return fetch(resolvedUrl, { ...init, headers });
152
+ // Drain the failed response body — letting it sit unread keeps the
153
+ // underlying connection occupied on some runtimes.
154
+ response.body?.cancel().catch(() => undefined);
155
+ return dispatch(true);
111
156
  };
112
157
  }
@@ -25,6 +25,7 @@ export interface MsalAccountInfo {
25
25
  export interface MsalClientApplication {
26
26
  initialize(): Promise<void>;
27
27
  handleRedirectPromise(): Promise<unknown>;
28
+ clearCache(request?: { account?: MsalAccountInfo }): Promise<void>;
28
29
  getAllAccounts(): MsalAccountInfo[];
29
30
  loginRedirect(request: { scopes: string[]; prompt?: string }): Promise<void>;
30
31
  logoutRedirect(): Promise<void>;
@@ -32,6 +33,16 @@ export interface MsalClientApplication {
32
33
  acquireTokenSilent(request: {
33
34
  scopes: string[];
34
35
  account: MsalAccountInfo;
36
+ /**
37
+ * Skip MSAL's local cache and force a round-trip to the token
38
+ * endpoint using the cached refresh token. The fetch interceptor
39
+ * uses this on a 401 retry — the first attempt may have served a
40
+ * cached access token MSAL still thought valid (within
41
+ * ``tokenRenewalOffsetSeconds``) that the resource server has
42
+ * since rejected (claims challenge, conditional access re-eval,
43
+ * audience drift).
44
+ */
45
+ forceRefresh?: boolean;
35
46
  }): Promise<{ accessToken: string }>;
36
47
  acquireTokenRedirect(request: {
37
48
  scopes: string[];
@@ -1,5 +1,6 @@
1
1
  import { createStore } from "zustand/vanilla";
2
2
  import { getMsalInstance, getMsalConfig } from "./auth-config.ts";
3
+ import type { MsalAccountInfo, MsalClientApplication } from "./auth-config.ts";
3
4
 
4
5
  export interface AuthUser {
5
6
  name: string;
@@ -8,6 +9,18 @@ export interface AuthUser {
8
9
  oid?: string;
9
10
  }
10
11
 
12
+ export class AuthInteractionRequiredError extends Error {
13
+ readonly code?: string;
14
+ readonly cause?: unknown;
15
+
16
+ constructor(message: string, options: { code?: string; cause?: unknown } = {}) {
17
+ super(message);
18
+ this.name = "AuthInteractionRequiredError";
19
+ this.code = options.code;
20
+ this.cause = options.cause;
21
+ }
22
+ }
23
+
11
24
  interface AuthState {
12
25
  user: AuthUser | null;
13
26
  isAuthenticated: boolean;
@@ -25,13 +38,8 @@ interface AuthState {
25
38
  * Recovery semantics — follows the canonical MSAL.js pattern documented
26
39
  * at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
27
40
  *
28
- * try {
29
- * await msal.acquireTokenSilent(req);
30
- * } catch (error) {
31
- * if (error instanceof InteractionRequiredAuthError) {
32
- * await msal.acquireTokenRedirect(req);
33
- * }
34
- * }
41
+ * Try acquireTokenSilent first, then use an interactive redirect when
42
+ * MSAL reports that user interaction is required.
35
43
  *
36
44
  * Plus one extra recoverable case explicitly called out in those docs:
37
45
  * `BrowserAuthError: monitor_window_timeout`. Microsoft's recommendation
@@ -41,6 +49,16 @@ interface AuthState {
41
49
  * blocking on Chrome 120+ / Edge / Safari (the silent iframe times
42
50
  * out because the cross-site cookie is partitioned).
43
51
  *
52
+ * Before starting that redirect, clear the stale local account/token
53
+ * cache. This avoids the broken half-authenticated state where the SPA
54
+ * still renders an account but every API call goes out without a bearer
55
+ * token after MSAL returned `interaction_required`.
56
+ *
57
+ * Recoverable auth failures reject with `AuthInteractionRequiredError`
58
+ * after recovery has been started. The error exposes `code` and `cause`
59
+ * so callers can distinguish a normal interaction-required redirect from
60
+ * a blocked/failed redirect attempt.
61
+ *
44
62
  * Other `BrowserAuthError` codes (`interaction_in_progress`,
45
63
  * `hash_empty_error`, `hash_does_not_contain_known_properties`,
46
64
  * `block_iframe_reload`) are config / race-condition bugs that another
@@ -54,8 +72,18 @@ interface AuthState {
54
72
  * Errors are matched by `name` / `errorCode` rather than `instanceof`
55
73
  * because `@azure/msal-browser` is loaded via dynamic import; the
56
74
  * error class identity isn't shared across module boundaries.
75
+ *
76
+ * `forceRefresh: true` skips MSAL's local cache and goes back to the
77
+ * token endpoint with the cached refresh token. Use it from the
78
+ * fetch interceptor when a protected API returns 401 — the first
79
+ * attempt may have used a stale cached access token (claims
80
+ * challenge, conditional-access re-eval, audience drift, etc.) that
81
+ * MSAL still considered valid against its own clock.
57
82
  */
58
- getAccessToken: (audience?: "api" | "spaces") => Promise<string | null>;
83
+ getAccessToken: (
84
+ audience?: "api" | "spaces",
85
+ options?: { forceRefresh?: boolean },
86
+ ) => Promise<string | null>;
59
87
  }
60
88
 
61
89
  /** Recoverable error codes per MSAL.js docs — every one of these has the
@@ -77,6 +105,8 @@ const RECOVERABLE_ERROR_CODES = new Set([
77
105
  * class name as a fallback when the error code isn't set. */
78
106
  const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
79
107
 
108
+ let interactiveRecoveryStarted = false;
109
+
80
110
  function isRecoverableAuthError(err: unknown): boolean {
81
111
  if (!err || typeof err !== "object") return false;
82
112
  const name = (err as { name?: string }).name ?? "";
@@ -84,6 +114,81 @@ function isRecoverableAuthError(err: unknown): boolean {
84
114
  return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
85
115
  }
86
116
 
117
+ function authErrorName(err: unknown): string | undefined {
118
+ return err && typeof err === "object" ? (err as { name?: string }).name : undefined;
119
+ }
120
+
121
+ function authErrorCode(err: unknown): string | undefined {
122
+ return err && typeof err === "object" ? (err as { errorCode?: string }).errorCode : undefined;
123
+ }
124
+
125
+ function authErrorMessage(err: unknown): string | undefined {
126
+ if (err instanceof Error) return err.message;
127
+ return err && typeof err === "object" ? (err as { message?: string }).message : undefined;
128
+ }
129
+
130
+ function describeAuthError(err: unknown): string {
131
+ const parts = [
132
+ authErrorName(err) ? `name=${authErrorName(err)}` : undefined,
133
+ authErrorCode(err) ? `code=${authErrorCode(err)}` : undefined,
134
+ authErrorMessage(err) ? `message=${authErrorMessage(err)}` : undefined,
135
+ ].filter(Boolean);
136
+ return parts.join(", ");
137
+ }
138
+
139
+ function createInteractionRequiredError(
140
+ tokenError: unknown,
141
+ redirectError?: unknown,
142
+ ): AuthInteractionRequiredError {
143
+ const tokenDetail = describeAuthError(tokenError) || "unknown token acquisition error";
144
+ const redirectDetail = redirectError ? describeAuthError(redirectError) : "";
145
+
146
+ if (redirectError) {
147
+ return new AuthInteractionRequiredError(
148
+ `Authentication interaction required, but login redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
149
+ {
150
+ code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
151
+ cause: redirectError,
152
+ },
153
+ );
154
+ }
155
+
156
+ return new AuthInteractionRequiredError(
157
+ `Authentication interaction required after token acquisition failed (${tokenDetail}).`,
158
+ {
159
+ code: authErrorCode(tokenError),
160
+ cause: tokenError,
161
+ },
162
+ );
163
+ }
164
+
165
+ async function startInteractiveRecovery(
166
+ msal: MsalClientApplication,
167
+ account: MsalAccountInfo,
168
+ scope: string,
169
+ reason: unknown,
170
+ ): Promise<never> {
171
+ let redirectError: unknown;
172
+ if (!interactiveRecoveryStarted) {
173
+ interactiveRecoveryStarted = true;
174
+ authStore.setState({ user: null, isAuthenticated: false });
175
+ msal.setActiveAccount(null);
176
+ await msal.clearCache({ account }).catch(() => undefined);
177
+ try {
178
+ await msal.loginRedirect({ scopes: [scope] });
179
+ } catch (err) {
180
+ // The redirect is expected to navigate away; if it settles by throwing,
181
+ // keep that failure attached so logs/UI can show what blocked recovery.
182
+ redirectError = err;
183
+ } finally {
184
+ // In tests or blocked-popup environments the promise can settle without
185
+ // navigation. Allow a later user action/retry to start recovery again.
186
+ interactiveRecoveryStarted = false;
187
+ }
188
+ }
189
+ throw createInteractionRequiredError(reason, redirectError);
190
+ }
191
+
87
192
  export const authStore = createStore<AuthState>((set) => ({
88
193
  user: null,
89
194
  isAuthenticated: false,
@@ -98,7 +203,7 @@ export const authStore = createStore<AuthState>((set) => ({
98
203
  }
99
204
  },
100
205
 
101
- getAccessToken: async (_audience = "api") => {
206
+ getAccessToken: async (_audience = "api", options) => {
102
207
  const msal = getMsalInstance();
103
208
  const config = getMsalConfig();
104
209
  if (!msal || !config) return null;
@@ -112,23 +217,18 @@ export const authStore = createStore<AuthState>((set) => ({
112
217
 
113
218
  // Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
114
219
  const scope = config.apiScope;
220
+ const forceRefresh = options?.forceRefresh === true;
115
221
 
116
222
  try {
117
223
  const result = await msal.acquireTokenSilent({
118
224
  scopes: [scope],
119
225
  account: accounts[0],
226
+ forceRefresh,
120
227
  });
121
228
  return result.accessToken;
122
229
  } catch (err) {
123
230
  if (isRecoverableAuthError(err)) {
124
- try {
125
- await msal.acquireTokenRedirect({
126
- scopes: [scope],
127
- account: accounts[0],
128
- });
129
- } catch {
130
- // Redirect navigates away; nothing to return.
131
- }
231
+ return startInteractiveRecovery(msal, accounts[0], scope, err);
132
232
  }
133
233
  return null;
134
234
  }
package/src/msal/index.ts CHANGED
@@ -1,4 +1,8 @@
1
- export { authStore, type AuthUser } from "./auth-store.ts";
1
+ export {
2
+ AuthInteractionRequiredError,
3
+ authStore,
4
+ type AuthUser,
5
+ } from "./auth-store.ts";
2
6
  export {
3
7
  type MsalAuthConfig,
4
8
  type MsalAccountInfo,
@@ -7,6 +7,11 @@
7
7
  * downstream services (e.g., Spaces).
8
8
  *
9
9
  * When MSAL is not configured, behaves identically to native `fetch()`.
10
+ *
11
+ * Long-lived tab recovery: on 401 the wrapper retries once with
12
+ * ``forceRefresh: true`` so the resource server doesn't keep seeing a
13
+ * stale-but-cached access token. See ``service-fetch.ts`` for the same
14
+ * pattern with URL rewriting + the deeper rationale.
10
15
  */
11
16
 
12
17
  import { authStore } from "./auth-store.ts";
@@ -15,16 +20,33 @@ export async function tokenFetch(
15
20
  input: string | URL | Request,
16
21
  init?: RequestInit,
17
22
  ): Promise<Response> {
18
- const token = await authStore.getState().getAccessToken("api");
23
+ async function dispatch(forceRefreshToken: boolean): Promise<Response> {
24
+ const token = await authStore
25
+ .getState()
26
+ .getAccessToken("api", forceRefreshToken ? { forceRefresh: true } : undefined);
27
+
28
+ // Clone Request inputs per-attempt: ReadableStream bodies are single-
29
+ // consume, so the 401 retry would otherwise see an empty body.
30
+ // ``input.clone()`` returns a fresh Request whose body stream is
31
+ // independent of the original.
32
+ const target = input instanceof Request ? input.clone() : input;
33
+
34
+ if (!token) {
35
+ return fetch(target, init);
36
+ }
19
37
 
20
- if (!token) {
21
- return fetch(input, init);
38
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
39
+ if (init?.headers) {
40
+ new Headers(init.headers).forEach((value, key) => headers.set(key, value));
41
+ }
42
+ headers.set("Authorization", `Bearer ${token}`);
43
+ return fetch(target, { ...init, headers });
22
44
  }
23
45
 
24
- const headers = new Headers(input instanceof Request ? input.headers : undefined);
25
- if (init?.headers) {
26
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
46
+ const response = await dispatch(false);
47
+ if (response.status !== 401) {
48
+ return response;
27
49
  }
28
- headers.set("Authorization", `Bearer ${token}`);
29
- return fetch(input, { ...init, headers });
50
+ response.body?.cancel().catch(() => undefined);
51
+ return dispatch(true);
30
52
  }