From 1a28cc50985538425a4d057666fa18e3fac91b1a Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:14:45 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcpd):=20make=20AgentProject=20real=20?= =?UTF-8?q?=E2=80=94=20an=20agent=20joins=20many=20projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentProject junction has existed since the data-model commit and nothing read it. `grep -c agentProject src/mcpd/src/services/chat.service.ts` returned 0; chat resolved an agent's project from the legacy single `Agent.projectId` scalar. So the schema claimed "an agent is a person who joins zero, one, or many projects" (docs/agentic-teams.md §4) while the running system still gave every agent exactly one project. A schema that lies about behaviour is worse than a missing feature: it reads as done, so nobody builds it. This makes the junction load-bearing. Membership is now a real surface (task 152): GET /api/v1/agents/:name/projects memberships POST /api/v1/agents/:name/projects/:project join, body {role?, llm?} DELETE /api/v1/agents/:name/projects/:project leave GET /api/v1/projects/:name/memberships the roster, with roles `role` is TEXT (developer|qa|security|sre in v1) and is validated in the service layer, not the database — production provisions with `prisma db push`, so a CHECK constraint written into a migration would never run there. Adding a fifth role stays a one-line change. RBAC: joining/leaving is `edit:agents:`, not the `create:agents` the default method→action mapping would have produced. Minting a whole new agent and adding an existing one to a team are not the same authority; this mirrors project↔server attach/detach already being `edit:projects`. Reading a roster is `view:` on the side you asked from. Chat honours it. `prepareContext` resolves the project from the membership when the caller names one, and the resolution runs *before* the Llm lookup so a per-membership `llmId` can replace the agent's pinned brain — and therefore its whole failover pool — rather than patching the dispatch list afterwards. That override is why the brain-ownership question (§15 Q1) can still go either way without a migration: today the answer is per membership. Naming a project the agent has not joined is an error that names the fix, not a silent fallback. Answering from a *different* project's tools and prompts than the one asked for is the failure mode worth being loud about. The legacy scalar keeps working this release, as the schema comment promises: `--project` accepts a membership *or* a legacy pin naming that same project, bare `mcpctl chat ` is unchanged, and `GET /projects/:p/agents` returns the union of members and legacy-pinned agents so nothing disappears from a project mid-migration (mcplocal's agents plugin reads that endpoint). CLI: `mcpctl chat --project

` (previously rejected as "not both"), plus `attach-agent` / `detach-agent` alongside the existing attach-server pair, `get agents --project`, and `get memberships` with ROLE and BRAIN columns. Completions come from the generator, not hand-edits. Threads stay scoped to the agent, not to (agent, project) — §4 wants the latter, but that needs a schema change and a story for what happens to those threads when a membership is revoked. Deliberately not decided here. Tests: 51 new (43 mcpd, 8 CLI). Full suite 2514 pass. Lint unchanged at 794 errors / 77 warnings. Co-Authored-By: Claude Opus 5 (1M context) --- completions/mcpctl.bash | 31 ++- completions/mcpctl.fish | 68 +++++- docs/agents.md | 44 +++- docs/chat.md | 30 +++ scripts/generate-completions.ts | 102 ++++++++- src/cli/src/commands/chat.ts | 41 +++- src/cli/src/commands/get.ts | 21 ++ src/cli/src/commands/project-ops.ts | 47 ++++ src/cli/src/commands/shared.ts | 5 + src/cli/src/index.ts | 30 ++- .../tests/commands/agent-membership.test.ts | 121 ++++++++++ src/mcpd/src/main.ts | 36 ++- .../repositories/agent-project.repository.ts | 81 +++++++ src/mcpd/src/routes/agent-chat.ts | 3 +- src/mcpd/src/routes/agent-projects.ts | 95 ++++++++ .../src/services/agent-project.service.ts | 132 +++++++++++ src/mcpd/src/services/agent.service.ts | 98 +++++++- src/mcpd/src/services/chat.service.ts | 21 +- .../src/validation/agent-project.schema.ts | 32 +++ src/mcpd/src/validation/agent.schema.ts | 6 + src/mcpd/tests/agent-membership.test.ts | 198 ++++++++++++++++ src/mcpd/tests/agent-project-routes.test.ts | 103 +++++++++ .../tests/chat-service-membership.test.ts | 180 +++++++++++++++ .../tests/chat-service-virtual-llm.test.ts | 1 + src/mcpd/tests/chat-service.test.ts | 17 ++ .../services/agent-project-service.test.ts | 215 ++++++++++++++++++ 26 files changed, 1731 insertions(+), 27 deletions(-) create mode 100644 src/cli/tests/commands/agent-membership.test.ts create mode 100644 src/mcpd/src/repositories/agent-project.repository.ts create mode 100644 src/mcpd/src/routes/agent-projects.ts create mode 100644 src/mcpd/src/services/agent-project.service.ts create mode 100644 src/mcpd/src/validation/agent-project.schema.ts create mode 100644 src/mcpd/tests/agent-membership.test.ts create mode 100644 src/mcpd/tests/agent-project-routes.test.ts create mode 100644 src/mcpd/tests/chat-service-membership.test.ts create mode 100644 src/mcpd/tests/services/agent-project-service.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8b4f96d..fdadfd5 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -6,10 +6,10 @@ _mcpctl() { _init_completion || return local commands="status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" - local project_commands="get describe delete logs create edit attach-server detach-server favourites" + local project_commands="get describe delete logs create edit attach-server detach-server attach-agent detach-agent favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all" - local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp" + local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all" + local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team" # Check if --project/-p was given local has_project=false @@ -374,6 +374,31 @@ _mcpctl() { fi COMPREPLY=($(compgen -W "$names" -- "$cur")) return ;; + attach-agent) + if [[ $((cword - subcmd_pos)) -ne 1 ]]; then + COMPREPLY=($(compgen -W "--role --llm -h --help" -- "$cur")) + return + fi + local proj names all_agents proj_agents + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + proj_agents=$(mcpctl --project "$proj" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort)) + else + names=$(_mcpctl_resource_names "agents") + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; + detach-agent) + if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi + local proj names + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + names=$(mcpctl --project "$proj" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; approve) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "promptrequest -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ec047eb..2df27b2 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -5,7 +5,7 @@ complete -c mcpctl -e set -l commands status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate -set -l project_commands get describe delete logs create edit attach-server detach-server favourites +set -l project_commands get describe delete logs create edit attach-server detach-server attach-agent detach-agent favourites # Disable file completions by default complete -c mcpctl -f @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all +set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -78,13 +78,14 @@ function __mcpctl_resolve_resource case task tasks; echo tasks case repo repos repository repositories; echo repos case gitprovider gitproviders forge gp; echo gitproviders + case membership memberships team; echo memberships case all; echo all case '*'; echo $argv[1] end end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -156,6 +157,31 @@ function __mcpctl_available_servers end end +# Agents already on the project team (for detach-agent) +function __mcpctl_project_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null +end + +# Agents NOT yet on the project team (for attach-agent) +function __mcpctl_available_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null + return + end + set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + for a in $all + if not contains -- $a $joined + echo $a + end + end +end + # Instance names for logs function __mcpctl_instance_names mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null @@ -202,6 +228,26 @@ function __mcpctl_needs_server_arg return 1 end +# Helper: check if attach-agent/detach-agent already has an agent argument +function __mcpctl_needs_agent_arg + set -l tokens (commandline -opc) + set -l found_cmd false + for tok in $tokens + if $found_cmd + if not string match -q -- '-*' $tok + return 1 # agent arg already present + end + end + if contains -- $tok attach-agent detach-agent + set found_cmd true + end + end + if $found_cmd + return 0 + end + return 1 +end + # Helper: check if a specific parent-child subcommand pair is active function __mcpctl_subcmd_active set -l parent $argv[1] @@ -263,6 +309,8 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-agent -d 'Add an agent to a project team (requires --project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-agent -d 'Remove an agent from a project team (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a favourites -d 'Inspect / derive a project\'s favourite-index tool shortlist (requires --project)' # Resource types — only when resource type not yet selected @@ -670,7 +718,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' # chat options -complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'With : view that project (agent must be a member). Alone: chat with the project itself' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l allow-secrets -d 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s m -l message -d 'One-shot: send a single message and exit (no REPL)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l thread -d 'Resume an existing thread' -x @@ -699,6 +747,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from chat-llm" -l async -d 'Enqueu # errors options complete -c mcpctl -n "__fish_seen_subcommand_from errors" -s n -l limit -d 'max entries to show (default 50)' -x +# attach-agent options +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l role -d 'Persona on this team: developer|qa|security|sre' -x +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l llm -d 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)' -x + # console options complete -c mcpctl -n "__fish_seen_subcommand_from console" -l stdin-mcp -d 'Run inspector as MCP server over stdin/stdout (for Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from console" -l audit -d 'Browse audit events from mcpd' @@ -715,6 +767,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_n # detach-server: show servers IN the project (only if no server arg yet) complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_project_servers)' -d 'Server' +# attach-agent: show agents NOT yet on the team (only if no agent arg yet) +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg" -a '(__mcpctl_available_agents)' -d 'Agent' + +# detach-agent: show agents ON the team (only if no agent arg yet) +complete -c mcpctl -n "__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg" -a '(__mcpctl_project_agents)' -d 'Agent' + # apply: allow file completions for positional argument complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F diff --git a/docs/agents.md b/docs/agents.md index eb1a3c8..c29e742 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -80,7 +80,11 @@ POST /api/v1/agents/:name/chat chat — non-streaming or SSE (run:agents POST /api/v1/agents/:name/threads create thread (run:agents:) GET /api/v1/agents/:name/threads list threads (run:agents:) GET /api/v1/threads/:id/messages replay history (view:agents) -GET /api/v1/projects/:p/agents project-scoped list (view:projects:

) +GET /api/v1/projects/:p/agents project roster — members + legacy pins (view:projects:

) +GET /api/v1/agents/:name/projects memberships (view:agents:) +POST /api/v1/agents/:name/projects/:p join, body {role?, llm?} (edit:agents:) +DELETE /api/v1/agents/:name/projects/:p leave (edit:agents:) +GET /api/v1/projects/:p/memberships roster with roles (view:projects:

) ``` The chat endpoint reuses the SSE pattern from `llm-infer.ts` exactly: same @@ -93,6 +97,37 @@ framing, same `[DONE]` terminator. SSE chunk types: - `{type:'final', threadId, turnIndex}` — terminal turn - `{type:'error', message}` — fatal error in the loop +## Team membership (an agent joins many projects) + +An agent is a **person**, not a project's property: it joins zero, one, or many +projects (`docs/agentic-teams.md` §4). Membership lives in the `AgentProject` +junction and carries two per-project facts the `Agent` row cannot: + +- **`role`** — the persona worn on this team. v1: `developer` (default), `qa`, + `security`, `sre`. TEXT in the schema, validated in the service layer, so + adding a role later is not a migration. +- **`llm`** — an optional **brain override**. When set, that Llm answers for + this project instead of the agent's pinned one; the agent keeps its own brain + everywhere else. + +```bash +mcpctl attach-agent alice --project sre --role sre --llm qwen3-thinking +mcpctl attach-agent alice --project payments --role security +mcpctl get memberships alice # every team alice is on +mcpctl get memberships --project sre # the sre roster, with roles +mcpctl get agents --project sre # the same roster as plain agent rows +mcpctl detach-agent alice --project sre +``` + +Joining is idempotent — re-running `attach-agent` updates the role/brain +instead of failing, so repeated applies converge. + +**Transition:** the legacy single `Agent.projectId` pin still works this +release. `mcpctl chat --project

