Skip to main content

agent_engine/tools/subagent/
collect.rs

1//! SubagentCollectTool — check if a reactive subagent is done and return its result.
2//!
3//! Non-blocking — checks the registry once and returns immediately.
4//! If the subagent is still running, returns status + partial output.
5//! If done, returns the full result. The natural pair to `subagent_start` —
6//! start async, check when you want the answer.
7//!
8
9use super::super::{Tool, ToolContext};
10use crate::runtime::subagent::SubagentStatus;
11use crate::{Result, RuntimeError};
12use serde_json::{json, Value};
13
14pub struct SubagentCollectTool;
15
16#[async_trait::async_trait]
17impl Tool for SubagentCollectTool {
18    fn origin(&self) -> crate::tools::ToolOrigin {
19        crate::tools::ToolOrigin::Builtin
20    }
21
22    fn name(&self) -> &str {
23        "subagent_collect"
24    }
25
26    fn description(&self) -> &str {
27        "Retrieve a reactive subagent's result. Non-blocking — returns immediately \
28         (status + partial output if still running). Standard flow: dispatch with \
29         subagent_start, carry on (or end your turn) — the subagent's completion event \
30         wakes you with a terminal status (completed/failed/timed_out/cancelled); then \
31         call this ONCE with reconciled=true — that single call both collects the result and attests \
32         reconciliation, clearing the completion gate. Calling without reconciled=true \
33         is an inspection-only read: it leaves the completion gate blocked and forces \
34         a second call. reconciled=true is idempotent and safe on repeats."
35    }
36
37    fn parameters(&self) -> Value {
38        json!({
39            "type": "object",
40            "properties": {
41                "handle_id": {
42                    "type": "string",
43                    "description": "Handle ID returned by subagent_start (e.g. \"sa_3\")."
44                },
45                "reconciled": {
46                    "type": "boolean",
47                    "default": false,
48                    "description": "Set true on your terminal collect (after subagent_status reports done) to attest the result is reconciled with foreground work and unblock completion — one call collects AND reconciles. Leave false only for a deliberate inspection-only read; a later collect with reconciled=true still reconciles (idempotent)."
49                }
50            },
51            "required": ["handle_id"]
52        })
53    }
54
55    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
56        let handle_id = params["handle_id"]
57            .as_str()
58            .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
59            .to_string();
60
61        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
62            RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
63        })?;
64
65        let mut reg = registry.lock().unwrap();
66        let archive_path = reg.archive_path(&handle_id);
67        let Some(handle) = reg.get_mut(&handle_id) else {
68            drop(reg);
69            // A reaped handle's result survives in its resume archive.
70            let archived = archive_path
71                .as_ref()
72                .and_then(|path| super::archive::read_archive(path).ok());
73            if let Some(orchestration) = &ctx.capabilities.orchestration {
74                if orchestration.is_unreconciled(&handle_id) {
75                    orchestration
76                        .terminal_and_collect(
77                            &handle_id,
78                            agent_core::orchestration::WorkerTerminal::Failed,
79                        )
80                        .map_err(RuntimeError::Tool)?;
81                    if params["reconciled"].as_bool().unwrap_or(false) {
82                        orchestration
83                            .reconcile(&handle_id)
84                            .map_err(RuntimeError::Tool)?;
85                    }
86                    if archived.is_none() {
87                        return Ok(json!({
88                            "handle_id": handle_id,
89                            "status": "expired",
90                            "note": "Subagent output expired; orchestration lifecycle remains recoverable.",
91                            "collected": false
92                        })
93                        .to_string());
94                    }
95                }
96            }
97            if let (Some(archive), Some(path)) = (archived, archive_path) {
98                return Ok(json!({
99                    "handle_id": handle_id,
100                    "status": archive.status,
101                    "output": archive.output,
102                    "model": archive.meta.model,
103                    "collected": true,
104                    "archive_path": path,
105                    "note": "Handle was reaped; result read from its resume archive. \
106                             subagent_resume can still continue this worker."
107                })
108                .to_string());
109            }
110            return Err(RuntimeError::Tool(format!(
111                "No subagent found with handle_id '{}'. Finished handles are retained for {} minutes after completion and then garbage-collected.",
112                handle_id,
113                crate::runtime::subagent::FINISHED_HANDLE_TTL.as_secs() / 60
114            )));
115        };
116
117        super::archive::retry_failed_archive(&handle.state, &handle_id);
118        let status = handle.status();
119        let output: String = handle.partial_output();
120        let elapsed = handle.elapsed_secs();
121        let model = handle.model.clone();
122        let terminal = handle.terminal_diagnostic();
123        let authorization = handle.authorization.clone();
124        let archive_error = handle.archive_error();
125
126        // Mark collected on any terminal read so the reaper knows it's safe to GC.
127        let already_collected = handle.is_collected();
128        if status != SubagentStatus::Running {
129            handle.mark_collected();
130        }
131        drop(reg);
132
133        if status == SubagentStatus::Running {
134            // Still going — return current state, don't block
135            let char_count = output.chars().count();
136            let output_so_far: String = if char_count > 500 {
137                output.chars().skip(char_count - 500).collect()
138            } else {
139                output
140            };
141            return Ok(json!({
142                "handle_id":    handle_id,
143                "status":       "running",
144                "elapsed_secs": (elapsed * 10.0).round() / 10.0,
145                "output_so_far": output_so_far
146            })
147            .to_string());
148        }
149
150        // Lifecycle (intentional caller attestation — never auto-reconcile):
151        // - First terminal collect always drives terminal_and_collect.
152        // - Any terminal collect with reconciled=true must reconcile, including
153        //   a repeated collect after an earlier unreconciled read. Gating
154        //   reconcile on !already_collected left workers stuck in Collected
155        //   forever and blocked completion.
156        // - terminal_and_collect stays first-collect-only so diagnostic
157        //   re-reads remain read-many idempotent at the handle layer.
158        if let Some(orchestration) = &ctx.capabilities.orchestration {
159            if !already_collected {
160                let terminal = match status {
161                    SubagentStatus::Completed => {
162                        agent_core::orchestration::WorkerTerminal::Completed
163                    }
164                    SubagentStatus::TimedOut => agent_core::orchestration::WorkerTerminal::TimedOut,
165                    _ => agent_core::orchestration::WorkerTerminal::Failed,
166                };
167                orchestration
168                    .terminal_and_collect(&handle_id, terminal)
169                    .map_err(RuntimeError::Tool)?;
170            }
171            if params["reconciled"].as_bool().unwrap_or(false) {
172                orchestration
173                    .reconcile(&handle_id)
174                    .map_err(RuntimeError::Tool)?;
175            }
176        }
177
178        // Done — return full result. The registry retains this record, making
179        // repeated collection diagnostically idempotent; `collected` signals
180        // idempotency to the caller.
181        let reconciled_now = params["reconciled"].as_bool().unwrap_or(false);
182        let mut body = json!({
183            "archiveError": archive_error,
184            "handle_id": handle_id,
185            "status":    status.as_str(),
186            "output":    output,
187            "model":     model,
188            "terminal_cause": terminal,
189            "authorization": authorization,
190            "collected": already_collected,
191        });
192        if !reconciled_now && ctx.capabilities.orchestration.is_some() {
193            // Nudge stragglers: an unreconciled terminal read leaves the
194            // completion gate blocked and forces a second collect. The
195            // standard flow (doctrine + tool description) is a single
196            // collect with reconciled=true after status turns terminal.
197            body["note"] = json!(
198                "UNRECONCILED read — the completion gate is still blocked. \
199                 Call subagent_collect again with reconciled=true after \
200                 integrating this result."
201            );
202        }
203        if let Some(reason) = status.failure_reason() {
204            body["error"] = json!(reason);
205        }
206        if let Some(path) = archive_path {
207            // Full conversation kept: subagent_resume continues it.
208            body["archive_path"] = json!(path);
209        }
210        Ok(body.to_string())
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::runtime::subagent::{
218        SubagentHandle, SubagentRegistry, SubagentState, SubagentStatus,
219    };
220    use crate::tools::test_helpers::create_tool_context;
221    use crate::tools::Tool;
222    use serde_json::json;
223    use std::sync::{Arc, Mutex, RwLock};
224    use tokio::sync::{mpsc, oneshot};
225
226    fn make_finished_handle(id: &str, output: &str) -> SubagentHandle {
227        let state = Arc::new(RwLock::new(SubagentState::new()));
228        {
229            let mut s = state.write().unwrap();
230            s.status = SubagentStatus::Completed;
231            s.partial_text = output.to_string();
232            s.finished_at = Some(std::time::Instant::now());
233        }
234        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
235        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
236        let (_result_tx, result_rx) = oneshot::channel();
237        SubagentHandle::new(
238            id.to_string(),
239            0,
240            "test-agent".to_string(),
241            "test task".to_string(),
242            "claude-sonnet-4-6".to_string(),
243            "system prompt".to_string(),
244            300,
245            state,
246            Some(steer_tx),
247            Some(shutdown_tx),
248            Some(result_rx),
249        )
250    }
251
252    #[tokio::test]
253    async fn managed_terminal_waits_for_finalization_before_collection() {
254        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
255        let h = make_finished_handle("sa_pending", "still draining");
256        let state = Arc::clone(&h.state);
257        {
258            let mut s = state.write().unwrap();
259            s.finished_at = None;
260            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
261                agent_name: "worker".into(),
262                model: "model".into(),
263                system_prompt: "system".into(),
264                timeout_secs: 1,
265            });
266        }
267        registry.lock().unwrap().register(h);
268        let response = SubagentCollectTool
269            .execute(
270                json!({"handle_id":"sa_pending", "reconciled":true}),
271                make_ctx_with_registry(Arc::clone(&registry)),
272            )
273            .await
274            .unwrap();
275        assert_eq!(
276            serde_json::from_str::<Value>(&response).unwrap()["status"],
277            "running"
278        );
279        let mut reg = registry.lock().unwrap();
280        assert!(!reg.get("sa_pending").unwrap().is_collected());
281        assert_eq!(reg.display_rows()[0].status, SubagentStatus::Running);
282        reg.release_finished_resources("sa_pending");
283        reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
284        assert!(reg.get("sa_pending").is_some());
285        assert!(state.read().unwrap().finished_at.is_none());
286    }
287
288    fn make_ctx_with_registry(registry: Arc<Mutex<SubagentRegistry>>) -> crate::tools::ToolContext {
289        let mut ctx = create_tool_context();
290        ctx.capabilities.subagent_registry = Some(registry);
291        // This suite exercises the registry-level collected/idempotency
292        // contract in isolation. The handle is registered directly (never
293        // dispatched through orchestration), so the policy registry must not
294        // participate — dispatch-path enforcement is covered by the
295        // orchestration and lifecycle suites.
296        ctx.capabilities.orchestration = None;
297        ctx
298    }
299
300    #[tokio::test]
301    async fn collect_after_reap_reads_the_archive() {
302        let dir = tempfile::tempdir().unwrap();
303        let meta = crate::runtime::subagent::SubagentArchiveMeta {
304            agent_name: "test-agent".into(),
305            model: "anthropic/claude-opus-5-5".into(),
306            system_prompt: "s".into(),
307            timeout_secs: 60,
308        };
309        let path = super::super::archive::write_archive(
310            dir.path(),
311            "sa_gone",
312            &meta,
313            "timed_out",
314            "[TIMED OUT after 60s] latest progress",
315            &[Arc::new(json!({"role": "user", "content": "task"}))],
316        )
317        .unwrap();
318        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
319        {
320            let mut reg = registry.lock().unwrap();
321            let state = Arc::new(RwLock::new(SubagentState::new()));
322            {
323                let mut st = state.write().unwrap();
324                st.status = SubagentStatus::TimedOut;
325                st.finished_at = Some(std::time::Instant::now());
326                st.archive_path = Some(path.clone());
327            }
328            let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
329            let (shutdown_tx, _shutdown_rx) = oneshot::channel();
330            let (_result_tx, result_rx) = oneshot::channel();
331            let mut h = SubagentHandle::new(
332                "sa_gone".into(),
333                0,
334                "test-agent".into(),
335                "task".into(),
336                "anthropic/claude-opus-5-5".into(),
337                "s".into(),
338                60,
339                state,
340                Some(steer_tx),
341                Some(shutdown_tx),
342                Some(result_rx),
343            );
344            h.mark_collected();
345            reg.register(h);
346            reg.cleanup_finished_with_ttl(std::time::Duration::ZERO);
347            assert!(reg.get("sa_gone").is_none());
348        }
349        let body: serde_json::Value = serde_json::from_str(
350            &SubagentCollectTool
351                .execute(
352                    json!({"handle_id": "sa_gone"}),
353                    make_ctx_with_registry(registry),
354                )
355                .await
356                .unwrap(),
357        )
358        .unwrap();
359        assert_eq!(body["status"], "timed_out");
360        assert_eq!(body["output"], "[TIMED OUT after 60s] latest progress");
361        assert_eq!(body["archive_path"], json!(path));
362    }
363
364    // U5: collect marks collected on first read; second read shows collected=true
365    #[tokio::test]
366    async fn collect_marks_collected_and_is_idempotent() {
367        let tool = SubagentCollectTool;
368
369        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
370        let handle = make_finished_handle("sa_42", "result text");
371        registry.lock().unwrap().register(handle);
372
373        // First collect — should return collected=false (not yet collected before this call)
374        let result1 = tool
375            .execute(
376                json!({"handle_id": "sa_42"}),
377                make_ctx_with_registry(registry.clone()),
378            )
379            .await
380            .unwrap();
381        let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
382        assert_eq!(body1["status"], "completed");
383        assert_eq!(body1["output"], "result text");
384        assert_eq!(
385            body1["collected"], false,
386            "first collect should report collected=false"
387        );
388
389        // Second collect — same handle still in registry (not yet reaped), collected=true
390        let result2 = tool
391            .execute(
392                json!({"handle_id": "sa_42"}),
393                make_ctx_with_registry(registry.clone()),
394            )
395            .await
396            .unwrap();
397        let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
398        assert_eq!(body2["status"], "completed");
399        assert_eq!(
400            body2["collected"], true,
401            "second collect should report collected=true"
402        );
403
404        // After collect, the registry should have the handle marked collected
405        let reg = registry.lock().unwrap();
406        let h = reg.get("sa_42").unwrap();
407        assert!(
408            h.is_collected(),
409            "handle must be marked collected in registry"
410        );
411    }
412
413    /// Dispatch a finished handle through orchestration so collect can drive
414    /// the terminal → collected → reconciled lifecycle. Returns (registry, orch).
415    fn make_orch_ctx(
416        handle_id: &str,
417        output: &str,
418    ) -> (
419        Arc<Mutex<SubagentRegistry>>,
420        Arc<crate::orchestration::OrchestrationRuntime>,
421        crate::tools::ToolContext,
422    ) {
423        use crate::orchestration::OrchestrationRuntime;
424        use agent_core::orchestration::CompletionGate;
425
426        let foreground = agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6")
427            .expect("test foreground is qualified");
428        let orch =
429            Arc::new(OrchestrationRuntime::baseline(foreground, 8, 64).expect("baseline runtime"));
430        orch.authorize(handle_id, "anthropic/claude-sonnet-4-6")
431            .expect("authorize worker");
432        // authorize() leaves the worker Running. Transition to Terminal+Collected
433        // so the gate blocks (it only catches finished-but-unreconciled workers).
434        orch.terminal_and_collect(
435            handle_id,
436            agent_core::orchestration::WorkerTerminal::Completed,
437        )
438        .expect("mark terminal + collected");
439        assert!(
440            matches!(orch.completion_gate(), CompletionGate::Blocked { .. }),
441            "terminal worker must block completion until reconciled"
442        );
443
444        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
445        registry
446            .lock()
447            .unwrap()
448            .register(make_finished_handle(handle_id, output));
449
450        let mut ctx = create_tool_context();
451        ctx.capabilities.subagent_registry = Some(registry.clone());
452        ctx.capabilities.orchestration = Some(orch.clone());
453        (registry, orch, ctx)
454    }
455
456    // (a) first collect without reconciled leaves gate blocked; second collect
457    // with reconciled=true advances Collected → Reconciled and allows completion.
458    #[tokio::test]
459    async fn deferred_reconcile_on_repeat_collect_unblocks_completion() {
460        use agent_core::orchestration::CompletionGate;
461
462        let tool = SubagentCollectTool;
463        let (registry, orch, ctx) = make_orch_ctx("sa_deferred", "result text");
464
465        let result1 = tool
466            .execute(
467                json!({"handle_id": "sa_deferred", "reconciled": false}),
468                ctx,
469            )
470            .await
471            .unwrap();
472        let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
473        assert_eq!(body1["collected"], false);
474        match orch.completion_gate() {
475            CompletionGate::Blocked { workers } => {
476                assert_eq!(workers, vec!["sa_deferred".to_string()]);
477                assert!(
478                    workers.iter().all(|id| id.starts_with("sa_")),
479                    "blocked IDs must be subagent_collect handles, got {workers:?}"
480                );
481                assert!(
482                    workers.iter().all(|id| !id.starts_with("worker-")),
483                    "blocked IDs must never leak policy WorkerHandle, got {workers:?}"
484                );
485            }
486            other => panic!(
487                "first collect without reconcile must leave completion blocked, got {other:?}"
488            ),
489        }
490
491        // Rebuild ctx so the same shared orch/registry participate on the
492        // second call (create_tool_context would allocate a fresh baseline).
493        let mut ctx2 = create_tool_context();
494        ctx2.capabilities.subagent_registry = Some(registry.clone());
495        ctx2.capabilities.orchestration = Some(orch.clone());
496
497        let result2 = tool
498            .execute(
499                json!({"handle_id": "sa_deferred", "reconciled": true}),
500                ctx2,
501            )
502            .await
503            .unwrap();
504        let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
505        assert_eq!(body2["collected"], true);
506        assert_eq!(
507            orch.completion_gate(),
508            CompletionGate::Allowed,
509            "repeat collect with reconciled=true must unblock completion"
510        );
511    }
512
513    // (b) first collect with reconciled=true allows completion immediately.
514    #[tokio::test]
515    async fn first_collect_with_reconcile_allows_completion() {
516        use agent_core::orchestration::CompletionGate;
517
518        let tool = SubagentCollectTool;
519        let (_registry, orch, ctx) = make_orch_ctx("sa_first_true", "result text");
520
521        let result = tool
522            .execute(
523                json!({"handle_id": "sa_first_true", "reconciled": true}),
524                ctx,
525            )
526            .await
527            .unwrap();
528        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
529        assert_eq!(body["collected"], false);
530        assert_eq!(
531            orch.completion_gate(),
532            CompletionGate::Allowed,
533            "first collect with reconciled=true must allow completion"
534        );
535    }
536
537    /// An unreconciled terminal read leaves the gate blocked, so the response
538    /// must carry an explicit nudge to re-collect with reconciled=true —
539    /// otherwise models stall on a gate they can no longer see. The standard
540    /// single-call flow (reconciled=true up front) must NOT carry the note.
541    #[tokio::test]
542    async fn unreconciled_terminal_collect_carries_reconcile_nudge() {
543        let tool = SubagentCollectTool;
544        let (_registry, _orch, ctx) = make_orch_ctx("sa_nudge", "result text");
545
546        let result = tool
547            .execute(json!({"handle_id": "sa_nudge", "reconciled": false}), ctx)
548            .await
549            .unwrap();
550        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
551        let note = body["note"].as_str().expect("unreconciled read must nudge");
552        assert!(note.contains("reconciled=true"), "{note}");
553    }
554
555    #[tokio::test]
556    async fn reconciled_collect_carries_no_nudge() {
557        let tool = SubagentCollectTool;
558        let (_registry, _orch, ctx) = make_orch_ctx("sa_no_nudge", "result text");
559
560        let result = tool
561            .execute(json!({"handle_id": "sa_no_nudge", "reconciled": true}), ctx)
562            .await
563            .unwrap();
564        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
565        assert!(
566            body.get("note").is_none(),
567            "single-call collect+reconcile must not carry a nudge: {body}"
568        );
569    }
570
571    #[tokio::test]
572    async fn forced_expiry_remains_collectible_and_clears_gate() {
573        use agent_core::orchestration::CompletionGate;
574
575        let tool = SubagentCollectTool;
576        let (registry, orch, ctx) = make_orch_ctx("sa_expired", &"result".repeat(10_000));
577        crate::runtime::subagent::reap_finished_with_ttl(
578            &registry,
579            Some(orch.as_ref()),
580            std::time::Duration::ZERO,
581        );
582        assert!(registry
583            .lock()
584            .unwrap()
585            .get("sa_expired")
586            .unwrap()
587            .is_tombstone());
588
589        let result = tool
590            .execute(json!({"handle_id": "sa_expired", "reconciled": true}), ctx)
591            .await
592            .unwrap();
593        assert_ne!(
594            serde_json::from_str::<Value>(&result).unwrap()["status"],
595            "expired"
596        );
597        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
598    }
599
600    #[tokio::test]
601    async fn collected_handle_reaped_then_degraded_reconcile_clears_gate() {
602        use agent_core::orchestration::CompletionGate;
603
604        let tool = SubagentCollectTool;
605        let (registry, orch, ctx) = make_orch_ctx("sa_collected_expired", "result text");
606        tool.execute(
607            json!({"handle_id": "sa_collected_expired", "reconciled": false}),
608            ctx,
609        )
610        .await
611        .unwrap();
612        assert!(matches!(
613            orch.completion_gate(),
614            CompletionGate::Blocked { .. }
615        ));
616
617        crate::runtime::subagent::reap_finished_with_ttl(
618            &registry,
619            Some(orch.as_ref()),
620            std::time::Duration::ZERO,
621        );
622        assert!(registry
623            .lock()
624            .unwrap()
625            .get("sa_collected_expired")
626            .is_none());
627
628        let mut ctx2 = create_tool_context();
629        ctx2.capabilities.subagent_registry = Some(registry);
630        ctx2.capabilities.orchestration = Some(orch.clone());
631        let response = tool
632            .execute(
633                json!({"handle_id": "sa_collected_expired", "reconciled": true}),
634                ctx2,
635            )
636            .await
637            .unwrap();
638        assert_eq!(
639            serde_json::from_str::<Value>(&response).unwrap()["status"],
640            "expired"
641        );
642        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
643    }
644
645    #[tokio::test]
646    async fn missing_mapped_handle_has_degraded_collect_not_404() {
647        use agent_core::orchestration::CompletionGate;
648
649        let tool = SubagentCollectTool;
650        let (registry, orch, ctx) = make_orch_ctx("sa_missing", "lost");
651        registry.lock().unwrap().remove("sa_missing");
652        let result = tool
653            .execute(json!({"handle_id": "sa_missing", "reconciled": true}), ctx)
654            .await
655            .unwrap();
656        let body: Value = serde_json::from_str(&result).unwrap();
657        assert_eq!(body["status"], "expired");
658        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
659    }
660
661    // (c) repeated collect with reconciled=true remains read-many idempotent.
662    #[tokio::test]
663    async fn repeated_reconcile_true_is_idempotent() {
664        use agent_core::orchestration::CompletionGate;
665
666        let tool = SubagentCollectTool;
667        let (registry, orch, ctx) = make_orch_ctx("sa_repeat_true", "result text");
668
669        let result1 = tool
670            .execute(
671                json!({"handle_id": "sa_repeat_true", "reconciled": true}),
672                ctx,
673            )
674            .await
675            .unwrap();
676        let body1: serde_json::Value = serde_json::from_str(&result1).unwrap();
677        assert_eq!(body1["collected"], false);
678        assert_eq!(orch.completion_gate(), CompletionGate::Allowed);
679
680        let mut ctx2 = create_tool_context();
681        ctx2.capabilities.subagent_registry = Some(registry.clone());
682        ctx2.capabilities.orchestration = Some(orch.clone());
683
684        let result2 = tool
685            .execute(
686                json!({"handle_id": "sa_repeat_true", "reconciled": true}),
687                ctx2,
688            )
689            .await
690            .unwrap();
691        let body2: serde_json::Value = serde_json::from_str(&result2).unwrap();
692        assert_eq!(body2["collected"], true);
693        assert_eq!(
694            orch.completion_gate(),
695            CompletionGate::Allowed,
696            "repeat reconciled collect must stay Allowed (idempotent)"
697        );
698        // Registry handle remains readable and marked collected.
699        let reg = registry.lock().unwrap();
700        assert!(reg.get("sa_repeat_true").unwrap().is_collected());
701    }
702}