@aprovan/chat-backend 0.1.0-dev.4d82df8

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +85 -0
  4. package/dist/index.js +1158 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lambda.d.ts +5 -0
  7. package/dist/lambda.js +1145 -0
  8. package/dist/lambda.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/app.ts +33 -0
  11. package/src/env.ts +27 -0
  12. package/src/fallback-prompts.ts +343 -0
  13. package/src/gateway-session.ts +148 -0
  14. package/src/index.ts +19 -0
  15. package/src/lambda.ts +17 -0
  16. package/src/middleware/.gitkeep +0 -0
  17. package/src/middleware/auth.ts +38 -0
  18. package/src/middleware/plan.ts +67 -0
  19. package/src/middleware/workspace.ts +96 -0
  20. package/src/posthog.ts +48 -0
  21. package/src/providers/openrouter.ts +37 -0
  22. package/src/routes/chat.ts +235 -0
  23. package/src/routes/edit.ts +57 -0
  24. package/src/routes/health.ts +7 -0
  25. package/src/routes/proxy.ts +60 -0
  26. package/src/routes/services.ts +82 -0
  27. package/src/routes/workspaces.ts +89 -0
  28. package/src/session.ts +80 -0
  29. package/src/tool-docs.ts +77 -0
  30. package/src/types.ts +29 -0
  31. package/test/app.test.ts +62 -0
  32. package/test/gateway-session.test.ts +211 -0
  33. package/test/middleware/auth.test.ts +87 -0
  34. package/test/middleware/plan.test.ts +99 -0
  35. package/test/middleware/workspace.test.ts +122 -0
  36. package/test/routes/chat.test.ts +587 -0
  37. package/test/routes/proxy.test.ts +133 -0
  38. package/test/routes/services.test.ts +128 -0
  39. package/test/routes/workspaces.test.ts +164 -0
  40. package/test/session.test.ts +56 -0
  41. package/tsconfig.json +13 -0
  42. package/tsup.config.ts +17 -0
