Skip to main content

forge_workspace/mcp/
agents.rs

1//! The `agents__*` family - one address space for every forge session.
2//!
3//! A target is a slot: `(org, project, label)`, with `lead` reserved
4//! for a project's own agent. Three verbs reach any slot from any
5//! session; the rest act on the caller's own project and are lead-only.
6
7pub mod facade;
8pub mod target;
9
10use std::sync::Arc;
11
12use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
13
14use crate::SessionSlot;
15use crate::mcp::agents::facade::AgentDispatcher;
16use crate::mcp::agents::target::{AgentTarget, TargetError};
17// Only `build_server` names this, and that is gated on the test features -
18// so an ungated import is unused in the configuration install.sh builds.
19#[cfg(any(test, feature = "testing"))]
20use crate::mcp::peers::facade::WorkspaceFacade;
21use crate::mcp::peers::types::{MessageId, PeerStatus, WrappedKind, WrappedPrompt};
22use crate::mcp::workers::facade::{
23    DespawnOutcome, WorkerCapSource, WorkerDespawnError, WorkerFacade, WorkerSpawnError,
24    WorkerUpdateError,
25};
26use crate::protocol::SessionChoice;
27
28/// Attach the three any-caller verbs to an existing
29/// [`McpServerBuilder`](forge_sdk::mcp::server::McpServerBuilder).
30pub(crate) fn add_shared_tools(
31    builder: forge_sdk::mcp::server::McpServerBuilder,
32    dispatcher: Arc<AgentDispatcher>,
33    slot: SessionSlot,
34) -> forge_sdk::mcp::server::McpServerBuilder {
35    let whoami = Whoami { dispatcher: dispatcher.clone(), slot: slot.clone() };
36    let list = List { dispatcher: dispatcher.clone() };
37    let handoff = Handoff { dispatcher: dispatcher.clone(), slot: slot.clone() };
38    let send = SendMessage { dispatcher, slot };
39    builder.tool(whoami).tool(list).tool(send).tool(handoff)
40}
41
42fn tool_error(text: String) -> ToolOutput {
43    ToolOutput::error(text)
44}
45
46fn json_output(body: &serde_json::Value) -> ToolOutput {
47    match serde_json::to_string_pretty(body) {
48        Ok(json) => ToolOutput::text(json),
49        Err(err) => tool_error(format!("response serialization failed: {err}")),
50    }
51}
52
53/// The slot an agent is addressed by, which is the shape every row and
54/// every response carries.
55fn slot_json(slot: &SessionSlot) -> serde_json::Value {
56    serde_json::json!({
57        "org": slot.org(),
58        "project": slot.project(),
59        "label": slot.label(),
60    })
61}
62
63/// A snapshot row: the address to pass back to `agents__send_message`,
64/// then whatever the engine that produced the snapshot knows about the
65/// seat.
66fn row(slot: &SessionSlot, detail: &impl serde::Serialize) -> Result<serde_json::Value, String> {
67    let mut body =
68        serde_json::to_value(detail).map_err(|err| format!("snapshot serialization: {err}"))?;
69    if let Some(obj) = body.as_object_mut() {
70        obj.insert("slot".to_owned(), slot_json(slot));
71    }
72    Ok(body)
73}
74
75/// LLM-facing explanation of a target that did not resolve. Names the
76/// reachable set, because a target that refuses is usually a typo.
77fn target_error_message(err: &TargetError, known: &[PeerStatus]) -> String {
78    match err {
79        TargetError::Malformed { component } => format!(
80            "the target's {component} is empty. Address an agent as (org, project, label), \
81             where label names a worker and may be omitted for a project's own agent."
82        ),
83        TargetError::UnknownProject { org, project } => format!(
84            "no project '{project}' is configured under org '{org}'. Call agents__list to see \
85             the agents you can reach. Configured: {}.",
86            known.iter().map(|p| format!("{}/{}", p.org, p.name)).collect::<Vec<_>>().join(", "),
87        ),
88    }
89}
90
91#[derive(serde::Deserialize)]
92struct TargetArgs {
93    #[serde(default)]
94    org: Option<String>,
95    #[serde(default)]
96    project: Option<String>,
97    #[serde(default)]
98    label: Option<String>,
99}
100
101/// The name and org an envelope carries for the session that sent it,
102/// derived from that session's own slot: the project for a project's own
103/// agent, `project/label` for a worker, with the org beside it.
104///
105/// Derived rather than delegated to an engine, because one identity has to
106/// hold on every path - the two engines answered this differently, the
107/// in-project one by label and the cross-project one by project, so a
108/// single worker rendered as two senders depending on which path carried
109/// its message. The label is what keeps a worker distinguishable from its
110/// own lead at all: without it a worker messaging another project carried
111/// the bare project name, which is exactly what its lead sends.
112fn sender_identity(slot: &SessionSlot) -> (String, String) {
113    (crate::mcp::peers::types::seat_name(slot), slot.org().to_owned())
114}
115
116impl TargetArgs {
117    /// The seat this call names. Optional in the parsed struct rather than
118    /// required, so a missing half is a message naming what to pass rather
119    /// than a serde error.
120    fn resolve(&self, known: &[PeerStatus]) -> Result<AgentTarget, String> {
121        let (Some(org), Some(project)) = (self.org.as_deref(), self.project.as_deref()) else {
122            return Err(
123                "a message needs a target: pass `org` and `project`, and `label` to reach a \
124                 worker rather than the project's own agent. Call agents__list to see who you \
125                 can reach."
126                    .to_owned(),
127            );
128        };
129        AgentTarget::parse(known, org, project, self.label.as_deref())
130            .map_err(|err| target_error_message(&err, known))
131    }
132}
133
134/// `agents__handoff` - hand this seat over to a fresh session, from the
135/// session itself.
136pub(crate) struct Handoff {
137    pub(crate) dispatcher: Arc<AgentDispatcher>,
138    pub(crate) slot: SessionSlot,
139}
140
141#[derive(serde::Deserialize)]
142struct HandoffArgs {
143    #[serde(default)]
144    brief: Option<String>,
145}
146
147#[async_trait::async_trait]
148impl Tool for Handoff {
149    fn name(&self) -> &'static str {
150        "agents__handoff"
151    }
152
153    fn description(&self) -> &'static str {
154        "Complete a running handoff by handing this seat to a fresh session that continues the \
155         work. **This is the exit, never the entry**: a handoff starts at `/handoff` or at the \
156         automatic trigger (fill or compaction), and this call is the handoff skill's last act. \
157         \
158         forge writes the ground truth itself - the seat, the working directory, the branch, the \
159         role, the files and the dirty list, the board rows, what is still running and the \
160         transcript path - and the note you pass becomes the judgment half of the brief, \
161         delivered as the new session's first turn and drawn as its handoff procedure row. \
162         The chat opens fresh with that row: the previous conversation goes with the seat that \
163         left. The swap happens after the current turn ends, never in the middle of it, on the \
164         same order `/new` uses. \
165         \
166         Pass the note the skill has you write: the continuation the next session needs, in \
167         whatever shape reads best."
168    }
169
170    fn input_schema(&self) -> serde_json::Value {
171        serde_json::json!({
172            "type": "object",
173            "properties": {
174                "brief": {
175                    "type": "string",
176                    "description": "The handoff note the skill has you write: the continuation the next session needs, in whatever shape reads best.",
177                },
178            },
179            "additionalProperties": false,
180        })
181    }
182
183    async fn call(&self, input: ToolInput) -> ToolOutput {
184        let args: HandoffArgs = match serde_json::from_value(input.value) {
185            Ok(args) => args,
186            Err(err) => return tool_error(format!("invalid arguments: {err}")),
187        };
188        // **A cold call gets the explanation, not a schema error.** The
189        // likeliest cold call carries no arguments at all, so `brief` is
190        // optional here and a missing note lands on the same refusal that
191        // names the two doors.
192        let note = args.brief.unwrap_or_default().trim().to_owned();
193        if note.is_empty() {
194            return tool_error(
195                "agents__handoff completes a running handoff and carries its note; a handoff \
196                 starts at /handoff or at the automatic trigger (fill or compaction)"
197                    .to_owned(),
198            );
199        }
200        match self.dispatcher.peers().handoff(&self.slot, note) {
201            Ok(()) => json_output(&serde_json::json!({
202                "status": "handoff note accepted",
203                "runs": "after this turn ends",
204            })),
205            Err(err) => tool_error(err.to_string()),
206        }
207    }
208}
209
210/// `agents__whoami` - the caller's own slot and identity. No args.
211pub(crate) struct Whoami {
212    pub(crate) dispatcher: Arc<AgentDispatcher>,
213    pub(crate) slot: SessionSlot,
214}
215
216#[async_trait::async_trait]
217impl Tool for Whoami {
218    fn name(&self) -> &'static str {
219        "agents__whoami"
220    }
221
222    fn description(&self) -> &'static str {
223        "Returns your own forge identity: the slot you are addressed by \
224         (org, project, label) and what forge knows about it - project \
225         path, current status, and when the session was spawned. Useful \
226         when an inbound envelope says 'from agent X' and you want to \
227         confirm whether X is you, when you need to tell another agent \
228         which slot to answer, or when you need your own org and project \
229         names to address someone else. Identity is stable for the \
230         session, so most callers need this only once. Takes no \
231         arguments."
232    }
233
234    fn input_schema(&self) -> serde_json::Value {
235        serde_json::json!({
236            "type": "object",
237            "properties": {},
238            "additionalProperties": false,
239        })
240    }
241
242    async fn call(&self, _input: ToolInput) -> ToolOutput {
243        let Some(identity) = self.dispatcher.peers().whoami(&self.slot) else {
244            return tool_error(format!(
245                "no identity resolved for caller {} (this is a forge bug; the caller slot \
246                 should always resolve to a forge.toml project)",
247                self.slot.display(),
248            ));
249        };
250        match row(&self.slot, &identity) {
251            Ok(body) => json_output(&body),
252            Err(err) => tool_error(err),
253        }
254    }
255}
256
257/// `agents__list` - every seat you can address, optionally narrowed to
258/// one project. No target args.
259pub(crate) struct List {
260    pub(crate) dispatcher: Arc<AgentDispatcher>,
261}
262
263#[derive(serde::Deserialize)]
264struct ListArgs {
265    #[serde(default)]
266    project: Option<String>,
267}
268
269#[async_trait::async_trait]
270impl Tool for List {
271    fn name(&self) -> &'static str {
272        "agents__list"
273    }
274
275    fn description(&self) -> &'static str {
276        "List the agents you can reach, one row per seat, each addressed \
277         by its slot (org, project, label). Two kinds of row: every \
278         project's own agent, which has label 'lead', and every \
279         project's workers, each under its own label. Pass \
280         `project` to narrow the list to one project. \
281         \
282         A project's own agent is reachable whether or not it is \
283         currently running: a sleeping project's agent is spawned by the \
284         first message it receives, which is true of another \
285         project's agent - your own is already up if you are reading \
286         this. A worker row must be live for the message to land. \
287         \
288         Rows differ in what they carry - a project's agent reports its \
289         path and liveness, a worker reports its charter, current \
290         activity and session id. Every row carries the slot to pass \
291         back to agents__send_message. \
292         \
293         CROSS-PROJECT RULE (mutations only): whenever the user asks you \
294         to CHANGE state in a project other than your own - edit files, \
295         run a command, file an issue, push a branch, anything with side \
296         effects - call this tool FIRST. If the target project appears \
297         here, do NOT cd into it and mutate its files directly. Hand the \
298         work off with agents__send_message, which delivers it to that \
299         project's own agent. Each agent owns its own repo; stay in \
300         your lane and let the other project's agent execute the change. \
301         \
302         Reading another project's files for context is fine - sometimes \
303         scanning the source yourself gives a sharper answer than asking \
304         the other agent. The constraint is only on writes / state \
305         changes. \
306         \
307         Takes no arguments unless you want the `project` filter."
308    }
309
310    fn input_schema(&self) -> serde_json::Value {
311        serde_json::json!({
312            "type": "object",
313            "properties": {
314                "project": {
315                    "type": "string",
316                    "description": "Optional. Project name to narrow the list to. Case-sensitive; omit to list every project.",
317                },
318            },
319            "additionalProperties": false,
320        })
321    }
322
323    async fn call(&self, input: ToolInput) -> ToolOutput {
324        let args: ListArgs = match serde_json::from_value(input.value) {
325            Ok(a) => a,
326            Err(err) => return tool_error(format!("invalid arguments: {err}")),
327        };
328        let filter = args.project.as_deref();
329
330        let mut rows = Vec::new();
331        for project in self.dispatcher.peers().list_peers() {
332            if filter.is_some_and(|name| name != project.name) {
333                continue;
334            }
335            let slot = SessionSlot::lead(&project.org, &project.name);
336            match row(&slot, &project) {
337                Ok(row) => rows.push(row),
338                Err(err) => return tool_error(err),
339            }
340        }
341        for worker in self.dispatcher.workers().list_all_workers() {
342            if filter.is_some_and(|name| name != worker.slot.project()) {
343                continue;
344            }
345            match row(&worker.slot, &worker) {
346                Ok(row) => rows.push(row),
347                Err(err) => return tool_error(err),
348            }
349        }
350        json_output(&serde_json::Value::Array(rows))
351    }
352}
353
354/// `agents__send_message` - one verb for every send, to any seat.
355///
356/// There is no reply verb: a reply is another message to the seat that
357/// sent you one, so every send is addressed the same way.
358pub(crate) struct SendMessage {
359    pub(crate) dispatcher: Arc<AgentDispatcher>,
360    pub(crate) slot: SessionSlot,
361}
362
363#[derive(serde::Deserialize)]
364struct SendMessageArgs {
365    #[serde(flatten)]
366    target: TargetArgs,
367    message: String,
368}
369
370#[async_trait::async_trait]
371impl Tool for SendMessage {
372    fn name(&self) -> &'static str {
373        "agents__send_message"
374    }
375
376    fn description(&self) -> &'static str {
377        "Send a message to another forge agent - its own agent, or a \
378         named worker - and return immediately. The target is a slot: \
379         `org` and `project` name it, and `label` names the seat inside \
380         that project. Omit `label` to reach the project's own agent; \
381         set it to a worker's label to reach that worker. Run \
382         agents__list for a label in any project; a worker is addressed \
383         by its slot. \
384         \
385         Every message is the same kind of thing, including a reply: to \
386         answer an agent that sent you one, address it by its own slot \
387         the way you would address anyone else. The target sees the \
388         message as a new user turn and may message you back, or simply \
389         continue its own work. \
390         \
391         Use this instead of mutating another project's files directly \
392         whenever the user asks you to notify or hand off work to another \
393         project - e.g. \"let the gateway backend know the rewriter \
394         cleanup landed\", \"ask forge to pick this up next \
395         session\". Reading another project's files for your own context \
396         is still allowed; only state changes and hand-offs go through \
397         this tool. \
398         \
399         A work package handed to another project lands on that \
400         project's board: the lead that accepts it builds the epic and \
401         its phase rows at acceptance, unprompted, before dispatching any \
402         of it. Expect that of a package you send, and do the same for \
403         one you accept. \
404         \
405         A request addressed to another project's own agent is delivered \
406         to that project's agent, spawning it first if it was sleeping, \
407         so expect extra latency on the first message to a sleeping \
408         project. A request addressed to a worker reaches it only while \
409         that worker is live; if the label is gone the call fails \
410         immediately and names the label. \
411         \
412         A `sent` status means the queue ACCEPTED the message, not that \
413         the target read it - a target that is alive but wedged still \
414         returns sent, so confirm real work happened by an answer or an \
415         observable artifact rather than by the ack. For a worker (in \
416         your project or another) and for your own project's agent, a \
417         message that reached no session is an error naming what \
418         happened, never a `sent`, and a target mid-turn is not \
419         interrupted: the message is held and delivered the moment its \
420         turn ends, so it arrives as a turn of its own rather than an \
421         aside inside the work in flight. A message to another project's \
422         own agent goes through the peer path, which reports `sent` once \
423         the dispatch was issued and does not hold a mid-turn arrival. \
424         \
425         A message the target has not read yet can also die with its \
426         session: a forge restart or a resume onto a new session id \
427         drops it, and nothing errors. \
428         \
429         When the message you are about to send asserts that work is \
430         done, reviewed, or verified, or hands over a pick where two or \
431         more options you have enumerated survive your own reasoning and \
432         nothing outside you decides between them (a routing call with \
433         several plausible owners and no evidence separating them is \
434         exactly that case), and the `systemone__*` tools are in your \
435         list, make one call on the claim or the pick first: put the \
436         claim and its evidence in `state` for a claim check, or the \
437         options you have already enumerated in `criteria` for a choice. \
438         A decisive answer is permission to send it plainly."
439    }
440
441    fn input_schema(&self) -> serde_json::Value {
442        serde_json::json!({
443            "type": "object",
444            "properties": {
445                "org": {
446                    "type": "string",
447                    "description": "Org the target project belongs to, as shown by agents__list. Case-sensitive.",
448                },
449                "project": {
450                    "type": "string",
451                    "description": "Project name of the target, as shown by agents__list. Case-sensitive. Your own project's name addresses a seat in your own project.",
452                },
453                "label": {
454                    "type": "string",
455                    "description": "Optional. Which seat inside the project: a worker's label, or 'lead' for the project's own agent. Omit for the project's own agent. Case-sensitive; if several workers share a label, the latest-spawned receives the message.",
456                },
457                "message": {
458                    "type": "string",
459                    "description": "The message body. Rendered as a new user turn in the target's chat, so write it as you would address the target directly.",
460                },
461            },
462            "required": ["org", "project", "message"],
463            "additionalProperties": false,
464        })
465    }
466
467    async fn call(&self, input: ToolInput) -> ToolOutput {
468        let args: SendMessageArgs = match serde_json::from_value(input.value) {
469            Ok(a) => a,
470            Err(err) => return tool_error(format!("invalid arguments: {err}")),
471        };
472        let known = self.dispatcher.peers().list_peers();
473        let target = match args.target.resolve(&known) {
474            Ok(target) => target,
475            Err(message) => return tool_error(message),
476        };
477
478        let id = MessageId::mint();
479        let (sender_name, sender_org) = sender_identity(&self.slot);
480        let wrapped = WrappedPrompt {
481            id: id.clone(),
482            kind: WrappedKind::Message,
483            sender_name,
484            sender_org,
485            body: args.message,
486        };
487        match self.dispatcher.deliver(&self.slot, &target, wrapped).await {
488            Ok(()) => json_output(&serde_json::json!({
489                "status": "sent",
490                "id": id.as_str(),
491                "to": slot_json(&SessionSlot::new(
492                    target.org(),
493                    target.project(),
494                    target.label(),
495                )),
496            })),
497            Err(message) => tool_error(message),
498        }
499    }
500}
501
502/// Build a standalone `forge` MCP server carrying the whole `agents__*`
503/// family as a lead sees it. The wire-conformance harness drives the
504/// surface through this against mock facades.
505#[cfg(any(test, feature = "testing"))]
506pub fn build_server(
507    peers: Arc<dyn WorkspaceFacade>,
508    workers: Arc<dyn WorkerFacade>,
509    slot: SessionSlot,
510) -> forge_sdk::mcp::server::McpServer {
511    let dispatcher = Arc::new(AgentDispatcher::new(peers, workers.clone()));
512    let builder = forge_sdk::mcp::server::McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION"));
513    let builder = add_shared_tools(builder, dispatcher, slot.clone());
514    add_lead_tools(builder, workers, slot).build()
515}
516
517/// Attach the four lead-only verbs to an existing
518/// [`McpServerBuilder`](forge_sdk::mcp::server::McpServerBuilder).
519/// Each acts on the caller's own project, so a worker has no project to
520/// act on and is not offered the surface.
521pub(crate) fn add_lead_tools(
522    builder: forge_sdk::mcp::server::McpServerBuilder,
523    facade: Arc<dyn WorkerFacade>,
524    slot: SessionSlot,
525) -> forge_sdk::mcp::server::McpServerBuilder {
526    let spawn = Spawn { facade: facade.clone(), slot: slot.clone() };
527    let capacity = Capacity { facade: facade.clone(), slot: slot.clone() };
528    let despawn = Despawn { facade: facade.clone(), slot: slot.clone() };
529    let update = Update { facade, slot };
530    builder.tool(spawn).tool(capacity).tool(despawn).tool(update)
531}
532
533fn format_spawn_error(err: &WorkerSpawnError) -> String {
534    err.message()
535}
536
537fn format_despawn_error(err: &WorkerDespawnError) -> String {
538    match err {
539        WorkerDespawnError::NotLeadCaller => {
540            "agents__despawn is lead-only; this session is a worker. Only the project lead may despawn workers.".to_owned()
541        }
542        WorkerDespawnError::EmptyLabel => "label must be non-empty after trim".to_owned(),
543        WorkerDespawnError::UnknownCallerProject => {
544            "could not resolve caller to a known project (forge bug)".to_owned()
545        }
546        WorkerDespawnError::UnknownLabel { label, project_key } => format!(
547            "no live worker with label '{label}' in project '{project_key}'. Call agents__list for the project's live workers."
548        ),
549        WorkerDespawnError::DispatchFailed { message } => {
550            format!("worker despawn failed: {message}")
551        }
552    }
553}
554
555fn format_update_error(err: &WorkerUpdateError) -> String {
556    match err {
557        WorkerUpdateError::UnknownCallerProject => {
558            "could not resolve caller to a known project (forge bug)".to_owned()
559        }
560        WorkerUpdateError::NoSuchWorker { label, project_key } => format!(
561            "no dynamic worker '{label}' in project '{project_key}'. agents__update revises a \
562             worker created by agents__spawn, so if you meant to create one, spawn it first."
563        ),
564        WorkerUpdateError::StoreFailed { message } => format!("worker update failed: {message}"),
565    }
566}
567
568/// `agents__spawn` - lead-only. Allocates a new session in the caller's
569/// project, threading `charter` through the new session's system-prompt
570/// addendum, then returns the assigned `session_id` and `tag`
571/// (`forge:worker:<label>`).
572pub(crate) struct Spawn {
573    pub(crate) facade: Arc<dyn WorkerFacade>,
574    pub(crate) slot: SessionSlot,
575}
576
577#[derive(serde::Deserialize)]
578struct SpawnArgs {
579    label: String,
580    charter: String,
581    #[serde(default)]
582    kick: Option<String>,
583    #[serde(default)]
584    resume_kick: Option<String>,
585    #[serde(default)]
586    interactive: bool,
587    #[serde(default)]
588    resume_session: bool,
589    #[serde(default)]
590    mcp_families: Option<Vec<String>>,
591    #[serde(default)]
592    cwd: Option<String>,
593}
594
595/// Validate and canonicalise a spawn or update `mcp_families` argument:
596/// names in wire order, de-duplicated; an empty list resolves to `None`
597/// (every family) for a spawn.
598fn canonical_spawn_families(names: Option<&[String]>) -> Result<Option<Vec<String>>, String> {
599    match names {
600        Some(names) => crate::mcp::canonical_mcp_families(names),
601        None => Ok(None),
602    }
603}
604
605#[async_trait::async_trait]
606impl Tool for Spawn {
607    fn name(&self) -> &'static str {
608        "agents__spawn"
609    }
610
611    fn description(&self) -> &'static str {
612        "Spawn a new worker session inside YOUR project (lead-only). \
613         The worker is a full forge session - its own claude subprocess, \
614         own chat view, own permissions - addressable from your session \
615         by its label, via agents__send_message with your own \
616         org and project. `charter` is the worker's mission, threaded \
617         into the new session's system prompt, and defines what that \
618         worker is. PROVIDE `kick` TO START THE WORKER IMMEDIATELY: \
619         the kick is delivered as the worker's first user-turn the moment \
620         it connects, so it begins working at once. WITHOUT a kick the \
621         worker sits idle until you send it an agents__send_message - a \
622         'begin now' line in the charter does NOT run on its own, so pass \
623         `kick` for any ad-hoc spawn you want to start now. Returns the \
624         worker's \
625         session_id and tag (`forge:worker:<label>`), whether the session \
626         was resumed or started fresh, the worker's own row (the same \
627         shape agents__list draws for it), and the worktree it landed in \
628         when the project is a git repo. A spawned worker is \
629         DURABLE: it survives forge restarts and is automatically \
630         re-spawned, resuming where it left off. A worker whose turn was \
631         cut off by the restart is told to continue; one that was idle \
632         when forge stopped is resumed parked, with nothing delivered, so \
633         a job you did not want restarted does not restart itself. It \
634         comes back this way until you explicitly despawn it with \
635         agents__despawn (or close its row in the Projects pane). A worker whose worktree has gone is not re-spawned \
636         automatically - its resume would have nowhere to start - so it \
637         stops being offered until the worktree is back; passing \
638         `resume_session` recreates that worktree and brings it back. \
639         DESPAWNED A WORKER WHOSE CONTEXT YOU STILL WANT? Re-spawn \
640         the same label with `resume_session` set: it resumes the label's \
641         most recent prior session instead of starting fresh. When the \
642         label has no prior session a fresh one starts and the response \
643         says which session it landed on. \
644         PASS `resume_kick` FOR A LONG-LIVED WORKER whose restart \
645         needs specific steps - re-read a file, catch up a queue, check \
646         what was mid-run - rather than that generic continue; it \
647         replaces the restart note for a worker whose turn was cut off. \
648         Omit it and the generic note is what an interrupted worker gets; \
649         a worker that was idle resumes with neither. A worker cannot ask \
650         the user anything directly - it has no AskUserQuestion - and \
651         reaches them through you instead; PASS `interactive` only for a \
652         worker the user asked to talk to directly. Give the worker the \
653         whole cut to land. A work cut - one feature, one issue, one fix \
654         - lands whole: one branch, one review, one look, one merge. \
655         Never slice it into pieces that land one after another, and \
656         never show or merge an intermediate step on the way. The look \
657         is the one that cut is owed: a feature waits for the user's own \
658         look before merge; a fix merges after the review loop as usual. \
659         So spawn one per distinct piece of work, and despawn once the \
660         worker has handed over what you spawned it to produce: a \
661         merged PR, or equally a written report, an answered question, a \
662         finished sweep - a worker whose output is not a PR has no merge \
663         to wait for and still needs closing. A forgotten worker keeps \
664         coming back on every restart. At most one live worker per \
665         label - if one already exists, this errors and you should \
666         message it with agents__send_message instead of spawning \
667         again. A git worker's worktree is shared across every project of \
668         one repository, not scoped to your project: one label maps to one \
669         `worktree-<label>` branch and one directory, so another project's \
670         worker under the same repository holding that label (even one that \
671         has crashed - its row is closed before its tree is reused), or a \
672         directory that is a declared project's own checkout, refuses this \
673         spawn. The label 'lead' is reserved (it addresses a project's \
674         own agent) and rejected here. Use agents__list to see your \
675         project's current worker pool. `mcp_families` selects which \
676         MCP families this worker gets: any family the argument lists \
677         (the agents__* verbs are always on and cannot be listed). \
678         Omit it, or pass an empty list, and the \
679         worker gets every family on a fresh spawn. Pass a list and it gets exactly \
680         those; each withheld family is named in the worker's own prompt \
681         so it knows what it does not have - a worker without systemone \
682         is told to route decisions to you. The choice is stored on the \
683         worker and survives restarts; revise it with agents__update. On \
684         a resume or re-spawn that states no `mcp_families`, the \
685         worker's stored selection applies; stating one then revises \
686         it. PASS `cwd` WHEN THE WORKER'S TREE IS NOT YOUR PROJECT'S: \
687         an absolute or `~`-prefixed path to the directory it should \
688         run in, for driving a sibling repo your project does not own. \
689         A subdirectory of a repo resolves to the repo root, and the \
690         resolved root is recorded on the worker's row so every resume \
691         lands back in it. A path inside a project declared in \
692         forge.toml is refused - that project is driven by its own \
693         agent, so message it instead. Omit `cwd` and the worker runs \
694         in your project exactly as before. This tool errors if called \
695         from a worker session; only the project lead may spawn."
696    }
697
698    fn input_schema(&self) -> serde_json::Value {
699        let families: Vec<&str> =
700            crate::mcp::McpFamily::ALL.iter().map(|family| family.as_str()).collect();
701        serde_json::json!({
702            "type": "object",
703            "properties": {
704                "label": {
705                    "type": "string",
706                    "description": "Identifier you will use to address this worker later, as the `label` of an agents__send_message target. Non-empty after trim. At most one live worker per label - reusing a label with a live worker is rejected.",
707                },
708                "charter": {
709                    "type": "string",
710                    "description": "The worker's mission, threaded into the new session's system prompt. This is what defines the worker, so say what it is responsible for and how it should work. Non-empty after trim.",
711                },
712                "kick": {
713                    "type": "string",
714                    "description": "Optional first-turn message delivered to the worker the moment it connects, so it STARTS WORKING IMMEDIATELY (equivalent to sending an agents__send_message right after spawn). STRONGLY RECOMMENDED for ad-hoc spawns: WITHOUT a kick the worker sits idle until you send it an agents__send_message - a 'begin now' line in the charter does NOT run on its own. Omit only when you intend to drive the worker yourself with a later agents__send_message.",
715                },
716                "resume_kick": {
717                    "type": "string",
718                    "description": "Optional re-orient message delivered when this worker is RESUMED after a forge restart with a turn that was cut off, in place of the generic 'continue where you left off' note. A worker that was idle when forge stopped resumes with nothing delivered, so this is never handed to a seat that was not working. For a LONG-LIVED worker whose restart needs specific steps - re-read a file, catch up a queue, check whether something was mid-run before re-running it - rather than a generic continue. Stored at spawn rather than delivered now; the first turn of a fresh spawn is `kick`. Non-empty after trim when provided - to keep the generic restart note, OMIT the argument rather than passing an empty string, which is rejected.",
719                },
720                "interactive": {
721                    "type": "boolean",
722                    "description": "Set true ONLY when the user asked for a worker they will talk to DIRECTLY and will have its row open. It keeps the built-in AskUserQuestion tool, which every other worker is denied: a worker's question renders in its own row, which nobody is usually watching, and an answer that does arrive is indistinguishable from a decision the user actually made - so a worker can attribute a choice to the user in good faith that the user never saw. Defaults to false, which is right for any worker you are spawning on your own initiative; that worker reaches the user through you, via its agents__send_message to you. This is fixed at spawn - changing it means despawning the worker and spawning it again.",
723                },
724                "resume_session": {
725                    "type": "boolean",
726                    "description": "Set true to RESUME this label's most recent prior session instead of starting fresh, so the old conversation arrives as history and the worker continues where it left off. The natural move after despawning a worker whose context you still want: re-spawn the same label with this set. The session is resolved from what the label is registered under, or from its own transcripts by worker tag once that is gone; if there is none to resume, a fresh one starts and the response says which happened. A live worker on the same label is still rejected; despawn or close it first. A git worker's worktree is recreated if despawn removed it, so the resumed session lands back in its run directory.",
727                },
728                "mcp_families": {
729                    "type": "array",
730                    "items": { "type": "string", "enum": families },
731                    "description": "The MCP families this worker gets, exactly those listed. Omit or pass an empty list for every family on a fresh spawn; a resume adopts the stored selection. `agents__*` is always on and cannot be listed. Each withheld family is named in the worker's own prompt. What each gives the worker: `review` - read and answer reviews filed on its branch or under a document it handed off; give it to any worker that implements or whose deliverable is a reviewable document. `cron` - schedule and manage durable work of its own; give it to any worker that needs a recurring or deferred task. `tasks` - claim, file and update rows on the project board; give it to any worker whose work is on the board. `gotify` - subscribe to and read Gotify notifications; give it to any worker that acts on them. `slack` - read Slack, subscribe, and post through held approval; give it to any worker that works in Slack. `systemone` - put a yes/no, a choice or a score to the decision model; give it to any worker that weighs decisions itself. `voice` - speak one utterance aloud to the person listening to this seat; give it to any worker whose replies someone should hear. `vault` - read and change credentials in the Vaultwarden vault, every call held for approval; give it to any worker that needs its own credentials.",
732                },
733                "cwd": {
734                    "type": "string",
735                    "description": "Optional tree for this worker, when it should run somewhere other than your project's own directory - a sibling repo you are driving, for instance. Absolute or `~`-prefixed; a bare relative path is refused. A subdirectory of a repo resolves to the repo root. A path inside a project declared in forge.toml is refused when it is another project's: that project is driven by its own agent, so message it instead. Recorded on the worker's row, so the resume after a forge restart lands back in the same tree, and a resume may state that same tree again. State a DIFFERENT tree on a `resume_session` and it is refused, because a conversation is found by the tree it ran in - move a worker between trees by despawning it and spawning fresh. Omit `cwd` and the worker runs in your project exactly as before.",
736                },
737            },
738            "required": ["label", "charter"],
739            "additionalProperties": false,
740        })
741    }
742
743    async fn call(&self, input: ToolInput) -> ToolOutput {
744        let args: SpawnArgs = match serde_json::from_value(input.value) {
745            Ok(a) => a,
746            Err(err) => return tool_error(format!("invalid arguments: {err}")),
747        };
748
749        // An empty one is `Some`, so it would beat the restart-note
750        // fallback and dispatch a blank first turn on every resume - the
751        // same contract agents__update holds this arg to.
752        if args.resume_kick.as_ref().is_some_and(|text| text.trim_end().is_empty()) {
753            return tool_error("resume_kick must be non-empty after trim when provided".to_owned());
754        }
755        let mcp_families = match canonical_spawn_families(args.mcp_families.as_deref()) {
756            Ok(families) => families,
757            Err(message) => return tool_error(message),
758        };
759        let label = args.label.clone();
760        match self
761            .facade
762            .spawn_worker(
763                &self.slot,
764                args.label,
765                args.charter,
766                args.kick,
767                args.resume_kick,
768                args.interactive,
769                args.resume_session,
770                mcp_families,
771                args.cwd,
772            )
773            .await
774        {
775            Ok(reply) => {
776                let resumed = reply.session_choice == SessionChoice::Resumed;
777                let mut body = serde_json::json!({
778                    "session_id": reply.session_id,
779                    "tag": reply.tag,
780                    "resumed": resumed,
781                    "session_choice": reply.session_choice,
782                });
783                if let Some(families) = &reply.mcp_families {
784                    body["mcp_families"] = serde_json::json!(families);
785                }
786                if let Some(worktree) = &reply.worktree {
787                    body["worktree"] = serde_json::Value::String(worktree.clone());
788                }
789                // The row `agents__list` draws for this worker, so a caller
790                // reads the spawn's own record rather than making a second
791                // call to learn the same fields. A row the registry has not
792                // landed yet is simply absent.
793                //
794                // Matched by the session id the reply carries, not by the
795                // label alone: a Failed row is deliberately kept under its
796                // label so a re-spawn can recover, so a label lookup lands
797                // on the PREVIOUS occupant in exactly the failure-then-recover
798                // flow a lead is most likely reading - stale status and a
799                // dead session id beside the fresh one above it.
800                if let Some(status) =
801                    self.facade.list_workers(&self.slot).into_iter().find(|worker| {
802                        worker.label == label && worker.session_id == reply.session_id
803                    })
804                    && let Ok(worker) = row(&status.slot, &status)
805                {
806                    body["worker"] = worker;
807                }
808                if let Some(account) = &reply.rate_limited_account {
809                    body["notice"] = serde_json::Value::String(format!(
810                        "assigned account '{account}' is currently rate-limited or bailed. The worker spawns anyway but may hit a 429 right away; free up an account or wait for a reset."
811                    ));
812                }
813                if let Some(warning) = &reply.durability_warning {
814                    body["durability_warning"] = serde_json::Value::String(warning.clone());
815                }
816                json_output(&body)
817            }
818            Err(err) => tool_error(format_spawn_error(&err)),
819        }
820    }
821}
822
823/// `agents__despawn` - lead-only. Closes a worker by label and cleans
824/// up its git worktree. A clean worktree is removed; a dirty one
825/// (uncommitted/untracked or unpushed commits) blocks the despawn
826/// unless `force`.
827pub(crate) struct Despawn {
828    pub(crate) facade: Arc<dyn WorkerFacade>,
829    pub(crate) slot: SessionSlot,
830}
831
832#[derive(serde::Deserialize)]
833struct DespawnArgs {
834    label: String,
835    #[serde(default)]
836    force: Option<bool>,
837}
838
839#[async_trait::async_trait]
840impl Tool for Despawn {
841    fn name(&self) -> &'static str {
842        "agents__despawn"
843    }
844
845    fn description(&self) -> &'static str {
846        "Despawn (close + clean up) a worker in YOUR project by label \
847         (lead-only). Kills the worker's claude subprocess, removes it \
848         from agents__list, AND cleans up its git worktree. A CLEAN \
849         worktree is removed as \
850         part of the despawn; a DIRTY one (uncommitted/untracked changes \
851         or unpushed commits) BLOCKS the despawn and returns a reason - \
852         clean it up (commit + push, or reset) and retry, or pass \
853         force=true to tear down and discard the worktree. force does NOT \
854         override a worktree shared with another project: because a \
855         repository's workers share one `<main>/.claude/worktrees/<label>` \
856         and one branch, a directory that is another project's checkout or \
857         another project's worker's tree refuses the despawn outright, \
858         whatever that worker's state. Never prune a worker's worktree by \
859         hand to reclaim disk - reclaim is the despawn's, and a tree \
860         removed mid-cut loses its branch. Nothing is \
861         ever silently discarded. The worktree-<label> branch claude \
862         created for the worker is deleted alongside the worktree, but \
863         only when every commit on it is reachable from some other ref - \
864         another branch, a tag, a remote-tracking ref, or a worktree's \
865         HEAD, so a branch \
866         you already pushed still counts as reapable. One carrying \
867         commits that exist nowhere else is left alone and named in a \
868         branch_cleanup_warning. Returns {status:\"despawned\"} (with an \
869         optional worktree_cleanup_warning when the worktree removal \
870         itself failed, and an optional branch_cleanup_warning when the \
871         branch was kept) or {status:\"blocked\", reason}. This is how you \
872         PERMANENTLY remove a durable worker: a spawned worker otherwise \
873         survives forge restarts and re-spawns automatically, so despawn \
874         is what makes it stop coming back. Closing the worker's row in \
875         the Projects pane does the same. Despawn once a worker has handed \
876         over what it was spawned to produce: a worker whose output is a \
877         PR lives until that PR merges; a worker whose output is not a PR \
878         - a written report, an answered question - has no merge to wait \
879         for and is done when it hands over. Errors if called from \
880         a worker session; only the project lead may despawn."
881    }
882
883    fn input_schema(&self) -> serde_json::Value {
884        serde_json::json!({
885            "type": "object",
886            "properties": {
887                "label": {
888                    "type": "string",
889                    "description": "Worker label from agents__list to close. Non-empty after trim.",
890                },
891                "force": {
892                    "type": "boolean",
893                    "description": "Tear down and discard the worktree even if it has uncommitted/untracked changes or unpushed commits. Default false: a dirty worktree blocks the despawn with a reason instead, so work is never silently discarded.",
894                },
895            },
896            "required": ["label"],
897            "additionalProperties": false,
898        })
899    }
900
901    async fn call(&self, input: ToolInput) -> ToolOutput {
902        let args: DespawnArgs = match serde_json::from_value(input.value) {
903            Ok(a) => a,
904            Err(err) => return tool_error(format!("invalid arguments: {err}")),
905        };
906
907        match self.facade.despawn_worker(&self.slot, &args.label, args.force.unwrap_or(false)).await
908        {
909            Ok(DespawnOutcome::Despawned { worktree_cleanup_warning, branch_cleanup_warning }) => {
910                let mut body = serde_json::json!({ "status": "despawned" });
911                if let Some(warning) = worktree_cleanup_warning
912                    && let Some(map) = body.as_object_mut()
913                {
914                    map.insert(
915                        "worktree_cleanup_warning".to_owned(),
916                        serde_json::Value::String(warning),
917                    );
918                }
919                if let Some(warning) = branch_cleanup_warning
920                    && let Some(map) = body.as_object_mut()
921                {
922                    map.insert(
923                        "branch_cleanup_warning".to_owned(),
924                        serde_json::Value::String(warning),
925                    );
926                }
927                json_output(&body)
928            }
929            Ok(DespawnOutcome::Blocked { reason }) => {
930                json_output(&serde_json::json!({ "status": "blocked", "reason": reason }))
931            }
932            Err(err) => tool_error(format_despawn_error(&err)),
933        }
934    }
935}
936
937/// `agents__capacity` - lead-only aggregate read of the caller's
938/// project worker capacity. One JSON object rather than the per-worker
939/// snapshots `agents__list` returns.
940pub(crate) struct Capacity {
941    pub(crate) facade: Arc<dyn WorkerFacade>,
942    pub(crate) slot: SessionSlot,
943}
944
945#[async_trait::async_trait]
946impl Tool for Capacity {
947    fn name(&self) -> &'static str {
948        "agents__capacity"
949    }
950
951    fn description(&self) -> &'static str {
952        "Report the worker capacity of YOUR project (lead-only): the \
953         configured cap, how many slots are taken, and how many are free. \
954         A worker between heal attempts still holds its slot until its heal \
955         gives up. Use it before spawning to see whether a spawn would \
956         hit the limit. The cap is the project's max_workers in \
957         forge.toml when set, else forge's default; cap_source names \
958         which. Takes no arguments."
959    }
960
961    fn input_schema(&self) -> serde_json::Value {
962        serde_json::json!({
963            "type": "object",
964            "properties": {},
965            "additionalProperties": false,
966        })
967    }
968
969    async fn call(&self, _input: ToolInput) -> ToolOutput {
970        let Some(capacity) = self.facade.capacity(&self.slot) else {
971            return tool_error(
972                "could not resolve caller to a known project (forge bug)".to_owned(),
973            );
974        };
975        json_output(&serde_json::json!({
976            "project": capacity.project,
977            "cap": capacity.cap,
978            "live": capacity.live,
979            "available": capacity.cap.saturating_sub(capacity.live),
980            "cap_source": match capacity.cap_source {
981                WorkerCapSource::ProjectMaxWorkers => "max_workers",
982                WorkerCapSource::Default => "default",
983            },
984        }))
985    }
986}
987
988/// `agents__update` - lead-only. Revises the stored `charter`, `kick`
989/// and `resume_kick` of an EXISTING worker, keyed by
990/// `(project_key, label)` exactly as `agents__spawn` persisted it.
991///
992/// Refuses when no row exists. A row is what makes a worker re-spawn on
993/// the next lead connect, so creating one here would mean revising a
994/// definition silently produces a worker.
995pub(crate) struct Update {
996    pub(crate) facade: Arc<dyn WorkerFacade>,
997    pub(crate) slot: SessionSlot,
998}
999
1000#[derive(serde::Deserialize)]
1001struct UpdateArgs {
1002    label: String,
1003    #[serde(default)]
1004    charter: Option<String>,
1005    #[serde(default)]
1006    kick: Option<String>,
1007    #[serde(default)]
1008    resume_kick: Option<String>,
1009    #[serde(default)]
1010    mcp_families: Option<Vec<String>>,
1011}
1012
1013#[async_trait::async_trait]
1014impl Tool for Update {
1015    fn name(&self) -> &'static str {
1016        "agents__update"
1017    }
1018
1019    fn description(&self) -> &'static str {
1020        "Revise a worker's stored instructions without despawning it \
1021         (lead-only). Replaces any of `charter`, `kick`, `resume_kick` and \
1022         `mcp_families` on that worker's persisted record; a field you omit \
1023         keeps its current value, and at least one must be supplied. TAKES \
1024         EFFECT ON THE WORKER'S NEXT RESPAWN, NOT IMMEDIATELY - a session's \
1025         system prompt and tool list are fixed when the session spawns, so \
1026         a running worker keeps what it started with; use \
1027         agents__send_message to redirect it now. \
1028         `mcp_families` revises which MCP families the worker gets, \
1029         validated exactly as agents__spawn validates it: a list replaces \
1030         the stored selection, and an empty list restores every family. \
1031         The worker must already exist: this never creates one, so spawn \
1032         it with agents__spawn first (which takes the same four texts). \
1033         Address it by the same `label` you spawned it with, as shown by \
1034         agents__list."
1035    }
1036
1037    fn input_schema(&self) -> serde_json::Value {
1038        let families: Vec<&str> =
1039            crate::mcp::McpFamily::ALL.iter().map(|family| family.as_str()).collect();
1040        serde_json::json!({
1041            "type": "object",
1042            "properties": {
1043                "label": {
1044                    "type": "string",
1045                    "description": "The worker to revise, as passed to agents__spawn and listed by agents__list. Non-empty after trim. Must already exist - this never creates a worker.",
1046                },
1047                "charter": {
1048                    "type": "string",
1049                    "description": "Replacement mission text, threaded into the worker's system prompt on its next respawn. Omit to leave the stored charter unchanged. Non-empty after trim when provided.",
1050                },
1051                "kick": {
1052                    "type": "string",
1053                    "description": "Replacement first-turn message used when this worker is spawned fresh. Omit to leave the stored kick unchanged. Non-empty after trim when provided.",
1054                },
1055                "resume_kick": {
1056                    "type": "string",
1057                    "description": "Replacement re-orient message delivered when this worker is resumed after a forge restart with a turn that was cut off, in place of the generic restart note. A worker that was idle when forge stopped resumes with nothing delivered. Omit to leave the stored value unchanged. Non-empty after trim when provided.",
1058                },
1059                "mcp_families": {
1060                    "type": "array",
1061                    "items": { "type": "string", "enum": families },
1062                    "description": "Replacement MCP-family selection for this worker, validated as agents__spawn validates it. Omit to leave the stored selection unchanged; pass an empty list to restore every family. Takes effect on the next respawn. The families, and what each gives a worker, are listed on agents__spawn's `mcp_families` argument; read them there before narrowing.",
1063                },
1064            },
1065            "required": ["label"],
1066            "additionalProperties": false,
1067        })
1068    }
1069
1070    async fn call(&self, input: ToolInput) -> ToolOutput {
1071        let args: UpdateArgs = match serde_json::from_value(input.value) {
1072            Ok(a) => a,
1073            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1074        };
1075
1076        let Some(caller_project) = self.facade.caller_project(&self.slot) else {
1077            return tool_error("agents__update: caller resolves to no known project".to_owned());
1078        };
1079        if !caller_project.is_lead {
1080            return tool_error(
1081                "agents__update is lead-only; this session is a worker. Workers cannot revise \
1082                 other workers."
1083                    .to_owned(),
1084            );
1085        }
1086
1087        let label = args.label.trim();
1088        if label.is_empty() {
1089            return tool_error("label must be non-empty after trim".to_owned());
1090        }
1091
1092        // Same non-empty contract the spawn path holds these texts to.
1093        // #685 and #686 record its known gaps; match the predicate rather
1094        // than inventing a stronger one here.
1095        let mut updated: Vec<&str> = Vec::new();
1096        for (name, value) in
1097            [("charter", &args.charter), ("kick", &args.kick), ("resume_kick", &args.resume_kick)]
1098        {
1099            if let Some(text) = value {
1100                if text.trim_end().is_empty() {
1101                    return tool_error(format!(
1102                        "{name} must be non-empty after trim when provided"
1103                    ));
1104                }
1105                updated.push(name);
1106            }
1107        }
1108        // A present-but-empty list is the reset (every family); anything
1109        // else is canonicalised like the spawn argument.
1110        let mcp_families = match args.mcp_families.as_deref() {
1111            Some([]) => Some(Vec::new()),
1112            Some(names) => match crate::mcp::canonical_mcp_families(names) {
1113                Ok(families) => families,
1114                Err(message) => return tool_error(message),
1115            },
1116            None => None,
1117        };
1118        if mcp_families.is_some() {
1119            updated.push("mcp_families");
1120        }
1121        if updated.is_empty() {
1122            return tool_error(
1123                "supply at least one of charter, kick, resume_kick or mcp_families; an update \
1124                 with none of them would change nothing."
1125                    .to_owned(),
1126            );
1127        }
1128
1129        match self.facade.update_worker(
1130            &self.slot,
1131            label,
1132            args.charter,
1133            args.kick,
1134            args.resume_kick,
1135            mcp_families,
1136        ) {
1137            Ok(()) => json_output(&serde_json::json!({ "label": label, "updated": updated })),
1138            Err(err) => tool_error(format_update_error(&err)),
1139        }
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146    use crate::ProjectKey;
1147    use crate::mcp::agents::target::{AgentTarget, LEAD_LABEL};
1148    use crate::mcp::peers::facade::{MockWorkspaceFacade, WorkspaceFacade};
1149    use crate::mcp::peers::types::PeerLiveness;
1150    use crate::mcp::test_support::text_of;
1151    use crate::mcp::workers::facade::{
1152        CallerProject, MockWorkerFacade, WorkerCapacity, WorkerFacade,
1153    };
1154    use crate::protocol::WorkerSpawnReply;
1155    use forge_primitives::{WorkerLiveness, WorkerStatus};
1156
1157    struct Host {
1158        peers: Arc<MockWorkspaceFacade>,
1159        workers: Arc<MockWorkerFacade>,
1160        dispatcher: Arc<AgentDispatcher>,
1161    }
1162
1163    /// The lead of `acme`/`core` is a caller, and so is one of its workers;
1164    /// `other`/`proj` runs a worker `w1`. Both callers resolve to the same
1165    /// project, which is what makes a lead's call and a worker's call
1166    /// comparable.
1167    fn host() -> Host {
1168        let peers = Arc::new(MockWorkspaceFacade::new());
1169        // The peers mock resolves identity by the caller's label rather
1170        // than by its project, so the worker caller needs a project
1171        // named after it. The lead's slot is already named `lead`, which
1172        // is what the mock looks for.
1173        peers.peers.lock().extend([
1174            configured("acme", "core"),
1175            configured("other", "proj"),
1176            PeerStatus { name: worker_caller().label().to_owned(), ..configured("acme", "core") },
1177        ]);
1178        let workers = Arc::new(MockWorkerFacade::new());
1179        // `core` is the key both callers resolve to, so its own pool is
1180        // what `list` reads; `proj` is what a cross-project target
1181        // resolves against.
1182        workers.workers.lock().insert(
1183            "core".to_owned(),
1184            vec![worker("acme", "core", "w1"), worker("acme", "core", "w2")],
1185        );
1186        workers.workers.lock().insert("proj".to_owned(), vec![worker("other", "proj", "w1")]);
1187        for (slot, is_lead) in [(caller(), true), (worker_caller(), false)] {
1188            workers
1189                .callers
1190                .lock()
1191                .insert(slot, CallerProject { project_key: ProjectKey::new("core"), is_lead });
1192        }
1193        let dispatcher = AgentDispatcher::new(
1194            Arc::clone(&peers) as Arc<dyn WorkspaceFacade>,
1195            Arc::clone(&workers) as Arc<dyn WorkerFacade>,
1196        );
1197        Host { peers, workers, dispatcher: Arc::new(dispatcher) }
1198    }
1199
1200    fn caller() -> SessionSlot {
1201        SessionSlot::lead("acme", "core")
1202    }
1203
1204    /// A worker on the lead's own team. Every shared verb has to work for
1205    /// it as well as for the lead: the whole point of the merge is that a
1206    /// worker's reach is the same.
1207    fn worker_caller() -> SessionSlot {
1208        SessionSlot::worker("acme", "core", "w2")
1209    }
1210
1211    fn configured(org: &str, name: &str) -> PeerStatus {
1212        PeerStatus {
1213            name: name.to_owned(),
1214            org: org.to_owned(),
1215            path: std::path::PathBuf::from(format!("/tmp/{name}")),
1216            status: PeerLiveness::Running,
1217            spawned_at: None,
1218        }
1219    }
1220
1221    fn worker(org: &str, project: &str, label: &str) -> WorkerStatus {
1222        WorkerStatus {
1223            label: label.to_owned(),
1224            charter: format!("{label}'s charter"),
1225            status: WorkerLiveness::Running,
1226            session_id: format!("{label}-session"),
1227            slot: SessionSlot::worker(org, project, label),
1228            spawned_at: std::time::SystemTime::UNIX_EPOCH,
1229            spawned_by: SessionSlot::lead(org, project),
1230            diagnostic: None,
1231            heal_attempt: None,
1232            heal_next_at: None,
1233            activity: None,
1234        }
1235    }
1236
1237    fn target(org: &str, project: &str, label: Option<&str>) -> AgentTarget {
1238        let known = [configured("acme", "core"), configured("other", "proj")];
1239        AgentTarget::parse(&known, org, project, label).expect("configured project")
1240    }
1241
1242    fn fill_address(args: &mut serde_json::Value, to: &AgentTarget) {
1243        args["org"] = serde_json::json!(to.org());
1244        args["project"] = serde_json::json!(to.project());
1245        if to.label() != LEAD_LABEL {
1246            args["label"] = serde_json::json!(to.label());
1247        }
1248    }
1249
1250    async fn call_send(host: &Host, to: AgentTarget, message: &str) -> ToolOutput {
1251        let tool = SendMessage { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
1252        let mut args = serde_json::json!({ "message": message });
1253        fill_address(&mut args, &to);
1254        tool.call(ToolInput { value: args }).await
1255    }
1256
1257    /// `agents__handoff` is a shared verb: either kind hands its OWN seat
1258    /// over, and the note it carries is the brief's judgment half.
1259    #[tokio::test]
1260    async fn handoff_hands_the_callers_own_seat_over() {
1261        for slot in [caller(), worker_caller()] {
1262            let host = host();
1263            let tool = Handoff { dispatcher: Arc::clone(&host.dispatcher), slot: slot.clone() };
1264            let output = tool
1265                .call(ToolInput {
1266                    value: serde_json::json!({ "brief": "Now: the cut is half in." }),
1267                })
1268                .await;
1269            assert!(!output.is_error, "a handoff must not error: {:?}", output.blocks);
1270            assert_eq!(
1271                host.peers.handoff_calls.lock().as_slice(),
1272                &[(slot, "Now: the cut is half in.".to_owned())],
1273                "the caller's own seat, with the note it wrote",
1274            );
1275        }
1276    }
1277
1278    /// **The tool's own words do not prescribe a shape the skill no longer
1279    /// asks for.** A heading list here tells the model to write a note shaped
1280    /// by a contract forge retired; the note's shape is its author's, so both
1281    /// strings have to say so.
1282    #[test]
1283    fn the_handoff_tool_leaves_the_notes_shape_to_its_author() {
1284        let host = host();
1285        let tool = Handoff { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
1286        let field = tool.input_schema()["properties"]["brief"]["description"]
1287            .as_str()
1288            .map(str::to_owned)
1289            .expect("the brief's own description");
1290
1291        for text in [tool.description().to_owned(), field] {
1292            assert!(
1293                text.contains("in whatever shape reads best"),
1294                "the tool's words name no shape: {text}",
1295            );
1296            for heading in ["five headings", "Where it stood", "Pointers", "Done means"] {
1297                assert!(!text.contains(heading), "the tool prescribes '{heading}' again: {text}");
1298            }
1299        }
1300    }
1301
1302    /// A call with no note, or a blank one, gets the explanation that names
1303    /// the two doors, not a schema error.
1304    #[tokio::test]
1305    async fn a_call_without_a_note_is_refused_by_name() {
1306        for value in [serde_json::json!({}), serde_json::json!({ "brief": "   " })] {
1307            let host = host();
1308            let tool = Handoff { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
1309            let output = tool.call(ToolInput { value }).await;
1310            assert!(output.is_error, "a handoff with no note is an error");
1311            let text = text_of(&output);
1312            assert!(
1313                text.contains("/handoff") && text.contains("automatic trigger"),
1314                "the refusal names both doors: {text}",
1315            );
1316            assert!(host.peers.handoff_calls.lock().is_empty(), "and nothing is dispatched");
1317        }
1318    }
1319
1320    async fn call_list(host: &Host, project_filter: Option<&str>) -> Vec<serde_json::Value> {
1321        let tool = List { dispatcher: Arc::clone(&host.dispatcher) };
1322        let mut args = serde_json::json!({});
1323        if let Some(name) = project_filter {
1324            args["project"] = serde_json::json!(name);
1325        }
1326        let output = tool.call(ToolInput { value: args }).await;
1327        assert!(!output.is_error, "list must not error: {:?}", output.blocks);
1328        serde_json::from_str(text_of(&output)).expect("list returns a JSON array")
1329    }
1330
1331    /// The despawn trigger is shipped text a session reads at the moment it
1332    /// decides whether to despawn, and this description plus
1333    /// [`Despawn::description`] are two of its three homes (the third is
1334    /// `lead_charter.md`). Both cases have to survive an edit here: a worker
1335    /// whose output is a PR lives until that PR merges, and one whose output
1336    /// is not a PR closes at handover.
1337    #[test]
1338    fn spawn_metadata_shape() {
1339        let host = host();
1340        let tool =
1341            Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1342        assert_eq!(tool.name(), "agents__spawn");
1343        assert!(
1344            tool.description().contains("no merge to wait for"),
1345            "the two-case despawn trigger stays: {}",
1346            tool.description()
1347        );
1348        assert!(
1349            tool.description().contains("a finished sweep"),
1350            "non-PR outputs stay named as despawn triggers: {}",
1351            tool.description()
1352        );
1353        assert!(
1354            tool.description().contains(
1355                "A work cut - one feature, one issue, one fix - lands whole: one branch, one \
1356                 review, one look, one merge. Never slice it into pieces that land one after \
1357                 another, and never show or merge an intermediate step on the way."
1358            ),
1359            "the handle and the ban, through the ban's period, are pinned together - the same \
1360             sentence the conduct block and the charter carry: {}",
1361            tool.description()
1362        );
1363        assert!(
1364            tool.description().contains(
1365                "The look is the one that cut is owed: a feature waits for the user's own look \
1366                 before merge; a fix merges after the review loop as usual."
1367            ),
1368            "the look's scope is pinned: {}",
1369            tool.description()
1370        );
1371        let schema = tool.input_schema();
1372        let required = schema["required"].as_array().expect("required field present");
1373        assert!(required.iter().any(|v| v == "label"));
1374        assert!(
1375            required.iter().any(|v| v == "charter"),
1376            "charter is required - there is no file to fall back to"
1377        );
1378        // The empty-list default is a FRESH SPAWN's: a resume adopts the
1379        // stored selection, so the unconditional wording promises a reset
1380        // the spawn does not perform.
1381        assert!(
1382            tool.description().contains("the worker gets every family on a fresh spawn"),
1383            "the top-level text scopes the default to a fresh spawn: {}",
1384            tool.description(),
1385        );
1386        // `review` reads as review-pass tooling, so a lead withholding it
1387        // from an implementing worker leaves that worker unable to answer
1388        // a review on its own branch (#2510) - or to read the review a
1389        // handed-off document comes back with. Each family names what a
1390        // worker gets from it and who should have it.
1391        let families = schema["properties"]["mcp_families"]["description"]
1392            .as_str()
1393            .expect("mcp_families carries a description");
1394        assert!(
1395            families.contains(
1396                "Omit or pass an empty list for every family on a fresh spawn; a resume \
1397                 adopts the stored selection"
1398            ),
1399            "the argument text scopes the empty-list default to a fresh spawn: {families}",
1400        );
1401        // Drive the list from the enum, not a hand-written copy: a family
1402        // added to `McpFamily::ALL` fails the exhaustive match below, and
1403        // the shipped description then has to carry its purpose too.
1404        let advertised = |schema: &serde_json::Value| -> Vec<String> {
1405            schema["properties"]["mcp_families"]["items"]["enum"]
1406                .as_array()
1407                .expect("the family enum is advertised")
1408                .iter()
1409                .map(|value| value.as_str().expect("a family name is a string").to_owned())
1410                .collect()
1411        };
1412        let enum_names: Vec<String> =
1413            crate::mcp::McpFamily::ALL.iter().map(|family| family.as_str().to_owned()).collect();
1414        assert_eq!(advertised(&schema), enum_names, "spawn advertises the enum's own list");
1415        assert_eq!(
1416            families.matches("` - ").count(),
1417            enum_names.len(),
1418            "the description names every family once and no retired one: {families}",
1419        );
1420        for family in crate::mcp::McpFamily::ALL {
1421            let (purpose, guidance) = match family {
1422                crate::mcp::McpFamily::Review => (
1423                    "read and answer reviews filed on its branch or under a document it handed \
1424                     off",
1425                    "give it to any worker that implements or whose deliverable is a reviewable \
1426                     document",
1427                ),
1428                crate::mcp::McpFamily::Cron => (
1429                    "schedule and manage durable work of its own",
1430                    "give it to any worker that needs a recurring or deferred task",
1431                ),
1432                crate::mcp::McpFamily::Tasks => (
1433                    "claim, file and update rows on the project board",
1434                    "give it to any worker whose work is on the board",
1435                ),
1436                crate::mcp::McpFamily::Gotify => (
1437                    "subscribe to and read Gotify notifications",
1438                    "give it to any worker that acts on them",
1439                ),
1440                crate::mcp::McpFamily::Slack => (
1441                    "read Slack, subscribe, and post through held approval",
1442                    "give it to any worker that works in Slack",
1443                ),
1444                crate::mcp::McpFamily::Systemone => (
1445                    "put a yes/no, a choice or a score to the decision model",
1446                    "give it to any worker that weighs decisions itself",
1447                ),
1448                crate::mcp::McpFamily::Voice => (
1449                    "speak one utterance aloud to the person listening to this seat",
1450                    "give it to any worker whose replies someone should hear",
1451                ),
1452                crate::mcp::McpFamily::Vault => (
1453                    "read and change credentials in the Vaultwarden vault, every call held for approval",
1454                    "give it to any worker that needs its own credentials",
1455                ),
1456            };
1457            assert!(
1458                families.contains(&format!("`{}` - {purpose}; {guidance}.", family.as_str())),
1459                "the mcp_families description does not say what {} gives a worker, or who \
1460                 should have it: {families}",
1461                family.as_str(),
1462            );
1463        }
1464        // `agents__update` changes the same selection, so it points at the
1465        // spawn argument for the purposes instead of restating them.
1466        let update =
1467            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1468        assert_eq!(
1469            advertised(&update.input_schema()),
1470            enum_names,
1471            "update advertises the enum's own list",
1472        );
1473        assert!(
1474            update.input_schema()["properties"]["mcp_families"]["description"]
1475                .as_str()
1476                .expect("update's mcp_families carries a description")
1477                .contains("listed on agents__spawn's `mcp_families` argument"),
1478            "the update argument points at the one list of family purposes",
1479        );
1480    }
1481
1482    /// The send cue fires at the moment a report is composed, and the tools
1483    /// it names can be renamed - pinned so a rename fails here rather than
1484    /// shipping a cue that resolves to nothing.
1485    #[test]
1486    fn send_description_carries_its_systemone_cue() {
1487        let host = host();
1488        let tool = SendMessage { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
1489        assert!(
1490            tool.description().contains("and the `systemone__*` tools are in your list"),
1491            "the cue's guard names the family, so a rename of it fails here: {}",
1492            tool.description()
1493        );
1494        assert!(
1495            tool.description().contains(
1496                "make one call on the claim or the pick first: put the claim and its evidence in `state` for a claim check"
1497            ),
1498            "the report-moment cue and its fields stay: {}",
1499            tool.description()
1500        );
1501        assert!(
1502            tool.description()
1503                .contains("or the options you have already enumerated in `criteria` for a choice"),
1504            "the choice half names its own field: {}",
1505            tool.description()
1506        );
1507        assert!(
1508            tool.description().contains(
1509                "two or more options you have enumerated survive your own reasoning and nothing \
1510                 outside you decides between them (a routing call with several plausible owners \
1511                 and no evidence separating them is exactly that case)"
1512            ),
1513            "the pick cue carries the widened fork and its routing case: {}",
1514            tool.description()
1515        );
1516    }
1517
1518    /// A `sent` is the queue's receipt, not the target's read, and a
1519    /// session replaced under an unread message loses it with no error.
1520    /// "Not read yet" keeps it from contradicting the held-delivery
1521    /// guarantee above it; the action stays in the lead charter.
1522    #[test]
1523    fn send_description_carries_the_queued_message_restart_rule() {
1524        let host = host();
1525        let tool = SendMessage { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
1526        assert!(
1527            tool.description().contains(
1528                "A message the target has not read yet can also die with its session: a forge \
1529                 restart or a resume onto a new session id drops it, and nothing errors."
1530            ),
1531            "the unread-message loss and its silence stay stated: {}",
1532            tool.description()
1533        );
1534    }
1535
1536    #[test]
1537    fn despawn_metadata_shape() {
1538        let host = host();
1539        let tool =
1540            Despawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1541        assert_eq!(tool.name(), "agents__despawn");
1542        assert!(
1543            tool.description().contains("lives until that PR merges"),
1544            "the PR case waits for the merge: {}",
1545            tool.description()
1546        );
1547        assert!(
1548            tool.description().contains("done when it hands over"),
1549            "the non-PR case closes at handover: {}",
1550            tool.description()
1551        );
1552        assert!(
1553            tool.description().contains("Never prune a worker's worktree by hand to reclaim disk"),
1554            "the worktree is not a hand-reclaimable directory: {}",
1555            tool.description()
1556        );
1557        assert!(
1558            tool.description().contains("reclaim is the despawn's"),
1559            "reclaim is named as this tool's: {}",
1560            tool.description()
1561        );
1562        let schema = tool.input_schema();
1563        let required = schema["required"].as_array().expect("required field present");
1564        assert!(required.iter().any(|v| v == "label"));
1565        assert!(required.iter().all(|v| v != "force"), "force is optional");
1566        assert!(schema["properties"].as_object().unwrap().contains_key("force"));
1567    }
1568
1569    #[tokio::test]
1570    async fn a_send_reaches_a_worker_in_another_project() {
1571        // The reach the merge adds: today this takes two leads and a
1572        // prose relay.
1573        let host = host();
1574        let output = call_send(&host, target("other", "proj", Some("w1")), "hi").await;
1575        assert!(!output.is_error, "cross-project send must land: {:?}", output.blocks);
1576        assert_eq!(host.workers.deliver_to_project_calls.lock().len(), 1);
1577    }
1578
1579    #[tokio::test]
1580    async fn a_send_at_a_projects_own_agent_reaches_that_projects_lead() {
1581        let host = host();
1582        let output = call_send(&host, target("other", "proj", None), "hi").await;
1583        assert!(!output.is_error, "send to another project's agent must land: {:?}", output.blocks);
1584        let calls = host.peers.deliver_calls.lock();
1585        assert_eq!(calls.len(), 1, "the peers engine carried it");
1586        assert_eq!(calls[0].1, "proj", "addressed by project name");
1587    }
1588
1589    /// The result names the send and the seat it reached, and nothing
1590    /// else: the id is traceability for the sender's own echo, and there
1591    /// is no outstanding state for a second field to describe.
1592    #[tokio::test]
1593    async fn a_send_result_carries_the_id_and_the_seat_it_reached() {
1594        let host = host();
1595        let output = call_send(&host, target("other", "proj", Some("w1")), "hi").await;
1596        assert!(!output.is_error, "the send must land: {:?}", output.blocks);
1597        let parsed: serde_json::Value =
1598            serde_json::from_str(text_of(&output)).expect("send returns JSON");
1599        assert_eq!(parsed["status"], "sent");
1600        let id = parsed["id"].as_str().expect("the result carries the send's id");
1601        assert!(id.starts_with("m-"), "ids name the send: {id}");
1602        assert_eq!(
1603            parsed["to"],
1604            serde_json::json!({
1605                "org": "other",
1606                "project": "proj",
1607                "label": "w1",
1608            })
1609        );
1610        assert_eq!(
1611            parsed.as_object().expect("an object").len(),
1612            3,
1613            "the shape is status/id/to and nothing else: {parsed}",
1614        );
1615    }
1616
1617    #[tokio::test]
1618    async fn list_filters_by_project() {
1619        let host = host();
1620        let rows = call_list(&host, Some("proj")).await;
1621        let slots: Vec<(&str, &str)> = rows
1622            .iter()
1623            .map(|row| {
1624                (
1625                    row["slot"]["label"].as_str().expect("a label"),
1626                    row["slot"]["project"].as_str().expect("a project"),
1627                )
1628            })
1629            .collect();
1630        assert_eq!(
1631            slots,
1632            [("lead", "proj"), ("w1", "proj")],
1633            "the filter keeps that project's own agent and its worker"
1634        );
1635        assert!(call_list(&host, None).await.len() > 1, "without it, the list is flat");
1636    }
1637
1638    /// The listing scope is shipped text a session reads when it decides
1639    /// whether a label is knowable, so the caller-only paragraph it
1640    /// replaced must not come back silently.
1641    #[test]
1642    fn list_description_names_every_projects_workers() {
1643        let host = host();
1644        let tool = List { dispatcher: Arc::clone(&host.dispatcher) };
1645        assert!(
1646            tool.description().contains("every project's workers, each under its own label"),
1647            "the listing scope stays stated: {}",
1648            tool.description()
1649        );
1650        assert!(
1651            !tool.description().contains("Only your own project's workers are listed"),
1652            "the caller-only paragraph stays gone: {}",
1653            tool.description()
1654        );
1655    }
1656
1657    #[tokio::test]
1658    async fn list_addresses_every_row_by_its_slot() {
1659        let host = host();
1660        for row in call_list(&host, None).await {
1661            assert!(row["slot"]["org"].is_string(), "row carries its org: {row}");
1662            assert!(row["slot"]["project"].is_string(), "row carries its project: {row}");
1663            assert!(row["slot"]["label"].is_string(), "row carries its label: {row}");
1664        }
1665    }
1666
1667    #[tokio::test]
1668    async fn whoami_returns_the_callers_own_slot() {
1669        let host = host();
1670        // The peers mock resolves identity by the caller's label rather
1671        // than by its project, so the fixture names a project after the
1672        // caller. The slot in the response comes from the caller either
1673        // way, which is what this pins.
1674        host.peers
1675            .peers
1676            .lock()
1677            .push(PeerStatus { name: caller().label().to_owned(), ..configured("acme", "core") });
1678        let tool = Whoami { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
1679        let output = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1680        assert!(!output.is_error, "whoami must resolve the caller: {:?}", output.blocks);
1681        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1682        assert_eq!(parsed["slot"]["org"], "acme");
1683        assert_eq!(parsed["slot"]["project"], "core");
1684        assert_eq!(parsed["slot"]["label"], LEAD_LABEL);
1685    }
1686
1687    #[tokio::test]
1688    async fn list_carries_the_callers_own_team() {
1689        let host = host();
1690        let rows = call_list(&host, None).await;
1691        let row = rows
1692            .iter()
1693            .find(|row| row["slot"]["label"] == "w1" && row["slot"]["project"] == "core")
1694            .unwrap_or_else(|| panic!("the caller's own worker is a row: {rows:?}"));
1695        assert_eq!(row["charter"], "w1's charter", "a worker row carries its snapshot");
1696    }
1697
1698    /// The row the issue measured as missing: another project's live worker
1699    /// is listed under its own slot, not left to a side channel.
1700    #[tokio::test]
1701    async fn list_carries_every_projects_live_workers_under_their_slots() {
1702        let host = host();
1703        let rows = call_list(&host, None).await;
1704        let row = rows
1705            .iter()
1706            .find(|row| {
1707                row["slot"]
1708                    == serde_json::json!({
1709                        "org": "other",
1710                        "project": "proj",
1711                        "label": "w1",
1712                    })
1713            })
1714            .unwrap_or_else(|| panic!("another project's worker is a row: {rows:?}"));
1715        assert_eq!(row["charter"], "w1's charter", "a worker row carries its snapshot");
1716    }
1717
1718    /// A Failed row is the only trace that a label exists, so the listing
1719    /// keeps it; a future `.filter(|w| w.is_live())` would drop it silently.
1720    #[tokio::test]
1721    async fn list_keeps_a_failed_worker_row() {
1722        let host = host();
1723        let mut failed = worker("other", "proj", "crashed");
1724        failed.status = WorkerLiveness::Failed;
1725        host.workers.workers.lock().entry("proj".to_owned()).or_default().push(failed);
1726        let rows = call_list(&host, None).await;
1727        assert!(
1728            rows.iter()
1729                .any(|row| row["slot"]["project"] == "proj" && row["slot"]["label"] == "crashed"),
1730            "a Failed row stays listed: {rows:?}",
1731        );
1732    }
1733
1734    #[tokio::test]
1735    async fn a_project_without_live_workers_keeps_its_lead_row() {
1736        let host = host();
1737        host.workers.workers.lock().remove("proj");
1738        let rows = call_list(&host, None).await;
1739        assert!(
1740            rows.iter()
1741                .any(|row| row["slot"]["project"] == "proj" && row["slot"]["label"] == LEAD_LABEL),
1742            "its own agent stays reachable while it sleeps: {rows:?}",
1743        );
1744    }
1745
1746    /// A worker's own call, one per shared verb. The role-set tests pin that
1747    /// a worker is OFFERED these three; these pin that they work when it calls
1748    /// them, which is the reach the merge widened and which nothing else
1749    /// exercises - every other test in this file runs as a lead.
1750    #[tokio::test]
1751    async fn a_worker_can_read_its_own_identity() {
1752        let host = host();
1753        let tool = Whoami { dispatcher: Arc::clone(&host.dispatcher), slot: worker_caller() };
1754        let output = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1755        assert!(!output.is_error, "whoami must resolve a worker caller: {:?}", output.blocks);
1756        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1757        assert_eq!(parsed["slot"]["label"], "w2");
1758        assert_eq!(parsed["slot"]["project"], "core");
1759    }
1760
1761    #[tokio::test]
1762    async fn a_worker_can_message_its_lead() {
1763        let host = host();
1764        let tool = SendMessage { dispatcher: Arc::clone(&host.dispatcher), slot: worker_caller() };
1765        let output = tool
1766            .call(ToolInput {
1767                value: serde_json::json!({
1768                    "org": "acme",
1769                    "project": "core",
1770                    "label": LEAD_LABEL,
1771                    "message": "done",
1772                }),
1773            })
1774            .await;
1775        assert!(!output.is_error, "a worker's message to its lead must land: {:?}", output.blocks);
1776        // The reserved label takes the lead path, and only that path:
1777        // the worker path would look for a worker labelled `lead` in the
1778        // caller's pool and refuse.
1779        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1780        assert_eq!(parsed["status"], "sent");
1781    }
1782
1783    #[tokio::test]
1784    async fn capacity_floors_at_zero_free_slots_when_the_cap_drops_below_the_pool() {
1785        // `max_workers` can be lowered under a live pool, and the free-slot
1786        // count is rendered to the model: subtracting without flooring
1787        // wraps to a 20-digit number, or panics in a debug build. The
1788        // assertion below is therefore the release-profile kill; in debug
1789        // the panic is rustc's subtract-overflow and fires first.
1790        let host = host();
1791        *host.workers.capacity_reply.lock() = Some(WorkerCapacity {
1792            project: "core".to_owned(),
1793            cap: 1,
1794            live: 3,
1795            cap_source: WorkerCapSource::Default,
1796        });
1797        let tool =
1798            Capacity { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1799        let output = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1800        assert!(!output.is_error, "capacity must answer: {:?}", output.blocks);
1801        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1802        assert_eq!(parsed["available"], 0, "a cap under the live count leaves no free slot");
1803    }
1804
1805    /// The spawn result echoes the worker's own row - compared whole
1806    /// against the row the `agents__list` call itself draws, so a trimmed
1807    /// subset or a row addressed by the caller's own slot cannot pass -
1808    /// plus where it landed; the resume outcome is a field, and the old
1809    /// `session` sentence is gone rather than sent beside it.
1810    #[tokio::test]
1811    async fn spawn_echoes_the_worker_row_and_its_worktree() {
1812        let host = host();
1813        *host.workers.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1814            session_id: "reviewer-session".to_owned(),
1815            tag: "forge:worker:reviewer".to_owned(),
1816            mcp_families: Some(vec!["tasks".to_owned(), "slack".to_owned()]),
1817            rate_limited_account: None,
1818            durability_warning: None,
1819            session_choice: SessionChoice::Resumed,
1820            worktree: Some("/repo/.claude/worktrees/reviewer".to_owned()),
1821        }));
1822        // The live entry the spawn just created, as `agents__list` reads it.
1823        host.workers
1824            .workers
1825            .lock()
1826            .entry("core".to_owned())
1827            .or_default()
1828            .push(worker("acme", "core", "reviewer"));
1829        let tool =
1830            Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1831
1832        let output = tool
1833            .call(ToolInput {
1834                value: serde_json::json!({ "label": "reviewer", "charter": "review the diff" }),
1835            })
1836            .await;
1837        assert!(!output.is_error, "the spawn must be answered: {:?}", output.blocks);
1838        assert_eq!(output.blocks.len(), 1, "the answer stays one text block: {output:?}");
1839        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1840        assert_eq!(parsed["resumed"], true, "resumed is a field a caller can branch on");
1841        assert_eq!(
1842            parsed["session_choice"], "resumed",
1843            "and the three-way outcome stays machine-readable",
1844        );
1845        assert_eq!(parsed["worktree"], "/repo/.claude/worktrees/reviewer");
1846        let listed = call_list(&host, None).await;
1847        let row = listed
1848            .iter()
1849            .find(|row| row["slot"]["label"] == "reviewer")
1850            .expect("agents__list draws a row for the worker");
1851        assert_eq!(
1852            parsed["worker"], *row,
1853            "the echo is the whole row agents__list draws, slot included",
1854        );
1855        assert_eq!(parsed["session_id"], "reviewer-session", "the id stays where callers read it");
1856        assert_eq!(parsed["mcp_families"], serde_json::json!(["tasks", "slack"]));
1857        assert!(
1858            parsed.get("session").is_none(),
1859            "the outcome is fields now, not a sentence: {parsed}",
1860        );
1861    }
1862
1863    /// The echoed row is the occupant THIS spawn landed on: a Failed row is
1864    /// deliberately kept under its label so a re-spawn can recover, so a
1865    /// label-only lookup would echo the previous occupant - its stale status
1866    /// and its dead session id - beside the fresh id in the same answer.
1867    #[tokio::test]
1868    async fn spawn_echoes_the_occupant_it_landed_on_not_the_labels_previous_one() {
1869        let host = host();
1870        *host.workers.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1871            session_id: "fresh-session".to_owned(),
1872            tag: "forge:worker:reviewer".to_owned(),
1873            mcp_families: None,
1874            rate_limited_account: None,
1875            durability_warning: None,
1876            session_choice: SessionChoice::Fresh,
1877            worktree: None,
1878        }));
1879        let mut stale = worker("acme", "core", "reviewer");
1880        stale.status = WorkerLiveness::Failed;
1881        stale.session_id = "dead-session".to_owned();
1882        stale.diagnostic = Some("spawn failed".to_owned());
1883        let mut fresh = worker("acme", "core", "reviewer");
1884        fresh.session_id = "fresh-session".to_owned();
1885        host.workers.workers.lock().insert("core".to_owned(), vec![stale, fresh]);
1886        let tool =
1887            Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1888
1889        let output = tool
1890            .call(ToolInput {
1891                value: serde_json::json!({ "label": "reviewer", "charter": "review the diff" }),
1892            })
1893            .await;
1894        assert!(!output.is_error, "the spawn must be answered: {:?}", output.blocks);
1895        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1896        assert_eq!(
1897            parsed["worker"]["session_id"], "fresh-session",
1898            "the row is the occupant that was just spawned, not the failed predecessor: {parsed}",
1899        );
1900        assert_eq!(parsed["worker"]["status"], "Running", "and it reads as the state it holds");
1901    }
1902
1903    /// A non-git project spawns its worker in the project root, so there is
1904    /// no worktree to name and the key is absent rather than pointing at a
1905    /// directory that is not one.
1906    #[tokio::test]
1907    async fn a_spawn_without_a_worktree_omits_the_path() {
1908        let host = host();
1909        *host.workers.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1910            session_id: "s-1".to_owned(),
1911            tag: "forge:worker:reviewer".to_owned(),
1912            mcp_families: None,
1913            rate_limited_account: None,
1914            durability_warning: None,
1915            session_choice: SessionChoice::Fresh,
1916            worktree: None,
1917        }));
1918        let tool =
1919            Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1920
1921        let output = tool
1922            .call(ToolInput {
1923                value: serde_json::json!({ "label": "reviewer", "charter": "review the diff" }),
1924            })
1925            .await;
1926        assert!(!output.is_error, "the spawn must be answered: {:?}", output.blocks);
1927        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1928        assert_eq!(parsed["resumed"], false, "a fresh spawn says so");
1929        assert_eq!(parsed["session_choice"], "fresh");
1930        assert!(parsed.get("worktree").is_none(), "no worktree, no path: {parsed}");
1931    }
1932
1933    /// A resume that found no prior session started fresh, so `resumed`
1934    /// must read false: a caller told it resumed believes the old context
1935    /// came back when nothing did.
1936    #[tokio::test]
1937    async fn a_resume_that_found_nothing_reports_itself_as_not_resumed() {
1938        let host = host();
1939        *host.workers.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1940            session_id: "s-1".to_owned(),
1941            tag: "forge:worker:reviewer".to_owned(),
1942            mcp_families: None,
1943            rate_limited_account: None,
1944            durability_warning: None,
1945            worktree: None,
1946            session_choice: SessionChoice::FreshWithoutPrior,
1947        }));
1948        let tool =
1949            Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1950
1951        let output = tool
1952            .call(ToolInput {
1953                value: serde_json::json!({ "label": "reviewer", "charter": "review the diff" }),
1954            })
1955            .await;
1956        assert!(!output.is_error, "the spawn must be answered: {:?}", output.blocks);
1957        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
1958        assert_eq!(parsed["resumed"], false, "nothing was resumed");
1959        assert_eq!(parsed["session_choice"], "fresh_without_prior", "and the fallback is named");
1960    }
1961
1962    #[tokio::test]
1963    async fn spawn_hands_the_kick_and_the_interactive_flag_to_the_facade() {
1964        // `kick` is what starts a worker at all and `interactive` decides
1965        // whether it keeps AskUserQuestion. Both are persisted, so
1966        // defaulting either here is a worker that never starts or one
1967        // that can never ask its user anything.
1968        let host = host();
1969        *host.workers.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1970            session_id: "s-1".to_owned(),
1971            tag: "forge:worker:reviewer".to_owned(),
1972            mcp_families: None,
1973            rate_limited_account: None,
1974            durability_warning: None,
1975            worktree: None,
1976            session_choice: SessionChoice::Fresh,
1977        }));
1978        let tool =
1979            Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
1980        let output = tool
1981            .call(ToolInput {
1982                value: serde_json::json!({
1983                    "label": "reviewer",
1984                    "charter": "review the diff",
1985                    "kick": "start with the diff",
1986                    "interactive": true,
1987                }),
1988            })
1989            .await;
1990        assert!(!output.is_error, "the spawn must be answered: {:?}", output.blocks);
1991        let calls = host.workers.spawn_calls.lock();
1992        assert_eq!(calls.len(), 1, "the facade saw the spawn");
1993        assert_eq!(calls[0].3.as_deref(), Some("start with the diff"), "the kick reaches it");
1994        assert!(calls[0].5, "the interactive flag reaches it");
1995    }
1996
1997    #[tokio::test]
1998    async fn a_workers_envelope_carries_its_label_beside_its_project() {
1999        // Drop the label and a worker's envelope is the bare project name,
2000        // which is exactly what its own lead sends - so a recipient cannot
2001        // tell the two apart at all.
2002        let host = host();
2003        let tool = SendMessage { dispatcher: Arc::clone(&host.dispatcher), slot: worker_caller() };
2004        let output = tool
2005            .call(ToolInput {
2006                value: serde_json::json!({
2007                    "org": "other",
2008                    "project": "proj",
2009                    "label": "w1",
2010                    "message": "hi",
2011                }),
2012            })
2013            .await;
2014        assert!(!output.is_error, "the send must land: {:?}", output.blocks);
2015        let sent = host.workers.deliver_to_project_calls.lock();
2016        assert_eq!(sent[0].wrapped.sender_name, "core/w2", "the envelope names the worker");
2017        assert_eq!(sent[0].wrapped.sender_org, "acme", "and its org, not the project's");
2018    }
2019
2020    #[tokio::test]
2021    async fn update_refuses_a_revision_that_would_change_nothing() {
2022        // Every field is optional, so a call naming none of them is a
2023        // no-op the caller would read as a successful revision. The
2024        // boundary is the assertion: the empty case refuses, and a
2025        // supplied field is named back rather than silently accepted.
2026        let host = host();
2027        let tool =
2028            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2029
2030        let empty = tool.call(ToolInput { value: serde_json::json!({ "label": "w1" }) }).await;
2031        assert!(empty.is_error, "an update naming no field is refused");
2032        assert!(
2033            text_of(&empty).contains("at least one"),
2034            "the refusal says what to supply: {}",
2035            text_of(&empty),
2036        );
2037        assert!(host.workers.update_calls.lock().is_empty(), "nothing reached the facade");
2038
2039        let supplied = tool
2040            .call(ToolInput {
2041                value: serde_json::json!({ "label": "w1", "charter": "new mission" }),
2042            })
2043            .await;
2044        assert!(!supplied.is_error, "one supplied field is enough: {:?}", supplied.blocks);
2045        let parsed: serde_json::Value = serde_json::from_str(text_of(&supplied)).expect("JSON");
2046        assert_eq!(
2047            parsed["updated"],
2048            serde_json::json!(["charter"]),
2049            "only the supplied field is reported as changed",
2050        );
2051    }
2052
2053    #[tokio::test]
2054    async fn despawn_reports_a_blocked_worktree_as_a_result() {
2055        // A dirty worktree leaves the worker live, so this is a result
2056        // the caller has to read and act on rather than a tool error.
2057        let host = host();
2058        *host.workers.despawn_outcome.lock() =
2059            Some(DespawnOutcome::Blocked { reason: "3 uncommitted files".to_owned() });
2060        let tool =
2061            Despawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2062        let output = tool.call(ToolInput { value: serde_json::json!({ "label": "w1" }) }).await;
2063        assert!(!output.is_error, "a blocked despawn is not a tool error: {:?}", output.blocks);
2064        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
2065        assert_eq!(parsed["status"], "blocked");
2066        assert_eq!(parsed["reason"], "3 uncommitted files", "the reason reaches the caller");
2067        assert!(
2068            !host.workers.despawn_calls.lock()[0].2,
2069            "an omitted force means the worktree is protected",
2070        );
2071    }
2072
2073    #[tokio::test]
2074    async fn despawn_carries_a_cleanup_warning_into_the_result() {
2075        let host = host();
2076        *host.workers.despawn_outcome.lock() = Some(DespawnOutcome::Despawned {
2077            worktree_cleanup_warning: Some("the directory lingers".to_owned()),
2078            branch_cleanup_warning: None,
2079        });
2080        let tool =
2081            Despawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2082        let output = tool
2083            .call(ToolInput { value: serde_json::json!({ "label": "w1", "force": true }) })
2084            .await;
2085        assert!(!output.is_error, "the despawn must be answered: {:?}", output.blocks);
2086        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
2087        assert_eq!(parsed["status"], "despawned", "a clean despawn reports it");
2088        assert_eq!(
2089            parsed["worktree_cleanup_warning"], "the directory lingers",
2090            "the worktree warning reaches the caller",
2091        );
2092        assert!(
2093            parsed.get("branch_cleanup_warning").is_none(),
2094            "a warning that did not fire is absent, not null",
2095        );
2096        assert!(host.workers.despawn_calls.lock()[0].2, "force reaches the facade");
2097    }
2098
2099    #[tokio::test]
2100    async fn update_forwards_only_the_supplied_fields() {
2101        // Every field is optional, so an omitted one must arrive as `None`
2102        // rather than as an empty string - which the store would write,
2103        // blanking a text the caller never meant to touch.
2104        let host = host();
2105        let tool =
2106            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2107        let output = tool
2108            .call(ToolInput {
2109                value: serde_json::json!({
2110                    "label": "w1",
2111                    "resume_kick": "re-read the plan first",
2112                }),
2113            })
2114            .await;
2115        assert!(!output.is_error, "the update must succeed: {:?}", output.blocks);
2116        let calls = host.workers.update_calls.lock();
2117        assert_eq!(calls.len(), 1, "the facade saw one update");
2118        assert_eq!(calls[0].1, "w1", "the label reaches the facade");
2119        assert_eq!(calls[0].2, None, "an omitted charter stays None");
2120        assert_eq!(calls[0].3, None, "an omitted kick stays None");
2121        assert_eq!(
2122            calls[0].4.as_deref(),
2123            Some("re-read the plan first"),
2124            "the supplied resume_kick reaches the facade",
2125        );
2126        assert!(
2127            text_of(&output).contains("resume_kick"),
2128            "the reply names the changed field: {}",
2129            text_of(&output),
2130        );
2131    }
2132
2133    #[tokio::test]
2134    async fn update_refuses_when_the_worker_does_not_exist() {
2135        // Update revises a row; it must never create one, because a row is
2136        // what re-spawns a worker at the next lead connect.
2137        let host = host();
2138        *host.workers.update_result.lock() = Some(Err(WorkerUpdateError::NoSuchWorker {
2139            label: "ghost".to_owned(),
2140            project_key: "core".to_owned(),
2141        }));
2142        let tool =
2143            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2144        let output = tool
2145            .call(ToolInput { value: serde_json::json!({ "label": "ghost", "charter": "c" }) })
2146            .await;
2147        assert!(output.is_error, "an absent worker is refused, not reported as revised");
2148        assert!(
2149            text_of(&output).contains("agents__spawn"),
2150            "the refusal points at spawn: {}",
2151            text_of(&output),
2152        );
2153    }
2154
2155    #[tokio::test]
2156    async fn update_refuses_a_field_that_is_empty_after_trim() {
2157        let host = host();
2158        let tool =
2159            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2160        let output = tool
2161            .call(ToolInput { value: serde_json::json!({ "label": "w1", "kick": "  \n " }) })
2162            .await;
2163        assert!(output.is_error, "a whitespace-only field is refused");
2164        assert!(
2165            text_of(&output).contains("kick must be non-empty after trim"),
2166            "the refusal names the offending field: {}",
2167            text_of(&output),
2168        );
2169        assert!(host.workers.update_calls.lock().is_empty(), "refused before touching the store");
2170    }
2171
2172    #[tokio::test]
2173    async fn update_refuses_a_label_that_is_empty_after_trim() {
2174        // On the facade this tool runs against, `update_worker` checks no
2175        // label and defaults to `Ok(())`, so without this guard a blank
2176        // label would be reported as a successful revision.
2177        let host = host();
2178        let tool =
2179            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2180        let output = tool
2181            .call(ToolInput { value: serde_json::json!({ "label": "   ", "charter": "c" }) })
2182            .await;
2183        assert!(output.is_error, "a blank label is refused, not accepted as a revision");
2184        assert!(
2185            text_of(&output).contains("label must be non-empty after trim"),
2186            "the refusal names the label: {}",
2187            text_of(&output),
2188        );
2189        assert!(host.workers.update_calls.lock().is_empty(), "refused before touching the store");
2190    }
2191
2192    #[tokio::test]
2193    async fn update_is_lead_only() {
2194        let host = host();
2195        let tool = Update {
2196            facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>,
2197            slot: worker_caller(),
2198        };
2199        let output = tool
2200            .call(ToolInput { value: serde_json::json!({ "label": "w1", "charter": "c" }) })
2201            .await;
2202        assert!(output.is_error, "a worker caller is refused");
2203        assert!(host.workers.update_calls.lock().is_empty(), "refused before touching the store");
2204    }
2205
2206    #[tokio::test]
2207    async fn despawn_non_lead_caller_is_error() {
2208        let host = host();
2209        let tool = Despawn {
2210            facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>,
2211            slot: worker_caller(),
2212        };
2213        let output = tool.call(ToolInput { value: serde_json::json!({ "label": "w1" }) }).await;
2214        assert!(output.is_error, "a worker caller is refused");
2215        assert!(
2216            text_of(&output).to_lowercase().contains("lead-only"),
2217            "the refusal says why: {}",
2218            text_of(&output),
2219        );
2220        assert!(host.workers.despawn_calls.lock().is_empty(), "refused before the facade");
2221    }
2222
2223    #[tokio::test]
2224    async fn despawn_empty_label_is_error() {
2225        let host = host();
2226        let tool =
2227            Despawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2228        let output = tool.call(ToolInput { value: serde_json::json!({ "label": "   " }) }).await;
2229        assert!(output.is_error, "a blank label is refused");
2230        assert!(
2231            text_of(&output).to_lowercase().contains("label"),
2232            "the refusal names the label: {}",
2233            text_of(&output),
2234        );
2235    }
2236
2237    #[tokio::test]
2238    async fn despawn_unknown_label_is_error() {
2239        // The failure this pins: reporting `despawned` for a label that
2240        // resolved to nothing tells a lead its worker is gone while it
2241        // keeps coming back on the next reconnect.
2242        let host = host();
2243        let tool =
2244            Despawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2245        let output = tool.call(ToolInput { value: serde_json::json!({ "label": "ghost" }) }).await;
2246        assert!(output.is_error, "a label with no live worker is refused, not reported as gone");
2247        assert!(
2248            text_of(&output).contains("ghost"),
2249            "the refusal names the label: {}",
2250            text_of(&output),
2251        );
2252    }
2253
2254    #[tokio::test]
2255    async fn despawn_surfaces_a_branch_cleanup_warning() {
2256        // The description promises this key, so a kept branch has to reach
2257        // the caller on an otherwise successful despawn.
2258        let host = host();
2259        *host.workers.despawn_outcome.lock() = Some(DespawnOutcome::Despawned {
2260            worktree_cleanup_warning: None,
2261            branch_cleanup_warning: Some("branch 'worktree-w1' kept: 2 commits".to_owned()),
2262        });
2263        let tool =
2264            Despawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2265        let output = tool.call(ToolInput { value: serde_json::json!({ "label": "w1" }) }).await;
2266        assert!(!output.is_error, "a kept branch is not an error: {:?}", output.blocks);
2267        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
2268        assert_eq!(parsed["status"], "despawned", "a despawn with a kept branch still reports it");
2269        assert!(
2270            parsed["branch_cleanup_warning"]
2271                .as_str()
2272                .expect("the branch warning is present")
2273                .contains("worktree-w1"),
2274            "the warning names the branch: {parsed}",
2275        );
2276    }
2277
2278    #[tokio::test]
2279    async fn a_target_naming_an_unknown_project_is_refused() {
2280        let host = host();
2281        let tool = SendMessage { dispatcher: Arc::clone(&host.dispatcher), slot: caller() };
2282        let output = tool
2283            .call(ToolInput {
2284                value: serde_json::json!({
2285                    "org": "acme",
2286                    "project": "absent",
2287                    "message": "hi",
2288                }),
2289            })
2290            .await;
2291        assert!(output.is_error, "an unknown project must refuse, not pick a plausible seat");
2292        assert!(host.peers.deliver_calls.lock().is_empty(), "nothing was delivered");
2293    }
2294
2295    fn spawn_tool(host: &Host) -> Spawn {
2296        Spawn { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() }
2297    }
2298
2299    fn preloaded_reply(families: Option<Vec<String>>) -> WorkerSpawnReply {
2300        WorkerSpawnReply {
2301            session_id: "s-1".to_owned(),
2302            tag: "forge:worker:reviewer".to_owned(),
2303            mcp_families: families,
2304            rate_limited_account: None,
2305            durability_warning: None,
2306            worktree: None,
2307            session_choice: SessionChoice::Fresh,
2308        }
2309    }
2310
2311    /// A spawn's `cwd`, when stated, reaches the facade as the tree the
2312    /// worker runs in - the facade resolves it, because a `~`-form or a
2313    /// repo subdirectory is not a directory the record can hold.
2314    #[tokio::test]
2315    async fn spawn_passes_the_stated_cwd_to_the_facade() {
2316        let host = host();
2317        *host.workers.spawn_reply.lock() = Some(Ok(preloaded_reply(None)));
2318        let tool = spawn_tool(&host);
2319
2320        let output = tool
2321            .call(ToolInput {
2322                value: serde_json::json!({
2323                    "label": "reviewer",
2324                    "charter": "c",
2325                    "cwd": "~/Projects/nazar",
2326                }),
2327            })
2328            .await;
2329
2330        assert!(!output.is_error, "{:?}", output.blocks);
2331        assert_eq!(
2332            host.workers.spawn_calls.lock()[0].8.as_deref(),
2333            Some("~/Projects/nazar"),
2334            "the stated cwd reaches the facade, which resolves it",
2335        );
2336    }
2337
2338    /// No `cwd` named: the facade hears `None`, so the project's own path
2339    /// keeps answering exactly as it always did.
2340    #[tokio::test]
2341    async fn spawn_without_a_cwd_sends_none() {
2342        let host = host();
2343        *host.workers.spawn_reply.lock() = Some(Ok(preloaded_reply(None)));
2344        let tool = spawn_tool(&host);
2345
2346        let output = tool
2347            .call(ToolInput { value: serde_json::json!({"label": "reviewer", "charter": "c"}) })
2348            .await;
2349
2350        assert!(!output.is_error, "{:?}", output.blocks);
2351        assert_eq!(
2352            host.workers.spawn_calls.lock()[0].8,
2353            None,
2354            "no cwd means the project's own path",
2355        );
2356    }
2357
2358    /// The spawn argument reaches the facade in canonical wire order,
2359    /// and the reply echoes exactly what the worker got.
2360    #[tokio::test]
2361    async fn spawn_families_are_canonicalised_and_echoed() {
2362        let host = host();
2363        *host.workers.spawn_reply.lock() =
2364            Some(Ok(preloaded_reply(Some(vec!["tasks".to_owned(), "slack".to_owned()]))));
2365        let tool = spawn_tool(&host);
2366
2367        let output = tool
2368            .call(ToolInput {
2369                value: serde_json::json!({
2370                    "label": "reviewer",
2371                    "charter": "c",
2372                    "mcp_families": ["slack", "tasks"],
2373                }),
2374            })
2375            .await;
2376
2377        assert!(!output.is_error, "{:?}", output.blocks);
2378        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
2379        assert_eq!(
2380            parsed["mcp_families"],
2381            serde_json::json!(["tasks", "slack"]),
2382            "the echo names the canonical order"
2383        );
2384        assert_eq!(
2385            host.workers.spawn_calls.lock()[0].7,
2386            Some(vec!["tasks".to_owned(), "slack".to_owned()]),
2387            "the facade gets the canonical list"
2388        );
2389    }
2390
2391    /// No families named: the facade hears `None` and the reply carries
2392    /// no key at all - absent means every family throughout.
2393    #[tokio::test]
2394    async fn spawn_without_families_sends_none_and_omits_the_key() {
2395        let host = host();
2396        *host.workers.spawn_reply.lock() = Some(Ok(preloaded_reply(None)));
2397        let tool = spawn_tool(&host);
2398
2399        let output = tool
2400            .call(ToolInput { value: serde_json::json!({"label": "reviewer", "charter": "c"}) })
2401            .await;
2402
2403        assert!(!output.is_error, "{:?}", output.blocks);
2404        let parsed: serde_json::Value = serde_json::from_str(text_of(&output)).expect("JSON");
2405        assert!(parsed.get("mcp_families").is_none(), "no key when nothing is narrowed: {parsed}");
2406        assert_eq!(host.workers.spawn_calls.lock()[0].7, None);
2407    }
2408
2409    /// A name that is not selectable is refused before anything is
2410    /// persisted, and the refusal names the selectable set; `agents` is
2411    /// refused as always-on.
2412    #[tokio::test]
2413    async fn spawn_refuses_unselectable_families() {
2414        for (bad, needle) in [("bogus", "selectable families are"), ("agents", "always on")] {
2415            let host = host();
2416            let tool = spawn_tool(&host);
2417
2418            let output = tool
2419                .call(ToolInput {
2420                    value: serde_json::json!({
2421                        "label": "reviewer",
2422                        "charter": "c",
2423                        "mcp_families": [bad],
2424                    }),
2425                })
2426                .await;
2427
2428            assert!(output.is_error, "{bad} must refuse");
2429            assert!(text_of(&output).contains(needle), "{bad}: {}", text_of(&output));
2430            assert!(host.workers.spawn_calls.lock().is_empty(), "{bad} persists nothing");
2431        }
2432    }
2433
2434    /// Update's family contract: a list replaces, an empty list resets,
2435    /// an absent argument leaves the stored selection alone.
2436    #[tokio::test]
2437    async fn update_families_replace_reset_or_stay() {
2438        let host = host();
2439        let tool =
2440            Update { facade: Arc::clone(&host.workers) as Arc<dyn WorkerFacade>, slot: caller() };
2441
2442        let output = tool
2443            .call(ToolInput { value: serde_json::json!({"label": "w1", "mcp_families": ["cron"]}) })
2444            .await;
2445        assert!(!output.is_error, "{:?}", output.blocks);
2446        assert!(
2447            text_of(&output).contains("mcp_families"),
2448            "the update names what changed: {}",
2449            text_of(&output)
2450        );
2451        assert_eq!(host.workers.update_calls.lock()[0].5, Some(vec!["cron".to_owned()]));
2452
2453        let output = tool
2454            .call(ToolInput { value: serde_json::json!({"label": "w1", "mcp_families": []}) })
2455            .await;
2456        assert!(!output.is_error, "{:?}", output.blocks);
2457        assert_eq!(
2458            host.workers.update_calls.lock()[1].5,
2459            Some(Vec::new()),
2460            "empty list is the reset"
2461        );
2462
2463        let output = tool
2464            .call(ToolInput { value: serde_json::json!({"label": "w1", "charter": "c2"}) })
2465            .await;
2466        assert!(!output.is_error, "{:?}", output.blocks);
2467        assert_eq!(
2468            host.workers.update_calls.lock()[2].5,
2469            None,
2470            "absent keeps the stored selection"
2471        );
2472    }
2473}