Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/drizzle/0001_agent_display_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `display_name` text;
7 changes: 7 additions & 0 deletions apps/server/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1778489600000,
"tag": "0000_initial",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1778573600000,
"tag": "0001_agent_display_name",
"breakpoints": true
}
]
}
28 changes: 27 additions & 1 deletion apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export class Repository {
id: capability.id,
nodeId,
name: capability.name,
displayName: null,
backend: "acpx",
status: "online",
capabilities: JSON.stringify({
Expand Down Expand Up @@ -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<Record<string, unknown>>(row.capabilities)
Expand All @@ -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;
Expand Down Expand Up @@ -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<Record<string, unknown>>(row.capabilities)
Expand Down
24 changes: 24 additions & 0 deletions apps/server/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,13 @@ export function connectRealtime(onEvent: (event: BrowserRealtimeEvent) => void)

return socket;
}


export async function renameAgent(agentId: string, displayName: string | null): Promise<void> {
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");
}
13 changes: 12 additions & 1 deletion apps/web/src/components/AgentPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { TopologySnapshot } from "@amesh/protocol";
import { useState } from "react";

import { renameAgent } from "../api.js";

type Props = {
topology: TopologySnapshot;
Expand All @@ -7,6 +10,7 @@ type Props = {
};

export function AgentPicker({ topology, selectedAgentId, onSelect }: Props) {
const [savingId, setSavingId] = useState<string | null>(null);
const onlineAgents = topology.agents.filter((agent) => agent.status === "online");
const others = topology.agents.filter((agent) => agent.status !== "online");

Expand All @@ -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);}
}}
>
<div>
<div className="agent-picker__name">{agent.name}</div>
<div className="agent-picker__name">{agent.displayName ?? agent.name}</div>
<div className="agent-picker__sub">
<span>{node?.name ?? "unknown node"}</span>
<span>·</span>
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ beforeEach(() => {
id: "agent-1",
nodeId: "node-1",
name: "Planner",
displayName: null,
backend: "acpx",
status: "online",
capabilities: {}
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading