@agent-native/dispatch 0.14.7 → 0.14.9

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/dist/actions/ask_app.d.ts +16 -1
  2. package/dist/actions/ask_app.js +12 -1
  3. package/dist/actions/ask_app.js.map +1 -1
  4. package/dist/actions/ask_app_status.d.ts +23 -0
  5. package/dist/actions/ask_app_status.d.ts.map +1 -0
  6. package/dist/actions/ask_app_status.js +14 -0
  7. package/dist/actions/ask_app_status.js.map +1 -0
  8. package/dist/actions/create-workspace-resource.d.ts +13 -13
  9. package/dist/actions/delete-destination.d.ts +12 -12
  10. package/dist/actions/delete-workspace-resource.d.ts +13 -13
  11. package/dist/actions/index.d.ts.map +1 -1
  12. package/dist/actions/index.js +2 -0
  13. package/dist/actions/index.js.map +1 -1
  14. package/dist/actions/update-workspace-resource.d.ts +13 -13
  15. package/dist/actions/upsert-destination.d.ts +12 -12
  16. package/dist/components/layout/Layout.d.ts +2 -1
  17. package/dist/components/layout/Layout.d.ts.map +1 -1
  18. package/dist/components/layout/Layout.js +5 -5
  19. package/dist/components/layout/Layout.js.map +1 -1
  20. package/dist/routes/pages/agents.d.ts.map +1 -1
  21. package/dist/routes/pages/agents.js +2 -2
  22. package/dist/routes/pages/agents.js.map +1 -1
  23. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  24. package/dist/server/lib/mcp-access-store.js +2 -2
  25. package/dist/server/lib/mcp-access-store.js.map +1 -1
  26. package/dist/server/lib/mcp-gateway.d.ts +25 -5
  27. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  28. package/dist/server/lib/mcp-gateway.js +196 -6
  29. package/dist/server/lib/mcp-gateway.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/actions/ask_app.ts +13 -1
  32. package/src/actions/ask_app_status.ts +16 -0
  33. package/src/actions/index.spec.ts +1 -0
  34. package/src/actions/index.ts +2 -0
  35. package/src/components/layout/Layout.spec.tsx +21 -0
  36. package/src/components/layout/Layout.tsx +14 -9
  37. package/src/components/workspace-app-card.spec.tsx +34 -3
  38. package/src/routes/pages/agents.tsx +1 -2
  39. package/src/server/lib/mcp-access-store.spec.ts +3 -3
  40. package/src/server/lib/mcp-access-store.ts +2 -3
  41. package/src/server/lib/mcp-gateway.spec.ts +299 -14
  42. package/src/server/lib/mcp-gateway.ts +264 -6
@@ -51,9 +51,13 @@ describe("WorkspaceAppCard", () => {
51
51
  );
52
52
  });
53
53
 
54
- expect(
55
- container.querySelector('a[aria-label="Open Analytics"]')?.className,
56
- ).toContain("focus-visible:ring-2");
54
+ const appLink = container.querySelector<HTMLAnchorElement>(
55
+ 'a[aria-label="Open Analytics"]',
56
+ );
57
+ expect(appLink?.getAttribute("href")).toBe("/analytics");
58
+ expect(appLink?.getAttribute("target")).toBeNull();
59
+ expect(appLink?.getAttribute("rel")).toBeNull();
60
+ expect(appLink?.className).toContain("focus-visible:ring-2");
57
61
 
58
62
  const actions = [
59
63
  container.querySelector<HTMLButtonElement>(
@@ -77,4 +81,31 @@ describe("WorkspaceAppCard", () => {
77
81
  expect(action?.className).not.toContain("opacity-0");
78
82
  }
79
83
  });
84
+
85
+ it("opens pending Builder apps in a new tab", async () => {
86
+ await act(async () => {
87
+ root.render(
88
+ <TooltipProvider>
89
+ <WorkspaceAppCard
90
+ app={{
91
+ id: "new-app",
92
+ name: "New app",
93
+ path: "/new-app",
94
+ builderUrl: "https://builder.example.com/projects/new-app",
95
+ status: "pending",
96
+ }}
97
+ />
98
+ </TooltipProvider>,
99
+ );
100
+ });
101
+
102
+ const appLink = container.querySelector<HTMLAnchorElement>(
103
+ 'a[aria-label="Open New app"]',
104
+ );
105
+ expect(appLink?.getAttribute("href")).toBe(
106
+ "https://builder.example.com/projects/new-app",
107
+ );
108
+ expect(appLink?.getAttribute("target")).toBe("_blank");
109
+ expect(appLink?.getAttribute("rel")).toBe("noreferrer");
110
+ });
80
111
  });
