From 090bf00850aa9da9837dc79fa3b505ee2fd94f03 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 3 Aug 2026 17:18:53 -0600 Subject: [PATCH] feat: add personality management MCP tools --- apps/server/src/db/personalities.ts | 72 ++++-- apps/server/src/routes/mcp.ts | 18 ++ apps/server/src/routes/personalities.ts | 6 +- apps/server/src/server.ts | 6 + apps/server/src/server/mcp-handlers.ts | 57 +++++ .../src/shared/mcp/personality-tools.ts | 212 ++++++++++++++++++ apps/server/src/shared/mcp/server.ts | 47 ++++ apps/server/test/db/personalities.test.ts | 68 ++++++ .../server/test/mcp-personality-tools.test.ts | 200 +++++++++++++++++ apps/server/test/personalities-routes.test.ts | 8 +- 10 files changed, 667 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/shared/mcp/personality-tools.ts create mode 100644 apps/server/test/db/personalities.test.ts create mode 100644 apps/server/test/mcp-personality-tools.test.ts diff --git a/apps/server/src/db/personalities.ts b/apps/server/src/db/personalities.ts index e89785a54..8783d1533 100644 --- a/apps/server/src/db/personalities.ts +++ b/apps/server/src/db/personalities.ts @@ -86,26 +86,32 @@ export async function deletePersonality( pool: Pool, id: string ): Promise { - // Atomic delete + active-id clear in one round-trip. The first CTE deletes - // the row; the second clears the active-id setting iff the row existed and - // was the active one. The trailing SELECT always returns one row, so we - // can read the deletion outcome without depending on either DELETE's - // RETURNING set. Closes the read-then-delete-then-clear race that an - // out-of-band activate would otherwise hit. - const result = await pool.query<{ deleted_count: number }>( - `WITH deleted AS ( - DELETE FROM personalities WHERE id = $1 RETURNING id - ), - cleared AS ( - DELETE FROM settings - WHERE key = 'active_personality_id' - AND value = $1 - AND EXISTS (SELECT 1 FROM deleted) - ) - SELECT COUNT(*)::int AS deleted_count FROM deleted`, - [id] - ); - return (result.rows[0]?.deleted_count ?? 0) > 0; + const client = await pool.connect(); + try { + await client.query("BEGIN"); + // This statement waits for activatePersonality's row lock when needed. + // The separate settings DELETE gets a fresh READ COMMITTED snapshot after + // that wait, ensuring it sees an activation that committed meanwhile. + const deleted = await client.query<{ id: string }>( + "DELETE FROM personalities WHERE id = $1 RETURNING id", + [id] + ); + if (deleted.rowCount === 0) { + await client.query("COMMIT"); + return false; + } + await client.query("DELETE FROM settings WHERE key = $1 AND value = $2", [ + ACTIVE_PERSONALITY_KEY, + id, + ]); + await client.query("COMMIT"); + return true; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } } export async function getActivePersonalityId( @@ -126,6 +132,32 @@ export async function setActivePersonalityId( await setSetting(pool, ACTIVE_PERSONALITY_KEY, id); } +/** + * Set the active personality only while holding a row lock on it. Deletion + * acquires that same row lock before it clears the active setting, so a delete + * cannot interleave and leave a dangling active_personality_id behind. + */ +export async function activatePersonality( + pool: Pool, + id: string +): Promise { + const result = await pool.query<{ activated: boolean }>( + `WITH locked AS ( + SELECT id FROM personalities WHERE id = $1 FOR UPDATE + ), + activated AS ( + INSERT INTO settings (key, value, updated_at) + SELECT '${ACTIVE_PERSONALITY_KEY}', id, NOW() FROM locked + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW() + RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM activated) AS activated`, + [id] + ); + return result.rows[0]?.activated ?? false; +} + export async function getActivePersonality( pool: Pool ): Promise { diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index 1be697da9..2aefcc565 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -54,6 +54,12 @@ type McpRouteDeps = { mcpClearWhiteboard: unknown; mcpListPersonas: unknown; mcpLaunchPersona: unknown; + mcpListPersonalities: unknown; + mcpCreatePersonality: unknown; + mcpUpdatePersonality: unknown; + mcpDeletePersonality: unknown; + mcpSetActivePersonality: unknown; + mcpClearActivePersonality: unknown; mcpLaunchAgent: unknown; mcpResolveReviewFeedback: unknown; mcpReopenReviewFeedback: unknown; @@ -212,6 +218,12 @@ export async function registerMcpRoutes( listPins: deps.mcpListPins, listPersonas: deps.mcpListPersonas, launchPersona: deps.mcpLaunchPersona, + listPersonalities: deps.mcpListPersonalities, + createPersonality: deps.mcpCreatePersonality, + updatePersonality: deps.mcpUpdatePersonality, + deletePersonality: deps.mcpDeletePersonality, + setActivePersonality: deps.mcpSetActivePersonality, + clearActivePersonality: deps.mcpClearActivePersonality, launchAgent: deps.mcpLaunchAgent, resolveReviewFeedback: deps.mcpResolveReviewFeedback, reopenReviewFeedback: deps.mcpReopenReviewFeedback, @@ -297,6 +309,12 @@ export async function registerMcpRoutes( clearWhiteboard: deps.mcpClearWhiteboard, listPersonas: deps.mcpListPersonas, launchPersona: deps.mcpLaunchPersona, + listPersonalities: deps.mcpListPersonalities, + createPersonality: deps.mcpCreatePersonality, + updatePersonality: deps.mcpUpdatePersonality, + deletePersonality: deps.mcpDeletePersonality, + setActivePersonality: deps.mcpSetActivePersonality, + clearActivePersonality: deps.mcpClearActivePersonality, launchAgent: deps.mcpLaunchAgent, resolveReviewFeedback: deps.mcpResolveReviewFeedback, reopenReviewFeedback: deps.mcpReopenReviewFeedback, diff --git a/apps/server/src/routes/personalities.ts b/apps/server/src/routes/personalities.ts index 49aff61d6..7c01c1ca3 100644 --- a/apps/server/src/routes/personalities.ts +++ b/apps/server/src/routes/personalities.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { Pool } from "pg"; import { + activatePersonality, createPersonality, deletePersonality, getActivePersonalityId, @@ -149,12 +150,9 @@ export async function registerPersonalityRoutes( return reply.code(400).send({ error: "id must be a string or null." }); } - const personality = await getPersonality(pool, id); - if (!personality) { + if (!(await activatePersonality(pool, id))) { return reply.code(404).send({ error: "Personality not found." }); } - - await setActivePersonalityId(pool, id); return { activeId: id }; }); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7f4e40d98..e6604abc9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -599,6 +599,12 @@ async function registerRoutes() { mcpClearWhiteboard: mcpHandlers.clearWhiteboard, mcpListPersonas: mcpHandlers.listPersonas, mcpLaunchPersona: mcpHandlers.launchPersona, + mcpListPersonalities: mcpHandlers.listPersonalities, + mcpCreatePersonality: mcpHandlers.createPersonality, + mcpUpdatePersonality: mcpHandlers.updatePersonality, + mcpDeletePersonality: mcpHandlers.deletePersonality, + mcpSetActivePersonality: mcpHandlers.setActivePersonality, + mcpClearActivePersonality: mcpHandlers.clearActivePersonality, mcpLaunchAgent: mcpHandlers.launchAgent, mcpResolveReviewFeedback: mcpHandlers.resolveReviewFeedback, mcpReopenReviewFeedback: mcpHandlers.reopenReviewFeedback, diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 97ef66d1d..8569adf0f 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -28,6 +28,16 @@ import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js"; import { createReviewHandlers } from "./mcp-review-handlers.js"; import { MessageStore } from "../messages/store.js"; import { createWhiteboardHandlers } from "./mcp-whiteboard-handlers.js"; +import { + activatePersonality, + createPersonality, + deletePersonality, + getActivePersonalityId, + listPersonalities, + setActivePersonalityId, + updatePersonality, +} from "../db/personalities.js"; +import { errorMessage } from "../shared/lib/error-message.js"; function buildChildAgentInitialPrompt( parentAgentId: string, @@ -55,6 +65,13 @@ type CreateMcpHandlersDeps = { appLog: FastifyBaseLogger; }; +function normalizePersonalityDuplicateName(error: unknown): never { + if (errorMessage(error).includes("personalities_name_key")) { + throw new Error("A personality with that name already exists."); + } + throw error; +} + export function mcpMethodNotAllowed(): { jsonrpc: "2.0"; error: { code: number; message: string }; @@ -720,6 +737,46 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { ...reviewHandlers, ...whiteboardHandlers, + listPersonalities: async () => { + const [personalities, activeId] = await Promise.all([ + listPersonalities(deps.pool), + getActivePersonalityId(deps.pool), + ]); + return { personalities, activeId }; + }, + + createPersonality: async (input: { name: string; prompt: string }) => { + try { + return await createPersonality(deps.pool, input); + } catch (error) { + return normalizePersonalityDuplicateName(error); + } + }, + + updatePersonality: ( + id: string, + input: { name?: string; prompt?: string } + ) => + updatePersonality(deps.pool, id, input) + .catch(normalizePersonalityDuplicateName) + .then((personality) => { + if (!personality) throw new Error("Personality not found."); + return personality; + }), + + deletePersonality: (id: string) => + deletePersonality(deps.pool, id).then((deleted) => { + if (!deleted) throw new Error("Personality not found."); + }), + + setActivePersonality: async (id: string) => { + if (!(await activatePersonality(deps.pool, id))) { + throw new Error("Personality not found."); + } + }, + + clearActivePersonality: () => setActivePersonalityId(deps.pool, null), + upsertEvent: ( agentId: string, event: { diff --git a/apps/server/src/shared/mcp/personality-tools.ts b/apps/server/src/shared/mcp/personality-tools.ts new file mode 100644 index 000000000..9f85b5c1f --- /dev/null +++ b/apps/server/src/shared/mcp/personality-tools.ts @@ -0,0 +1,212 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; + +import type { Personality } from "../../db/personalities.js"; +import { toToolError } from "./tool-error.js"; + +const NAME_MAX = 80; +const PROMPT_MAX = 1000; + +export type PersonalityToolCallbacks = { + listPersonalities?: () => Promise<{ + personalities: Personality[]; + activeId: string | null; + }>; + createPersonality?: (input: { + name: string; + prompt: string; + }) => Promise; + updatePersonality?: ( + id: string, + input: { name?: string; prompt?: string } + ) => Promise; + deletePersonality?: (id: string) => Promise; + setActivePersonality?: (id: string) => Promise; + clearActivePersonality?: () => Promise; +}; + +export function registerPersonalityTools( + server: McpServer, + allowed: Set, + callbacks: PersonalityToolCallbacks +): void { + if (allowed.has("list_personalities") && callbacks.listPersonalities) { + const listPersonalities = callbacks.listPersonalities; + server.registerTool( + "list_personalities", + { + description: + "List saved Dispatch personalities and the currently active personality ID. Personalities shape standard agents; they are unrelated to review personas.", + inputSchema: {}, + }, + async () => { + try { + const result = await listPersonalities(); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("create_personality") && callbacks.createPersonality) { + const createPersonality = callbacks.createPersonality; + server.registerTool( + "create_personality", + { + description: + "Create a saved Dispatch personality. It is not made active automatically.", + inputSchema: { + name: z.string().trim().min(1).max(NAME_MAX), + prompt: z + .string() + .min(1) + .max(PROMPT_MAX) + .refine((value) => value.trim().length > 0, { + message: "prompt cannot be blank.", + }), + }, + }, + async ({ name, prompt }) => { + try { + const personality = await createPersonality({ name, prompt }); + return { + content: [ + { + type: "text", + text: `Created personality \"${personality.name}\" (${personality.id}).`, + }, + ], + structuredContent: { personality }, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("update_personality") && callbacks.updatePersonality) { + const updatePersonality = callbacks.updatePersonality; + server.registerTool( + "update_personality", + { + description: + "Update the name and/or prompt of a saved Dispatch personality.", + inputSchema: { + id: z.string().min(1), + name: z.string().trim().min(1).max(NAME_MAX).optional(), + prompt: z + .string() + .min(1) + .max(PROMPT_MAX) + .refine((value) => value.trim().length > 0, { + message: "prompt cannot be blank.", + }) + .optional(), + }, + }, + async ({ id, name, prompt }) => { + try { + if (name === undefined && prompt === undefined) { + throw new Error( + "Provide name and/or prompt to update a personality." + ); + } + const personality = await updatePersonality(id, { name, prompt }); + return { + content: [ + { + type: "text", + text: `Updated personality \"${personality.name}\" (${personality.id}).`, + }, + ], + structuredContent: { personality }, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("delete_personality") && callbacks.deletePersonality) { + const deletePersonality = callbacks.deletePersonality; + server.registerTool( + "delete_personality", + { + description: + "Delete a saved Dispatch personality. If it is active, the active personality is cleared.", + inputSchema: { id: z.string().min(1) }, + }, + async ({ id }) => { + try { + await deletePersonality(id); + return { + content: [{ type: "text", text: `Deleted personality ${id}.` }], + structuredContent: { id, deleted: true }, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("set_active_personality") && callbacks.setActivePersonality) { + const setActivePersonality = callbacks.setActivePersonality; + server.registerTool( + "set_active_personality", + { + description: + "Set a saved Dispatch personality as active for subsequently launched standard agents.", + inputSchema: { id: z.string().min(1) }, + }, + async ({ id }) => { + try { + await setActivePersonality(id); + return { + content: [ + { type: "text", text: `Set active personality to ${id}.` }, + ], + structuredContent: { activeId: id }, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if ( + allowed.has("clear_active_personality") && + callbacks.clearActivePersonality + ) { + const clearActivePersonality = callbacks.clearActivePersonality; + server.registerTool( + "clear_active_personality", + { + description: + "Clear the active Dispatch personality so subsequently launched standard agents receive no personality text.", + inputSchema: {}, + }, + async () => { + try { + await clearActivePersonality(); + return { + content: [ + { type: "text", text: "Cleared the active personality." }, + ], + structuredContent: { activeId: null }, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } +} diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index 58523d451..2a754c829 100644 --- a/apps/server/src/shared/mcp/server.ts +++ b/apps/server/src/shared/mcp/server.ts @@ -16,6 +16,7 @@ import { registerBrainTools } from "./brain-tools.js"; import { registerCrudTools, type CrudToolCallbacks } from "./crud-tools.js"; import { registerJobTools, type JobTools } from "./job-tools.js"; import { registerMessagingTools } from "./messaging-tools.js"; +import { registerPersonalityTools } from "./personality-tools.js"; import { registerWhiteboardTools } from "./whiteboard-tools.js"; import type { WhiteboardGetResult, @@ -64,6 +65,12 @@ const AGENT_TOOLS = new Set([ "dispatch_list_pins", "list_personas", "dispatch_launch_persona", + "list_personalities", + "create_personality", + "update_personality", + "delete_personality", + "set_active_personality", + "clear_active_personality", "dispatch_review_list_feedback", "dispatch_review_resolve", "dispatch_review_reopen", @@ -255,6 +262,36 @@ export type McpRequestContext = { includeDiff?: boolean; } ) => Promise<{ agentId: string; persona: string; parentAgentId: string }>; + listPersonalities?: () => Promise<{ + personalities: Array<{ + id: string; + name: string; + prompt: string; + createdAt: string; + updatedAt: string; + }>; + activeId: string | null; + }>; + createPersonality?: (input: { name: string; prompt: string }) => Promise<{ + id: string; + name: string; + prompt: string; + createdAt: string; + updatedAt: string; + }>; + updatePersonality?: ( + id: string, + input: { name?: string; prompt?: string } + ) => Promise<{ + id: string; + name: string; + prompt: string; + createdAt: string; + updatedAt: string; + }>; + deletePersonality?: (id: string) => Promise; + setActivePersonality?: (id: string) => Promise; + clearActivePersonality?: () => Promise; launchAgent?: ( agentId: string, input: { @@ -475,6 +512,16 @@ async function createDispatchMcpServer( }); } + // ── Personalities ──────────────────────────────────────────────── + registerPersonalityTools(server, allowed, { + listPersonalities: context.listPersonalities, + createPersonality: context.createPersonality, + updatePersonality: context.updatePersonality, + deletePersonality: context.deletePersonality, + setActivePersonality: context.setActivePersonality, + clearActivePersonality: context.clearActivePersonality, + }); + if (allowed.has("dispatch_pin")) registerPinTool(server, context); if (allowed.has("dispatch_delete_pin")) registerDeletePinTool(server, context); diff --git a/apps/server/test/db/personalities.test.ts b/apps/server/test/db/personalities.test.ts new file mode 100644 index 000000000..f642e9924 --- /dev/null +++ b/apps/server/test/db/personalities.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + deletePersonality, + getActivePersonalityId, +} from "../../src/db/personalities.js"; +import { useInjectApp } from "../helpers/inject-app.js"; + +const ctx = useInjectApp(); + +beforeEach(async () => { + await ctx.pool.query( + "DELETE FROM settings WHERE key = 'active_personality_id'" + ); + await ctx.pool.query("DELETE FROM personalities"); +}); + +async function waitForDeleteToBlock(): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const result = await ctx.pool.query<{ waiting: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE query = 'DELETE FROM personalities WHERE id = $1 RETURNING id' + AND wait_event_type = 'Lock' + ) AS waiting` + ); + if (result.rows[0]?.waiting) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for personality deletion to block."); +} + +describe("deletePersonality", () => { + it("clears an activation committed while deletion waits on the personality lock", async () => { + const id = "personality-race"; + await ctx.pool.query( + "INSERT INTO personalities (id, name, prompt) VALUES ($1, 'Race', 'Prompt')", + [id] + ); + + const activation = await ctx.pool.connect(); + try { + await activation.query("BEGIN"); + await activation.query( + "SELECT id FROM personalities WHERE id = $1 FOR UPDATE", + [id] + ); + + const deleting = deletePersonality(ctx.pool, id); + await waitForDeleteToBlock(); + + await activation.query( + `INSERT INTO settings (key, value, updated_at) + VALUES ('active_personality_id', $1, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW()`, + [id] + ); + await activation.query("COMMIT"); + + expect(await deleting).toBe(true); + expect(await getActivePersonalityId(ctx.pool)).toBeNull(); + } finally { + await activation.query("ROLLBACK").catch(() => undefined); + activation.release(); + } + }); +}); diff --git a/apps/server/test/mcp-personality-tools.test.ts b/apps/server/test/mcp-personality-tools.test.ts new file mode 100644 index 000000000..0959b3383 --- /dev/null +++ b/apps/server/test/mcp-personality-tools.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useInjectApp } from "./helpers/inject-app.js"; + +const ctx = useInjectApp(); +let authToken: string; + +beforeEach(async () => { + if (!authToken) { + const result = await ctx.pool.query<{ value: string }>( + "SELECT value FROM settings WHERE key = 'auth_token'" + ); + authToken = result.rows[0]!.value; + } + await ctx.pool.query( + "DELETE FROM settings WHERE key = 'active_personality_id'" + ); + await ctx.pool.query("DELETE FROM personalities"); + await ctx.pool.query("DELETE FROM agents"); +}); + +async function mcpToolsList(agentId: string) { + return ctx.app.inject({ + method: "POST", + url: `/api/mcp/${agentId}`, + headers: { + authorization: `Bearer ${ctx.auth.createAgentMcpToken(authToken, agentId)}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + payload: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + }); +} + +async function mcpToolCall( + agentId: string, + name: string, + args: Record +) { + return ctx.app.inject({ + method: "POST", + url: `/api/mcp/${agentId}`, + headers: { + authorization: `Bearer ${ctx.auth.createAgentMcpToken(authToken, agentId)}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + payload: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }, + }); +} + +function parseToolResult(body: string): Record { + for (const line of body.split("\n")) { + if (!line.startsWith("data: ")) continue; + const json = JSON.parse(line.slice(6)); + if (json.result?.structuredContent) return json.result.structuredContent; + } + throw new Error("No MCP structured result found."); +} + +describe("MCP personality tools", () => { + const agentId = "agt_personality_tools"; + const toolNames = [ + "list_personalities", + "create_personality", + "update_personality", + "delete_personality", + "set_active_personality", + "clear_active_personality", + ]; + + beforeEach(async () => { + await ctx.pool.query( + `INSERT INTO agents (id, name, type, status, cwd, full_access) + VALUES ($1, 'personality-tools', 'claude', 'running', '/tmp', false)`, + [agentId] + ); + }); + + it("exposes personality tools to standard agents, not review agents", async () => { + const standardResponse = await mcpToolsList(agentId); + expect(standardResponse.statusCode).toBe(200); + for (const name of toolNames) expect(standardResponse.body).toContain(name); + + await ctx.pool.query( + `INSERT INTO agents (id, name, type, role, status, cwd, persona, parent_agent_id, full_access) + VALUES ('agt_personality_review', 'reviewer', 'claude', 'review', 'running', '/tmp', 'security-review', $1, false)`, + [agentId] + ); + const reviewResponse = await mcpToolsList("agt_personality_review"); + expect(reviewResponse.statusCode).toBe(200); + for (const name of toolNames) { + expect(reviewResponse.body).not.toContain(`\"${name}\"`); + } + + await ctx.pool.query( + `INSERT INTO agents (id, name, type, status, cwd, full_access) + VALUES ('agt_personality_job', 'job', 'claude', 'running', '/tmp', false)` + ); + await ctx.pool.query( + `INSERT INTO jobs (id, directory, name, enabled, agent_type, use_worktree, full_access, schedule, timeout_ms, needs_input_timeout_ms, auto_archive) + VALUES ('job_personality_tools', '/tmp', 'Personality Job', false, 'claude', false, false, null, 1800000, 1800000, true)` + ); + await ctx.pool.query( + `INSERT INTO job_runs (id, job_id, status, started_at, status_updated_at, agent_id) + VALUES ('run_personality_tools', 'job_personality_tools', 'running', NOW(), NOW(), 'agt_personality_job')` + ); + const jobResponse = await ctx.app.inject({ + method: "POST", + url: "/api/mcp/jobs/run_personality_tools/agt_personality_job", + headers: { + authorization: `Bearer ${ctx.auth.createJobMcpToken(authToken, "run_personality_tools", "agt_personality_job")}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + payload: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + }); + expect(jobResponse.statusCode).toBe(200); + for (const name of toolNames) { + expect(jobResponse.body).not.toContain(`\"${name}\"`); + } + }); + + it("creates, updates, activates, clears, and deletes a personality", async () => { + const created = parseToolResult( + ( + await mcpToolCall(agentId, "create_personality", { + name: "Focused engineer", + prompt: "Be concise and verify every implementation detail.", + }) + ).body + ).personality as { id: string; name: string; prompt: string }; + expect(created).toMatchObject({ name: "Focused engineer" }); + + const updated = parseToolResult( + ( + await mcpToolCall(agentId, "update_personality", { + id: created.id, + name: "Focused builder", + prompt: "Be concise, practical, and verify details.", + }) + ).body + ).personality as { name: string; prompt: string }; + expect(updated).toMatchObject({ + name: "Focused builder", + prompt: "Be concise, practical, and verify details.", + }); + + expect( + parseToolResult( + ( + await mcpToolCall(agentId, "set_active_personality", { + id: created.id, + }) + ).body + ) + ).toEqual({ activeId: created.id }); + + const listed = parseToolResult( + (await mcpToolCall(agentId, "list_personalities", {})).body + ) as { activeId: string | null; personalities: Array<{ id: string }> }; + expect(listed.activeId).toBe(created.id); + expect(listed.personalities).toEqual( + expect.arrayContaining([expect.objectContaining({ id: created.id })]) + ); + + expect( + parseToolResult( + (await mcpToolCall(agentId, "clear_active_personality", {})).body + ) + ).toEqual({ activeId: null }); + + expect( + parseToolResult( + (await mcpToolCall(agentId, "delete_personality", { id: created.id })) + .body + ) + ).toEqual({ id: created.id, deleted: true }); + }); + + it("returns a stable duplicate-name error", async () => { + await mcpToolCall(agentId, "create_personality", { + name: "Duplicate", + prompt: "First prompt", + }); + const duplicate = await mcpToolCall(agentId, "create_personality", { + name: "Duplicate", + prompt: "Second prompt", + }); + expect(duplicate.body).toContain( + "A personality with that name already exists." + ); + expect(duplicate.body).not.toContain("personalities_name_key"); + }); +}); diff --git a/apps/server/test/personalities-routes.test.ts b/apps/server/test/personalities-routes.test.ts index 0977ef4e7..386385132 100644 --- a/apps/server/test/personalities-routes.test.ts +++ b/apps/server/test/personalities-routes.test.ts @@ -13,6 +13,7 @@ import { registerPersonalityRoutes } from "../src/routes/personalities.js"; import * as personalityQueries from "../src/db/personalities.js"; vi.mock("../src/db/personalities.js", () => ({ + activatePersonality: vi.fn(), listPersonalities: vi.fn(), getPersonality: vi.fn(), createPersonality: vi.fn(), @@ -62,6 +63,7 @@ beforeEach(() => { vi.mocked(personalityQueries.setActivePersonalityId).mockResolvedValue( undefined ); + vi.mocked(personalityQueries.activatePersonality).mockResolvedValue(true); }); // ── GET /api/v1/personalities ────────────────────────────────────────── @@ -435,7 +437,7 @@ describe("POST /api/v1/personalities/active", () => { }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ activeId: "p-1" }); - expect(personalityQueries.setActivePersonalityId).toHaveBeenCalledWith( + expect(personalityQueries.activatePersonality).toHaveBeenCalledWith( pool, "p-1" ); @@ -480,7 +482,7 @@ describe("POST /api/v1/personalities/active", () => { }); it("returns 404 for empty string id", async () => { - vi.mocked(personalityQueries.getPersonality).mockResolvedValue(null); + vi.mocked(personalityQueries.activatePersonality).mockResolvedValue(false); const res = await app.inject({ method: "POST", url: "/api/v1/personalities/active", @@ -491,7 +493,7 @@ describe("POST /api/v1/personalities/active", () => { }); it("returns 404 when personality does not exist", async () => { - vi.mocked(personalityQueries.getPersonality).mockResolvedValue(null); + vi.mocked(personalityQueries.activatePersonality).mockResolvedValue(false); const res = await app.inject({ method: "POST", url: "/api/v1/personalities/active",