` accepts a membership *or* a legacy +pin naming that same project; anything else is an error rather than a silent +answer from the wrong project's context. `GET /projects/:p/agents` returns the +union of both, so nothing disappears from a project mid-migration. + ## Tool-use loop When the agent's project has MCP servers attached, mcpd's `ChatService` lists @@ -113,10 +148,15 @@ Recommended: - `view:agents` — list / describe - `create:agents` / `edit:agents` / `delete:agents` — CRUD - `run:agents:` — drive a chat turn or manage its threads +- `edit:agents:` — join/leave a project team (NOT `create:agents`, which + mints whole agents; this mirrors project↔server attach/detach being `edit`) Project-attached agents do not implicitly inherit project RBAC. If a project member should be able to chat with the project's agents, grant them -`run:agents:` (or wildcard `run:agents`) explicitly. +`run:agents:` (or wildcard `run:agents`) explicitly. The reverse also +holds: membership grants the *agent* project context, not the *caller* project +permissions — `run:agents:` is still the only check on +`mcpctl chat --project

`. ## YAML round-trip diff --git a/docs/chat.md b/docs/chat.md index 1ece80b..8280fb2 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -8,6 +8,7 @@ create one. ```bash mcpctl chat # interactive REPL, new thread +mcpctl chat --project

# same agent, viewing project p mcpctl chat --thread # interactive REPL, resume thread mcpctl chat -m "hi" # one-shot, prints reply, no REPL mcpctl chat -m "hi" --no-stream # one-shot, single JSON response (no SSE) @@ -17,6 +18,35 @@ Streaming is on by default. Text deltas land on stdout as they arrive; tool calls and tool results print to stderr in dim brackets so the chat output stays clean. +## Two lenses on the same agent + +`mcpctl chat alice` is the person **on their own** — their system prompt, their +own pinned brain, no project tools. + +`mcpctl chat alice --project sre` is the **same person viewing one of their +teams**: the project's Prompts join the system block, the project's MCP servers +become callable tools, and if that membership carries a brain override, that +Llm answers instead of alice's pinned one. + +```bash +mcpctl attach-agent alice --project sre --role sre # join first +mcpctl chat alice --project sre +``` + +The agent must actually be a member. Naming a project it has not joined is an +error naming the fix, not a silent fallback to some other project's context: + +``` +Agent 'alice' is not a member of project 'payments'. +Join it with: mcpctl attach-agent alice --project payments +``` + +(The legacy single-project pin on the agent still counts as membership in that +one project for this release — see [agents.md](agents.md#team-membership-an-agent-joins-many-projects).) + +Threads stay scoped to the **agent**, not to (agent, project) — switching +`--project` mid-conversation changes the context layer, not the history. + ## Project chat Chat directly with a **project** — no Agent needed. The session sees the diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index a61c7d3..21c268b 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -29,6 +29,8 @@ type CompletionHint = | 'instance_names' | 'available_servers' | 'project_servers' + | 'available_agents' + | 'project_agents' | 'file' | { static: string[] }; @@ -53,6 +55,8 @@ const COMPLETION_HINTS: Record = { 'logs.name': 'instance_names', 'attach-server.server-name': 'available_servers', 'detach-server.server-name': 'project_servers', + 'attach-agent.agent-name': 'available_agents', + 'detach-agent.agent-name': 'project_agents', 'apply.file': 'file', }; @@ -66,7 +70,9 @@ const FILE_OPTIONS = new Set([ ]); /** Commands shown ONLY when --project/-p is on the command line. */ -const PROJECT_ONLY_COMMANDS = new Set(['attach-server', 'detach-server', 'favourites']); +const PROJECT_ONLY_COMMANDS = new Set([ + 'attach-server', 'detach-server', 'attach-agent', 'detach-agent', 'favourites', +]); /** Commands that appear in BOTH project and non-project context. */ const PROJECT_SCOPED_COMMANDS = new Set([ @@ -188,7 +194,7 @@ const CANONICAL_RESOURCES = [ 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'inference-tasks', // Agentic teams - 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'all', + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'memberships', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ @@ -220,6 +226,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['repo', 'repos'], ['repos', 'repos'], ['repository', 'repos'], ['repositories', 'repos'], ['gitprovider', 'gitproviders'], ['gitproviders', 'gitproviders'], ['forge', 'gitproviders'], ['gp', 'gitproviders'], + ['membership', 'memberships'], ['memberships', 'memberships'], ['team', 'memberships'], ['all', 'all'], ]; @@ -452,6 +459,14 @@ function generateFish(root: CmdInfo): string { emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg\" -a '(__mcpctl_project_servers)' -d 'Server'"); emit(''); + // attach-agent / detach-agent — the AgentProject team roster + emit("# attach-agent: show agents NOT yet on the team (only if no agent arg yet)"); + emit("complete -c mcpctl -n \"__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_available_agents)' -d 'Agent'"); + emit(''); + emit("# detach-agent: show agents ON the team (only if no agent arg yet)"); + emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_project_agents)' -d 'Agent'"); + emit(''); + // apply: allow file completions for positional argument emit("# apply: allow file completions for positional argument"); emit("complete -c mcpctl -n \"__fish_seen_subcommand_from apply\" -F"); @@ -603,6 +618,33 @@ function __mcpctl_available_servers end end end +`); + + emit(`# Agents already on the project team (for detach-agent) +function __mcpctl_project_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null +end +`); + + emit(`# Agents NOT yet on the project team (for attach-agent) +function __mcpctl_available_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null + return + end + set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + for a in $all + if not contains -- $a $joined + echo $a + end + end +end `); emit(`# Instance names for logs @@ -652,6 +694,27 @@ function __mcpctl_needs_server_arg end return 1 end +`); + + emit(`# Helper: check if attach-agent/detach-agent already has an agent argument +function __mcpctl_needs_agent_arg + set -l tokens (commandline -opc) + set -l found_cmd false + for tok in $tokens + if $found_cmd + if not string match -q -- '-*' $tok + return 1 # agent arg already present + end + end + if contains -- $tok attach-agent detach-agent + set found_cmd true + end + end + if $found_cmd + return 0 + end + return 1 +end `); emit(`# Helper: check if a specific parent-child subcommand pair is active @@ -994,6 +1057,41 @@ function emitBashCase(emit: (s: string) => void, cmd: CmdInfo, root: CmdInfo): v return; } + // attach-agent — the team roster, minus whoever already joined + if (name === 'attach-agent') { + emit(' attach-agent)'); + emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then'); + emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`); + emit(' return'); + emit(' fi'); + emit(' local proj names all_agents proj_agents'); + emit(' proj=$(_mcpctl_get_project_value)'); + emit(' if [[ -n "$proj" ]]; then'); + emit(" all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(" proj_agents=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(' names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort))'); + emit(' else'); + emit(' names=$(_mcpctl_resource_names "agents")'); + emit(' fi'); + emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))'); + emit(' return ;;'); + return; + } + + // detach-agent — only agents already on the team + if (name === 'detach-agent') { + emit(' detach-agent)'); + emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi'); + emit(' local proj names'); + emit(' proj=$(_mcpctl_get_project_value)'); + emit(' if [[ -n "$proj" ]]; then'); + emit(" names=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(' fi'); + emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))'); + emit(' return ;;'); + return; + } + // apply: file completions if (name === 'apply') { emit(' apply)'); diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 628642b..8a4307a 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -9,8 +9,10 @@ * * Modes: * mcpctl chat # REPL, new thread + * mcpctl chat --project

# same person, viewing project p * mcpctl chat --thread # REPL, resume thread * mcpctl chat -m "hi" # one-shot, prints reply, no REPL + * mcpctl chat --project

# the project itself, no agent * * Slash-commands inside the REPL: * /set KEY VALUE # adjust an override (temperature 0.2) @@ -40,7 +42,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { return new Command('chat') .description('Open an interactive chat session with an agent or a project (REPL or one-shot).') .argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.') - .option('-p, --project ', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets') + .option('-p, --project ', 'With : view that project (agent must be a member). Alone: chat with the project itself') .option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)') .option('-m, --message ', 'One-shot: send a single message and exit (no REPL)') .option('--thread ', 'Resume an existing thread') @@ -77,18 +79,30 @@ interface ChatSubject { path: string; /** Project chat only: opt into the gated get_secret tool. */ allowSecrets: boolean; + /** + * Agent chat only: the project this turn is viewed through. The same person, + * looking at one of the teams they joined — mcpd requires an AgentProject + * membership and 404s with a join hint when there isn't one. + */ + project?: string; } function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject { - if (opts.project !== undefined && agent !== undefined) { - throw new Error('Pass either an or --project , not both.'); + if (agent !== undefined) { + if (opts.allowSecrets === true) { + throw new Error('--allow-secrets is project-chat only (drop the argument).'); + } + return { + kind: 'agent', + name: agent, + path: `agents/${encodeURIComponent(agent)}`, + allowSecrets: false, + ...(opts.project !== undefined ? { project: opts.project } : {}), + }; } if (opts.project !== undefined) { return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true }; } - if (agent !== undefined) { - return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false }; - } throw new Error('Specify an name or --project . See `mcpctl chat --help`.'); } @@ -102,6 +116,10 @@ function chatBody(subject: ChatSubject, message: string, threadId: string | unde if (subject.kind === 'project') { delete o.personality; if (subject.allowSecrets) o.allowSecrets = true; + } else if (subject.project !== undefined) { + // Agent chat viewed through one of the agent's projects. The endpoint is + // still the agent's — only the context layer changes. + o.project = subject.project; } const body: Record = { message, ...o }; if (threadId !== undefined) body.threadId = threadId; @@ -372,6 +390,10 @@ async function handleSlash( let projectName: string | null; if (subject.kind === 'project') { projectName = subject.name; + } else if (subject.project !== undefined) { + // --project is the lens for this session; it beats the agent's + // legacy pin, exactly as it does server-side. + projectName = subject.project; } else { const a = await deps.client.get<{ project: { name: string } | null }>( `/api/v1/agents/${encodeURIComponent(subject.name)}`, @@ -880,7 +902,10 @@ async function printChatHeader( out(sep); out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`); - const tail = info.project !== null ? ` Project: ${info.project.name}` : ''; + // --project is the lens for this session; without it the agent's legacy + // `project` pin (if any) is what mcpd will use. + const viewing = subject.project ?? info.project?.name; + const tail = viewing !== undefined ? ` Project: ${viewing}` : ''; out(`LLM: ${info.llm.name}${tail}`); // Personality overlay: explicit --personality wins; otherwise agent's @@ -903,7 +928,7 @@ async function printChatHeader( out(`System append (--system-append):`); out(indent(overrides.systemAppend)); } - if (info.project !== null) { + if (viewing !== undefined) { out(`(project prompts auto-appended at chat time; /tools lists MCP servers)`); } diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 401bf92..6156b69 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -234,6 +234,25 @@ const agentColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +/** One AgentProject row — an agent's seat on a project team. */ +interface MembershipRow { + id: string; + agent: { id: string; name: string }; + project: { id: string; name: string }; + role: string; + llm: { id: string; name: string } | null; + createdAt: string; +} + +const membershipColumns: Column[] = [ + { header: 'AGENT', key: (r) => r.agent.name, width: 24 }, + { header: 'PROJECT', key: (r) => r.project.name, width: 20 }, + { header: 'ROLE', key: 'role', width: 12 }, + // '-' means "no override" — the agent's own pinned Llm answers here. + { header: 'BRAIN', key: (r) => r.llm?.name ?? '-', width: 24 }, + { header: 'JOINED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, +]; + interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string } const threadColumns: Column[] = [ { header: 'ID', key: 'id' }, @@ -520,6 +539,8 @@ function getColumnsForResource(resource: string): Column return agentColumns as unknown as Column>[]; case 'personalities': return personalityColumns as unknown as Column>[]; + case 'memberships': + return membershipColumns as unknown as Column>[]; case 'threads': return threadColumns as unknown as Column>[]; case 'inference-tasks': diff --git a/src/cli/src/commands/project-ops.ts b/src/cli/src/commands/project-ops.ts index 706fbcb..1edafa9 100644 --- a/src/cli/src/commands/project-ops.ts +++ b/src/cli/src/commands/project-ops.ts @@ -46,6 +46,53 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command { }); } +/** + * v1 persona roles. Mirrors MEMBERSHIP_ROLES in + * src/mcpd/src/validation/agent-project.schema.ts — mcpd is the authority, this + * list only exists so `--help` names the options and a typo fails locally. + */ +const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const; + +export function createAttachAgentCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('attach-agent') + .description('Add an agent to a project team (requires --project)') + .argument('', 'Agent name to add') + .option('--role ', `Persona on this team: ${MEMBERSHIP_ROLES.join('|')}`) + .option('--llm ', 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)') + .action(async (agentName: string, opts: { role?: string; llm?: string }) => { + const projectName = requireProject(deps); + if (opts.role !== undefined && !MEMBERSHIP_ROLES.includes(opts.role as typeof MEMBERSHIP_ROLES[number])) { + throw new Error(`--role must be one of: ${MEMBERSHIP_ROLES.join(', ')}`); + } + const membership = await client.post<{ role: string; llm: { name: string } | null }>( + `/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`, + { + ...(opts.role !== undefined ? { role: opts.role } : {}), + ...(opts.llm !== undefined ? { llm: opts.llm } : {}), + }, + ); + const brain = membership.llm !== null ? `, brain ${membership.llm.name}` : ''; + log(`agent '${agentName}' joined project '${projectName}' as ${membership.role}${brain}`); + }); +} + +export function createDetachAgentCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('detach-agent') + .description('Remove an agent from a project team (requires --project)') + .argument('', 'Agent name to remove') + .action(async (agentName: string) => { + const projectName = requireProject(deps); + await client.delete( + `/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`, + ); + log(`agent '${agentName}' left project '${projectName}'`); + }); +} + export function createApproveCommand(deps: ProjectOpsDeps): Command { const { client, log } = deps; diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 6ac08e7..081aba0 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -69,6 +69,11 @@ export const RESOURCE_ALIASES: Record = { agents: 'agents', personality: 'personalities', personalities: 'personalities', + // Agent ↔ Project team membership (the AgentProject junction). Read through + // one side or the other: `--project

` for the roster, or an agent name. + membership: 'memberships', + memberships: 'memberships', + team: 'memberships', thread: 'threads', threads: 'threads', // v5 shipped the durable inference queue under the short `task`/`tasks` diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index a5c3da7..70ae7f1 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -15,7 +15,13 @@ import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; import { createBackupCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; -import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js'; +import { + createAttachServerCommand, + createDetachServerCommand, + createAttachAgentCommand, + createDetachAgentCommand, + createApproveCommand, +} from './commands/project-ops.js'; import { createFavouritesCommand } from './commands/favourites.js'; import { createMcpCommand } from './commands/mcp.js'; import { createPatchCommand } from './commands/patch.js'; @@ -135,6 +141,25 @@ export function createProgram(): Command { return client.get(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`); } + // `get agents --project X` — the team: members plus any legacy-pinned + // agents. Same shape as the unscoped list, so the columns still line up. + if (nameOrId === undefined && resource === 'agents' && projectName !== undefined && projectName !== '') { + return client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/agents`); + } + + // Memberships are a junction, not a resource with its own name — they are + // always read through one side. `--project` gives the roster with roles; + // otherwise name the agent (`get memberships `). + if (resource === 'memberships') { + if (projectName !== undefined && projectName !== '') { + return client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/memberships`); + } + if (nameOrId === undefined) { + throw new Error('memberships are scoped — pass --project or an agent name (`mcpctl get memberships `)'); + } + return client.get(`/api/v1/agents/${encodeURIComponent(nameOrId)}/projects`); + } + // --project scoping for mcptokens if (!nameOrId && resource === 'mcptokens' && projectName) { return client.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`); @@ -306,6 +331,9 @@ export function createProgram(): Command { }; program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); + // Agentic teams: agents join projects the same way servers attach to them. + program.addCommand(createAttachAgentCommand(projectOpsDeps), { hidden: true }); + program.addCommand(createDetachAgentCommand(projectOpsDeps), { hidden: true }); program.addCommand(createApproveCommand(projectOpsDeps)); program.addCommand(createFavouritesCommand(projectOpsDeps)); // PR-4: reviewer queue for proposed prompts + skills. diff --git a/src/cli/tests/commands/agent-membership.test.ts b/src/cli/tests/commands/agent-membership.test.ts new file mode 100644 index 0000000..d6c45a7 --- /dev/null +++ b/src/cli/tests/commands/agent-membership.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ApiClient } from '../../src/api-client.js'; +import { createGetCommand } from '../../src/commands/get.js'; +import { + createAttachAgentCommand, + createDetachAgentCommand, +} from '../../src/commands/project-ops.js'; + +function mockClient(overrides: Partial = {}): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ role: 'developer', llm: null })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + ...overrides, + } as unknown as ApiClient; +} + +describe('mcpctl attach-agent / detach-agent', () => { + let client: ApiClient; + let output: string[]; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + it('joins an agent to the --project team', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', {}); + expect(output.join('\n')).toContain("agent 'alice' joined project 'sre' as developer"); + }); + + it('passes --role and --llm through', async () => { + client = mockClient({ + post: vi.fn(async () => ({ role: 'qa', llm: { id: 'llm-1', name: 'qwen3-thinking' } })) as ApiClient['post'], + }); + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice', '--role', 'qa', '--llm', 'qwen3-thinking'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', { + role: 'qa', + llm: 'qwen3-thinking', + }); + expect(output.join('\n')).toContain('brain qwen3-thinking'); + }); + + it('rejects a role outside the v1 set before hitting the network', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await expect( + cmd.parseAsync(['alice', '--role', 'chief-vibes-officer'], { from: 'user' }), + ).rejects.toThrow(/--role must be one of/); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('requires --project', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => undefined }); + await expect(cmd.parseAsync(['alice'], { from: 'user' })).rejects.toThrow(/--project required/); + }); + + it('url-encodes names', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'my team' }); + await cmd.parseAsync(['a/b'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/a%2Fb/projects/my%20team', {}); + }); + + it('detach-agent deletes the membership', async () => { + const cmd = createDetachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice'], { from: 'user' }); + + expect(client.delete).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre'); + expect(output.join('\n')).toContain("agent 'alice' left project 'sre'"); + }); +}); + +describe('mcpctl get memberships', () => { + it('renders the roster with role and brain columns', async () => { + const out: string[] = []; + const cmd = createGetCommand({ + fetchResource: vi.fn(async () => [ + { + id: 'ap-1', + agent: { id: 'agent-alice', name: 'alice' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'sre', + llm: { id: 'llm-1', name: 'qwen3-thinking' }, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'ap-2', + agent: { id: 'agent-bob', name: 'bob' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'qa', + llm: null, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]), + log: (...args: string[]) => out.push(args.join(' ')), + }); + await cmd.parseAsync(['node', 'test', 'memberships', '--project', 'sre']); + + const text = out.join('\n'); + expect(text).toContain('ROLE'); + expect(text).toContain('BRAIN'); + expect(text).toContain('qwen3-thinking'); + // No override reads as '-', not as an empty cell. + expect(text).toMatch(/bob\s+sre\s+qa\s+-/); + }); + + it('accepts the `team` alias', async () => { + const fetchResource = vi.fn(async () => []); + const out: string[] = []; + const cmd = createGetCommand({ fetchResource, log: (...a: string[]) => out.push(a.join(' ')) }); + await cmd.parseAsync(['node', 'test', 'team', '--project', 'sre']); + expect(fetchResource).toHaveBeenCalledWith('memberships', undefined, { project: 'sre' }); + }); +}); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3beb8d0..6fb72ce 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -36,8 +36,10 @@ import { LlmService } from './services/llm.service.js'; import { InferenceTaskRepository } from './repositories/inference-task.repository.js'; import { InferenceTaskService } from './services/inference-task.service.js'; import { AgentRepository } from './repositories/agent.repository.js'; +import { AgentProjectRepository } from './repositories/agent-project.repository.js'; import { ChatRepository } from './repositories/chat.repository.js'; import { AgentService } from './services/agent.service.js'; +import { AgentProjectService } from './services/agent-project.service.js'; import { ChatService } from './services/chat.service.js'; import { ChatToolDispatcherImpl } from './services/chat-tool-dispatcher.js'; import { LlmAdapterRegistry } from './services/llm/dispatcher.js'; @@ -47,6 +49,7 @@ import { registerVirtualLlmRoutes } from './routes/virtual-llms.js'; import { registerInferenceTaskRoutes } from './routes/inference-tasks.js'; import { VirtualLlmService } from './services/virtual-llm.service.js'; import { registerAgentRoutes } from './routes/agents.js'; +import { registerAgentProjectRoutes } from './routes/agent-projects.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { registerProjectChatRoutes } from './routes/project-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; @@ -192,6 +195,22 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] }; } + // /api/v1/agents/:name/projects[/:project] — team membership. + // Reading the roster is `view:agents:`; joining/leaving is + // `edit:agents:`, mirroring project↔server attach/detach (which is + // `edit:projects`, not `create`). Without this the default mapping would + // grade a join as `create:agents` — the permission for minting a whole new + // agent, which is far too broad for "add this person to a team". + const membershipAgentName = url.match(/^\/api\/v1\/agents\/([^/?]+)\/projects/)?.[1]; + if (membershipAgentName !== undefined) { + return { + kind: 'resource', + resource: 'agents', + action: method === 'GET' || method === 'HEAD' ? 'view' : 'edit', + resourceName: membershipAgentName, + }; + } + // /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent // name in the URL; the service-level owner check enforces fine-grained // access on top). @@ -296,6 +315,11 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] }; } + // Note: /api/v1/projects/:name/agents and /:name/memberships intentionally + // fall through to the default mapping below — reading a project's roster is + // `view:projects:`. Membership *writes* only happen on the agent side + // (/api/v1/agents/:name/projects/:project), handled above. + // Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit' const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/); if (projectServersMatch?.[1] && method !== 'GET') { @@ -599,7 +623,16 @@ async function main(): Promise { ); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); - const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); + // Agentic teams: an agent is a person who joins projects. The junction repo + // goes into AgentService so both the project roster and the chat path read + // memberships, not just the legacy `Agent.projectId` pin. + const agentProjectRepo = new AgentProjectRepository(prisma); + const agentService = new AgentService( + agentRepo, llmService, projectService, personalityRepo, agentProjectRepo, + ); + const agentProjectService = new AgentProjectService( + agentProjectRepo, agentService, projectService, llmService, + ); // v5: durable inference task queue. VirtualLlmService persists infer // tasks here; the result-route updates them; an in-process emitter // wakes blocked HTTP handlers when results land. @@ -738,6 +771,7 @@ async function main(): Promise { registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); + registerAgentProjectRoutes(app, agentProjectService); registerPersonalityRoutes(app, personalityService); // ChatService needs an `app.log`-aware tool dispatcher. const chatToolDispatcher = new ChatToolDispatcherImpl({ diff --git a/src/mcpd/src/repositories/agent-project.repository.ts b/src/mcpd/src/repositories/agent-project.repository.ts new file mode 100644 index 0000000..db56a59 --- /dev/null +++ b/src/mcpd/src/repositories/agent-project.repository.ts @@ -0,0 +1,81 @@ +import type { PrismaClient, AgentProject } from '@prisma/client'; + +/** + * AgentProject repository — the junction that makes an agent a *person who + * joins projects* rather than a thing owned by one. + * + * The row carries the two things that vary per project: the persona `role` + * (TEXT, validated in the service — production runs `prisma db push`, so a + * CHECK constraint here would never exist in prod) and an optional `llmId` + * brain override. + */ + +export interface AgentProjectCreateInput { + agentId: string; + projectId: string; + role?: string; + llmId?: string | null; +} + +export interface AgentProjectUpdateInput { + role?: string; + llmId?: string | null; +} + +export interface IAgentProjectRepository { + findByAgent(agentId: string): Promise; + findByProject(projectId: string): Promise; + find(agentId: string, projectId: string): Promise; + create(data: AgentProjectCreateInput): Promise; + update(id: string, data: AgentProjectUpdateInput): Promise; + delete(id: string): Promise; +} + +export class AgentProjectRepository implements IAgentProjectRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findByAgent(agentId: string): Promise { + return this.prisma.agentProject.findMany({ + where: { agentId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async findByProject(projectId: string): Promise { + return this.prisma.agentProject.findMany({ + where: { projectId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async find(agentId: string, projectId: string): Promise { + return this.prisma.agentProject.findUnique({ + where: { agentId_projectId: { agentId, projectId } }, + }); + } + + async create(data: AgentProjectCreateInput): Promise { + return this.prisma.agentProject.create({ + data: { + agentId: data.agentId, + projectId: data.projectId, + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), + }, + }); + } + + async update(id: string, data: AgentProjectUpdateInput): Promise { + return this.prisma.agentProject.update({ + where: { id }, + data: { + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), + }, + }); + } + + async delete(id: string): Promise { + await this.prisma.agentProject.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index 7dde512..39cdbb3 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -37,7 +37,7 @@ export function registerAgentChatRoutes( } const { - threadId, message, messages: messagesOverride, stream, personality, + threadId, message, messages: messagesOverride, stream, personality, project, ...paramsRest } = parsed; @@ -58,6 +58,7 @@ export function registerAgentChatRoutes( ? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) } : {}), ...(personality !== undefined ? { personalityName: personality } : {}), + ...(project !== undefined ? { projectName: project } : {}), params: paramsRest, }; diff --git a/src/mcpd/src/routes/agent-projects.ts b/src/mcpd/src/routes/agent-projects.ts new file mode 100644 index 0000000..32af661 --- /dev/null +++ b/src/mcpd/src/routes/agent-projects.ts @@ -0,0 +1,95 @@ +/** + * Agent ↔ Project membership HTTP surface. + * + * GET /api/v1/agents/:name/projects — projects this agent joined + * POST /api/v1/agents/:name/projects/:project — join (body: { role?, llm? }) + * DELETE /api/v1/agents/:name/projects/:project — leave + * GET /api/v1/projects/:name/memberships — the roster, with roles + * + * The reverse *agent list* stays at `GET /api/v1/projects/:name/agents` + * (routes/agents.ts) because mcplocal's agents plugin consumes that shape; + * it now returns members as well as legacy-pinned agents. This route adds the + * membership detail (role + brain override) that an `Agent` row cannot carry. + * + * RBAC comes from the global hook in `main.ts:mapUrlToPermission`: reading the + * roster is `view:agents:`, joining/leaving is `edit:agents:` — + * mirroring project↔server attach/detach, which is `edit:projects`, not + * `create`. + */ +import type { FastifyInstance } from 'fastify'; + +import type { AgentProjectService } from '../services/agent-project.service.js'; +import { NotFoundError } from '../services/mcp-server.service.js'; + +export function registerAgentProjectRoutes( + app: FastifyInstance, + service: AgentProjectService, +): void { + app.get<{ Params: { name: string } }>( + '/api/v1/agents/:name/projects', + async (request, reply) => { + try { + return await service.listForAgent(request.params.name); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { name: string; project: string } }>( + '/api/v1/agents/:name/projects/:project', + async (request, reply) => { + try { + const membership = await service.join( + request.params.name, + request.params.project, + request.body ?? {}, + ); + reply.code(201); + return membership; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { name: string; project: string } }>( + '/api/v1/agents/:name/projects/:project', + async (request, reply) => { + try { + await service.leave(request.params.name, request.params.project); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.get<{ Params: { name: string } }>( + '/api/v1/projects/:name/memberships', + async (request, reply) => { + try { + return await service.listForProject(request.params.name); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} diff --git a/src/mcpd/src/services/agent-project.service.ts b/src/mcpd/src/services/agent-project.service.ts new file mode 100644 index 0000000..e80fc22 --- /dev/null +++ b/src/mcpd/src/services/agent-project.service.ts @@ -0,0 +1,132 @@ +/** + * AgentProjectService — team membership, the thing that turns an Agent from + * "a persona owned by one project" into "a person who joins zero, one, or many + * projects" (docs/agentic-teams.md §4). + * + * The junction row is where the two per-project facts hang: + * - `role` — the persona this agent wears on this team (developer/qa/…). + * - `llmId` — an optional brain override, so the open brain-ownership + * question (§15 Q1) can be answered per membership instead of + * globally. + * + * Invariants are enforced here, not in the database: production provisions with + * `prisma db push`, so migration-only CHECK constraints never exist in prod. + */ +import type { AgentProject } from '@prisma/client'; + +import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js'; +import { + DEFAULT_MEMBERSHIP_ROLE, + JoinProjectSchema, + MEMBERSHIP_ROLES, +} from '../validation/agent-project.schema.js'; + +import type { AgentService } from './agent.service.js'; +import type { LlmService } from './llm.service.js'; +import type { ProjectService } from './project.service.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** One membership, name-resolved so callers never handle raw CUIDs. */ +export interface MembershipView { + id: string; + agent: { id: string; name: string }; + project: { id: string; name: string }; + role: string; + /** Per-membership brain override; null means "use the agent's pinned Llm". */ + llm: { id: string; name: string } | null; + createdAt: Date; +} + +export class AgentProjectService { + constructor( + private readonly repo: IAgentProjectRepository, + private readonly agents: AgentService, + private readonly projects: ProjectService, + private readonly llms: LlmService, + ) {} + + /** Every project this agent has joined. */ + async listForAgent(agentName: string): Promise { + const agent = await this.agents.getByName(agentName); + const rows = await this.repo.findByAgent(agent.id); + return Promise.all(rows.map((r) => this.toView(r))); + } + + /** Every agent that has joined this project — the team roster. */ + async listForProject(projectName: string): Promise { + const project = await this.projects.resolveAndGet(projectName); + const rows = await this.repo.findByProject(project.id); + return Promise.all(rows.map((r) => this.toView(r))); + } + + /** + * Join a project. Idempotent by design: re-joining an existing membership + * updates the role/brain rather than 409-ing, so `apply`-style repeat calls + * converge instead of failing. + */ + async join(agentName: string, projectName: string, input: unknown): Promise { + const data = JoinProjectSchema.parse(input ?? {}); + const agent = await this.agents.getByName(agentName); + const project = await this.projects.resolveAndGet(projectName); + + // `llm: null` clears the override; an absent `llm` leaves it untouched. + let llmId: string | null | undefined; + if (data.llm !== undefined) { + llmId = data.llm === null ? null : (await this.llms.getByName(data.llm)).id; + } + + const existing = await this.repo.find(agent.id, project.id); + const row = existing !== null + ? await this.repo.update(existing.id, { + ...(data.role !== undefined ? { role: data.role } : {}), + ...(llmId !== undefined ? { llmId } : {}), + }) + : await this.repo.create({ + agentId: agent.id, + projectId: project.id, + role: data.role ?? DEFAULT_MEMBERSHIP_ROLE, + ...(llmId !== undefined ? { llmId } : {}), + }); + + return this.toView(row); + } + + /** Leave a project. 404s when the agent was never a member — a silent no-op + * here would hide a typo'd project name. */ + async leave(agentName: string, projectName: string): Promise { + const agent = await this.agents.getByName(agentName); + const project = await this.projects.resolveAndGet(projectName); + const existing = await this.repo.find(agent.id, project.id); + if (existing === null) { + throw new NotFoundError( + `Agent '${agent.name}' is not a member of project '${project.name}'`, + ); + } + await this.repo.delete(existing.id); + } + + /** The valid role set, for error messages and CLI help. */ + static roles(): readonly string[] { + return MEMBERSHIP_ROLES; + } + + private async toView(row: AgentProject): Promise { + const agent = await this.agents.getById(row.agentId); + const project = await this.projects.getById(row.projectId); + let llm: { id: string; name: string } | null = null; + if (row.llmId !== null) { + // A brain deleted out from under a membership sets llmId to NULL + // (onDelete: SetNull), so this only tolerates a torn read. + const found = await this.llms.getById(row.llmId).catch(() => null); + if (found !== null) llm = { id: found.id, name: found.name }; + } + return { + id: row.id, + agent: { id: agent.id, name: agent.name }, + project: { id: project.id, name: project.name }, + role: row.role, + llm, + createdAt: row.createdAt, + }; + } +} diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index ed824f4..8417723 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -9,6 +9,7 @@ * identifiers. */ import type { Agent } from '@prisma/client'; +import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js'; import type { IAgentRepository } from '../repositories/agent.repository.js'; import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { LlmService } from './llm.service.js'; @@ -88,12 +89,30 @@ export interface VirtualAgentInput { visibility?: 'public' | 'private'; } +/** + * One resolved AgentProject membership, in the shape the chat path needs: + * which project the agent is looking at, and which brain wins there. + */ +export interface ResolvedMembership { + projectId: string; + projectName: string; + role: string; + /** Per-membership brain override; null means "use the agent's pinned Llm". */ + llmName: string | null; +} + export class AgentService { constructor( private readonly repo: IAgentRepository, private readonly llms: LlmService, private readonly projects: ProjectService, private readonly personalities?: IPersonalityRepository, + /** + * AgentProject junction. Optional so the many existing test wirings still + * compile; without it an agent simply has no memberships and the legacy + * `Agent.projectId` pin is the only project link. + */ + private readonly memberships?: IAgentProjectRepository, ) {} async list(viewer: AgentViewer | null = null): Promise { @@ -102,13 +121,90 @@ export class AgentService { return Promise.all(visible.map((r) => this.toView(r))); } + /** + * Agents attached to a project — by AgentProject membership OR by the legacy + * `Agent.projectId` pin. The union is what keeps this correct during the + * transition release: a joined agent must show up in the project's roster + * (mcplocal's agents plugin publishes these as virtual MCP servers), and a + * pre-membership agent must not vanish from it. + */ async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise { const project = await this.projects.resolveAndGet(projectName); const rows = await this.repo.findByProjectId(project.id); - const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + + const byId = new Map(rows.map((r) => [r.id, r])); + if (this.memberships !== undefined) { + for (const m of await this.memberships.findByProject(project.id)) { + if (byId.has(m.agentId)) continue; + const agent = await this.repo.findById(m.agentId); + if (agent !== null) byId.set(agent.id, agent); + } + } + + const visible = [...byId.values()] + .filter((r) => isAgentVisibleTo(r, viewer)) + .sort((a, b) => a.name.localeCompare(b.name)); return Promise.all(visible.map((r) => this.toView(r))); } + /** + * Which project is this agent looking at, and with which brain? + * + * Two resolution modes, deliberately different: + * + * - `projectName` given (`mcpctl chat --project

