From 97e0df01fed20fa4b133f1385e1868a58477cd77 Mon Sep 17 00:00:00 2001 From: Nitay Rabinovich Date: Tue, 12 May 2026 10:47:41 +0200 Subject: [PATCH] Add UI-driven agent display name renaming --- .../drizzle/0001_agent_display_name.sql | 1 + apps/server/drizzle/meta/_journal.json | 7 +++++ apps/server/src/app.ts | 28 ++++++++++++++++++- apps/server/src/db/schema.ts | 1 + apps/server/src/repository.ts | 14 ++++++++++ apps/server/test/app.test.ts | 24 ++++++++++++++++ apps/web/src/api.ts | 10 +++++++ apps/web/src/components/AgentPicker.tsx | 13 ++++++++- apps/web/test/app.test.tsx | 1 + packages/protocol/src/index.ts | 1 + 10 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 apps/server/drizzle/0001_agent_display_name.sql diff --git a/apps/server/drizzle/0001_agent_display_name.sql b/apps/server/drizzle/0001_agent_display_name.sql new file mode 100644 index 0000000..abc1041 --- /dev/null +++ b/apps/server/drizzle/0001_agent_display_name.sql @@ -0,0 +1 @@ +ALTER TABLE `agents` ADD `display_name` text; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index eb59cc0..2cb8982 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1778489600000, "tag": "0000_initial", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1778573600000, + "tag": "0001_agent_display_name", + "breakpoints": true } ] } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6219435..3b1a94a 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -27,7 +27,8 @@ import { sessionInputPayloadSchema, sessionStartPayloadSchema, topologySnapshotSchema, - upsertTriggerRuleRequestSchema + upsertTriggerRuleRequestSchema, + z } from "@amesh/protocol"; import cookie from "@fastify/cookie"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; @@ -606,6 +607,31 @@ function registerApiRoutes({ app.get("/api/nodes", { preHandler: requireBrowserAuth }, async () => (await topologySnapshot()).nodes); app.get("/api/agents", { preHandler: requireBrowserAuth }, async () => repository.listTopology().agents); + app.patch("/api/agents/:id", { preHandler: requireBrowserAuth }, async (request: FastifyRequest, reply: FastifyReply) => { + const params = request.params as { id?: string }; + const body = (request.body ?? {}) as { displayName?: string | null }; + const rawName = body.displayName; + if (typeof params.id !== "string") { + reply.code(400); + return { message: "invalid agent id" }; + } + if (!(rawName === null || rawName === undefined || typeof rawName === "string")) { + reply.code(400); + return { message: "invalid display name" }; + } + const normalized = typeof rawName === "string" ? rawName.trim() : null; + if (normalized && normalized.length > 64) { + reply.code(400); + return { message: "display name too long" }; + } + const renamed = repository.renameAgent(params.id, normalized && normalized.length > 0 ? normalized : null); + if (!renamed) { + reply.code(404); + return { message: "agent not found" }; + } + await broadcastTopology(); + return renamed; + }); app.get("/api/bootstrap", { preHandler: requireBrowserAuth }, async () => ({ registrationToken })); app.get("/api/trigger-rules", { preHandler: requireBrowserAuth }, async () => repository.listTopology().triggerRules diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 9152440..e41e8bd 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -17,6 +17,7 @@ export const agentsTable = sqliteTable("agents", { .notNull() .references(() => nodesTable.id, { onDelete: "cascade" }), name: text("name").notNull(), + displayName: text("display_name"), backend: text("backend").notNull(), status: text("status").notNull(), capabilities: text("capabilities").notNull() diff --git a/apps/server/src/repository.ts b/apps/server/src/repository.ts index a168bae..2fd6ce9 100644 --- a/apps/server/src/repository.ts +++ b/apps/server/src/repository.ts @@ -175,6 +175,7 @@ export class Repository { id: capability.id, nodeId, name: capability.name, + displayName: null, backend: "acpx", status: "online", capabilities: JSON.stringify({ @@ -224,6 +225,7 @@ export class Repository { id: row.id, nodeId: row.nodeId, name: row.name, + displayName: row.displayName ?? null, backend: row.backend, status: row.status, capabilities: parseJson>(row.capabilities) @@ -245,6 +247,17 @@ export class Repository { }; } + + renameAgent(agentId: string, displayName: string | null) { + const changes = this.db + .update(agentsTable) + .set({ displayName }) + .where(eq(agentsTable.id, agentId)) + .run().changes; + if (!changes) return null; + return this.findAgent(agentId); + } + upsertTriggerRule(input: { sourceAgentId: string; targetAgentId: string; @@ -428,6 +441,7 @@ export class Repository { id: row.id, nodeId: row.nodeId, name: row.name, + displayName: row.displayName ?? null, backend: row.backend, status: row.status, capabilities: parseJson>(row.capabilities) diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts index 8af68c0..6b56d37 100644 --- a/apps/server/test/app.test.ts +++ b/apps/server/test/app.test.ts @@ -109,6 +109,30 @@ describe("server app", () => { socket.close(); }); + + it("renames an agent display name from browser API", async () => { + const socket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-1`); + await waitForOpen(socket); + socket.send(JSON.stringify(registerNode("node-1", "a"))); + await readNodeMessage(socket); + socket.send(JSON.stringify(syncCapabilities("node-1", [{ id: "agent-a", name: "Planner", acpxAgent: "planner" }]))); + await waitForIdle(); + + const patch = await injectAuthed(app, authCookie, { + method: "PATCH", + url: "/api/agents/agent-a", + payload: { displayName: "Triage Agent" } + }); + expect(patch.statusCode).toBe(200); + expect(patch.json()).toMatchObject({ id: "agent-a", displayName: "Triage Agent" }); + + const topology = await injectAuthed(app, authCookie, { method: "GET", url: "/api/topology" }); + expect(topology.statusCode).toBe(200); + expect(topology.json().agents).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "agent-a", displayName: "Triage Agent" })]) + ); + socket.close(); + }); it("keeps a node in topology even when it advertises zero agents", async () => { const socket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-empty`); await waitForOpen(socket); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 838a90f..9b521bd 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -141,3 +141,13 @@ export function connectRealtime(onEvent: (event: BrowserRealtimeEvent) => void) return socket; } + + +export async function renameAgent(agentId: string, displayName: string | null): Promise { + const response = await apiFetch(`/api/agents/${agentId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ displayName }) + }); + if (!response.ok) throw new Error("Rename failed"); +} diff --git a/apps/web/src/components/AgentPicker.tsx b/apps/web/src/components/AgentPicker.tsx index b5c2810..f257741 100644 --- a/apps/web/src/components/AgentPicker.tsx +++ b/apps/web/src/components/AgentPicker.tsx @@ -1,4 +1,7 @@ import type { TopologySnapshot } from "@amesh/protocol"; +import { useState } from "react"; + +import { renameAgent } from "../api.js"; type Props = { topology: TopologySnapshot; @@ -7,6 +10,7 @@ type Props = { }; export function AgentPicker({ topology, selectedAgentId, onSelect }: Props) { + const [savingId, setSavingId] = useState(null); const onlineAgents = topology.agents.filter((agent) => agent.status === "online"); const others = topology.agents.filter((agent) => agent.status !== "online"); @@ -33,9 +37,16 @@ export function AgentPicker({ topology, selectedAgentId, onSelect }: Props) { data-selected={selectedAgentId === agent.id} disabled={disabled} onClick={() => onSelect(agent.id)} + onDoubleClick={async () => { + if (savingId===agent.id) return; + const value = window.prompt("Set display name", agent.displayName ?? agent.name); + if (value===null) return; + setSavingId(agent.id); + try { await renameAgent(agent.id, value.trim() ? value.trim() : null); } finally { setSavingId(null);} + }} >
-
{agent.name}
+
{agent.displayName ?? agent.name}
{node?.name ?? "unknown node"} ยท diff --git a/apps/web/test/app.test.tsx b/apps/web/test/app.test.tsx index a51aaee..c6e4608 100644 --- a/apps/web/test/app.test.tsx +++ b/apps/web/test/app.test.tsx @@ -76,6 +76,7 @@ beforeEach(() => { id: "agent-1", nodeId: "node-1", name: "Planner", + displayName: null, backend: "acpx", status: "online", capabilities: {} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 49d30e3..10d9c0c 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -42,6 +42,7 @@ export const agentSchema = z.object({ id: z.string(), nodeId: z.string(), name: z.string(), + displayName: z.string().nullable().default(null), backend: z.literal("acpx"), status: agentStatusSchema, capabilities: payloadSchema