diff --git a/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.createFlow.test.tsx b/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.createFlow.test.tsx index c2f519aebb..594cd47113 100644 --- a/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.createFlow.test.tsx +++ b/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.createFlow.test.tsx @@ -1506,6 +1506,23 @@ describe("SwarmsTab — New swarm create flow", () => { expect(new Set(launchKeys).size).toBe(2); }); + it("stamps the swarm with the same wave id its runs carry", async () => { + // How the Overview names a wave: it looks the swarm up BY this id rather + // than through a journey, whose authoring swarm is someone else's as soon + // as the launch reuses it. + openDescribe(); + fillDescribe(); + fireEvent.click(screen.getByTestId("new-swarm-continue")); + await screen.findByTestId("new-swarm-proposed-personas"); + fireEvent.click(screen.getByTestId("new-swarm-launch")); + + await waitFor(() => expect(launchJourneyRunMock).toHaveBeenCalledTimes(2)); + const waveId = (launchJourneyRunMock.mock.calls[0]![0] as any) + .swarmRunGroupId; + expect(waveId).toBeTruthy(); + expect(createSwarmMock.mock.calls[0]![0].swarmRunGroupId).toBe(waveId); + }); + it("reuses the wave id when a failed launch is retried", async () => { // A partial-failure retry replays the already-launched journeys' keys and // gets back their ORIGINAL runs; minting a fresh wave would split one diff --git a/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.overview.test.tsx b/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.overview.test.tsx index 0ef11ee5a1..dba563b432 100644 --- a/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.overview.test.tsx +++ b/mcpjam-inspector/client/src/components/swarms/__tests__/SwarmsTab.overview.test.tsx @@ -15,6 +15,7 @@ import { SWARM_COLUMN_HEADER, filterAndSortSwarmWaves, groupRunsIntoSwarmWaves, + swarmWaveTitle, waveLiveProgress, } from "../swarm-overview-panel"; @@ -483,6 +484,36 @@ describe("groupRunsIntoSwarmWaves", () => { }); }); +describe("swarmWaveTitle", () => { + it("titles a wave with the name its author gave the swarm", () => { + const [newest, second] = overview.runs; + const [wave] = groupRunsIntoSwarmWaves([ + withGroup({ ...newest!, swarmName: "Checkout regression" }, "wave-a"), + withGroup({ ...second!, swarmName: "Checkout regression" }, "wave-a"), + ]); + expect(swarmWaveTitle(wave!)).toBe("Checkout regression"); + }); + + it("falls back to the short route id when no run carries a name", () => { + // Runs launched outside a swarm, plus every row from a backend that + // predates the field. + const [newest] = overview.runs; + const [wave] = groupRunsIntoSwarmWaves([withGroup(newest!, "wave-a")]); + expect(swarmWaveTitle(wave!)).toBe("Swarm wave-a"); + }); + + it("names a mixed wave after its newest member's swarm", () => { + // A reused journey carries its ORIGINAL swarm into another wave, so the + // wave can hold two names — the newest run decides, as it does for the id. + const [newest, second] = overview.runs; + const [wave] = groupRunsIntoSwarmWaves([ + withGroup({ ...newest!, swarmName: "Checkout regression" }, "wave-a"), + withGroup({ ...second!, swarmName: "Last quarter's swarm" }, "wave-a"), + ]); + expect(swarmWaveTitle(wave!)).toBe("Checkout regression"); + }); +}); + describe("Overview — swarm runs (waves), not bare journeys", () => { it("lists co-launched journeys as ONE Swarm Run titled by short id", async () => { renderTab(); diff --git a/mcpjam-inspector/client/src/components/swarms/new-swarm-create-flow.tsx b/mcpjam-inspector/client/src/components/swarms/new-swarm-create-flow.tsx index 9cd6f7cf29..85956135f5 100644 --- a/mcpjam-inspector/client/src/components/swarms/new-swarm-create-flow.tsx +++ b/mcpjam-inspector/client/src/components/swarms/new-swarm-create-flow.tsx @@ -210,6 +210,8 @@ export type CreateSwarmDraft = { config: { sessionsPerTarget: number; maxTurns: number }; judgeConfig?: GoalJudgeConfig; rubric?: ReturnType; + /** The launch wave this swarm names — see `swarmRunGroupId` on the runs. */ + swarmRunGroupId?: string; idempotencyKey: string; }; @@ -1196,6 +1198,10 @@ export function NewSwarmCreateFlow({ ...(payload.rubric.length > 0 ? { rubric: serializeRubricForWire(payload.rubric) } : {}), + // Ties the swarm to the wave its runs carry. Without it the + // Overview falls back to each journey's authoring swarm, which + // for a reused journey names someone else's swarm. + swarmRunGroupId, idempotencyKey: `${flowId}:swarm`, }); } catch (err) { diff --git a/mcpjam-inspector/client/src/components/swarms/swarm-overview-panel.tsx b/mcpjam-inspector/client/src/components/swarms/swarm-overview-panel.tsx index ad145c1b83..c2c62b15e7 100644 --- a/mcpjam-inspector/client/src/components/swarms/swarm-overview-panel.tsx +++ b/mcpjam-inspector/client/src/components/swarms/swarm-overview-panel.tsx @@ -247,11 +247,17 @@ export function formatSwarmId(swarmId: string): string { } /** - * ID-first title, matching evals (`Run n57bwtsk`): `Swarm` + short route id. - * Scope (goals / personas) lives in the subtitle, not the title. + * The name its author gave the swarm, else the ID-first title matching evals + * (`Run n57bwtsk`): `Swarm` + short route id. Scope (goals / personas) lives in + * the subtitle, not the title. */ export function swarmWaveTitle(wave: SwarmWave): string { - return `Swarm ${formatSwarmId(swarmWaveRouteId(wave))}`; + // The backend resolves the name per WAVE, so a wave's runs agree. The scan + // is for legacy rows, whose name falls back to each journey's authoring + // swarm and can therefore differ across a wave that reused journeys — the + // newest member wins, as it does for `swarmWaveRouteId`. + const authored = wave.runs.find((run) => run.swarmName)?.swarmName; + return authored ?? `Swarm ${formatSwarmId(swarmWaveRouteId(wave))}`; } /** diff --git a/mcpjam-inspector/client/src/lib/swarm-api.ts b/mcpjam-inspector/client/src/lib/swarm-api.ts index 0a69f3c812..74058a8b37 100644 --- a/mcpjam-inspector/client/src/lib/swarm-api.ts +++ b/mcpjam-inspector/client/src/lib/swarm-api.ts @@ -362,6 +362,12 @@ export interface SwarmOverviewRun { * only for runs without one, so legacy rows render exactly as before. */ swarmRunGroupId?: string; + /** + * Authored swarm name, present once the backend carries it. Absent for runs + * launched outside a swarm and on older backends, so the wave title keeps its + * short-id fallback rather than rendering an empty heading. + */ + swarmName?: string; status: string; summary: JourneyRunSummary; goalScoreSummary?: GoalScoreRollup;