`): the agent + * must actually be on that team. A membership wins; the legacy + * `Agent.projectId` pin naming the same project is accepted as the + * transition fallback (the schema comment promises the column keeps + * working this release). Anything else throws, because silently + * answering from a *different* project's context would be worse than an + * error. + * - `projectName` omitted (`mcpctl chat `): unchanged legacy + * behaviour — the pinned `Agent.projectId`, or no project at all. + * + * The brain override only applies on the membership path: a legacy pin has + * no membership row and therefore no override to read. + */ + async resolveMembership( + agentName: string, + projectName?: string, + ): Promise { + const agent = await this.getByName(agentName); + + if (projectName === undefined) { + if (agent.project === null) return null; + return { + projectId: agent.project.id, + projectName: agent.project.name, + role: '', + llmName: null, + }; + } + + const project = await this.projects.resolveAndGet(projectName); + const row = this.memberships !== undefined + ? await this.memberships.find(agent.id, project.id) + : null; + + if (row !== null) { + return { + projectId: project.id, + projectName: project.name, + role: row.role, + llmName: row.llmId !== null ? (await this.llms.getById(row.llmId)).name : null, + }; + } + + if (agent.project !== null && agent.project.id === project.id) { + return { projectId: project.id, projectName: project.name, role: '', llmName: null }; + } + + throw new NotFoundError( + `Agent '${agent.name}' is not a member of project '${project.name}'. ` + + `Join it with: mcpctl attach-agent ${agent.name} --project ${project.name}`, + ); + } + async getById(id: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Agent not found: ${id}`); diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index cab46b0..45a7e4d 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -137,6 +137,13 @@ export interface PoolCandidate { export interface ChatRequestArgs { agentName: string; + /** + * Which project the agent is looking at for this turn — the same person, + * viewing one of the teams they joined (docs/agentic-teams.md §4). Requires + * an AgentProject membership; see AgentService.resolveMembership. Omitted = + * the agent on its own (falling back to the legacy `Agent.projectId` pin). + */ + projectName?: string; /** * The end user's bearer token. Only used by the `mcpctl` toolset, which acts * AS the caller — see services/mcpctl-tools.ts. Absent for every other agent. @@ -693,7 +700,15 @@ export class ChatService { private async prepareContext(args: ChatRequestArgs): Promise { const agent = await this.agents.getByName(args.agentName); - const pinned = await this.llms.getByName(agent.llm.name); + + // Membership resolution runs BEFORE the Llm lookup, because a + // per-membership brain override has to be able to *replace* the agent's + // pinned Llm (and therefore its whole failover pool), not patch the + // dispatch list afterwards. Throws when `--project` names a team this + // agent has not joined. + const membership = await this.agents.resolveMembership(args.agentName, args.projectName); + + const pinned = await this.llms.getByName(membership?.llmName ?? agent.llm.name); const poolCandidates = await this.resolveCandidatesWithFallbacks(pinned); if (poolCandidates.length === 0) { // Pool exists but every member is inactive (publishers offline, @@ -707,10 +722,10 @@ export class ChatService { } const threadId = await this.resolveThreadId(args, agent.id); - const projectId = agent.project?.id ?? null; + const projectId = membership?.projectId ?? null; // Project prompts (existing): only those whose projectId actually matches - // the agent's project — `findAll(projectId)` also returns globals which + // the project in view — `findAll(projectId)` also returns globals which // we exclude here so they don't double-count if a future change adds an // explicit "global" injection step. const projectPrompts = projectId !== null diff --git a/src/mcpd/src/validation/agent-project.schema.ts b/src/mcpd/src/validation/agent-project.schema.ts new file mode 100644 index 0000000..aaf81ce --- /dev/null +++ b/src/mcpd/src/validation/agent-project.schema.ts @@ -0,0 +1,32 @@ +/** + * AgentProject (team membership) validation. + * + * `role` is TEXT in the schema on purpose — adding a role later must not be a + * migration. The allowed set therefore lives here, in the service layer, which + * is also the only place it *can* live: production provisions with + * `prisma db push`, so a CHECK constraint written into a migration would never + * run there. + */ +import { z } from 'zod'; + +/** v1 persona roles. Extend this list, not the database. */ +export const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const; + +export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number]; + +export const DEFAULT_MEMBERSHIP_ROLE: MembershipRole = 'developer'; + +/** + * Body for `POST /api/v1/agents/:name/projects/:project`. + * + * `llm` names the per-membership brain override; `null` clears it so a + * re-join can drop back to the agent's own pinned Llm. + */ +export const JoinProjectSchema = z + .object({ + role: z.enum(MEMBERSHIP_ROLES).optional(), + llm: z.string().min(1).nullable().optional(), + }) + .strict(); + +export type JoinProjectInput = z.infer; diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index cdfb215..459c572 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -120,6 +120,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge( * agent's `defaultPersonalityId` when omitted. */ personality: z.string().min(1).optional(), + /** + * Which of the agent's projects to view for this turn — the second lens in + * docs/agentic-teams.md §4. Requires an AgentProject membership; the chat + * path 404s when the agent has not joined it. + */ + project: z.string().min(1).optional(), }), ).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, { message: 'Either `message` or `messages` is required', diff --git a/src/mcpd/tests/agent-membership.test.ts b/src/mcpd/tests/agent-membership.test.ts new file mode 100644 index 0000000..a65164b --- /dev/null +++ b/src/mcpd/tests/agent-membership.test.ts @@ -0,0 +1,198 @@ +/** + * The membership half of AgentService: which project an agent is looking at, + * and which brain answers there. + * + * These tests exist because the AgentProject junction shipped inert — the table + * was there but nothing read it, so an agent still had exactly one project. The + * assertions below are what "an agent joins many projects" actually means in + * behaviour rather than in schema. + */ +import type { Agent, AgentProject } from '@prisma/client'; +import { describe, it, expect, vi } from 'vitest'; + +import type { IAgentProjectRepository } from '../src/repositories/agent-project.repository.js'; +import type { IAgentRepository } from '../src/repositories/agent.repository.js'; +import { AgentService } from '../src/services/agent.service.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import type { ProjectService } from '../src/services/project.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-alice', + name: 'alice', + description: '', + systemPrompt: '', + llmId: 'llm-home', + projectId: null, + defaultPersonalityId: null, + proxyModelName: null, + toolset: 'project', + defaultParams: {} as Agent['defaultParams'], + extras: {} as Agent['extras'], + visibility: 'public', + kind: 'public', + providerSessionId: null, + status: 'active', + lastHeartbeatAt: null, + inactiveSince: null, + ownerId: 'owner-1', + version: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as Agent; +} + +function harness(agents: Agent[], memberships: Array> = []) { + const rows = new Map(agents.map((a) => [a.id, a])); + const links: AgentProject[] = memberships.map((m, i) => ({ + id: `ap-${String(i)}`, + agentId: m.agentId ?? 'agent-alice', + projectId: m.projectId ?? 'proj-sre', + role: m.role ?? 'developer', + llmId: m.llmId ?? null, + createdAt: NOW, + }) as AgentProject); + + const repo = { + findAll: vi.fn(async () => [...rows.values()]), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByName: vi.fn(async (name: string) => [...rows.values()].find((r) => r.name === name) ?? null), + findByProjectId: vi.fn(async (projectId: string) => + [...rows.values()].filter((r) => r.projectId === projectId)), + } as unknown as IAgentRepository; + + const junction = { + findByAgent: vi.fn(async (agentId: string) => links.filter((l) => l.agentId === agentId)), + findByProject: vi.fn(async (projectId: string) => links.filter((l) => l.projectId === projectId)), + find: vi.fn(async (agentId: string, projectId: string) => + links.find((l) => l.agentId === agentId && l.projectId === projectId) ?? null), + } as unknown as IAgentProjectRepository; + + const llms = { + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), + getByName: vi.fn(async (name: string) => ({ id: `llm-${name}`, name })), + } as unknown as LlmService; + + const projects = { + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), + resolveAndGet: vi.fn(async (name: string) => ({ id: `proj-${name}`, name })), + } as unknown as ProjectService; + + return { + withMemberships: new AgentService(repo, llms, projects, undefined, junction), + // The same service without the junction wired — proves the transition + // fallback still works for a deployment that has not migrated. + legacyOnly: new AgentService(repo, llms, projects), + }; +} + +describe('AgentService.resolveMembership — no --project (the person on their own)', () => { + it('returns nothing for an agent with no project at all', async () => { + const { withMemberships } = harness([makeAgent()]); + expect(await withMemberships.resolveMembership('alice')).toBeNull(); + }); + + it('falls back to the legacy Agent.projectId pin', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-legacy' })]); + const m = await withMemberships.resolveMembership('alice'); + expect(m?.projectName).toBe('legacy'); + expect(m?.llmName).toBeNull(); + }); +}); + +describe('AgentService.resolveMembership — with --project (the person viewing a team)', () => { + it('resolves a joined project', async () => { + const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre', role: 'sre' }]); + const m = await withMemberships.resolveMembership('alice', 'sre'); + expect(m).toMatchObject({ projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: null }); + }); + + it('picks the RIGHT membership when the agent is on several teams', async () => { + const { withMemberships } = harness( + [makeAgent()], + [ + { projectId: 'proj-sre', role: 'sre' }, + { projectId: 'proj-payments', role: 'security' }, + ], + ); + expect((await withMemberships.resolveMembership('alice', 'payments'))?.role).toBe('security'); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.role).toBe('sre'); + }); + + it('throws with a join hint when the agent is not on that team', async () => { + const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre' }]); + await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow( + /is not a member of project 'payments'.*mcpctl attach-agent alice --project payments/s, + ); + }); + + it('accepts the legacy pin as the transition fallback when it names the same project', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]); + const m = await withMemberships.resolveMembership('alice', 'sre'); + expect(m?.projectId).toBe('proj-sre'); + // No membership row means no override to read. + expect(m?.llmName).toBeNull(); + }); + + it('still refuses a DIFFERENT project than the legacy pin — silently answering from the wrong context would be worse than an error', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]); + await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/); + }); + + it('works without the junction wired at all (pre-migration deployment)', async () => { + const { legacyOnly } = harness([makeAgent({ projectId: 'proj-sre' })]); + expect((await legacyOnly.resolveMembership('alice', 'sre'))?.projectId).toBe('proj-sre'); + await expect(legacyOnly.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/); + }); +}); + +describe('AgentService.resolveMembership — the per-membership brain override', () => { + it('reports the override so chat can dispatch to it', async () => { + const { withMemberships } = harness( + [makeAgent({ llmId: 'llm-home' })], + [{ projectId: 'proj-sre', llmId: 'llm-sre-brain' }], + ); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain'); + }); + + it('is per-membership: the same agent keeps its own brain on another team', async () => { + const { withMemberships } = harness( + [makeAgent()], + [ + { projectId: 'proj-sre', llmId: 'llm-sre-brain' }, + { projectId: 'proj-payments', llmId: null }, + ], + ); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain'); + expect((await withMemberships.resolveMembership('alice', 'payments'))?.llmName).toBeNull(); + }); +}); + +describe('AgentService.listByProject — the roster', () => { + it('unions members with legacy-pinned agents, without duplicates', async () => { + const { withMemberships } = harness( + [ + makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), // legacy pin + makeAgent({ id: 'agent-bob', name: 'bob' }), // member only + makeAgent({ id: 'agent-carol', name: 'carol' }), // neither + ], + [ + { agentId: 'agent-bob', projectId: 'proj-sre' }, + { agentId: 'agent-alice', projectId: 'proj-sre' }, // both pinned AND joined + ], + ); + const roster = await withMemberships.listByProject('sre'); + expect(roster.map((a) => a.name)).toEqual(['alice', 'bob']); + }); + + it('returns only legacy-pinned agents when the junction is not wired', async () => { + const { legacyOnly } = harness( + [makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), makeAgent({ id: 'agent-bob', name: 'bob' })], + [{ agentId: 'agent-bob', projectId: 'proj-sre' }], + ); + expect((await legacyOnly.listByProject('sre')).map((a) => a.name)).toEqual(['alice']); + }); +}); diff --git a/src/mcpd/tests/agent-project-routes.test.ts b/src/mcpd/tests/agent-project-routes.test.ts new file mode 100644 index 0000000..7906b59 --- /dev/null +++ b/src/mcpd/tests/agent-project-routes.test.ts @@ -0,0 +1,103 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { errorHandler } from '../src/middleware/error-handler.js'; +import { registerAgentProjectRoutes } from '../src/routes/agent-projects.js'; +import type { AgentProjectService, MembershipView } from '../src/services/agent-project.service.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function view(overrides: Partial = {}): MembershipView { + return { + id: 'ap-1', + agent: { id: 'agent-alice', name: 'alice' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'sre', + llm: null, + createdAt: NOW, + ...overrides, + }; +} + +function build(service: Partial>): FastifyInstance { + const app = Fastify(); + app.setErrorHandler(errorHandler); + registerAgentProjectRoutes(app, service as unknown as AgentProjectService); + return app; +} + +let app: FastifyInstance | undefined; +afterEach(async () => { await app?.close(); app = undefined; }); + +describe('agent ↔ project membership routes', () => { + it('GET /agents/:name/projects lists memberships', async () => { + app = build({ listForAgent: vi.fn(async () => [view(), view({ id: 'ap-2', project: { id: 'proj-pay', name: 'payments' } })]) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/agents/alice/projects' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(2); + }); + + it('POST /agents/:name/projects/:project joins and returns 201', async () => { + const join = vi.fn(async () => view({ role: 'qa' })); + app = build({ join }); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/agents/alice/projects/sre', + payload: { role: 'qa' }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().role).toBe('qa'); + expect(join).toHaveBeenCalledWith('alice', 'sre', { role: 'qa' }); + }); + + it('POST with no body still joins — role and brain are both optional', async () => { + const join = vi.fn(async () => view()); + app = build({ join }); + const res = await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(201); + expect(join).toHaveBeenCalledWith('alice', 'sre', {}); + }); + + it('DELETE /agents/:name/projects/:project leaves and returns 204', async () => { + const leave = vi.fn(async () => undefined); + app = build({ leave }); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(204); + expect(leave).toHaveBeenCalledWith('alice', 'sre'); + }); + + it('DELETE 404s when the agent was never a member', async () => { + app = build({ + leave: vi.fn(async () => { throw new NotFoundError("Agent 'alice' is not a member of project 'sre'"); }), + }); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/not a member/); + }); + + it('404s on an unknown agent', async () => { + app = build({ listForAgent: vi.fn(async () => { throw new NotFoundError('Agent not found: ghost'); }) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/agents/ghost/projects' }); + expect(res.statusCode).toBe(404); + }); + + it('GET /projects/:name/memberships is the reverse view, with roles', async () => { + app = build({ + listForProject: vi.fn(async () => [ + view({ agent: { id: 'agent-alice', name: 'alice' }, role: 'sre' }), + view({ id: 'ap-2', agent: { id: 'agent-bob', name: 'bob' }, role: 'qa' }), + ]), + }); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/sre/memberships' }); + expect(res.statusCode).toBe(200); + expect(res.json().map((m: MembershipView) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); + }); + + it('url-decodes names before handing them to the service', async () => { + const join = vi.fn(async () => view()); + app = build({ join }); + await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/my%2Dteam' }); + expect(join).toHaveBeenCalledWith('alice', 'my-team', {}); + }); +}); diff --git a/src/mcpd/tests/chat-service-membership.test.ts b/src/mcpd/tests/chat-service-membership.test.ts new file mode 100644 index 0000000..1ddeb38 --- /dev/null +++ b/src/mcpd/tests/chat-service-membership.test.ts @@ -0,0 +1,180 @@ +/** + * `mcpctl chat --project

