@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,126 @@
1
+ import { FastifyInstance } from "fastify";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { AgentCard, LocalRuntimeConfig } from "@axiom-lattice/protocols";
4
+ import { localA2AService } from "../services/local-a2a";
5
+ import { normalizeA2AAgentCardUrl } from "../utils/a2a-url";
6
+ import {
7
+ validateLocalRuntimeCommand,
8
+ validateLocalRuntimePort,
9
+ } from "../utils/local-a2a-runtime";
10
+
11
+ type TestConnectionError = Error & {
12
+ code?: string;
13
+ cause?: unknown;
14
+ };
15
+
16
+ function describeTestConnectionError(error: unknown, agentCardUrl: string): string {
17
+ if (!(error instanceof Error)) {
18
+ return `Failed to fetch agent card from ${agentCardUrl}: ${String(error)}`;
19
+ }
20
+
21
+ const cause = (error as TestConnectionError).cause;
22
+ const causeParts: string[] = [];
23
+ if (cause instanceof Error) {
24
+ causeParts.push(cause.message);
25
+ const causeCode = (cause as TestConnectionError).code;
26
+ if (causeCode && !causeParts.some((part) => part.includes(causeCode))) {
27
+ causeParts.push(causeCode);
28
+ }
29
+ } else if (typeof cause === "object" && cause !== null) {
30
+ const code = "code" in cause ? String(cause.code) : "";
31
+ const message = "message" in cause ? String(cause.message) : "";
32
+ if (message) causeParts.push(message);
33
+ if (code && !message.includes(code)) causeParts.push(code);
34
+ }
35
+
36
+ const detail = causeParts.length > 0 ? ` (${causeParts.join(", ")})` : "";
37
+ return `Failed to fetch agent card from ${agentCardUrl}: ${error.message}${detail}`;
38
+ }
39
+
40
+ async function ensureLocalRuntimeForConnectionTest(
41
+ localRuntime: LocalRuntimeConfig,
42
+ agentCardUrl: string,
43
+ ): Promise<{ runtimeId: string } | { error: string }> {
44
+ const commandValidation = validateLocalRuntimeCommand(localRuntime);
45
+ if (!commandValidation.ok) {
46
+ return { error: commandValidation.error };
47
+ }
48
+ const testRuntime = {
49
+ ...localRuntime,
50
+ agentCardUrl,
51
+ command: {
52
+ ...localRuntime.command,
53
+ executable: localRuntime.command.executable.trim(),
54
+ },
55
+ };
56
+ const portValidation = validateLocalRuntimePort(testRuntime, agentCardUrl);
57
+ if (!portValidation.ok) {
58
+ return { error: portValidation.error };
59
+ }
60
+
61
+ const state = await localA2AService.startProvider(
62
+ `test-${randomUUID()}`,
63
+ testRuntime,
64
+ );
65
+ if (state.status === "running") return { runtimeId: state.runtimeId };
66
+ const detail = state.message ? ` ${state.message}` : "";
67
+ localA2AService.removeState(state.runtimeId);
68
+ return { error: `Local managed A2A server could not start for connection test.${detail}` };
69
+ }
70
+
71
+ async function readShortResponseBody(resp: Response): Promise<string> {
72
+ const body = await resp.text().catch(() => "");
73
+ return body.length > 500 ? `${body.slice(0, 500)}...` : body;
74
+ }
75
+
76
+ export function registerA2ATestConnectionRoute(app: FastifyInstance): void {
77
+ app.post("/api/a2a/test-connection", async (request, reply) => {
78
+ const { agentCardUrl, apiKey, localRuntime } = (request.body ?? {}) as {
79
+ agentCardUrl?: string;
80
+ apiKey?: string;
81
+ localRuntime?: LocalRuntimeConfig;
82
+ };
83
+ const normalizedAgentCardUrl = normalizeA2AAgentCardUrl(
84
+ agentCardUrl,
85
+ localRuntime ? "http://" : "https://",
86
+ );
87
+ if (!normalizedAgentCardUrl) {
88
+ return reply.status(400).send({ success: false, error: "agentCardUrl is required" });
89
+ }
90
+
91
+ let testRuntimeId: string | null = null;
92
+ try {
93
+ const headers: Record<string, string> = {};
94
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
95
+
96
+ if (localRuntime) {
97
+ const localStartResult = await ensureLocalRuntimeForConnectionTest(localRuntime, normalizedAgentCardUrl);
98
+ if ("error" in localStartResult) {
99
+ return reply.send({ success: true, data: { reachable: false, error: localStartResult.error } });
100
+ }
101
+ testRuntimeId = localStartResult.runtimeId;
102
+ }
103
+
104
+ const resp = await fetch(normalizedAgentCardUrl, { headers, signal: AbortSignal.timeout(8000) });
105
+ if (!resp.ok) {
106
+ const body = await readShortResponseBody(resp);
107
+ const suffix = body ? `: ${body}` : "";
108
+ return reply.send({ success: true, data: { reachable: false, error: `HTTP ${resp.status} ${resp.statusText}${suffix}` } });
109
+ }
110
+ const card = await resp.json() as AgentCard;
111
+ return reply.send({ success: true, data: { reachable: true, agentName: card.name, agentCard: card } });
112
+ } catch (error: unknown) {
113
+ return reply.send({
114
+ success: true,
115
+ data: {
116
+ reachable: false,
117
+ error: describeTestConnectionError(error, normalizedAgentCardUrl),
118
+ },
119
+ });
120
+ } finally {
121
+ if (testRuntimeId) {
122
+ localA2AService.removeState(testRuntimeId);
123
+ }
124
+ }
125
+ });
126
+ }
package/src/routes/a2a.ts CHANGED
@@ -23,6 +23,8 @@ import type {
23
23
  interface TaskRecord {
24
24
  id: string;
25
25
  threadId: string;
26
+ contextId?: string;
27
+ referenceTaskIds?: string[];
26
28
  messageId?: string;
27
29
  assistantId: string;
28
30
  tenantId: string;
@@ -36,6 +38,17 @@ interface TaskRecord {
36
38
 
37
39
  const taskStore = new Map<string, TaskRecord>();
38
40
 
41
+ const MAX_CONTEXT_THREADS = 100;
42
+ const contextThreadMap = new Map<string, string>();
43
+
44
+ function cacheContextThread(contextId: string, threadId: string): void {
45
+ if (contextThreadMap.size >= MAX_CONTEXT_THREADS) {
46
+ const oldest = contextThreadMap.keys().next().value;
47
+ if (oldest !== undefined) contextThreadMap.delete(oldest);
48
+ }
49
+ contextThreadMap.set(contextId, threadId);
50
+ }
51
+
39
52
  // ─── Key Store ────────────────────────────────────────────────────────────
40
53
 
41
54
  let _a2aKeyStore: A2AApiKeyStore | null = null;
@@ -200,10 +213,12 @@ type SendEventFn = (event: string, data: Record<string, unknown>) => void;
200
213
  async function streamChunksAsA2A(
201
214
  stream: AsyncIterable<{ type: unknown; data?: { content?: string } }>,
202
215
  taskId: string,
216
+ contextId: string | undefined,
203
217
  sendEvent: SendEventFn,
204
218
  onInterrupt?: () => void,
205
- ): Promise<string> {
219
+ ): Promise<{ text: string; interrupted: boolean }> {
206
220
  let accumulatedText = "";
221
+ let interrupted = false;
207
222
 
208
223
  for await (const chunk of stream) {
209
224
  const chunkText = (chunk as { data?: { content?: string } }).data?.content || "";
@@ -212,6 +227,7 @@ async function streamChunksAsA2A(
212
227
  if (chunkType === MessageChunkTypes.INTERRUPT) {
213
228
  sendEvent("status-update", {
214
229
  id: taskId,
230
+ contextId,
215
231
  status: {
216
232
  state: "input-required",
217
233
  message: {
@@ -223,7 +239,8 @@ async function streamChunksAsA2A(
223
239
  final: false,
224
240
  });
225
241
  onInterrupt?.();
226
- continue;
242
+ interrupted = true;
243
+ break;
227
244
  }
228
245
 
229
246
  if (
@@ -234,6 +251,7 @@ async function streamChunksAsA2A(
234
251
  accumulatedText += chunkText;
235
252
  sendEvent("status-update", {
236
253
  id: taskId,
254
+ contextId,
237
255
  status: {
238
256
  state: "working",
239
257
  message: {
@@ -248,7 +266,7 @@ async function streamChunksAsA2A(
248
266
  }
249
267
  }
250
268
 
251
- return accumulatedText;
269
+ return { text: accumulatedText, interrupted };
252
270
  }
253
271
 
254
272
  // ─── Task Status ──────────────────────────────────────────────────────────
@@ -331,8 +349,52 @@ async function handleTasksSend(
331
349
  return;
332
350
  }
333
351
 
352
+ const contextId = body.message.contextId;
353
+ const referenceTaskIds = body.message.referenceTaskIds;
334
354
  const taskId = body.id || v4();
335
- const threadId = v4();
355
+
356
+ let threadId: string;
357
+ let resolvedReferenceTaskIds: string[] | undefined;
358
+
359
+ // Explicit task continuation: if body.id matches a stored task
360
+ if (body.id) {
361
+ const existingTask = taskStore.get(body.id);
362
+ if (existingTask) {
363
+ if (existingTask.status === "input-required") {
364
+ threadId = existingTask.threadId;
365
+ resolvedReferenceTaskIds = referenceTaskIds;
366
+ } else if (
367
+ existingTask.status === "completed" ||
368
+ existingTask.status === "failed" ||
369
+ existingTask.status === "canceled"
370
+ ) {
371
+ threadId = v4();
372
+ resolvedReferenceTaskIds = referenceTaskIds
373
+ ? [...referenceTaskIds, body.id]
374
+ : [body.id];
375
+ } else {
376
+ threadId = v4();
377
+ resolvedReferenceTaskIds = referenceTaskIds;
378
+ }
379
+ } else {
380
+ threadId = v4();
381
+ resolvedReferenceTaskIds = referenceTaskIds
382
+ ? [...referenceTaskIds, body.id]
383
+ : [body.id];
384
+ }
385
+ } else if (contextId && contextThreadMap.has(contextId)) {
386
+ threadId = contextThreadMap.get(contextId)!;
387
+ console.warn({
388
+ event: "a2a:deprecated:contextId_thread_reuse",
389
+ message: "contextId-based thread reuse is deprecated; use referenceTaskIds and application-layer conversation state instead",
390
+ contextId,
391
+ threadId,
392
+ });
393
+ resolvedReferenceTaskIds = referenceTaskIds;
394
+ } else {
395
+ threadId = v4();
396
+ resolvedReferenceTaskIds = referenceTaskIds;
397
+ }
336
398
 
337
399
  const tenantId = auth.tenantId || config.defaultTenantId;
338
400
 
@@ -341,6 +403,8 @@ async function handleTasksSend(
341
403
  const record: TaskRecord = {
342
404
  id: taskId,
343
405
  threadId,
406
+ contextId,
407
+ referenceTaskIds: resolvedReferenceTaskIds,
344
408
  assistantId,
345
409
  tenantId,
346
410
  projectId: auth.projectId || undefined,
@@ -401,26 +465,29 @@ async function handleTasksSend(
401
465
  MessageChunkTypes.MESSAGE_COMPLETED,
402
466
  ]);
403
467
 
404
- const accumulatedText = await streamChunksAsA2A(stream, taskId, sendEvent, () => {
468
+ const { text: accumulatedText, interrupted } = await streamChunksAsA2A(stream, taskId, contextId, sendEvent, () => {
405
469
  record.status = "input-required";
406
470
  record.updatedAt = isoNow();
407
471
  });
408
472
 
409
- record.status = "completed";
410
- record.updatedAt = isoNow();
411
- record.history.push({
412
- role: "agent",
413
- parts: [buildTextPart(accumulatedText)],
414
- });
473
+ if (!interrupted) {
474
+ record.status = "completed";
475
+ record.updatedAt = isoNow();
476
+ record.history.push({
477
+ role: "agent",
478
+ parts: [buildTextPart(accumulatedText)],
479
+ });
415
480
 
416
- sendEvent("status-update", {
417
- id: taskId,
418
- status: {
419
- state: "completed",
420
- timestamp: isoNow(),
421
- },
422
- final: true,
423
- });
481
+ sendEvent("status-update", {
482
+ id: taskId,
483
+ contextId,
484
+ status: {
485
+ state: "completed",
486
+ timestamp: isoNow(),
487
+ },
488
+ final: true,
489
+ });
490
+ }
424
491
  } catch (err) {
425
492
  record.status = "failed";
426
493
  record.updatedAt = isoNow();
@@ -432,6 +499,7 @@ async function handleTasksSend(
432
499
 
433
500
  sendEvent("status-update", {
434
501
  id: taskId,
502
+ contextId,
435
503
  status: {
436
504
  state: "failed",
437
505
  timestamp: isoNow(),
@@ -542,6 +610,7 @@ async function handleTasksStream(
542
610
  if (!record.messageId) {
543
611
  sendEvent("status-update", {
544
612
  id: taskId,
613
+ contextId: record.contextId,
545
614
  status: {
546
615
  state: record.status,
547
616
  timestamp: record.updatedAt,
@@ -557,16 +626,19 @@ async function handleTasksStream(
557
626
  MessageChunkTypes.MESSAGE_COMPLETED,
558
627
  ]);
559
628
 
560
- await streamChunksAsA2A(stream, taskId, sendEvent);
629
+ const { interrupted } = await streamChunksAsA2A(stream, taskId, record.contextId, sendEvent);
561
630
 
562
- sendEvent("status-update", {
563
- id: taskId,
564
- status: {
565
- state: record.status,
566
- timestamp: record.updatedAt,
567
- },
568
- final: true,
569
- });
631
+ if (!interrupted) {
632
+ sendEvent("status-update", {
633
+ id: taskId,
634
+ contextId: record.contextId,
635
+ status: {
636
+ state: record.status,
637
+ timestamp: record.updatedAt,
638
+ },
639
+ final: true,
640
+ });
641
+ }
570
642
  } catch (err) {
571
643
  sendEvent("error", {
572
644
  code: "STREAM_ERROR",
@@ -65,11 +65,38 @@ import type { ChannelInstallationStore } from "@axiom-lattice/protocols";
65
65
  // } from "../controllers/thread_status";
66
66
 
67
67
  import { registerA2ABridgeRoutes } from './a2a-bridge';
68
+ import { registerSTTRoutes } from './stt';
69
+ import {
70
+ getLocalA2AProvider,
71
+ getLocalA2AStatus,
72
+ restartLocalA2AProvider,
73
+ startLocalA2AProvider,
74
+ stopLocalA2AProvider,
75
+ } from "../controllers/local-a2a";
76
+ import {
77
+ getLocalA2ATemplates,
78
+ } from "../controllers/local-a2a-templates";
79
+ import { registerA2ATestConnectionRoute } from "./a2a-test-connection";
68
80
 
69
81
  export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { router: MessageRouter; installationStore: ChannelInstallationStore }): void => {
70
82
  // A2A Bridge — WebSocket-based reverse proxy for agents behind NAT
71
83
  registerA2ABridgeRoutes(app);
72
84
 
85
+ // STT (speech-to-text) transcription
86
+ registerSTTRoutes(app);
87
+
88
+ // Local A2A runtime management
89
+ app.get("/api/local-a2a", getLocalA2AStatus);
90
+
91
+ app.get("/api/local-a2a/templates", getLocalA2ATemplates);
92
+
93
+ app.get("/api/local-a2a/:assistantId", getLocalA2AProvider);
94
+ app.post("/api/local-a2a/:assistantId/start", startLocalA2AProvider);
95
+ app.post("/api/local-a2a/:assistantId/stop", stopLocalA2AProvider);
96
+ app.post("/api/local-a2a/:assistantId/restart", restartLocalA2AProvider);
97
+
98
+ registerA2ATestConnectionRoute(app);
99
+
73
100
  // 运行路由
74
101
  app.post<{
75
102
  Body: any;
@@ -0,0 +1,102 @@
1
+ import { FastifyInstance } from "fastify";
2
+ import { getSTTClient, sttModelLatticeManager } from "@axiom-lattice/core";
3
+
4
+ const ALLOWED_AUDIO_FORMATS = new Set([
5
+ "webm",
6
+ "wav",
7
+ "mp3",
8
+ "m4a",
9
+ "ogg",
10
+ "flac",
11
+ "aac",
12
+ ]);
13
+
14
+ const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
15
+
16
+ /**
17
+ * Register STT (speech-to-text) transcription route.
18
+ *
19
+ * POST /api/stt/transcribe
20
+ * Content-Type: multipart/form-data
21
+ * Body: { file: File }
22
+ * Query: ?model=default
23
+ * Response: { text: string, confidence?: number }
24
+ */
25
+ export const registerSTTRoutes = (app: FastifyInstance): void => {
26
+ // List registered STT models
27
+ app.get("/api/stt/models", async (_request, reply) => {
28
+ const models = sttModelLatticeManager.getAllSTTModelInfo();
29
+ return reply.send({ models });
30
+ });
31
+
32
+ app.post<{ Querystring: { model?: string } }>(
33
+ "/api/stt/transcribe",
34
+ {
35
+ schema: {
36
+ querystring: {
37
+ type: "object",
38
+ properties: {
39
+ model: { type: "string" },
40
+ },
41
+ },
42
+ response: {
43
+ 200: {
44
+ type: "object",
45
+ properties: {
46
+ text: { type: "string" },
47
+ confidence: { type: "number" },
48
+ },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ async (request, reply) => {
54
+ const modelKey = request.query.model || "default";
55
+ const data = await request.file({
56
+ limits: { fileSize: MAX_AUDIO_FILE_SIZE },
57
+ });
58
+
59
+ if (!data) {
60
+ return reply.status(400).send({ error: "No audio file provided" });
61
+ }
62
+
63
+ const buffer = await data.toBuffer();
64
+ if (buffer.length === 0) {
65
+ return reply.status(400).send({ error: "Empty audio file" });
66
+ }
67
+
68
+ const format = (data.filename.split(".").pop() || "webm").toLowerCase();
69
+
70
+ if (!ALLOWED_AUDIO_FORMATS.has(format)) {
71
+ return reply
72
+ .status(400)
73
+ .send({ error: `Unsupported audio format: ${format}` });
74
+ }
75
+
76
+ try {
77
+ let client;
78
+ try {
79
+ client = getSTTClient(modelKey);
80
+ } catch {
81
+ return reply.status(404).send({ error: `STT model "${modelKey}" not found` });
82
+ }
83
+ const result = await client.transcribe(buffer, format);
84
+
85
+ return reply.send({
86
+ text: result.text,
87
+ confidence: result.confidence,
88
+ });
89
+ } catch (error: unknown) {
90
+ const sanitizedMessage =
91
+ error instanceof Error ? error.message : "Transcription failed";
92
+ request.log.error(
93
+ { error: sanitizedMessage },
94
+ "STT transcription failed",
95
+ );
96
+ return reply
97
+ .status(500)
98
+ .send({ error: "Transcription service error. Please try again." });
99
+ }
100
+ },
101
+ );
102
+ };
@@ -0,0 +1,124 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import {
5
+ appendStderrTail,
6
+ LocalA2AService,
7
+ resolveLocalA2AWorkingDirectory,
8
+ } from "../local-a2a";
9
+ import type { LocalRuntimeConfig } from "@axiom-lattice/protocols";
10
+
11
+ describe("LocalA2AService working directory resolution", () => {
12
+ let tempDir: string;
13
+
14
+ beforeEach(() => {
15
+ tempDir = mkdtempSync(join(tmpdir(), "local-a2a-"));
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ it("prefers env override, then explicit command cwd", () => {
23
+ expect(resolveLocalA2AWorkingDirectory({
24
+ envWorkdir: "/env/root",
25
+ commandCwd: "/command/root",
26
+ currentCwd: tempDir,
27
+ })).toBe("/env/root");
28
+
29
+ expect(resolveLocalA2AWorkingDirectory({
30
+ envWorkdir: "",
31
+ commandCwd: "/command/root",
32
+ currentCwd: tempDir,
33
+ })).toBe("/command/root");
34
+ });
35
+
36
+ it("falls back to the nearest pnpm workspace root", () => {
37
+ writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: []\n");
38
+ const nested = join(tempDir, "examples", "deep_research");
39
+ mkdirSync(nested, { recursive: true });
40
+
41
+ expect(resolveLocalA2AWorkingDirectory({
42
+ commandCwd: undefined,
43
+ currentCwd: nested,
44
+ })).toBe(tempDir);
45
+ });
46
+
47
+ it("falls back to current cwd when no workspace root exists", () => {
48
+ expect(resolveLocalA2AWorkingDirectory({
49
+ commandCwd: undefined,
50
+ currentCwd: tempDir,
51
+ })).toBe(tempDir);
52
+ });
53
+ });
54
+
55
+ describe("LocalA2AService startup diagnostics", () => {
56
+ const originalAutoStart = process.env.LOCAL_A2A_AUTO_START;
57
+ const originalWorkdir = process.env.LOCAL_A2A_WORKDIR;
58
+ let tempDir: string;
59
+
60
+ beforeEach(() => {
61
+ tempDir = mkdtempSync(join(tmpdir(), "local-a2a-"));
62
+ process.env.LOCAL_A2A_AUTO_START = "true";
63
+ delete process.env.LOCAL_A2A_WORKDIR;
64
+ });
65
+
66
+ afterEach(() => {
67
+ if (originalAutoStart === undefined) {
68
+ delete process.env.LOCAL_A2A_AUTO_START;
69
+ } else {
70
+ process.env.LOCAL_A2A_AUTO_START = originalAutoStart;
71
+ }
72
+
73
+ if (originalWorkdir === undefined) {
74
+ delete process.env.LOCAL_A2A_WORKDIR;
75
+ } else {
76
+ process.env.LOCAL_A2A_WORKDIR = originalWorkdir;
77
+ }
78
+
79
+ rmSync(tempDir, { recursive: true, force: true });
80
+ });
81
+
82
+ function runtime(command: LocalRuntimeConfig["command"]): LocalRuntimeConfig {
83
+ return {
84
+ label: "Test Runtime",
85
+ agentCardUrl: "http://127.0.0.1:1/.well-known/agent-card.json",
86
+ command,
87
+ };
88
+ }
89
+
90
+ it("fails before spawn when cwd is not accessible", async () => {
91
+ const service = new LocalA2AService();
92
+
93
+ const state = await service.startProvider("runtime-1", runtime({
94
+ executable: process.execPath,
95
+ args: ["-e", "process.exit(0)"],
96
+ cwd: join(tempDir, "missing"),
97
+ }));
98
+
99
+ expect(state.status).toBe("failed");
100
+ expect(state.message).toContain("Working directory is not accessible");
101
+ });
102
+
103
+ it("includes stderr tail when the child exits before ready", async () => {
104
+ const service = new LocalA2AService();
105
+
106
+ const state = await service.startProvider("runtime-2", runtime({
107
+ executable: process.execPath,
108
+ args: ["-e", "console.error('diagnostic stderr'); process.exit(1)"],
109
+ cwd: tempDir,
110
+ }));
111
+
112
+ expect(state.status).toBe("failed");
113
+ expect(state.message).toContain("Exited before becoming ready (1)");
114
+ expect(state.message).toContain("diagnostic stderr");
115
+ });
116
+
117
+ it("keeps only the last 4KB of stderr", () => {
118
+ const tail = appendStderrTail("prefix", `${"x".repeat(5000)}end`);
119
+
120
+ expect(tail.length).toBe(4096);
121
+ expect(tail.endsWith("end")).toBe(true);
122
+ expect(tail.startsWith("prefix")).toBe(false);
123
+ });
124
+ });
@@ -109,6 +109,10 @@ class EvalRunner {
109
109
  const concurrency = Math.max(1, Math.floor((project as unknown as Record<string, unknown>).concurrency as number) || 3);
110
110
  await store.createRun(tenantId, projectId, runId, { totalCases, concurrency });
111
111
 
112
+ const tsc = (project.targetServerConfig ?? {}) as Record<string, unknown>;
113
+ const workspaceId = (tsc.workspace_id as string) || "";
114
+ const projectIdFromConfig = (tsc.project_id as string) || "";
115
+
112
116
  const projectConfig: LatticeEvalProjectType = {
113
117
  projectName: project.name,
114
118
  version: project.version,
@@ -117,6 +121,8 @@ class EvalRunner {
117
121
  judge_agent_config: { modelKey: resolvedJudgeModelKey },
118
122
  lattice_server_config: {
119
123
  tenant_id: tenantId,
124
+ workspace_id: workspaceId,
125
+ project_id: projectIdFromConfig,
120
126
  },
121
127
  concurrency,
122
128
  };