@axiom-lattice/gateway 2.1.116 → 3.0.0

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 (34) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +37 -0
  3. package/dist/{a2a-ERG5RMUW.mjs → a2a-QMHEH3ZT.mjs} +77 -29
  4. package/dist/a2a-QMHEH3ZT.mjs.map +1 -0
  5. package/dist/index.js +1144 -177
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1013 -92
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +6 -5
  10. package/src/__tests__/a2a.test.ts +82 -0
  11. package/src/__tests__/workspace.test.ts +13 -3
  12. package/src/config/local-a2a-templates.ts +59 -0
  13. package/src/controllers/__tests__/assistant-local-a2a.test.ts +147 -0
  14. package/src/controllers/__tests__/local-a2a-templates.test.ts +112 -0
  15. package/src/controllers/assistant.ts +126 -4
  16. package/src/controllers/local-a2a-templates.ts +18 -0
  17. package/src/controllers/local-a2a.ts +149 -0
  18. package/src/controllers/tasks.ts +2 -3
  19. package/src/controllers/workspace.ts +144 -17
  20. package/src/index.ts +2 -10
  21. package/src/routes/__tests__/a2a-test-connection.test.ts +177 -0
  22. package/src/routes/a2a-test-connection.ts +126 -0
  23. package/src/routes/a2a.ts +100 -28
  24. package/src/routes/index.ts +27 -0
  25. package/src/routes/stt.ts +102 -0
  26. package/src/services/__tests__/local-a2a.test.ts +124 -0
  27. package/src/services/eval-runner.ts +6 -0
  28. package/src/services/local-a2a.ts +352 -0
  29. package/src/utils/__tests__/local-a2a-runtime.test.ts +77 -0
  30. package/src/utils/__tests__/path-containment.test.ts +35 -0
  31. package/src/utils/a2a-url.ts +20 -0
  32. package/src/utils/local-a2a-runtime.ts +107 -0
  33. package/src/utils/path-containment.ts +38 -0
  34. package/dist/a2a-ERG5RMUW.mjs.map +0 -1