@@ -1,5 +1,4 @@
1
1
  import {
2
- agentNativePath,
3
2
  useActionMutation,
4
3
  useActionQuery,
5
4
  useT,
@@ -39,7 +38,7 @@ interface McpAccessState {
39
38
  }
40
39
 
41
40
  function dispatchMcpUrl(): string {
42
- const path = agentNativePath("/_agent-native/mcp");
41
+ const path = "/mcp";
43
42
  if (typeof window === "undefined") return path;
44
43
  return new URL(path, window.location.origin).href;
45
44
  }
@@ -33,10 +33,10 @@ beforeEach(() => {
33
33
  });
34
34
 
35
35
  describe("normalizeMcpAppAccessSettings", () => {
36
- it("defaults to Dispatch only", () => {
36
+ it("defaults to all apps when no access setting exists", () => {
37
37
  expect(normalizeMcpAppAccessSettings(null)).toEqual({
38
- mode: "selected-apps",
39
- selectedAppIds: ["dispatch"],
38
+ mode: "all-apps",
39
+ selectedAppIds: [],
40
40
  updatedAt: undefined,
41
41
  updatedBy: undefined,
42
42
  });
@@ -56,11 +56,10 @@ export function normalizeMcpAppAccessSettings(
56
56
  raw && typeof raw === "object" && !Array.isArray(raw)
57
57
  ? (raw as Record<string, unknown>)
58
58
  : {};
59
- const mode = record.mode === "all-apps" ? "all-apps" : "selected-apps";
59
+ const mode = record.mode === "selected-apps" ? "selected-apps" : "all-apps";
60
60
  return {
61
61
  mode,
62
- selectedAppIds:
63
- raw == null ? ["dispatch"] : uniqueAppIds(record.selectedAppIds),
62
+ selectedAppIds: uniqueAppIds(record.selectedAppIds),
64
63
  updatedAt:
65
64
  typeof record.updatedAt === "string" ? record.updatedAt : undefined,
66
65
  updatedBy:
@@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({
12
12
  managerStop: vi.fn(),
13
13
  managerCallTool: vi.fn(),
14
14
  managerConstructor: vi.fn(),
15
- callAgent: vi.fn(),
15
+ a2aConstructor: vi.fn(),
16
+ a2aSend: vi.fn(),
17
+ a2aGetTask: vi.fn(),
16
18
  signA2AToken: vi.fn(),
17
19
  getOrgA2ASecret: vi.fn(),
18
20
  getOrgDomain: vi.fn(),
@@ -40,7 +42,19 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
40
42
  });
41
43
 
42
44
  vi.mock("@agent-native/core/a2a", () => ({
43
- callAgent: mocks.callAgent,
45
+ A2AClient: class MockA2AClient {
46
+ constructor(...args: unknown[]) {
47
+ mocks.a2aConstructor(...args);
48
+ }
49
+
50
+ send(...args: unknown[]) {
51
+ return mocks.a2aSend(...args);
52
+ }
53
+
54
+ getTask(...args: unknown[]) {
55
+ return mocks.a2aGetTask(...args);
56
+ }
57
+ },
44
58
  signA2AToken: mocks.signA2AToken,
45
59
  }));
46
60
 
@@ -76,6 +90,7 @@ import { runWithRequestContext } from "@agent-native/core/server";
76
90
  import {
77
91
  createGrantedDispatchMcpEmbedSession,
78
92
  askGrantedDispatchMcpApp,
93
+ getGrantedDispatchMcpAppTask,
79
94
  listGrantedDispatchMcpApps,
80
95
  listGrantedDispatchMcpAppOrigins,
81
96
  openGrantedDispatchMcpApp,
@@ -91,6 +106,10 @@ const analyticsAgent = {
91
106
  };
92
107
 
93
108
  beforeEach(() => {
109
+ mocks.a2aConstructor.mockReset();
110
+ mocks.a2aSend.mockReset();
111
+ mocks.a2aGetTask.mockReset();
112
+ mocks.signA2AToken.mockReset();
94
113
  mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
95
114
  mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
96
115
  mocks.getOrgSetting.mockResolvedValue({ mode: "all-apps" });
@@ -106,19 +125,29 @@ beforeEach(() => {
106
125
  startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
107
126
  },
108
127
  });
109
- mocks.callAgent.mockResolvedValue("Created the requested dashboard.");
128
+ mocks.a2aSend.mockResolvedValue({
129
+ id: "task-1",
130
+ status: {
131
+ state: "completed",
132
+ message: {
133
+ role: "agent",
134
+ parts: [{ type: "text", text: "Created the requested dashboard." }],
135
+ },
136
+ },
137
+ });
110
138
  mocks.signA2AToken.mockResolvedValue("signed-token");
111
139
  mocks.getOrgA2ASecret.mockResolvedValue(null);
112
140
  mocks.getOrgDomain.mockResolvedValue(null);
113
141
  });
114
142
 
115
143
  afterEach(() => {
144
+ vi.useRealTimers();
116
145
  vi.unstubAllEnvs();
117
146
  vi.clearAllMocks();
118
147
  });
119
148
 
120
149
  describe("Dispatch MCP gateway app discovery", () => {
121
- it("defaults to exposing Dispatch only until an owner or admin grants more apps", async () => {
150
+ it("defaults to exposing every discovered app", async () => {
122
151
  mocks.getUserSetting.mockResolvedValue(null);
123
152
  const apps = await runWithRequestContext(
124
153
  {
@@ -128,7 +157,7 @@ describe("Dispatch MCP gateway app discovery", () => {
128
157
  () => listGrantedDispatchMcpApps(),
129
158
  );
130
159
 
131
- expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
160
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "analytics"]);
132
161
  });
133
162
 
134
163
  it("includes Dispatch itself so agents can target extension routes", async () => {
@@ -275,20 +304,258 @@ describe("askGrantedDispatchMcpApp", () => {
275
304
  ),
276
305
  );
277
306
 
278
- expect(mocks.callAgent).toHaveBeenCalledWith(
307
+ expect(mocks.a2aConstructor).toHaveBeenCalledWith(
279
308
  "http://localhost:8086",
280
- "Build a weekly active users dashboard.",
309
+ "signed-token",
310
+ { requestTimeoutMs: 10_000 },
311
+ );
312
+ expect(mocks.a2aSend).toHaveBeenCalledWith(
313
+ {
314
+ role: "user",
315
+ parts: [
316
+ { type: "text", text: "Build a weekly active users dashboard." },
317
+ ],
318
+ },
319
+ {
320
+ async: true,
321
+ metadata: {
322
+ userEmail: "owner@example.test",
323
+ orgDomain: "builder.io",
324
+ requestOrigin: "http://localhost:8092",
325
+ },
326
+ },
327
+ );
328
+ expect(result).toMatchObject({
329
+ app: "analytics",
330
+ routedVia: "a2a",
331
+ response: "Created the requested dashboard.",
332
+ taskId: "task-1",
333
+ status: "completed",
334
+ });
335
+ });
336
+
337
+ it("returns a durable polling handle when the downstream task is still working", async () => {
338
+ mocks.a2aSend.mockResolvedValueOnce({
339
+ id: "task-working",
340
+ status: { state: "working" },
341
+ });
342
+
343
+ const result = await runWithRequestContext(
281
344
  {
282
345
  userEmail: "owner@example.test",
283
- orgDomain: "builder.io",
284
- orgSecret: "org-specific-secret",
285
- timeoutMs: 5 * 60_000,
346
+ requestOrigin: "http://localhost:8092",
286
347
  },
348
+ () =>
349
+ askGrantedDispatchMcpApp("analytics", "Build the report.", {
350
+ async: true,
351
+ }),
287
352
  );
353
+
288
354
  expect(result).toEqual({
289
355
  app: "analytics",
290
356
  routedVia: "a2a",
291
- response: "Created the requested dashboard.",
357
+ taskId: "task-working",
358
+ status: "working",
359
+ pollAfterMs: 1_500,
360
+ poll: {
361
+ tool: "ask_app_status",
362
+ arguments: { app: "analytics", taskId: "task-working" },
363
+ },
364
+ message:
365
+ 'ask_app is still working. Call ask_app_status with taskId "task-working" to retrieve the final response.',
366
+ });
367
+ });
368
+
369
+ it("counts submission and every poll against one inline deadline", async () => {
370
+ vi.useFakeTimers();
371
+ vi.setSystemTime(0);
372
+ mocks.a2aSend.mockImplementationOnce(
373
+ () =>
374
+ new Promise((resolve) => {
375
+ setTimeout(
376
+ () =>
377
+ resolve({
378
+ id: "task-deadline",
379
+ status: { state: "working" },
380
+ }),
381
+ 3_000,
382
+ );
383
+ }),
384
+ );
385
+ mocks.a2aGetTask.mockImplementationOnce(() => new Promise(() => undefined));
386
+
387
+ const resultPromise = runWithRequestContext(
388
+ {
389
+ userEmail: "owner@example.test",
390
+ requestOrigin: "http://localhost:8092",
391
+ },
392
+ () =>
393
+ askGrantedDispatchMcpApp("analytics", "Build the report.", {
394
+ maxWaitMs: 5_000,
395
+ }),
396
+ );
397
+
398
+ await vi.advanceTimersByTimeAsync(5_000);
399
+
400
+ await expect(resultPromise).resolves.toMatchObject({
401
+ taskId: "task-deadline",
402
+ status: "working",
403
+ pollAfterMs: 1_500,
404
+ });
405
+ expect(Date.now()).toBe(5_000);
406
+ expect(mocks.a2aConstructor).toHaveBeenCalledWith(
407
+ "http://localhost:8086",
408
+ undefined,
409
+ { requestTimeoutMs: 5_000 },
410
+ );
411
+ expect(mocks.a2aGetTask).toHaveBeenCalledTimes(1);
412
+ });
413
+
414
+ it.each([401, 403, 404])(
415
+ "surfaces permanent %s status errors without waiting until the deadline",
416
+ async (statusCode) => {
417
+ vi.useFakeTimers();
418
+ vi.setSystemTime(0);
419
+ mocks.a2aSend.mockResolvedValueOnce({
420
+ id: "task-missing",
421
+ status: { state: "working" },
422
+ });
423
+ mocks.a2aGetTask.mockRejectedValueOnce(
424
+ new Error(`A2A request failed (${statusCode}): Task not found`),
425
+ );
426
+
427
+ const resultPromise = runWithRequestContext(
428
+ {
429
+ userEmail: "owner@example.test",
430
+ requestOrigin: "http://localhost:8092",
431
+ },
432
+ () =>
433
+ askGrantedDispatchMcpApp("analytics", "Build the report.", {
434
+ maxWaitMs: 20_000,
435
+ }),
436
+ );
437
+ const rejection = resultPromise.catch((err) => err);
438
+
439
+ await vi.advanceTimersByTimeAsync(0);
440
+ await vi.advanceTimersByTimeAsync(1_500);
441
+
442
+ await expect(rejection).resolves.toEqual(
443
+ expect.objectContaining({
444
+ message: expect.stringMatching(
445
+ new RegExp(`${statusCode}.*not found`, "i"),
446
+ ),
447
+ }),
448
+ );
449
+ expect(Date.now()).toBe(1_500);
450
+ expect(mocks.a2aGetTask).toHaveBeenCalledTimes(1);
451
+ },
452
+ );
453
+
454
+ it("retries transient status failures within the same deadline", async () => {
455
+ vi.useFakeTimers();
456
+ vi.setSystemTime(0);
457
+ mocks.a2aSend.mockResolvedValueOnce({
458
+ id: "task-transient",
459
+ status: { state: "working" },
460
+ });
461
+ mocks.a2aGetTask
462
+ .mockRejectedValueOnce(new Error("A2A request failed (503): retry"))
463
+ .mockResolvedValueOnce({
464
+ id: "task-transient",
465
+ status: {
466
+ state: "completed",
467
+ message: {
468
+ role: "agent",
469
+ parts: [{ type: "text", text: "The report is ready." }],
470
+ },
471
+ },
472
+ });
473
+
474
+ const resultPromise = runWithRequestContext(
475
+ {
476
+ userEmail: "owner@example.test",
477
+ requestOrigin: "http://localhost:8092",
478
+ },
479
+ () =>
480
+ askGrantedDispatchMcpApp("analytics", "Build the report.", {
481
+ maxWaitMs: 20_000,
482
+ }),
483
+ );
484
+
485
+ await vi.advanceTimersByTimeAsync(0);
486
+ await vi.advanceTimersByTimeAsync(3_000);
487
+
488
+ await expect(resultPromise).resolves.toMatchObject({
489
+ taskId: "task-transient",
490
+ status: "completed",
491
+ response: "The report is ready.",
492
+ });
493
+ expect(mocks.a2aGetTask).toHaveBeenCalledTimes(2);
494
+ });
495
+
496
+ it("returns input-required as a terminal handoff instead of a poll loop", async () => {
497
+ mocks.a2aSend.mockResolvedValueOnce({
498
+ id: "task-input",
499
+ status: {
500
+ state: "input-required",
501
+ message: {
502
+ role: "agent",
503
+ parts: [{ type: "text", text: "Which date range should I use?" }],
504
+ },
505
+ },
506
+ });
507
+
508
+ const result = await runWithRequestContext(
509
+ {
510
+ userEmail: "owner@example.test",
511
+ requestOrigin: "http://localhost:8092",
512
+ },
513
+ () => askGrantedDispatchMcpApp("analytics", "Build the report."),
514
+ );
515
+
516
+ expect(result).toEqual({
517
+ app: "analytics",
518
+ routedVia: "a2a",
519
+ taskId: "task-input",
520
+ status: "input-required",
521
+ response: "Which date range should I use?",
522
+ inputRequired: "Which date range should I use?",
523
+ message: "Which date range should I use?",
524
+ });
525
+ expect(mocks.a2aGetTask).not.toHaveBeenCalled();
526
+ });
527
+
528
+ it("polls a granted app task through the same authenticated A2A route", async () => {
529
+ mocks.a2aGetTask.mockResolvedValueOnce({
530
+ id: "task-working",
531
+ status: {
532
+ state: "completed",
533
+ message: {
534
+ role: "agent",
535
+ parts: [{ type: "text", text: "The report is ready." }],
536
+ },
537
+ },
538
+ });
539
+ mocks.getUserSetting.mockResolvedValue({
540
+ mode: "selected-apps",
541
+ selectedAppIds: ["analytics"],
542
+ });
543
+
544
+ const result = await runWithRequestContext(
545
+ {
546
+ userEmail: "owner@example.test",
547
+ requestOrigin: "http://localhost:8092",
548
+ },
549
+ () => getGrantedDispatchMcpAppTask("analytics", "task-working"),
550
+ );
551
+
552
+ expect(mocks.a2aGetTask).toHaveBeenCalledWith("task-working");
553
+ expect(result).toEqual({
554
+ app: "analytics",
555
+ routedVia: "a2a",
556
+ taskId: "task-working",
557
+ status: "completed",
558
+ response: "The report is ready.",
292
559
  });
293
560
  });
294
561
 
@@ -307,7 +574,25 @@ describe("askGrantedDispatchMcpApp", () => {
307
574
  () => askGrantedDispatchMcpApp("analytics", "Show signups."),
308
575
  ),
309
576
  ).rejects.toThrow(/not granted/);
310
- expect(mocks.callAgent).not.toHaveBeenCalled();
577
+ expect(mocks.a2aSend).not.toHaveBeenCalled();
578
+ });
579
+
580
+ it("rejects polling a task for an app outside the grant", async () => {
581
+ mocks.getUserSetting.mockResolvedValue({
582
+ mode: "selected-apps",
583
+ selectedAppIds: ["dispatch"],
584
+ });
585
+
586
+ await expect(
587
+ runWithRequestContext(
588
+ {
589
+ userEmail: "owner@example.test",
590
+ requestOrigin: "http://localhost:8092",
591
+ },
592
+ () => getGrantedDispatchMcpAppTask("analytics", "task-working"),
593
+ ),
594
+ ).rejects.toThrow(/not granted/);
595
+ expect(mocks.a2aGetTask).not.toHaveBeenCalled();
311
596
  });
312
597
  });
313
598
 
@@ -547,7 +832,7 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
547
832
  expect(mocks.managerConstructor).toHaveBeenCalledWith({
548
833
  servers: {
549
834
  target: expect.objectContaining({
550
- url: "http://localhost:8092/analytics/_agent-native/mcp",
835
+ url: "http://localhost:8092/analytics/mcp",
551
836
  }),
552
837
  },
553
838
  });
@@ -596,7 +881,7 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
596
881
  expect(mocks.managerConstructor).toHaveBeenCalledWith({
597
882
  servers: {
598
883
  target: expect.objectContaining({
599
- url: "https://mail.agent-native.com/_agent-native/mcp",
884
+ url: "https://mail.agent-native.com/mcp",
600
885
  }),
601
886
  },
602
887
  });