@@ -0,0 +1,587 @@
1
+ import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
2
+ import { Hono } from "hono";
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import type { AppVariables, WorkspaceItem } from "../../src/types";
5
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
6
+
7
+ // ── Hoisted mocks — must be declared before vi.mock calls ────────────────────
8
+
9
+ const {
10
+ mockGetOpenRouterKey,
11
+ mockCreateOpenRouterProvider,
12
+ mockProviderFactory,
13
+ mockGetGatewaySession,
14
+ mockEvictGatewaySession,
15
+ mockGetCachedTools,
16
+ mockSetCachedTools,
17
+ mockGetPrompt,
18
+ mockCompilePrompt,
19
+ mockGetPostHogClient,
20
+ mockGetToolDocs,
21
+ mockMakeHttpGatewayClient,
22
+ } = vi.hoisted(() => {
23
+ const mockProviderFactory = vi.fn();
24
+ return {
25
+ mockGetOpenRouterKey: vi.fn().mockResolvedValue("test-key"),
26
+ mockCreateOpenRouterProvider: vi.fn().mockReturnValue(mockProviderFactory),
27
+ mockProviderFactory,
28
+ mockGetGatewaySession: vi.fn(),
29
+ mockEvictGatewaySession: vi.fn(),
30
+ mockGetCachedTools: vi.fn().mockReturnValue(undefined),
31
+ mockSetCachedTools: vi.fn(),
32
+ mockGetPrompt: vi.fn().mockResolvedValue({
33
+ source: "code_fallback",
34
+ prompt: "System: {{compilers}} {{tool_docs}}",
35
+ name: undefined,
36
+ version: undefined,
37
+ }),
38
+ mockCompilePrompt: vi
39
+ .fn()
40
+ .mockImplementation(
41
+ (template: string, vars: Record<string, string>) =>
42
+ template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => vars[k] ?? ""),
43
+ ),
44
+ mockGetPostHogClient: vi.fn().mockReturnValue(null),
45
+ mockGetToolDocs: vi.fn().mockResolvedValue(""),
46
+ mockMakeHttpGatewayClient: vi.fn().mockReturnValue(null),
47
+ };
48
+ });
49
+
50
+ vi.mock("../../src/providers/openrouter.js", () => ({
51
+ getOpenRouterKey: mockGetOpenRouterKey,
52
+ createOpenRouterProvider: mockCreateOpenRouterProvider,
53
+ }));
54
+
55
+ vi.mock("../../src/gateway-session.js", () => ({
56
+ getGatewaySession: mockGetGatewaySession,
57
+ evictGatewaySession: mockEvictGatewaySession,
58
+ getCachedTools: mockGetCachedTools,
59
+ setCachedTools: mockSetCachedTools,
60
+ GatewaySessionError: class GatewaySessionError extends Error {
61
+ status: number;
62
+ constructor(status: number, message: string) {
63
+ super(message);
64
+ this.status = status;
65
+ }
66
+ },
67
+ resetGatewaySessionCache: vi.fn(),
68
+ }));
69
+
70
+ vi.mock("../../src/posthog.js", () => ({
71
+ getPrompt: mockGetPrompt,
72
+ compilePrompt: mockCompilePrompt,
73
+ getPostHogClient: mockGetPostHogClient,
74
+ }));
75
+
76
+ vi.mock("../../src/tool-docs.js", () => ({
77
+ getToolDocs: mockGetToolDocs,
78
+ makeHttpGatewayClient: mockMakeHttpGatewayClient,
79
+ }));
80
+
81
+ // Global fetch stub — reset per-test; not used unless GATEWAY_URL is set.
82
+ const mockFetch = vi.fn();
83
+ vi.stubGlobal("fetch", mockFetch);
84
+
85
+ // Import the route AFTER mocks are in place
86
+ const { chatRoute } = await import("../../src/routes/chat.js");
87
+
88
+ // ── Test fixtures ─────────────────────────────────────────────────────────────
89
+
90
+ const fakeWorkspace: WorkspaceItem = {
91
+ workspaceId: "ws-test",
92
+ name: "Test Workspace",
93
+ plan: "free",
94
+ limits: {
95
+ dailyChatCap: 50,
96
+ maxModels: ["openrouter/auto"],
97
+ maxToolSteps: 5,
98
+ maxTokensPerRequest: 4096,
99
+ },
100
+ features: {
101
+ advancedTools: false,
102
+ customPrompts: false,
103
+ },
104
+ createdAt: "2026-07-01T00:00:00Z",
105
+ updatedAt: "2026-07-01T00:00:00Z",
106
+ };
107
+
108
+ const fakeClaims = { sub: "user-sub-test" } as unknown as CognitoAccessTokenPayload;
109
+
110
+ function buildApp(workspace: WorkspaceItem = fakeWorkspace) {
111
+ const app = new Hono<{ Variables: AppVariables }>();
112
+ // Bypass auth / workspace / plan middleware
113
+ app.use("/chat/*", async (c, next) => {
114
+ c.set("claims", fakeClaims);
115
+ c.set("workspaceId", workspace.workspaceId);
116
+ c.set("workspace", workspace);
117
+ await next();
118
+ });
119
+ app.route("/chat", chatRoute);
120
+ return app;
121
+ }
122
+
123
+ const validBody = JSON.stringify({
124
+ id: "chat-1",
125
+ messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
126
+ trigger: "submit-message",
127
+ });
128
+
129
+ const validHeaders = { "Content-Type": "application/json" };
130
+
131
+ function makeSuccessStream() {
132
+ return simulateReadableStream({
133
+ chunks: [
134
+ { type: "stream-start", warnings: [] },
135
+ { type: "text-start", id: "txt-1" },
136
+ { type: "text-delta", id: "txt-1", delta: "Hello!" },
137
+ { type: "text-end", id: "txt-1" },
138
+ {
139
+ type: "finish",
140
+ finishReason: { unified: "stop" as const, raw: "stop" },
141
+ usage: {
142
+ inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
143
+ outputTokens: { total: 3, text: 3, reasoning: undefined },
144
+ },
145
+ },
146
+ ],
147
+ });
148
+ }
149
+
150
+ function makePartialThenErrorStream() {
151
+ return simulateReadableStream({
152
+ chunks: [
153
+ { type: "stream-start", warnings: [] },
154
+ { type: "text-start", id: "txt-1" },
155
+ { type: "text-delta", id: "txt-1", delta: "Part" },
156
+ { type: "text-end", id: "txt-1" },
157
+ { type: "error", error: new Error("upstream connection lost") },
158
+ ],
159
+ });
160
+ }
161
+
162
+ const MOCK_TOOLS_RESPONSE = {
163
+ tools: [
164
+ {
165
+ provider: "github",
166
+ name: "github.repos_list",
167
+ operation: "repos_list",
168
+ description: "List repos",
169
+ inputSchema: { type: "object", properties: { per_page: { type: "number" } } },
170
+ },
171
+ ],
172
+ workspace_id: "ws-test",
173
+ };
174
+
175
+ // ── Tests ─────────────────────────────────────────────────────────────────────
176
+
177
+ describe("POST /chat", () => {
178
+ let mockDoStream: ReturnType<typeof vi.fn>;
179
+
180
+ beforeEach(() => {
181
+ vi.clearAllMocks();
182
+ mockFetch.mockReset();
183
+ delete process.env["GATEWAY_URL"];
184
+
185
+ mockDoStream = vi.fn();
186
+ // Wire: createOpenRouterProvider(key) → providerFactory(modelId) → MockLanguageModelV3
187
+ const mockModel = new MockLanguageModelV3({ doStream: mockDoStream });
188
+ mockProviderFactory.mockReturnValue(mockModel);
189
+ mockCreateOpenRouterProvider.mockReturnValue(mockProviderFactory);
190
+ mockGetOpenRouterKey.mockResolvedValue("test-key");
191
+ mockGetCachedTools.mockReturnValue(undefined);
192
+ mockSetCachedTools.mockReset();
193
+
194
+ // Reset prompt + tool-docs mocks to defaults
195
+ mockGetPrompt.mockResolvedValue({
196
+ source: "code_fallback",
197
+ prompt: "System: {{compilers}} {{tool_docs}}",
198
+ name: undefined,
199
+ version: undefined,
200
+ });
201
+ mockCompilePrompt.mockImplementation(
202
+ (template: string, vars: Record<string, string>) =>
203
+ template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => vars[k] ?? ""),
204
+ );
205
+ mockGetPostHogClient.mockReturnValue(null);
206
+ mockGetToolDocs.mockResolvedValue("");
207
+ mockMakeHttpGatewayClient.mockReturnValue(null);
208
+ });
209
+
210
+ it("returns 400 for invalid request body", async () => {
211
+ const app = buildApp();
212
+ const res = await app.request("/chat", {
213
+ method: "POST",
214
+ headers: validHeaders,
215
+ body: JSON.stringify({ wrong: "shape" }),
216
+ });
217
+ expect(res.status).toBe(400);
218
+ const body = await res.json();
219
+ expect(body).toMatchObject({ error: "Invalid request body" });
220
+ });
221
+
222
+ it("streams UI-message stream for a valid request", async () => {
223
+ mockDoStream.mockResolvedValueOnce({
224
+ stream: makeSuccessStream(),
225
+ rawResponse: { headers: {} },
226
+ });
227
+
228
+ const app = buildApp();
229
+ const res = await app.request("/chat", {
230
+ method: "POST",
231
+ headers: validHeaders,
232
+ body: validBody,
233
+ });
234
+
235
+ expect(res.status).toBe(200);
236
+ expect(res.headers.get("content-type")).toMatch("text/event-stream");
237
+ const text = await res.text();
238
+ expect(text).toContain('"type":"text-delta"');
239
+ expect(text).toContain('"delta":"Hello!"');
240
+ expect(mockDoStream).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ it("retries once on pre-stream error; if second call also fails, surfaces error part", async () => {
244
+ const upstreamError = new Error("503 Service Unavailable");
245
+ mockDoStream
246
+ .mockRejectedValueOnce(upstreamError) // first attempt
247
+ .mockRejectedValueOnce(upstreamError); // retry attempt
248
+
249
+ const app = buildApp();
250
+ const res = await app.request("/chat", {
251
+ method: "POST",
252
+ headers: validHeaders,
253
+ body: validBody,
254
+ });
255
+
256
+ // Response is still a streaming UI-message stream (error part inside)
257
+ expect(res.status).toBe(200);
258
+ expect(res.headers.get("content-type")).toMatch("text/event-stream");
259
+ const text = await res.text();
260
+ expect(text).toContain('"type":"error"');
261
+ // Exactly two doStream calls: initial attempt + one retry
262
+ expect(mockDoStream).toHaveBeenCalledTimes(2);
263
+ });
264
+
265
+ it("retries once on pre-stream error; succeeds on retry", async () => {
266
+ const upstreamError = new Error("503 Service Unavailable");
267
+ mockDoStream
268
+ .mockRejectedValueOnce(upstreamError) // first attempt fails
269
+ .mockResolvedValueOnce({ // retry succeeds
270
+ stream: makeSuccessStream(),
271
+ rawResponse: { headers: {} },
272
+ });
273
+
274
+ const app = buildApp();
275
+ const res = await app.request("/chat", {
276
+ method: "POST",
277
+ headers: validHeaders,
278
+ body: validBody,
279
+ });
280
+
281
+ expect(res.status).toBe(200);
282
+ const text = await res.text();
283
+ expect(text).toContain('"type":"text-delta"');
284
+ expect(mockDoStream).toHaveBeenCalledTimes(2);
285
+ });
286
+
287
+ it("does NOT retry when OpenRouter fails mid-stream", async () => {
288
+ mockDoStream.mockResolvedValueOnce({
289
+ stream: makePartialThenErrorStream(),
290
+ rawResponse: { headers: {} },
291
+ });
292
+
293
+ const app = buildApp();
294
+ const res = await app.request("/chat", {
295
+ method: "POST",
296
+ headers: validHeaders,
297
+ body: validBody,
298
+ });
299
+
300
+ expect(res.status).toBe(200);
301
+ const text = await res.text();
302
+ // Got some real text before the error
303
+ expect(text).toContain('"type":"text-delta"');
304
+ // Error part is present in the stream
305
+ expect(text).toContain('"type":"error"');
306
+ // doStream was called only once (no retry for mid-stream errors)
307
+ expect(mockDoStream).toHaveBeenCalledTimes(1);
308
+ });
309
+
310
+ it("returns 403 when requested model is not in workspace.limits.maxModels", async () => {
311
+ const app = buildApp();
312
+ const res = await app.request("/chat", {
313
+ method: "POST",
314
+ headers: validHeaders,
315
+ body: JSON.stringify({
316
+ id: "chat-1",
317
+ messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
318
+ trigger: "submit-message",
319
+ model: "anthropic/claude-opus-4",
320
+ }),
321
+ });
322
+ expect(res.status).toBe(403);
323
+ const body = await res.json();
324
+ expect(body).toMatchObject({ error: expect.stringContaining("Model not allowed") });
325
+ expect(mockDoStream).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("uses the requested model when it is in workspace.limits.maxModels", async () => {
329
+ mockDoStream.mockResolvedValueOnce({
330
+ stream: makeSuccessStream(),
331
+ rawResponse: { headers: {} },
332
+ });
333
+
334
+ const proWorkspace: WorkspaceItem = {
335
+ ...fakeWorkspace,
336
+ plan: "pro",
337
+ limits: {
338
+ ...fakeWorkspace.limits,
339
+ maxModels: ["openrouter/auto", "anthropic/claude-opus-4"],
340
+ },
341
+ };
342
+
343
+ const app = buildApp(proWorkspace);
344
+ const res = await app.request("/chat", {
345
+ method: "POST",
346
+ headers: validHeaders,
347
+ body: JSON.stringify({
348
+ id: "chat-1",
349
+ messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
350
+ trigger: "submit-message",
351
+ model: "anthropic/claude-opus-4",
352
+ }),
353
+ });
354
+
355
+ expect(res.status).toBe(200);
356
+ expect(mockProviderFactory).toHaveBeenCalledWith("anthropic/claude-opus-4");
357
+ });
358
+
359
+ it("selects model from workspace.limits.maxModels[0]", async () => {
360
+ mockDoStream.mockResolvedValueOnce({
361
+ stream: makeSuccessStream(),
362
+ rawResponse: { headers: {} },
363
+ });
364
+
365
+ const proWorkspace: WorkspaceItem = {
366
+ ...fakeWorkspace,
367
+ plan: "pro",
368
+ limits: { ...fakeWorkspace.limits, maxModels: ["anthropic/claude-opus-4"] },
369
+ };
370
+
371
+ const app = buildApp(proWorkspace);
372
+ await app.request("/chat", {
373
+ method: "POST",
374
+ headers: validHeaders,
375
+ body: validBody,
376
+ });
377
+
378
+ // The provider factory should have been called with the plan's first model
379
+ expect(mockProviderFactory).toHaveBeenCalledWith("anthropic/claude-opus-4");
380
+ });
381
+
382
+ // ── Prompt loading ──────────────────────────────────────────────────────────
383
+
384
+ it("defaults to chat-patchwork-widget when prompt field is omitted", async () => {
385
+ mockDoStream.mockResolvedValueOnce({
386
+ stream: makeSuccessStream(),
387
+ rawResponse: { headers: {} },
388
+ });
389
+
390
+ const app = buildApp();
391
+ const res = await app.request("/chat", {
392
+ method: "POST",
393
+ headers: validHeaders,
394
+ body: validBody,
395
+ });
396
+
397
+ expect(res.status).toBe(200);
398
+ expect(mockGetPrompt).toHaveBeenCalledWith("chat-patchwork-widget");
399
+ });
400
+
401
+ it("loads the correct prompt and compiles with compilers + tool_docs", async () => {
402
+ mockGetPrompt.mockResolvedValue({
403
+ source: "code_fallback",
404
+ prompt: "---\ncompilers: {{compilers}}\n---\n{{tool_docs}}",
405
+ name: undefined,
406
+ version: undefined,
407
+ });
408
+ mockGetToolDocs.mockResolvedValue("## Services\n- weather");
409
+ mockDoStream.mockResolvedValueOnce({
410
+ stream: makeSuccessStream(),
411
+ rawResponse: { headers: {} },
412
+ });
413
+
414
+ const app = buildApp();
415
+ const res = await app.request("/chat", {
416
+ method: "POST",
417
+ headers: validHeaders,
418
+ body: JSON.stringify({
419
+ id: "chat-1",
420
+ messages: [{ role: "user", parts: [{ type: "text", text: "hi" }], id: "m1" }],
421
+ trigger: "submit-message",
422
+ prompt: {
423
+ id: "chat-patchwork-widget",
424
+ vars: { compilers: ["@aprovan/patchwork-image-shadcn"] },
425
+ },
426
+ }),
427
+ });
428
+
429
+ expect(res.status).toBe(200);
430
+ expect(mockGetPrompt).toHaveBeenCalledWith("chat-patchwork-widget");
431
+ expect(mockCompilePrompt).toHaveBeenCalledWith(
432
+ "---\ncompilers: {{compilers}}\n---\n{{tool_docs}}",
433
+ expect.objectContaining({
434
+ compilers: "@aprovan/patchwork-image-shadcn",
435
+ tool_docs: "## Services\n- weather",
436
+ }),
437
+ );
438
+ });
439
+
440
+ it("returns 400 for an unknown promptId", async () => {
441
+ const app = buildApp();
442
+ const res = await app.request("/chat", {
443
+ method: "POST",
444
+ headers: validHeaders,
445
+ body: JSON.stringify({
446
+ id: "chat-1",
447
+ messages: [{ role: "user", parts: [{ type: "text", text: "hi" }], id: "m1" }],
448
+ trigger: "submit-message",
449
+ prompt: { id: "unknown-prompt-id" },
450
+ }),
451
+ });
452
+
453
+ expect(res.status).toBe(400);
454
+ const json = await res.json();
455
+ expect(json).toMatchObject({ error: "Unknown prompt id" });
456
+ });
457
+
458
+ it("accepts chat-plain as a valid promptId", async () => {
459
+ mockDoStream.mockResolvedValueOnce({
460
+ stream: makeSuccessStream(),
461
+ rawResponse: { headers: {} },
462
+ });
463
+
464
+ const app = buildApp();
465
+ const res = await app.request("/chat", {
466
+ method: "POST",
467
+ headers: validHeaders,
468
+ body: JSON.stringify({
469
+ id: "chat-1",
470
+ messages: [{ role: "user", parts: [{ type: "text", text: "hi" }], id: "m1" }],
471
+ trigger: "submit-message",
472
+ prompt: { id: "chat-plain" },
473
+ }),
474
+ });
475
+
476
+ expect(res.status).toBe(200);
477
+ expect(mockGetPrompt).toHaveBeenCalledWith("chat-plain");
478
+ });
479
+
480
+ // ── Gateway tool wiring ────────────────────────────────────────────────────
481
+
482
+ describe("when GATEWAY_URL is configured", () => {
483
+ beforeEach(() => {
484
+ process.env["GATEWAY_URL"] = "https://gateway.test";
485
+ mockGetGatewaySession.mockResolvedValue({
486
+ token: "session-bearer",
487
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
488
+ });
489
+ });
490
+
491
+ afterEach(() => {
492
+ delete process.env["GATEWAY_URL"];
493
+ });
494
+
495
+ it("fetches gateway tools and passes them to the model", async () => {
496
+ mockFetch.mockResolvedValueOnce(
497
+ new Response(JSON.stringify(MOCK_TOOLS_RESPONSE), { status: 200 }),
498
+ );
499
+ mockDoStream.mockResolvedValueOnce({
500
+ stream: makeSuccessStream(),
501
+ rawResponse: { headers: {} },
502
+ });
503
+
504
+ const app = buildApp();
505
+ const res = await app.request("/chat", {
506
+ method: "POST",
507
+ headers: validHeaders,
508
+ body: validBody,
509
+ });
510
+
511
+ expect(res.status).toBe(200);
512
+ // Gateway tools endpoint was called with the session bearer
513
+ const [toolsUrl, toolsOpts] = mockFetch.mock.calls[0] as [string, RequestInit];
514
+ expect(toolsUrl).toBe("https://gateway.test/tools");
515
+ expect((toolsOpts.headers as Record<string, string>)["Authorization"]).toBe(
516
+ "Bearer session-bearer",
517
+ );
518
+ });
519
+
520
+ it("requests a gateway session with the correct claims and workspace ID", async () => {
521
+ mockFetch.mockResolvedValueOnce(
522
+ new Response(JSON.stringify(MOCK_TOOLS_RESPONSE), { status: 200 }),
523
+ );
524
+ mockDoStream.mockResolvedValueOnce({
525
+ stream: makeSuccessStream(),
526
+ rawResponse: { headers: {} },
527
+ });
528
+
529
+ const app = buildApp();
530
+ const res = await app.request("/chat", {
531
+ method: "POST",
532
+ headers: validHeaders,
533
+ body: validBody,
534
+ });
535
+
536
+ expect(res.status).toBe(200);
537
+ expect(mockGetGatewaySession).toHaveBeenCalledOnce();
538
+ expect(mockGetGatewaySession).toHaveBeenCalledWith(
539
+ fakeClaims,
540
+ fakeWorkspace.workspaceId,
541
+ expect.any(String),
542
+ );
543
+ });
544
+
545
+ it("uses cached tools when available — skips gateway fetch", async () => {
546
+ mockGetCachedTools.mockReturnValue(MOCK_TOOLS_RESPONSE.tools);
547
+ mockDoStream.mockResolvedValueOnce({
548
+ stream: makeSuccessStream(),
549
+ rawResponse: { headers: {} },
550
+ });
551
+
552
+ const app = buildApp();
553
+ const res = await app.request("/chat", {
554
+ method: "POST",
555
+ headers: validHeaders,
556
+ body: validBody,
557
+ });
558
+
559
+ expect(res.status).toBe(200);
560
+ // No gateway tools fetch should have occurred
561
+ expect(mockFetch).not.toHaveBeenCalled();
562
+ });
563
+
564
+ it("caches fetched tools via setCachedTools", async () => {
565
+ mockFetch.mockResolvedValueOnce(
566
+ new Response(JSON.stringify(MOCK_TOOLS_RESPONSE), { status: 200 }),
567
+ );
568
+ mockDoStream.mockResolvedValueOnce({
569
+ stream: makeSuccessStream(),
570
+ rawResponse: { headers: {} },
571
+ });
572
+
573
+ const app = buildApp();
574
+ await app.request("/chat", {
575
+ method: "POST",
576
+ headers: validHeaders,
577
+ body: validBody,
578
+ });
579
+
580
+ expect(mockSetCachedTools).toHaveBeenCalledOnce();
581
+ expect(mockSetCachedTools).toHaveBeenCalledWith(
582
+ fakeClaims.sub,
583
+ MOCK_TOOLS_RESPONSE.tools,
584
+ );
585
+ });
586
+ });
587
+ });