Skip to content

Commit 4654e74

Browse files
committed
fix(showcase): resolve starter-smoke chat agentId from /info agents map
The chat rung hardcoded `/api/copilotkit/agent/default/run`, which 404s for mastra — it registers dynamic non-`default` agent keys via `MastraAgent.getLocalAgents` rather than `agents:{default}`. Resolve the agent id per-starter from the first key of the `/info` `agents` map (the same info response the health + agent rungs already fetch), falling back to `default` only when that map is empty/unreadable, and surface the resolved id on the chat row signal for drilldown.
1 parent 0fbf0e9 commit 4654e74

2 files changed

Lines changed: 215 additions & 15 deletions

File tree

showcase/harness/src/probes/drivers/starter-smoke.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,116 @@ describe("starterSmokeDriver", () => {
591591
expect(chatRow.signal.errorClass).toBe("smoke-failed");
592592
});
593593

594+
it("resolves the chat agentId from /info agents map (mastra-shaped non-default key)", async () => {
595+
// A2: most starters register `agents:{default}`, but mastra registers a
596+
// DYNAMIC non-`default` key (`MastraAgent.getLocalAgents`). The chat rung
597+
// must read the FIRST key of the `/info` `agents` map and POST to
598+
// `/agent/<that-id>/run`, NOT the hardcoded `/agent/default/run` (which
599+
// 404s for mastra). Here `/info` advertises `{ weatherAgent: {...} }`, so
600+
// the chat POST must target `/agent/weatherAgent/run`.
601+
const { writer, writes } = mkWriter();
602+
const driver = createStarterSmokeDriver();
603+
const seenUrls: Partial<
604+
Record<"health" | "agent" | "chat" | "interaction", string>
605+
> = {};
606+
const r = (await driver.run(
607+
mkCtx(
608+
fakeFetch({
609+
agentBody: JSON.stringify({
610+
version: "1.59.5",
611+
agents: { weatherAgent: { description: "weather" } },
612+
}),
613+
seenUrls,
614+
}),
615+
writer,
616+
),
617+
{
618+
key: "starter_smoke:starter-mastra",
619+
name: "starter-mastra",
620+
publicUrl: "https://starter-mastra.up.railway.app",
621+
},
622+
)) as ProbeResult<StarterSmokeAggregateSignal>;
623+
624+
expect(seenUrls.chat).toBe(
625+
"https://starter-mastra.up.railway.app/api/copilotkit/agent/weatherAgent/run",
626+
);
627+
// The resolved agentId is surfaced on the chat row signal for drilldown.
628+
const chatRow = sideRows(writes).find(
629+
(w) => w.key === "starter:mastra/chat",
630+
)!;
631+
expect(chatRow.signal.agentId).toBe("weatherAgent");
632+
// Full round-trip still passes (the fake chat SSE is the happy stream).
633+
expect(chatRow.state).toBe("green");
634+
expect(r.signal.failed).not.toContain("chat");
635+
});
636+
637+
it("falls back to agentId 'default' when /info agents map is empty/absent", async () => {
638+
// The 11 default-registering starters have `agents:{default}` (or an
639+
// absent/empty map in degraded info). `default` is the EXPECTED resolved
640+
// value for them — a last-resort fallback, not an error.
641+
const { writer, writes } = mkWriter();
642+
const driver = createStarterSmokeDriver();
643+
const seenUrls: Partial<
644+
Record<"health" | "agent" | "chat" | "interaction", string>
645+
> = {};
646+
await driver.run(
647+
mkCtx(
648+
// info body carries a `version` but NO `agents` map.
649+
fakeFetch({ agentBody: '{"version":"1.59.5"}', seenUrls }),
650+
writer,
651+
),
652+
{
653+
key: "starter_smoke:starter-agno",
654+
name: "starter-agno",
655+
publicUrl: "https://starter-agno.up.railway.app",
656+
},
657+
);
658+
659+
expect(seenUrls.chat).toBe(
660+
"https://starter-agno.up.railway.app/api/copilotkit/agent/default/run",
661+
);
662+
const chatRow = sideRows(writes).find(
663+
(w) => w.key === "starter:agno/chat",
664+
)!;
665+
expect(chatRow.signal.agentId).toBe("default");
666+
});
667+
668+
it("resolves the chat agentId from a default-registering /info agents map", async () => {
669+
// The 11 default-registering starters advertise `agents:{default}`; the
670+
// resolver must pick `default` (first key) and the chat POST targets
671+
// `/agent/default/run` — keeping the default case green.
672+
const { writer, writes } = mkWriter();
673+
const driver = createStarterSmokeDriver();
674+
const seenUrls: Partial<
675+
Record<"health" | "agent" | "chat" | "interaction", string>
676+
> = {};
677+
await driver.run(
678+
mkCtx(
679+
fakeFetch({
680+
agentBody: JSON.stringify({
681+
version: "1.59.5",
682+
agents: { default: { description: "the agent" } },
683+
}),
684+
seenUrls,
685+
}),
686+
writer,
687+
),
688+
{
689+
key: "starter_smoke:starter-agno",
690+
name: "starter-agno",
691+
publicUrl: "https://starter-agno.up.railway.app",
692+
},
693+
);
694+
695+
expect(seenUrls.chat).toBe(
696+
"https://starter-agno.up.railway.app/api/copilotkit/agent/default/run",
697+
);
698+
const chatRow = sideRows(writes).find(
699+
(w) => w.key === "starter:agno/chat",
700+
)!;
701+
expect(chatRow.signal.agentId).toBe("default");
702+
});
703+
594704
it("the chat POST targets /api/copilotkit/agent/default/run (path-based)", async () => {
595705
const { writer } = mkWriter();
596706
const driver = createStarterSmokeDriver();

showcase/harness/src/probes/drivers/starter-smoke.ts

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ import type { ProbeContext, ProbeResult } from "../../types/index.js";
4949
* HTML error page, a JSON body lacking `version`, or any
5050
* 4xx FAILS. A real `{version}` info response proves the
5151
* v2 runtime is mounted and answering.
52-
* - **chat** POST `${base}/api/copilotkit/agent/default/run` with
52+
* - **chat** POST `${base}/api/copilotkit/agent/<id>/run` where
53+
* `<id>` is resolved PER-STARTER from the `/info` `agents`
54+
* map (`Object.keys(info.agents)[0]`, the same `info`
55+
* response the health + agent rungs fetch) — `default` for
56+
* the 11 default-registering starters, a dynamic key (e.g.
57+
* `weatherAgent`) for mastra, falling back to `default`
58+
* only when the map is empty/unreadable — with
5359
* `Accept: text/event-stream` and the AG-UI run body
5460
* `{threadId, runId, messages, state, tools, context,
5561
* forwardedProps}`. Read the AG-UI SSE stream and require
@@ -154,6 +160,14 @@ export interface StarterSmokeLevelSignal {
154160
/** Keyed failure class (red rows only). */
155161
errorClass?: StarterFailureClass;
156162
latencyMs: number;
163+
/**
164+
* The agent id this level resolved/targeted, for drilldown. Set on the chat
165+
* rung (the id read from `/info` `agents` and POSTed at
166+
* `/agent/<id>/run`); the EXPECTED value is `default` for the 11
167+
* default-registering starters and a dynamic key (e.g. `weatherAgent`) for
168+
* mastra. Absent on the other rungs.
169+
*/
170+
agentId?: string;
157171
}
158172

159173
/**
@@ -171,14 +185,19 @@ const DEFAULT_TIMEOUT_MS = 30_000;
171185
const TIMEOUT_ENV_VAR = "STARTER_SMOKE_TIMEOUT_MS";
172186

173187
/**
174-
* Canonical agent id every starter registers in its `agents` map
175-
* (`examples/integrations/<slug>/src/app/api/copilotkit/[[...slug]]/route.ts`
176-
* registers `{ default: new <Framework>Agent(...) }`). The path-based chat
177-
* rung targets `/agent/${CHAT_AGENT_ID}/run`; `matchRoute` reads the id from
178-
* the URL segment. Confirmed both in the starter routes and via a deployed
179-
* starter's `info` `agents` map.
188+
* LAST-RESORT agent id for the path-based chat rung when `/info` advertises
189+
* no readable `agents` map. Most starters register `{ default: ... }`
190+
* (`examples/integrations/<slug>/src/app/api/copilotkit/[[...slug]]/route.ts`),
191+
* so `default` is the EXPECTED resolved value for those 11 starters — NOT an
192+
* error. But some starters register DYNAMIC non-`default` keys: mastra wires
193+
* `MastraAgent.getLocalAgents(...)`, whose keys are the Mastra agent names
194+
* (e.g. `weatherAgent`), so a hardcoded `/agent/default/run` 404s for it. The
195+
* driver therefore resolves the chat agent id PER-STARTER from the `/info`
196+
* `agents` map (`Object.keys(info.agents)[0]`, the first registered agent),
197+
* the same `info` response the health + agent rungs already fetch, and only
198+
* falls back to this constant when that map is empty/unreadable.
180199
*/
181-
const CHAT_AGENT_ID = "default";
200+
const FALLBACK_CHAT_AGENT_ID = "default";
182201

183202
/**
184203
* The AG-UI run body for the chat rung. Shape matches
@@ -277,19 +296,33 @@ export function createStarterSmokeDriver(
277296
// path, so health/agent GET `/api/copilotkit/info` and chat POSTs the
278297
// per-agent run path `/api/copilotkit/agent/<id>/run`. The dead
279298
// single-route envelope POST to bare `/api/copilotkit` is never issued.
280-
const levelUrls: Record<StarterLevel, string> = {
281-
health: `${base}/api/copilotkit/info`,
282-
agent: `${base}/api/copilotkit/info`,
283-
chat: `${base}/api/copilotkit/agent/${encodeURIComponent(CHAT_AGENT_ID)}/run`,
284-
interaction: `${base}/`,
299+
// The chat rung's agent id is resolved PER-STARTER from the `/info`
300+
// `agents` map read by the agent rung (which runs before chat in
301+
// STARTER_LEVELS order). It starts at the last-resort fallback and is
302+
// overwritten once the agent rung reads a non-empty `agents` map, so the
303+
// chat POST targets the agent the deployed starter actually registered
304+
// (mastra registers a dynamic non-`default` key).
305+
let resolvedChatAgentId = FALLBACK_CHAT_AGENT_ID;
306+
const chatUrlFor = (agentId: string): string =>
307+
`${base}/api/copilotkit/agent/${encodeURIComponent(agentId)}/run`;
308+
const urlForLevel = (level: StarterLevel): string => {
309+
switch (level) {
310+
case "health":
311+
case "agent":
312+
return `${base}/api/copilotkit/info`;
313+
case "chat":
314+
return chatUrlFor(resolvedChatAgentId);
315+
case "interaction":
316+
return `${base}/`;
317+
}
285318
};
286319

287320
const failed: StarterLevel[] = [];
288321
let passed = 0;
289322
let worstClass: StarterFailureClass | undefined;
290323

291324
for (const level of STARTER_LEVELS) {
292-
const url = levelUrls[level];
325+
const url = urlForLevel(level);
293326
// Short-circuit on an external (outer-timeout) abort BEFORE issuing a
294327
// fresh fetch. Without this, an abort mid-tick still fires a cold-start
295328
// request for every remaining level — wasting wake requests and
@@ -317,6 +350,14 @@ export function createStarterSmokeDriver(
317350
});
318351
}
319352

353+
// Capture the agent id the agent rung resolved from `/info` so the
354+
// chat rung (next in STARTER_LEVELS order) POSTs the per-agent run path
355+
// the starter actually registered. A missing resolution leaves the
356+
// last-resort fallback in place.
357+
if (level === "agent" && result.resolvedAgentId) {
358+
resolvedChatAgentId = result.resolvedAgentId;
359+
}
360+
320361
const state = result.ok ? "green" : "red";
321362
if (result.ok) {
322363
passed++;
@@ -337,6 +378,10 @@ export function createStarterSmokeDriver(
337378
errorDesc: result.errorDesc,
338379
errorClass: result.ok ? undefined : result.errorClass,
339380
latencyMs: result.latencyMs,
381+
// Surface the resolved chat agent id on the chat row for
382+
// drilldown (the EXPECTED value is `default` for the 11
383+
// default-registering starters, a dynamic key for mastra).
384+
...(level === "chat" ? { agentId: resolvedChatAgentId } : {}),
340385
},
341386
observedAt: ctx.now().toISOString(),
342387
});
@@ -367,6 +412,13 @@ interface LevelOutcome {
367412
errorDesc?: string;
368413
errorClass?: StarterFailureClass;
369414
latencyMs: number;
415+
/**
416+
* Agent rung only: the agent id resolved from the `/info` `agents` map
417+
* (`Object.keys(info.agents)[0]`), used to drive the chat rung's per-agent
418+
* run path. Absent when the map is empty/unreadable (the caller then keeps
419+
* the last-resort fallback). Other rungs never set this.
420+
*/
421+
resolvedAgentId?: string;
370422
}
371423

372424
/**
@@ -500,7 +552,16 @@ async function probeLevel(opts: {
500552
latencyMs,
501553
};
502554
}
503-
return { ok: true, status: res.status, latencyMs };
555+
// Resolve the chat agent id from the SAME `/info` body the version check
556+
// just validated, so the chat rung targets the agent the starter
557+
// actually registered (mastra uses a dynamic non-`default` key) without a
558+
// second `info` fetch. Absent → caller keeps the last-resort fallback.
559+
return {
560+
ok: true,
561+
status: res.status,
562+
latencyMs,
563+
resolvedAgentId: resolveAgentId(body) ?? undefined,
564+
};
504565
}
505566

506567
// chat: require an SSE stream carrying assistant text content.
@@ -709,6 +770,35 @@ function verifyInfoVersion(body: string): string | null {
709770
return null;
710771
}
711772

773+
/**
774+
* Resolve the chat agent id from an `info` response body: the FIRST key of its
775+
* `agents` map (a `Record<string, AgentDescription>`). Most starters register
776+
* `{ default: ... }` → `default`; mastra registers dynamic keys (e.g.
777+
* `weatherAgent`). Returns null when the body is unparseable, has no `agents`
778+
* map, or the map is empty — the caller then keeps the last-resort fallback.
779+
* Order follows JS object insertion order, matching the runtime's
780+
* registration order.
781+
*/
782+
function resolveAgentId(body: string): string | null {
783+
let parsed: unknown;
784+
try {
785+
parsed = JSON.parse(body);
786+
} catch {
787+
return null;
788+
}
789+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
790+
return null;
791+
}
792+
const agents = (parsed as Record<string, unknown>)["agents"];
793+
if (agents === null || typeof agents !== "object" || Array.isArray(agents)) {
794+
return null;
795+
}
796+
const keys = Object.keys(agents as Record<string, unknown>);
797+
if (keys.length === 0) return null;
798+
const first = keys[0];
799+
return first.trim().length > 0 ? first : null;
800+
}
801+
712802
/**
713803
* Validate a chat `agent/run` SSE body. A passing stream must satisfy ALL of:
714804
* - ≥1 `TEXT_MESSAGE_CONTENT`/`TEXT_MESSAGE_CHUNK` event with a non-empty

0 commit comments

Comments
 (0)