1pub 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#[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
28pub(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
53fn 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
63fn 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
75fn 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
101fn sender_identity(slot: &SessionSlot) -> (String, String) {
113 (crate::mcp::peers::types::seat_name(slot), slot.org().to_owned())
114}
115
116impl TargetArgs {
117 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
134pub(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 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
210pub(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
257pub(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
354pub(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#[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
517pub(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
568pub(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
595fn 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 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 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
823pub(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
937pub(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
988pub(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 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 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 fn host() -> Host {
1168 let peers = Arc::new(MockWorkspaceFacade::new());
1169 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 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 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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}