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