` — the same person, viewing one of the + * teams they joined (docs/agentic-teams.md §4). + * + * The regression these lock down: the AgentProject junction existed for a whole + * release without the chat path reading it, so `--project` on an agent could + * only ever mean the one project pinned on the Agent row. + */ +import type { ChatMessage, ChatThread, Prompt } from '@prisma/client'; +import { describe, it, expect, vi } from 'vitest'; + +import type { IChatRepository } from '../src/repositories/chat.repository.js'; +import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; +import type { AgentService, ResolvedMembership } from '../src/services/agent.service.js'; +import { ChatService, type ChatToolDispatcher } from '../src/services/chat.service.js'; +import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; +import type { LlmAdapter, NonStreamingResult } from '../src/services/llm/types.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function mockChatRepo(): IChatRepository { + const msgs: ChatMessage[] = []; + let n = 1; + return { + createThread: vi.fn(async ({ agentId, ownerId }) => ({ + id: `thread-${String(n++)}`, agentId, projectId: null, ownerId, + title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW, + } as ChatThread)), + findThread: vi.fn(async () => null), + listThreadsByAgent: vi.fn(async () => []), + listMessages: vi.fn(async () => []), + appendMessage: vi.fn(async (input: { threadId: string; role: string; content: string }) => { + const m = { + id: `msg-${String(n++)}`, threadId: input.threadId, turnIndex: msgs.length, + role: input.role, content: input.content, toolCalls: null, toolCallId: null, + status: 'complete', createdAt: NOW, + } as unknown as ChatMessage; + msgs.push(m); + return m; + }), + updateStatus: vi.fn(async () => ({}) as ChatMessage), + markPendingAsError: vi.fn(async () => 0), + touchThread: vi.fn(async () => undefined), + nextTurnIndex: vi.fn(async () => msgs.length), + } as unknown as IChatRepository; +} + +function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { + return { + findAll: vi.fn(async () => rows), + findByAgent: vi.fn(async () => []), + } as unknown as IPromptRepository; +} + +/** + * The agent: pinned to `home-brain`, legacy-pinned to project `legacy`, and a + * member of `sre` (with its own brain) and `payments` (without one). + */ +const MEMBERSHIPS: Record = { + sre: { projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: 'sre-brain' }, + payments: { projectId: 'proj-payments', projectName: 'payments', role: 'security', llmName: null }, +}; + +function mockAgents(): AgentService { + return { + getByName: vi.fn(async (name: string) => ({ + id: `agent-${name}`, name, description: '', systemPrompt: 'You are alice.', + llm: { id: 'llm-home-brain', name: 'home-brain' }, + project: { id: 'proj-legacy', name: 'legacy' }, + defaultPersonality: null, proxyModelName: null, toolset: 'project', + defaultParams: {}, extras: {}, ownerId: 'owner-1', version: 1, + createdAt: NOW, updatedAt: NOW, + })), + resolveMembership: vi.fn(async (_name: string, projectName?: string) => { + if (projectName === undefined) { + return { projectId: 'proj-legacy', projectName: 'legacy', role: '', llmName: null }; + } + const found = MEMBERSHIPS[projectName]; + if (found === undefined) { + throw new NotFoundError(`Agent 'alice' is not a member of project '${projectName}'.`); + } + return found; + }), + } as unknown as AgentService; +} + +function mockLlms(): LlmService { + const row = (name: string): Record => ({ + id: `llm-${name}`, name, type: 'openai', model: `${name}-model`, + url: '', tier: 'fast', description: '', + apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, poolName: null, + kind: 'public', providerSessionId: null, status: 'active', + lastHeartbeatAt: null, inactiveSince: null, + version: 1, createdAt: NOW, updatedAt: NOW, + }); + return { + getByName: vi.fn(async (name: string) => ({ ...row(name), apiKeyRef: null })), + findByPoolName: vi.fn(async (poolName: string) => [row(poolName)]), + resolveApiKey: vi.fn(async () => 'fake-key'), + } as unknown as LlmService; +} + +function reply(content: string): NonStreamingResult { + return { + status: 200, + body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] }, + }; +} + +function build() { + const listTools = vi.fn(async () => []); + const tools = { listTools, callTool: vi.fn(async () => ({})) } as unknown as ChatToolDispatcher; + const llms = mockLlms(); + const promptRepo = mockPromptRepo(); + const adapter: LlmAdapter = { + kind: 'scripted', + infer: vi.fn(async () => reply('ok')), + stream: async function*() { yield { data: '[DONE]', done: true }; }, + } as unknown as LlmAdapter; + const service = new ChatService( + mockAgents(), llms, { get: () => adapter } as unknown as LlmAdapterRegistry, + mockChatRepo(), promptRepo, tools, + ); + return { service, llms, listTools, promptRepo, adapter }; +} + +describe('agent chat scoped to a joined project', () => { + it('uses the MEMBERSHIP project for tools, not the legacy pin', async () => { + const { service, listTools } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + expect(listTools).toHaveBeenCalledWith('proj-payments'); + }); + + it('falls back to the legacy pin when no project is named', async () => { + const { service, listTools } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); + expect(listTools).toHaveBeenCalledWith('proj-legacy'); + }); + + it('the per-membership brain wins over the agent\'s pinned Llm', async () => { + const { service, llms } = build(); + const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' }); + expect(llms.getByName).toHaveBeenCalledWith('sre-brain'); + expect(llms.getByName).not.toHaveBeenCalledWith('home-brain'); + // Reported back to the caller, so `mcpctl chat` can show which brain answered. + expect(res.llm).toBe('sre-brain'); + }); + + it('keeps the agent\'s own brain on a membership with no override', async () => { + const { service, llms } = build(); + const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + expect(llms.getByName).toHaveBeenCalledWith('home-brain'); + expect(res.llm).toBe('home-brain'); + }); + + it('and on bare chat, with no project at all', async () => { + const { service, llms } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); + expect(llms.getByName).toHaveBeenCalledWith('home-brain'); + }); + + it('refuses a project the agent has not joined — before spending a token', async () => { + const { service, adapter } = build(); + await expect( + service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'finance' }), + ).rejects.toThrow(/not a member of project 'finance'/); + expect(adapter.infer).not.toHaveBeenCalled(); + }); + + it('scopes the streaming path the same way', async () => { + const { service, listTools } = build(); + const chunks = []; + for await (const c of service.chatStream({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' })) { + chunks.push(c); + } + expect(listTools).toHaveBeenCalledWith('proj-sre'); + }); +}); diff --git a/src/mcpd/tests/chat-service-virtual-llm.test.ts b/src/mcpd/tests/chat-service-virtual-llm.test.ts index 5d86b3f..a36cd82 100644 --- a/src/mcpd/tests/chat-service-virtual-llm.test.ts +++ b/src/mcpd/tests/chat-service-virtual-llm.test.ts @@ -56,6 +56,7 @@ function mockChatRepo(): IChatRepository { function mockAgents(): AgentService { return { + resolveMembership: vi.fn(async () => null), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: '', systemPrompt: 'You are a helpful agent.', diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index 42f4f9d..ef5f9aa 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -97,6 +97,15 @@ function mockTools(impl: Partial = {}): ChatToolDispatcher { function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService { return { + // Membership resolution with no `--project`: the legacy `Agent.projectId` + // pin, exactly what the real service returns for the same input. + resolveMembership: vi.fn(async (name: string, projectName?: string) => { + if (projectName !== undefined) { + return { projectId: `proj-${projectName}`, projectName, role: 'developer', llmName: null }; + } + if (name === 'no-project') return null; + return { projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null }; + }), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, @@ -801,6 +810,9 @@ describe('ChatService', () => { }); // Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail. const agentsLowCap = { + resolveMembership: vi.fn(async () => ({ + projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null, + })), getByName: vi.fn(async () => ({ id: 'agent-low', name: 'low', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, @@ -821,6 +833,9 @@ describe('ChatService', () => { // Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever. const agentsHostile = { + resolveMembership: vi.fn(async () => ({ + projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null, + })), getByName: vi.fn(async () => ({ id: 'agent-bad', name: 'bad', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, @@ -1106,6 +1121,8 @@ describe('ChatService — failover chain', () => { } function agentPinnedTo(llmName: string): AgentService { return { + // No project, no membership — the failover chain is about the Llm pool. + resolveMembership: vi.fn(async () => null), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: '', systemPrompt: 'sys', llm: { id: 'x', name: llmName }, project: null, defaultPersonality: null, diff --git a/src/mcpd/tests/services/agent-project-service.test.ts b/src/mcpd/tests/services/agent-project-service.test.ts new file mode 100644 index 0000000..c1d628a --- /dev/null +++ b/src/mcpd/tests/services/agent-project-service.test.ts @@ -0,0 +1,215 @@ +import type { AgentProject } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IAgentProjectRepository } from '../../src/repositories/agent-project.repository.js'; +import { AgentProjectService } from '../../src/services/agent-project.service.js'; +import type { AgentService } from '../../src/services/agent.service.js'; +import type { LlmService } from '../../src/services/llm.service.js'; +import { NotFoundError } from '../../src/services/mcp-server.service.js'; +import type { ProjectService } from '../../src/services/project.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +/** + * In-memory AgentProject junction. The unique (agentId, projectId) constraint + * is modelled here because the service's idempotent re-join depends on it — + * without it the "join twice" test would pass for the wrong reason. + */ +function makeHarness(seed: Array> = []) { + let idCounter = 1; + let rows: AgentProject[] = seed.map((r) => ({ + id: r.id ?? `ap-${String(idCounter++)}`, + agentId: r.agentId ?? 'agent-alice', + projectId: r.projectId ?? 'proj-sre', + role: r.role ?? 'developer', + llmId: r.llmId ?? null, + createdAt: NOW, + }) as AgentProject); + + const repo = { + findByAgent: vi.fn(async (agentId: string) => rows.filter((r) => r.agentId === agentId)), + findByProject: vi.fn(async (projectId: string) => rows.filter((r) => r.projectId === projectId)), + find: vi.fn(async (agentId: string, projectId: string) => + rows.find((r) => r.agentId === agentId && r.projectId === projectId) ?? null, + ), + create: vi.fn(async (data: { agentId: string; projectId: string; role?: string; llmId?: string | null }) => { + const row = { + id: `ap-${String(idCounter++)}`, + agentId: data.agentId, + projectId: data.projectId, + role: data.role ?? 'developer', + llmId: data.llmId ?? null, + createdAt: NOW, + } as AgentProject; + rows = [...rows, row]; + return row; + }), + update: vi.fn(async (id: string, data: { role?: string; llmId?: string | null }) => { + const row = rows.find((r) => r.id === id); + if (row === undefined) throw new Error(`no such membership: ${id}`); + if (data.role !== undefined) row.role = data.role; + if (data.llmId !== undefined) row.llmId = data.llmId; + return row; + }), + delete: vi.fn(async (id: string) => { rows = rows.filter((r) => r.id !== id); }), + } as unknown as IAgentProjectRepository & Record>; + + const agents = { + getByName: vi.fn(async (name: string) => { + if (name === 'ghost') throw new NotFoundError(`Agent not found: ${name}`); + return { id: `agent-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^agent-/, '') })), + } as unknown as AgentService; + + const projects = { + resolveAndGet: vi.fn(async (name: string) => { + if (name === 'nowhere') throw new NotFoundError(`Project not found: ${name}`); + return { id: `proj-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), + } as unknown as ProjectService; + + const llms = { + getByName: vi.fn(async (name: string) => { + if (name === 'missing-brain') throw new NotFoundError(`Llm not found: ${name}`); + return { id: `llm-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), + } as unknown as LlmService; + + return { + repo, agents, projects, llms, + service: new AgentProjectService(repo, agents, projects, llms), + all: () => rows, + }; +} + +describe('AgentProjectService — joining and leaving', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('joins a project with the default role', async () => { + const { service } = makeHarness(); + const m = await service.join('alice', 'sre', {}); + expect(m.project.name).toBe('sre'); + expect(m.agent.name).toBe('alice'); + expect(m.role).toBe('developer'); + expect(m.llm).toBeNull(); + }); + + it('joins the SAME agent to several projects — the whole point of the junction', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', {}); + await service.join('alice', 'payments', { role: 'security' }); + + const memberships = await service.listForAgent('alice'); + expect(memberships.map((m) => m.project.name).sort()).toEqual(['payments', 'sre']); + expect(memberships.find((m) => m.project.name === 'payments')?.role).toBe('security'); + }); + + it('re-joining updates the role instead of 409-ing — repeat applies converge', async () => { + const { service, repo, all } = makeHarness(); + await service.join('alice', 'sre', { role: 'developer' }); + const second = await service.join('alice', 'sre', { role: 'qa' }); + + expect(second.role).toBe('qa'); + expect(all()).toHaveLength(1); + expect(repo.create).toHaveBeenCalledTimes(1); + expect(repo.update).toHaveBeenCalledTimes(1); + }); + + it('rejects a role outside the v1 set', async () => { + const { service } = makeHarness(); + await expect(service.join('alice', 'sre', { role: 'chief-vibes-officer' })).rejects.toThrow(); + }); + + it('accepts every v1 role', async () => { + const { service } = makeHarness(); + for (const role of AgentProjectService.roles()) { + const m = await service.join('alice', 'sre', { role }); + expect(m.role).toBe(role); + } + }); + + it('rejects unknown body fields rather than silently dropping them', async () => { + const { service } = makeHarness(); + // A typo'd `llmName` would otherwise leave the membership with no brain + // override and no complaint — the exact failure the strict schema exists for. + await expect(service.join('alice', 'sre', { llmName: 'x' })).rejects.toThrow(); + }); + + it('404s on an unknown agent and on an unknown project', async () => { + const { service } = makeHarness(); + await expect(service.join('ghost', 'sre', {})).rejects.toThrow(/Agent not found/); + await expect(service.join('alice', 'nowhere', {})).rejects.toThrow(/Project not found/); + }); + + it('leaves a project', async () => { + const { service, all } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre' }]); + await service.leave('alice', 'sre'); + expect(all()).toHaveLength(0); + }); + + it('404s when leaving a project the agent never joined — a typo must not no-op', async () => { + const { service } = makeHarness(); + await expect(service.leave('alice', 'sre')).rejects.toThrow(/is not a member of project 'sre'/); + }); +}); + +describe('AgentProjectService — the per-membership brain override', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('records the named Llm', async () => { + const { service } = makeHarness(); + const m = await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + expect(m.llm).toEqual({ id: 'llm-qwen3-thinking', name: 'qwen3-thinking' }); + }); + + it('clears the override with an explicit null', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + const cleared = await service.join('alice', 'sre', { llm: null }); + expect(cleared.llm).toBeNull(); + }); + + it('leaves an existing override untouched when `llm` is absent', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + const roleOnly = await service.join('alice', 'sre', { role: 'qa' }); + expect(roleOnly.role).toBe('qa'); + expect(roleOnly.llm?.name).toBe('qwen3-thinking'); + }); + + it('404s on an Llm that does not exist', async () => { + const { service } = makeHarness(); + await expect(service.join('alice', 'sre', { llm: 'missing-brain' })).rejects.toThrow(/Llm not found/); + }); + + it('survives a brain that vanished under the membership', async () => { + // onDelete: SetNull should prevent this, but a torn read must not 500 the + // whole roster. + const { service, llms } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre', llmId: 'llm-gone' }]); + (llms.getById as ReturnType).mockRejectedValueOnce(new NotFoundError('Llm not found: llm-gone')); + const [m] = await service.listForAgent('alice'); + expect(m?.llm).toBeNull(); + }); +}); + +describe('AgentProjectService — the reverse view', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('lists the whole roster for a project, with roles', async () => { + const { service } = makeHarness([ + { agentId: 'agent-alice', projectId: 'proj-sre', role: 'sre' }, + { agentId: 'agent-bob', projectId: 'proj-sre', role: 'qa' }, + { agentId: 'agent-carol', projectId: 'proj-payments', role: 'security' }, + ]); + const roster = await service.listForProject('sre'); + expect(roster.map((m) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); + }); + + it('404s on an unknown project', async () => { + const { service } = makeHarness(); + await expect(service.listForProject('nowhere')).rejects.toThrow(/Project not found/); + }); +});