@@ -0,0 +1,149 @@
1
+ import { FastifyReply, FastifyRequest } from "fastify";
2
+ import { getStoreLattice } from "@axiom-lattice/core";
3
+ import { localA2AService } from "../services/local-a2a";
4
+ import {
5
+ AgentType,
6
+ type Assistant,
7
+ type A2ARemoteAgentConfig,
8
+ } from "@axiom-lattice/protocols";
9
+
10
+ function getTenantId(request: FastifyRequest): string {
11
+ const userTenantId = (request as any).user?.tenantId;
12
+ if (userTenantId) return userTenantId;
13
+ return (request.headers["x-tenant-id"] as string) || "default";
14
+ }
15
+
16
+ function getLocalRuntime(assistant: Assistant): A2ARemoteAgentConfig["localRuntime"] | undefined {
17
+ const graphDefinition = assistant.graphDefinition;
18
+ if (!graphDefinition || typeof graphDefinition !== "object") return undefined;
19
+ if ((graphDefinition as { type?: unknown }).type !== AgentType.A2A_REMOTE) return undefined;
20
+ return (graphDefinition as A2ARemoteAgentConfig).localRuntime;
21
+ }
22
+
23
+ async function getLocalA2AAssistants(
24
+ tenantId: string,
25
+ ): Promise<Array<{ assistant: Assistant; localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]> }>> {
26
+ const storeLattice = getStoreLattice("default", "assistant");
27
+ const assistants = await storeLattice.store.getAllAssistants(tenantId);
28
+ const results: Array<{
29
+ assistant: Assistant;
30
+ localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]>;
31
+ }> = [];
32
+
33
+ for (const assistant of assistants) {
34
+ const runtime = getLocalRuntime(assistant);
35
+ if (runtime) {
36
+ results.push({ assistant, localRuntime: runtime });
37
+ }
38
+ }
39
+
40
+ return results;
41
+ }
42
+
43
+ async function getLocalA2AAssistant(
44
+ tenantId: string,
45
+ assistantId: string,
46
+ ): Promise<{
47
+ assistant: Assistant;
48
+ localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]>;
49
+ } | null> {
50
+ const storeLattice = getStoreLattice("default", "assistant");
51
+ const assistant = await storeLattice.store.getAssistantById(tenantId, assistantId);
52
+ if (!assistant) return null;
53
+
54
+ const runtime = getLocalRuntime(assistant);
55
+ if (!runtime) return null;
56
+
57
+ return { assistant, localRuntime: runtime };
58
+ }
59
+
60
+ async function requireManagedAssistant(
61
+ request: FastifyRequest<{ Params: { assistantId: string } }>,
62
+ reply: FastifyReply,
63
+ ): Promise<{
64
+ assistantId: string;
65
+ localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]>;
66
+ } | null> {
67
+ const assistantId = request.params.assistantId;
68
+ if (!assistantId) {
69
+ reply.status(400).send({ success: false, error: "assistantId is required" });
70
+ return null;
71
+ }
72
+
73
+ const registered = await getLocalA2AAssistant(getTenantId(request), assistantId);
74
+ if (!registered) {
75
+ reply.status(404).send({
76
+ success: false,
77
+ error: `Assistant ${assistantId} is not a local managed A2A assistant`,
78
+ });
79
+ return null;
80
+ }
81
+
82
+ return { assistantId, localRuntime: registered.localRuntime };
83
+ }
84
+
85
+ export async function getLocalA2AStatus(
86
+ request: FastifyRequest,
87
+ reply: FastifyReply,
88
+ ): Promise<void> {
89
+ const tenantId = getTenantId(request);
90
+ const managed = await getLocalA2AAssistants(tenantId);
91
+ const states = await Promise.all(
92
+ managed.map(({ assistant, localRuntime }) =>
93
+ localA2AService.getRegisteredState(assistant.id, localRuntime),
94
+ ),
95
+ );
96
+ reply.send({ success: true, data: { records: states, total: states.length } });
97
+ }
98
+
99
+ export async function getLocalA2AProvider(
100
+ request: FastifyRequest<{ Params: { assistantId: string } }>,
101
+ reply: FastifyReply,
102
+ ): Promise<void> {
103
+ const registered = await requireManagedAssistant(request, reply);
104
+ if (!registered) return;
105
+ const state = await localA2AService.getRegisteredState(
106
+ registered.assistantId,
107
+ registered.localRuntime,
108
+ );
109
+ reply.send({ success: true, data: state });
110
+ }
111
+
112
+ export async function startLocalA2AProvider(
113
+ request: FastifyRequest<{ Params: { assistantId: string } }>,
114
+ reply: FastifyReply,
115
+ ): Promise<void> {
116
+ const registered = await requireManagedAssistant(request, reply);
117
+ if (!registered) return;
118
+
119
+ const state = await localA2AService.startProvider(
120
+ registered.assistantId,
121
+ registered.localRuntime,
122
+ );
123
+ reply.send({ success: true, data: state });
124
+ }
125
+
126
+ export async function stopLocalA2AProvider(
127
+ request: FastifyRequest<{ Params: { assistantId: string } }>,
128
+ reply: FastifyReply,
129
+ ): Promise<void> {
130
+ const registered = await requireManagedAssistant(request, reply);
131
+ if (!registered) return;
132
+
133
+ const state = await localA2AService.stopProvider(registered.assistantId);
134
+ reply.send({ success: true, data: state });
135
+ }
136
+
137
+ export async function restartLocalA2AProvider(
138
+ request: FastifyRequest<{ Params: { assistantId: string } }>,
139
+ reply: FastifyReply,
140
+ ): Promise<void> {
141
+ const registered = await requireManagedAssistant(request, reply);
142
+ if (!registered) return;
143
+
144
+ const state = await localA2AService.restartProvider(
145
+ registered.assistantId,
146
+ registered.localRuntime,
147
+ );
148
+ reply.send({ success: true, data: state });
149
+ }
@@ -25,16 +25,15 @@ export async function listTasks(
25
25
  ): Promise<{ success: boolean; data?: TaskItem[]; count?: number; error?: string }> {
26
26
  try {
27
27
  const tenantId = getTenantId(request);
28
- const userId = getUserId(request);
29
28
  const query = request.query as Record<string, string>;
30
29
 
31
30
  const store = getStore();
32
31
  const tasks = await store.list({
33
32
  tenantId,
34
- ownerId: query.ownerId || userId,
35
33
  status: query.status,
36
34
  priority: query.priority,
37
- ownerType: query.ownerType as 'user' | 'agent' | undefined,
35
+ ...(query.ownerId && { ownerId: query.ownerId }),
36
+ ...(query.ownerType && { ownerType: query.ownerType as 'user' | 'agent' }),
38
37
  metadata: query.metadata ? JSON.parse(query.metadata) : undefined,
39
38
  projectId: query.projectId,
40
39
  limit: query.limit ? parseInt(query.limit) : undefined,
@@ -8,11 +8,18 @@
8
8
  import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
9
9
  import * as fs from "fs/promises";
10
10
  import * as path from "path";
11
+ import { constants as fsConstants } from "fs";
12
+ import * as os from "os";
11
13
  import { getStoreLattice } from "@axiom-lattice/core";
12
14
  import { SandboxFilesystem, FilesystemBackend } from "@axiom-lattice/core";
13
15
  import { getSandBoxManager } from "@axiom-lattice/core";
14
16
  import { v4 as uuidv4 } from "uuid";
15
17
  import { getContentTypeFromFilename, getFilenameFromPath } from "../utils/mime";
18
+ import {
19
+ isPathWithinRoot,
20
+ PathOutsideRootError,
21
+ resolvePathWithinRoot,
22
+ } from "../utils/path-containment";
16
23
  import type {
17
24
  CreateWorkspaceRequest,
18
25
  UpdateWorkspaceRequest,
@@ -291,15 +298,30 @@ export class WorkspaceController {
291
298
  sandboxInstance: sandbox,
292
299
  }), workspace
293
300
  };
294
- } else {
295
- console.log(`[getBackend] using FilesystemBackend rootDir=/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`);
296
- return {
297
- backend: new FilesystemBackend({
298
- rootDir: `/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`,
299
- virtualMode: true,
300
- }), workspace
301
- };
302
301
  }
302
+
303
+ // Filesystem storage: use default /lattice_store path
304
+ const rootDir = await this.getFilesystemRootDirectory(
305
+ tenantId,
306
+ workspaceId,
307
+ projectId,
308
+ );
309
+
310
+ console.log(`[getBackend] using FilesystemBackend rootDir=${rootDir}`);
311
+ return {
312
+ backend: new FilesystemBackend({
313
+ rootDir,
314
+ virtualMode: true,
315
+ }), workspace
316
+ };
317
+ }
318
+
319
+ private async getFilesystemRootDirectory(
320
+ tenantId: string,
321
+ workspaceId: string,
322
+ projectId: string,
323
+ ): Promise<string> {
324
+ return `/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`;
303
325
  }
304
326
 
305
327
  private isBinaryContentType(filename: string): boolean {
@@ -572,7 +594,7 @@ export class WorkspaceController {
572
594
  * - `path`: optional directory path relative to project root (e.g., "docs", "src/utils")
573
595
  * - `assistantId`: optional query parameter for sandbox identity matching
574
596
  * For sandbox storage, delegates to the sandbox upload API.
575
- * For filesystem storage, writes directly under /lattice_store/workspaces/{workspaceId}/{projectId}.
597
+ * For filesystem storage, writes under the same project root used by the file backend.
576
598
  */
577
599
  async uploadFile(
578
600
  request: FastifyRequest<{
@@ -661,16 +683,46 @@ export class WorkspaceController {
661
683
  return reply.status(200).send({ success: true, data: result });
662
684
  }
663
685
 
664
- // Filesystem storage: write under /lattice_store/tenants/{tenantId}/workspaces/{workspaceId}/{projectId} and expose as virtual path "/<filename>"
665
- const rootDir = `/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`;
666
- const targetDir = pathValue ? path.join(rootDir, pathValue) : rootDir;
667
- const targetPath = path.join(targetDir, filename);
668
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
686
+ const rootDir = await this.getFilesystemRootDirectory(
687
+ tenantId,
688
+ workspaceId,
689
+ projectId,
690
+ );
691
+ await fs.mkdir(rootDir, { recursive: true });
692
+ const relativeDirectory = pathValue?.replace(/^[/\\]+/, "") || ".";
693
+ const targetDir = path.resolve(rootDir, relativeDirectory);
694
+ if (!isPathWithinRoot(path.resolve(rootDir), targetDir)) {
695
+ return reply.status(400).send({
696
+ success: false,
697
+ error: "Upload path must stay within the project directory",
698
+ });
699
+ }
700
+ await fs.mkdir(targetDir, { recursive: true });
701
+
702
+ const [resolvedRoot, resolvedTargetDir] = await Promise.all([
703
+ fs.realpath(rootDir),
704
+ fs.realpath(targetDir),
705
+ ]);
706
+ if (!isPathWithinRoot(resolvedRoot, resolvedTargetDir)) {
707
+ return reply.status(400).send({
708
+ success: false,
709
+ error: "Upload path must stay within the project directory",
710
+ });
711
+ }
712
+
713
+ const safeFilename = path.basename(filename);
714
+ if (safeFilename !== filename) {
715
+ return reply.status(400).send({
716
+ success: false,
717
+ error: "Invalid filename",
718
+ });
719
+ }
720
+ const targetPath = path.join(resolvedTargetDir, safeFilename);
669
721
  await fs.writeFile(targetPath, buffer);
670
722
  const stat = await fs.stat(targetPath);
671
723
 
672
724
  const result = {
673
- path: pathValue ? `/${pathValue}/${filename}` : `/${filename}`,
725
+ path: path.posix.join("/", relativeDirectory, safeFilename),
674
726
  name: filename,
675
727
  is_dir: false,
676
728
  size: stat.size,
@@ -678,10 +730,79 @@ export class WorkspaceController {
678
730
  };
679
731
 
680
732
  return reply.status(200).send({ success: true, data: result });
681
- } catch (error: any) {
733
+ } catch (error: unknown) {
734
+ const message = error instanceof Error ? error.message : String(error);
682
735
  return reply.status(500).send({
683
736
  success: false,
684
- error: `Upload handler error: ${error?.message || String(error)}`,
737
+ error: `Upload handler error: ${message}`,
738
+ });
739
+ }
740
+ }
741
+ /**
742
+ * Browse local filesystem directories for the folder picker.
743
+ *
744
+ * Security constraints:
745
+ * - Restricted to os.homedir()
746
+ * - Blacklists system directories
747
+ * - Returns only directory names, no file contents or hidden files
748
+ */
749
+ async browseFilesystem(
750
+ request: FastifyRequest<{
751
+ Querystring: { path?: string };
752
+ }>,
753
+ reply: FastifyReply
754
+ ) {
755
+ const homeDir = os.homedir();
756
+ const requestedPath = request.query.path || homeDir;
757
+
758
+ const SYSTEM_BLACKLIST = ["/etc", "/sys", "/proc", "/var", "/tmp", "/dev"];
759
+
760
+ try {
761
+ const resolved = await resolvePathWithinRoot(homeDir, requestedPath);
762
+ const normalized = resolved.targetPath;
763
+
764
+ for (const blacklisted of SYSTEM_BLACKLIST) {
765
+ if (isPathWithinRoot(blacklisted, normalized)) {
766
+ return reply.status(403).send({
767
+ success: false,
768
+ error: `Cannot browse system directory: ${blacklisted}`,
769
+ });
770
+ }
771
+ }
772
+
773
+ const entries = await fs.readdir(normalized, { withFileTypes: true });
774
+ const dirEntries = entries
775
+ .filter((entry) => !entry.name.startsWith(".") && entry.isDirectory())
776
+ .map((entry) => ({
777
+ name: entry.name,
778
+ path: path.join(normalized, entry.name),
779
+ isDirectory: true,
780
+ }));
781
+
782
+ const parentPath = path.dirname(normalized);
783
+ const hasParent =
784
+ parentPath !== normalized &&
785
+ isPathWithinRoot(resolved.rootPath, parentPath);
786
+
787
+ return {
788
+ success: true,
789
+ data: {
790
+ path: normalized,
791
+ parentPath: hasParent ? parentPath : undefined,
792
+ entries: dirEntries,
793
+ },
794
+ };
795
+ } catch (error: unknown) {
796
+ if (error instanceof PathOutsideRootError) {
797
+ return reply.status(403).send({
798
+ success: false,
799
+ error: "Browse restricted to home directory",
800
+ });
801
+ }
802
+ const message = error instanceof Error ? error.message : String(error);
803
+ return reply.status(400).send({
804
+ success: false,
805
+ error: `Cannot read directory: ${message}`,
685
806
  });
686
807
  }
687
808
  }
@@ -749,4 +870,10 @@ export function registerWorkspaceRoutes(app: FastifyInstance) {
749
870
  "/api/workspaces/:workspaceId/projects/:projectId/viewfile",
750
871
  controller.viewFile.bind(controller)
751
872
  );
873
+
874
+ // Filesystem browsing for folder picker
875
+ app.get(
876
+ "/api/filesystem/browse",
877
+ controller.browseFilesystem.bind(controller)
878
+ );
752
879
  }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import "dotenv/config";
1
2
  import fastify from "fastify";
2
3
  import cors from "@fastify/cors";
3
4
  import multipart from "@fastify/multipart";
@@ -42,8 +43,6 @@ import {
42
43
  PinoFileOptions,
43
44
  McpServerConfigStore,
44
45
  } from "@axiom-lattice/protocols";
45
-
46
-
47
46
  process.on("unhandledRejection", (reason, promise) => {
48
47
  console.error("未处理的Promise拒绝:", reason);
49
48
  // 可以在这里进行日志记录或其他处理
@@ -112,7 +111,7 @@ const getHeaderValue = (
112
111
  };
113
112
 
114
113
  // Routes that don't require authentication even when AUTH_REQUIRED=true
115
- const PUBLIC_ROUTES = ["/api/auth/login", "/api/auth/register", "/health"];
114
+ const PUBLIC_ROUTES = ["/api/auth/login", "/api/auth/register", "/health", "/api/stt/models"];
116
115
 
117
116
  // Global auth preHandler: extract user from Authorization header
118
117
  // When AUTH_REQUIRED=true, reject unauthenticated requests (except public routes)
@@ -455,13 +454,6 @@ const start = async (config?: LatticeGatewayConfig) => {
455
454
  await app.listen({ port: target_port, host: "0.0.0.0" });
456
455
  logger.info(`Lattice Gateway is running on port: ${target_port}`);
457
456
 
458
- // Initialize AgentLifecycleManager
459
- try {
460
- logger.info("AgentLifecycleManager initialized");
461
- } catch (error) {
462
- logger.warn("Failed to initialize AgentLifecycleManager", { error });
463
- }
464
-
465
457
  // Configure queue service
466
458
  const queueServiceConfig = config?.queueServiceConfig;
467
459
  if (queueServiceConfig) {
@@ -0,0 +1,177 @@
1
+ import fastify, { FastifyInstance } from "fastify";
2
+ import type { LocalRuntimeConfig } from "@axiom-lattice/protocols";
3
+ import { registerA2ATestConnectionRoute } from "../a2a-test-connection";
4
+
5
+ const mockStartProvider = jest.fn();
6
+ const mockRemoveState = jest.fn();
7
+
8
+ jest.mock("../../services/local-a2a", () => ({
9
+ localA2AService: {
10
+ startProvider: (...args: unknown[]) => mockStartProvider(...args),
11
+ removeState: (...args: unknown[]) => mockRemoveState(...args),
12
+ },
13
+ }));
14
+
15
+ describe("A2A test connection route", () => {
16
+ let app: FastifyInstance;
17
+ const originalFetch = global.fetch;
18
+ const mockFetch = jest.fn<(...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>>();
19
+
20
+ const localRuntime: LocalRuntimeConfig = {
21
+ label: "Local Test",
22
+ agentCardUrl: "http://old-host/.well-known/agent-card.json",
23
+ command: {
24
+ executable: "local-a2a",
25
+ args: ["--port", "4301"],
26
+ },
27
+ };
28
+
29
+ beforeEach(() => {
30
+ jest.clearAllMocks();
31
+ global.fetch = mockFetch as unknown as typeof fetch;
32
+ app = fastify();
33
+ registerA2ATestConnectionRoute(app);
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await app.close();
38
+ global.fetch = originalFetch;
39
+ });
40
+
41
+ it("starts a temporary local runtime before fetching the local agent card", async () => {
42
+ mockStartProvider.mockResolvedValueOnce({
43
+ runtimeId: "test-runtime",
44
+ label: "Local Test",
45
+ status: "running",
46
+ enabled: true,
47
+ });
48
+ mockFetch.mockResolvedValueOnce(
49
+ new Response(JSON.stringify({ name: "Local Agent" }), { status: 200 }),
50
+ );
51
+
52
+ const response = await app.inject({
53
+ method: "POST",
54
+ url: "/api/a2a/test-connection",
55
+ payload: {
56
+ agentCardUrl: "localhost:4301",
57
+ localRuntime,
58
+ },
59
+ });
60
+
61
+ expect(response.statusCode).toBe(200);
62
+ const body = JSON.parse(response.body);
63
+ expect(body.data.reachable).toBe(true);
64
+ expect(body.data.agentName).toBe("Local Agent");
65
+
66
+ expect(mockStartProvider).toHaveBeenCalledTimes(1);
67
+ const [runtimeId, startedRuntime] = mockStartProvider.mock.calls[0] as [
68
+ string,
69
+ LocalRuntimeConfig,
70
+ ];
71
+ expect(runtimeId).toMatch(/^test-/);
72
+ expect(startedRuntime.agentCardUrl).toBe(
73
+ "http://localhost:4301/.well-known/agent-card.json",
74
+ );
75
+
76
+ expect(mockFetch).toHaveBeenCalledWith(
77
+ "http://localhost:4301/.well-known/agent-card.json",
78
+ expect.objectContaining({ headers: {} }),
79
+ );
80
+ expect(mockRemoveState).toHaveBeenCalledWith("test-runtime");
81
+ });
82
+
83
+ it("uses https by default for remote URL tests and does not start a local runtime", async () => {
84
+ mockFetch.mockResolvedValueOnce(
85
+ new Response(JSON.stringify({ name: "Remote Agent" }), { status: 200 }),
86
+ );
87
+
88
+ const response = await app.inject({
89
+ method: "POST",
90
+ url: "/api/a2a/test-connection",
91
+ payload: {
92
+ agentCardUrl: "example.com/a2a",
93
+ apiKey: "secret",
94
+ },
95
+ });
96
+
97
+ expect(response.statusCode).toBe(200);
98
+ const body = JSON.parse(response.body);
99
+ expect(body.data.reachable).toBe(true);
100
+ expect(body.data.agentName).toBe("Remote Agent");
101
+ expect(mockStartProvider).not.toHaveBeenCalled();
102
+ expect(mockRemoveState).not.toHaveBeenCalled();
103
+ expect(mockFetch).toHaveBeenCalledWith(
104
+ "https://example.com/a2a/.well-known/agent-card.json",
105
+ expect.objectContaining({
106
+ headers: { Authorization: "Bearer secret" },
107
+ }),
108
+ );
109
+ });
110
+
111
+ it("does not fetch the agent card when the local runtime fails to start", async () => {
112
+ mockStartProvider.mockResolvedValueOnce({
113
+ runtimeId: "test-failed",
114
+ label: "Local Test",
115
+ status: "failed",
116
+ enabled: true,
117
+ message: "Server did not become ready",
118
+ });
119
+
120
+ const response = await app.inject({
121
+ method: "POST",
122
+ url: "/api/a2a/test-connection",
123
+ payload: {
124
+ agentCardUrl: "localhost:4301",
125
+ localRuntime,
126
+ },
127
+ });
128
+
129
+ expect(response.statusCode).toBe(200);
130
+ const body = JSON.parse(response.body);
131
+ expect(body.data.reachable).toBe(false);
132
+ expect(body.data.error).toContain("Server did not become ready");
133
+ expect(mockFetch).not.toHaveBeenCalled();
134
+ expect(mockRemoveState).toHaveBeenCalledWith("test-failed");
135
+ });
136
+
137
+ it.each([
138
+ ["--port with next arg", ["--port", "4302"]],
139
+ ["--port inline", ["--port=4302"]],
140
+ ["-p with next arg", ["-p", "4302"]],
141
+ ["-p inline", ["-p=4302"]],
142
+ ])("does not start local runtime when %s mismatches the agent card URL port", async (_label, args) => {
143
+ const response = await app.inject({
144
+ method: "POST",
145
+ url: "/api/a2a/test-connection",
146
+ payload: {
147
+ agentCardUrl: "http://localhost:4301/.well-known/agent-card.json",
148
+ localRuntime: {
149
+ ...localRuntime,
150
+ command: {
151
+ executable: "local-a2a",
152
+ args,
153
+ },
154
+ },
155
+ },
156
+ });
157
+
158
+ expect(response.statusCode).toBe(200);
159
+ const body = JSON.parse(response.body);
160
+ expect(body.data.reachable).toBe(false);
161
+ expect(body.data.error).toContain("command port (4302) does not match agentCardUrl port (4301)");
162
+ expect(mockStartProvider).not.toHaveBeenCalled();
163
+ expect(mockFetch).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it("returns 400 when agentCardUrl is missing", async () => {
167
+ const response = await app.inject({
168
+ method: "POST",
169
+ url: "/api/a2a/test-connection",
170
+ payload: {},
171
+ });
172
+
173
+ expect(response.statusCode).toBe(400);
174
+ expect(JSON.parse(response.body).error).toBe("agentCardUrl is required");
175
+ expect(mockFetch).not.toHaveBeenCalled();
176
+ });
177
+ });