diff --git a/apps/web/src/agent-deploy.test.ts b/apps/web/src/agent-deploy.test.ts new file mode 100644 index 000000000..da91c3782 --- /dev/null +++ b/apps/web/src/agent-deploy.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; + +import { agentDeploySourceAssetName, agentSlugFromSourceAssetName } from "./agent-deploy"; + +describe("agentSlugFromSourceAssetName", () => { + test("recovers the slug agentDeploySourceAssetName wrapped", () => { + expect(agentSlugFromSourceAssetName(agentDeploySourceAssetName("echo-bot"))).toBe("echo-bot"); + }); + + test("is null for a name this pipeline didn't produce", () => { + expect(agentSlugFromSourceAssetName("echo-bot")).toBeNull(); + expect(agentSlugFromSourceAssetName("agent-agent-echo-bot-source-source")).toBe( + "agent-echo-bot-source", + ); + }); +}); diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index 3d38dc915..298b511f7 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -183,7 +183,7 @@ export function agentDeploySourceAssetName(slug: string): string { return `agent-${slug}-source`; } -const AGENT_DEPLOY_SOURCE_ASSET_NAME = /^agent-.+-source$/; +const AGENT_DEPLOY_SOURCE_ASSET_NAME = /^agent-(.+)-source$/; /** True for any asset this deploy pipeline named — used to keep created * agents (and Myra, checked separately by callers) out of surfaces that @@ -192,10 +192,24 @@ export function isAgentDeploySourceAssetName(name: string): boolean { return AGENT_DEPLOY_SOURCE_ASSET_NAME.test(name); } +/** The inverse of `agentDeploySourceAssetName`: recovers the slug this + * pipeline deployed an asset under, so a caller re-deploying an existing + * agent can reuse its slug instead of re-deriving one from its display + * name. Null when the name isn't this pipeline's `agent--source` + * shape. */ +export function agentSlugFromSourceAssetName(assetName: string): string | null { + const match = AGENT_DEPLOY_SOURCE_ASSET_NAME.exec(assetName); + return match?.[1] ?? null; +} + export type NewAgentInput = { readonly name: string; readonly systemPrompt: string; readonly schedule?: string; + /** The agent's address slug, when a caller already knows it (e.g. + * redeploying or re-joining an existing agent) — used verbatim instead + * of being re-derived from `name`, so the asset name stays stable. */ + readonly slug?: string; }; export type DeployedAgent = typeof WorkflowDeploymentResponse.infer; @@ -216,7 +230,7 @@ export async function deployAgentSource( const systemPrompt = args.input.systemPrompt.trim(); if (systemPrompt === "") throw new AgentDeployError("an agent needs a system prompt"); - const slug = slugify(name); + const slug = args.input.slug ?? slugify(name); if (!isValidSlug(slug)) { throw new AgentDeployError("this name doesn't produce a usable agent address"); } diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts new file mode 100644 index 000000000..4ace15ac0 --- /dev/null +++ b/apps/web/src/chat/threads-api.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; + +import { agentDeploySourceAssetName } from "../agent-deploy"; +import { MYRA_SOURCE_CONFIG } from "../myra-source"; +import { displayAgentName } from "./threads-api"; + +describe("displayAgentName", () => { + test("renders Myra's fixed display name for her asset", () => { + expect(displayAgentName(MYRA_SOURCE_CONFIG.assetName)).toBe(MYRA_SOURCE_CONFIG.displayName); + }); + + test("title-cases a deployed agent's slug with hyphens as spaces", () => { + expect(displayAgentName(agentDeploySourceAssetName("echo-bot"))).toBe("Echo Bot"); + expect(displayAgentName(agentDeploySourceAssetName("scribe"))).toBe("Scribe"); + }); + + test("falls back to the raw name for anything unrecognized", () => { + expect(displayAgentName("some-other-asset")).toBe("some-other-asset"); + }); +}); diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 6cc0fdfd9..0adf0d17d 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -16,6 +16,7 @@ import { type } from "arktype"; import { WorkflowDeploymentResponse } from "@intx/types"; import { reportError } from "@corbits/error-sink"; +import { agentSlugFromSourceAssetName } from "../agent-deploy"; import { listTopLevelRuns } from "../agents-api"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; @@ -90,11 +91,18 @@ export type ChatSummary = { }; /** Myra is the one default agent; her deploy asset name is not a display - * name anyone should have to read. */ -function displayAgentName(definitionName: string): string { - return definitionName === MYRA_SOURCE_CONFIG.assetName - ? MYRA_SOURCE_CONFIG.displayName - : definitionName; + * name anyone should have to read. Any other agent's deploy asset name + * encodes its slug (`agent--source`), which renders title-cased with + * hyphens as spaces ("echo-bot" -> "Echo Bot"). */ +export function displayAgentName(definitionName: string): string { + if (definitionName === MYRA_SOURCE_CONFIG.assetName) return MYRA_SOURCE_CONFIG.displayName; + const slug = agentSlugFromSourceAssetName(definitionName); + if (slug === null) return definitionName; + return slug + .split("-") + .filter(Boolean) + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "); } /** Myra is always in a new workbench and never a pickable option. */ diff --git a/apps/web/src/workbench-create.ts b/apps/web/src/workbench-create.ts index 65842658c..d117c320c 100644 --- a/apps/web/src/workbench-create.ts +++ b/apps/web/src/workbench-create.ts @@ -5,7 +5,7 @@ // resolved offering, which does inherit. import { isMyraAgent, listRoomParticipants, sendToRoom } from "@/chat/threads-api"; -import { deployAgentSource } from "./agent-deploy"; +import { agentSlugFromSourceAssetName, deployAgentSource } from "./agent-deploy"; import { readAgentSource } from "./agent-source-read"; import { deployMyraSource } from "./myra-deploy"; import { createFetchStockHub } from "./needs-converge"; @@ -96,10 +96,14 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise