1use serde_json::{json, Value};
12use std::sync::atomic::Ordering;
13use std::sync::{Arc, RwLock};
14use tokio::sync::{mpsc, oneshot};
15
16use super::super::{Tool, ToolContext, NEXT_SUBAGENT_ID};
17use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
18use crate::{AgentEvent, Result, RuntimeError};
19
20pub struct SubagentResumeTool;
21
22fn expired_context_error(handle_id: &str) -> RuntimeError {
23 RuntimeError::Tool(format!(
24 "Subagent '{}' has expired resumable context; call subagent_collect with reconciled=true to reconcile its retained tombstone.",
25 handle_id
26 ))
27}
28
29#[derive(Debug)]
31struct PriorRun {
32 agent_name: String,
33 model: String,
34 system_prompt: String,
35 timeout_secs: u64,
36 messages: Vec<crate::SharedMessage>,
37}
38
39fn prior_run(
40 registry: &std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>,
41 prior_handle_id: &str,
42 explicit_archive: Option<std::path::PathBuf>,
43 instructions: &str,
44) -> Result<PriorRun> {
45 let (live, archive_path, legacy) = if explicit_archive.is_some() {
50 (None, explicit_archive, None)
51 } else {
52 let reg = registry.lock().unwrap_or_else(|p| p.into_inner());
53 let handle = reg.get(prior_handle_id);
54 if handle.is_some_and(|h| h.status() == SubagentStatus::Running) {
55 return Err(RuntimeError::Tool(format!(
56 "Subagent '{}' is still running. Call subagent_collect first, \
57 or wait until it finishes.",
58 prior_handle_id
59 )));
60 }
61 if let Some(h) = handle {
62 super::archive::retry_failed_archive(&h.state, prior_handle_id);
63 let s = h.state.read().unwrap_or_else(|p| p.into_inner());
64 if s.archive_error.is_some() || (s.archive_meta.is_some() && s.archive_path.is_none()) {
65 if s.finalization_ready {
66 if let Some(history) = &s.history {
67 return Ok(PriorRun {
68 agent_name: h.agent_name.clone(),
69 model: h.model.clone(),
70 system_prompt: h.system_prompt.clone(),
71 timeout_secs: h.timeout_secs,
72 messages: super::archive::resume_messages(
73 history.iter().map(|m| m.as_ref().clone()).collect(),
74 instructions,
75 ),
76 });
77 }
78 }
79 return Err(RuntimeError::Tool(format!(
80 "Subagent '{}' has no finalized full history: {}",
81 prior_handle_id,
82 s.archive_error.as_deref().unwrap_or("archive pending")
83 )));
84 }
85 }
86 let archive_path = reg.archive_path(prior_handle_id);
87 let live = handle.map(|h| {
88 (
89 h.agent_name.clone(),
90 h.model.clone(),
91 h.system_prompt.clone(),
92 h.timeout_secs,
93 )
94 });
95 let legacy = match handle {
96 Some(_) if archive_path.is_some() => None,
97 Some(h) if h.is_tombstone() => return Err(expired_context_error(prior_handle_id)),
98 Some(h) => {
99 let state = h.conversation_state();
100 Some(if state.is_empty() {
101 h.partial_output()
102 } else {
103 serde_json::to_string(&state).unwrap_or_else(|_| h.partial_output())
104 })
105 }
106 None if archive_path.is_some() => None,
107 None => {
108 return Err(RuntimeError::Tool(format!(
109 "No subagent found with handle_id '{}'",
110 prior_handle_id
111 )))
112 }
113 };
114 (live, archive_path, legacy)
115 };
116
117 let archive = match &archive_path {
118 Some(path) => Some(super::archive::read_archive(path).map_err(|e| {
119 RuntimeError::Tool(format!(
120 "Could not read the resume archive of '{}' ({}): {e}",
121 prior_handle_id,
122 path.display()
123 ))
124 })?),
125 None => None,
126 };
127 let (agent_name, model, system_prompt, timeout_secs) = match (live, &archive) {
128 (Some(live), _) => live,
129 (None, Some(a)) => (
130 a.meta.agent_name.clone(),
131 a.meta.model.clone(),
132 a.meta.system_prompt.clone(),
133 a.meta.timeout_secs,
134 ),
135 (None, None) => {
136 return Err(RuntimeError::Tool(format!(
137 "No subagent found with handle_id '{}'",
138 prior_handle_id
139 )))
140 }
141 };
142 let messages: Vec<crate::SharedMessage> = match (archive, legacy) {
143 (Some(a), _) => super::archive::resume_messages(a.history, instructions),
144 (None, Some(prior_context)) => vec![Arc::new(json!({
146 "role": "user",
147 "content": format!(
148 "{instructions}\n\n---\n[Prior conversation context from handle {prior_handle_id}]\n{prior_context}"
149 )
150 }))],
151 (None, None) => unreachable!("either an archive or a legacy snapshot was found"),
152 };
153 Ok(PriorRun {
154 agent_name,
155 model,
156 system_prompt,
157 timeout_secs,
158 messages,
159 })
160}
161
162#[async_trait::async_trait]
163impl Tool for SubagentResumeTool {
164 fn origin(&self) -> crate::tools::ToolOrigin {
165 crate::tools::ToolOrigin::Builtin
166 }
167
168 fn name(&self) -> &str {
169 "subagent_resume"
170 }
171
172 fn description(&self) -> &str {
173 "Resume a finished, timed-out, failed or cancelled reactive subagent with new \
174 instructions. The worker continues its own archived conversation (every tool \
175 call and result) with the instructions as the next user turn, so it picks up \
176 where it stopped. Works after the handle was collected or reaped. For a worker \
177 from an earlier or crashed session, pass archive_path. Returns a new handle_id."
178 }
179
180 fn parameters(&self) -> Value {
181 json!({
182 "type": "object",
183 "properties": {
184 "handle_id": {
185 "type": "string",
186 "description": "Handle ID of the completed subagent to resume (e.g. \"sa_3\")."
187 },
188 "instructions": {
189 "type": "string",
190 "description": "What to do next. Sent as the next user turn after the \
191 worker's own prior conversation."
192 },
193 "archive_path": {
194 "type": "string",
195 "description": "Optional: a resume archive (.json under \
196 ~/.synaps-cli/subagent-history/) of a worker from an \
197 earlier or crashed session. handle_id is then only a label."
198 },
199 "timeout": {
200 "type": "integer",
201 "description": "Optional wall-clock limit in seconds for the resumed run \
202 (default: the prior run's); 0 = no limit."
203 }
204 },
205 "required": ["handle_id", "instructions"]
206 })
207 }
208
209 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
210 let prior_handle_id = params["handle_id"]
211 .as_str()
212 .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
213 .to_string();
214
215 let instructions = params["instructions"]
216 .as_str()
217 .ok_or_else(|| RuntimeError::Tool("Missing 'instructions' parameter".to_string()))?
218 .to_string();
219
220 let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
221 RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
222 })?;
223
224 let explicit_archive = match params["archive_path"].as_str() {
225 Some(raw) => {
226 Some(super::archive::checked_archive_path(raw).map_err(RuntimeError::Tool)?)
227 }
228 None => None,
229 };
230
231 let prior = prior_run(registry, &prior_handle_id, explicit_archive, &instructions)?;
232 let (agent_name, model, system_prompt, initial_messages) = (
233 prior.agent_name,
234 prior.model,
235 prior.system_prompt,
236 prior.messages,
237 );
238 let timeout_secs = params["timeout"].as_u64().unwrap_or(prior.timeout_secs);
239
240 let label = agent_name.clone();
243 let task_preview: String = instructions.chars().take(80).collect();
244 let task_full = instructions.clone();
245 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
246 let handle_id = format!("sa_{}", subagent_id);
247 let orchestration = ctx
248 .capabilities
249 .orchestration
250 .as_ref()
251 .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
252 orchestration
253 .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
254 .map_err(|reason| {
255 RuntimeError::Tool(format!("delegation tree budget denied: {reason:?}"))
256 })?;
257 let decision = orchestration
258 .resolve_and_authorize(&handle_id, Some(&model))
259 .map_err(|error| {
260 orchestration
261 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
262 RuntimeError::Tool(error.to_string())
263 })?;
264 let model = decision.model.as_str().to_owned();
265 let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
266 let memory_backend = ctx.capabilities.memory_backend.clone();
267
268 tracing::info!(
269 "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
270 label,
271 handle_id,
272 prior_handle_id,
273 model
274 );
275
276 let state = Arc::new(RwLock::new(SubagentState::new()));
277 state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
278 agent_name: label.clone(),
279 model: model.clone(),
280 system_prompt: system_prompt.clone(),
281 timeout_secs,
282 });
283
284 state.write().unwrap().archive_dir = Some(super::archive::process_dir());
285
286 state.write().unwrap().history = Some(initial_messages.clone());
288
289 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
290 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
291 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
292
293 if let Some(ref tx) = ctx.channels.tx_events {
294 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
295 subagent_id,
296 agent_name: label.clone(),
297 task_preview: task_preview.clone(),
298 }));
299 }
300
301 let state_t = Arc::clone(&state);
302 let task_full_a = task_full.clone();
303 let label_inner = label.clone();
304 let model_inner = model.clone();
305 let tx_events_inner = ctx.channels.tx_events.clone();
306 let start_time = std::time::Instant::now();
307 let parent_queue = ctx.capabilities.event_queue.clone();
308 let handle_id_inner = handle_id.clone();
309 let prior_handle_for_finalizer = prior_handle_id.clone();
310 let child_parent_id = handle_id.clone();
311
312 let system_prompt_for_handle = system_prompt.clone();
314 let handle = SubagentHandle::new(
315 handle_id.clone(),
316 subagent_id,
317 label.clone(),
318 task_preview,
319 model.clone(),
320 system_prompt_for_handle,
321 timeout_secs,
322 Arc::clone(&state),
323 Some(steer_tx),
324 Some(shutdown_tx),
325 Some(result_rx),
326 )
327 .with_authorization(&decision);
328 {
329 let mut reg = registry.lock().unwrap();
330 reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
331 }
332
333 let orchestration_for_worker = Arc::clone(orchestration);
334 let parent_for_worker = ctx.capabilities.delegation_parent.clone();
335 if let Err(error) = orchestration.mark_starting(&handle_id) {
336 orchestration.rollback(&handle_id);
337 orchestration
338 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
339 return Err(RuntimeError::Tool(error));
340 }
341 let thread_handle = std::thread::spawn(move || {
343 let state_for_finalizer = Arc::clone(&state_t);
345 let label_for_finalizer = label_inner.clone();
346 let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
347
348 let mut pending_result = None;
349 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
350 let rt = match tokio::runtime::Builder::new_current_thread()
351 .enable_all()
352 .build()
353 {
354 Ok(rt) => rt,
355 Err(_) => {
356 state_t.write().unwrap().status =
357 SubagentStatus::Failed("runtime initialization failed".into());
358 return;
359 }
360 };
361
362 let state_a = Arc::clone(&state_t);
363 let label_a = label_inner.clone();
364 let model_a = model_inner.clone();
365 let tx_events_a = tx_events_inner.clone();
366 let task_for_timeout = task_full_a.clone();
367 let task_for_complete = task_full_a;
368
369 let outcome: std::result::Result<SubagentResult, String> =
370 rt.block_on(async move {
371 let mut runtime = match super::spawn_runtime().await {
374 Ok(r) => r,
375 Err(_) => return Err("subagent runtime initialization failed".into()),
376 };
377
378 super::apply_subagent_runtime_policy(
383 &mut runtime,
384 &crate::config::load_config(),
385 memory_backend.as_ref(),
386 );
387 runtime.set_system_prompt(super::compose_system_prompt(
388 system_prompt,
389 runtime.memory_backend_is_axel(),
390 ));
391 runtime.set_model(model_a.clone());
392 super::apply_codex_worker_reasoning(
393 &mut runtime,
394 codex_parent_plan.as_ref(),
395 );
396 runtime
397 .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
398 runtime.set_delegation_parent(Some(child_parent_id.clone()));
399
400 let cancel = crate::CancellationToken::new();
401 let cancel_inner = cancel.clone();
402 tokio::spawn(async move {
403 let _ = shutdown_rx.await;
404 cancel_inner.cancel();
405 });
406
407 let cancel_on_timeout = cancel.clone();
408 let stream = runtime.run_stream_with_messages(
409 initial_messages,
410 cancel,
411 Some(steer_rx),
412 None,
413 false,
414 ).await;
415
416 super::drive::WorkerDrive {
417 state: Arc::clone(&state_a),
418 tx_events: tx_events_a.clone(),
419 subagent_id,
420 label: label_a.clone(),
421 model: model_a.clone(),
422 timeout_secs,
423 cancel: cancel_on_timeout,
424 task: task_for_timeout,
425 }
426 .run(stream)
427 .await
428 });
429
430 match outcome {
431 Ok(sa_result) => {
432 {
433 let mut s = state_t.write().unwrap();
434 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
435 s.status = SubagentStatus::Completed;
436 s.conversation_state = vec![
437 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
438 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
439 ];
440 }
441 }
442 pending_result = Some(sa_result);
443 }
444 Err(e) => {
445 state_t.write().unwrap().status = SubagentStatus::Failed(e);
446 }
447 }
448 }));
449
450 if let Err(panic_info) = panic_result {
451 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
452 s.to_string()
453 } else if let Some(s) = panic_info.downcast_ref::<String>() {
454 s.clone()
455 } else {
456 "unknown panic".to_string()
457 };
458 tracing::error!("Resumed subagent thread panicked: {}", msg);
459 state_t.write().unwrap_or_else(|p| p.into_inner()).status =
460 SubagentStatus::Failed(format!("panic: {}", msg));
461 }
462
463 super::finalize::finalize_subagent(
467 &state_for_finalizer,
468 parent_queue.as_ref(),
469 &handle_id_inner,
470 subagent_id,
471 &label_for_finalizer,
472 start_time,
473 Some(&prior_handle_for_finalizer),
474 );
475 if let Some(ref tx) = tx_events_inner {
477 let s = state_for_finalizer
478 .read()
479 .unwrap_or_else(|p| p.into_inner());
480 let preview = match s.status.failure_reason() {
481 Some(reason) => format!("ERROR: {reason}"),
482 None => s.partial_text.chars().take(120).collect(),
483 };
484 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
485 subagent_id,
486 agent_name: label_for_finalizer.clone(),
487 result_preview: preview,
488 duration_secs: start_time.elapsed().as_secs_f64(),
489 }));
490 }
491 if let Some(result) = pending_result {
492 let _ = result_tx.send(result);
493 }
494 orchestration_for_worker
495 .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
496 });
497
498 {
500 let mut reg = registry.lock().unwrap();
501 if let Some(h) = reg.get_mut(&handle_id) {
502 h.set_thread_handle(thread_handle);
503 }
504 }
505 if let Err(error) = orchestration.mark_running(&handle_id) {
506 let mut handle = registry.lock().unwrap().remove(&handle_id);
507 if let Some(handle) = handle.as_mut() {
508 handle.cancel();
509 }
510 if let Some(handle) = handle {
511 let _ = handle.collect().await;
512 }
513 orchestration.rollback(&handle_id);
514 orchestration
515 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
516 return Err(RuntimeError::Tool(error));
517 }
518
519 Ok(json!({
520 "handle_id": handle_id,
521 "resumed_from": prior_handle_id,
522 "agent_name": label,
523 "status": "running"
524 })
525 .to_string())
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
533 use crate::tools::test_helpers::create_tool_context;
534 use std::sync::Mutex;
535 use std::time::Duration;
536
537 fn finished_handle(
538 id: &str,
539 status: SubagentStatus,
540 archive_path: Option<std::path::PathBuf>,
541 ) -> SubagentHandle {
542 let state = Arc::new(RwLock::new(SubagentState::new()));
543 {
544 let mut s = state.write().unwrap();
545 s.status = status;
546 s.partial_text = "prior text".into();
547 s.conversation_state = vec![json!({"role": "assistant", "content": "prior text"})];
548 s.finished_at = Some(std::time::Instant::now());
549 s.archive_path = archive_path;
550 }
551 let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
552 let (shutdown_tx, _shutdown_rx) = oneshot::channel();
553 let (_result_tx, result_rx) = oneshot::channel();
554 SubagentHandle::new(
555 id.into(),
556 1,
557 "live-label".into(),
558 "task".into(),
559 "anthropic/claude-sonnet-4-6".into(),
560 "live system".into(),
561 30,
562 state,
563 Some(steer_tx),
564 Some(shutdown_tx),
565 Some(result_rx),
566 )
567 }
568
569 fn archive_in(dir: &std::path::Path, id: &str) -> std::path::PathBuf {
570 let meta = crate::runtime::subagent::SubagentArchiveMeta {
571 agent_name: "archived-label".into(),
572 model: "anthropic/claude-opus-5-5".into(),
573 system_prompt: "archived system".into(),
574 timeout_secs: 3500,
575 };
576 let history: Vec<crate::SharedMessage> = vec![
577 Arc::new(json!({"role": "user", "content": "build the plate"})),
578 Arc::new(json!({"role": "assistant", "content": [
579 {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "render"}}
580 ]})),
581 Arc::new(json!({"role": "user", "content": [
582 {"type": "tool_result", "tool_use_id": "t1", "content": "rendered 300 frames"}
583 ]})),
584 ];
585 super::super::archive::write_archive(dir, id, &meta, "timed_out", "report", &history)
586 .unwrap()
587 }
588
589 fn assert_continues_archive(prior: &PriorRun, instructions: &str) {
590 assert_eq!(
591 prior.messages.len(),
592 3,
593 "archived turns replayed, instructions merged"
594 );
595 assert_eq!(prior.messages[0]["content"], "build the plate");
596 let last = &prior.messages[2]["content"];
597 assert_eq!(last[0]["content"], "rendered 300 frames");
598 assert!(last[1]["text"].as_str().unwrap().ends_with(instructions));
599 }
600
601 #[test]
602 fn reaped_worker_resumes_from_its_archive() {
603 let dir = tempfile::tempdir().unwrap();
604 let path = archive_in(dir.path(), "sa_reaped");
605 let registry = Mutex::new(SubagentRegistry::new());
606 {
607 let mut reg = registry.lock().unwrap();
608 let mut h = finished_handle("sa_reaped", SubagentStatus::TimedOut, Some(path));
609 h.mark_collected();
610 reg.register(h);
611 reg.cleanup_finished_with_ttl(Duration::ZERO);
612 assert!(reg.get("sa_reaped").is_none(), "handle reaped");
613 }
614 let prior = prior_run(®istry, "sa_reaped", None, "render pass 3").unwrap();
615 assert_eq!(prior.agent_name, "archived-label");
616 assert_eq!(prior.model, "anthropic/claude-opus-5-5");
617 assert_eq!(prior.system_prompt, "archived system");
618 assert_eq!(prior.timeout_secs, 3500);
619 assert_continues_archive(&prior, "render pass 3");
620 }
621
622 #[test]
623 fn tombstoned_worker_with_archive_resumes() {
624 let dir = tempfile::tempdir().unwrap();
625 let path = archive_in(dir.path(), "sa_tomb");
626 let registry = Mutex::new(SubagentRegistry::new());
627 {
628 let mut reg = registry.lock().unwrap();
629 reg.register(finished_handle(
630 "sa_tomb",
631 SubagentStatus::Failed("x".into()),
632 Some(path),
633 ));
634 reg.release_finished_resources("sa_tomb");
635 assert!(reg.get("sa_tomb").unwrap().is_tombstone());
636 }
637 let prior = prior_run(®istry, "sa_tomb", None, "continue").unwrap();
638 assert_eq!(prior.agent_name, "live-label");
640 assert_eq!(prior.timeout_secs, 30);
641 assert_continues_archive(&prior, "continue");
642 }
643
644 #[test]
645 fn review_202_terminal_but_unpublished_worker_must_not_resume_lossily() {
646 let registry = Mutex::new(SubagentRegistry::new());
647 let h = finished_handle("sa_review", SubagentStatus::TimedOut, None);
648 {
649 let mut s = h.state.write().unwrap();
650 s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
652 agent_name: "worker".into(),
653 model: "model".into(),
654 system_prompt: "system".into(),
655 timeout_secs: 1,
656 });
657 s.finished_at = None;
658 }
659 let state = Arc::clone(&h.state);
660 registry.lock().unwrap().register(h);
661 assert!(prior_run(®istry, "sa_review", None, "continue").is_err());
662 registry
663 .lock()
664 .unwrap()
665 .cleanup_finished_with_ttl(Duration::ZERO);
666 assert!(registry.lock().unwrap().get("sa_review").is_some());
667 let dir = tempfile::tempdir().unwrap();
668 let blocked = dir.path().join("blocked");
669 std::fs::write(&blocked, b"x").unwrap();
670 let history = vec![
671 Arc::new(json!({"role":"user", "content":"task"})),
672 Arc::new(json!({"role":"assistant", "content":[
673 {"type":"tool_use", "id":"t1", "name":"read", "input":{"path":"x"}}
674 ]})),
675 Arc::new(json!({"role":"user", "content":[
676 {"type":"tool_result", "tool_use_id":"t1", "content":"preserved body"}
677 ]})),
678 ];
679 state.write().unwrap().history = Some(history);
680 super::super::finalize::finalize_subagent_in(
681 &state,
682 None,
683 "sa_review",
684 1,
685 "worker",
686 std::time::Instant::now(),
687 None,
688 &blocked,
689 );
690 state.write().unwrap().archive_meta = None;
692 let prior = prior_run(®istry, "sa_review", None, "continue").unwrap();
693 assert_eq!(prior.messages[2]["content"][0]["content"], "preserved body");
694 {
695 let mut reg = registry.lock().unwrap();
696 reg.get_mut("sa_review").unwrap().mark_collected();
697 reg.release_finished_resources("sa_review");
698 reg.cleanup_finished_with_ttl(Duration::ZERO);
699 assert!(reg.get("sa_review").is_some());
700 }
701 assert!(state.read().unwrap().history.is_some());
702 }
703
704 #[test]
705 fn running_worker_cannot_be_resumed() {
706 let registry = Mutex::new(SubagentRegistry::new());
707 registry
708 .lock()
709 .unwrap()
710 .register(finished_handle("sa_run", SubagentStatus::Running, None));
711 let err = prior_run(®istry, "sa_run", None, "x")
712 .unwrap_err()
713 .to_string();
714 assert!(err.contains("still running"), "{err}");
715 }
716
717 #[test]
718 fn explicit_archive_ignores_the_registry() {
719 let dir = tempfile::tempdir().unwrap();
722 let path = archive_in(dir.path(), "sa_3");
723 let registry = Mutex::new(SubagentRegistry::new());
724 registry
725 .lock()
726 .unwrap()
727 .register(finished_handle("sa_3", SubagentStatus::Running, None));
728 let prior = prior_run(®istry, "sa_3", Some(path), "pick up").unwrap();
729 assert_eq!(prior.agent_name, "archived-label");
730 assert_continues_archive(&prior, "pick up");
731 }
732
733 #[test]
734 fn legacy_handle_without_archive_pastes_prior_text() {
735 let registry = Mutex::new(SubagentRegistry::new());
736 registry.lock().unwrap().register(finished_handle(
737 "sa_old",
738 SubagentStatus::Completed,
739 None,
740 ));
741 let prior = prior_run(®istry, "sa_old", None, "next").unwrap();
742 assert_eq!(prior.messages.len(), 1);
743 let text = prior.messages[0]["content"].as_str().unwrap();
744 assert!(text.starts_with("next") && text.contains("prior text"));
745 }
746
747 #[test]
748 fn unknown_handle_without_archive_is_not_found() {
749 let registry = Mutex::new(SubagentRegistry::new());
750 let err = prior_run(®istry, "sa_none", None, "x")
751 .unwrap_err()
752 .to_string();
753 assert!(err.contains("No subagent found"), "{err}");
754 }
755
756 #[tokio::test]
757 async fn tombstone_resume_reports_expired_context_without_side_effects() {
758 let state = Arc::new(RwLock::new(SubagentState::new()));
759 {
760 let mut state = state.write().unwrap();
761 state.status = SubagentStatus::Completed;
762 state.partial_text = "terminal output".into();
763 state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
764 state.finished_at = Some(std::time::Instant::now());
765 }
766 let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
767 let (shutdown_tx, _shutdown_rx) = oneshot::channel();
768 let (_result_tx, result_rx) = oneshot::channel();
769 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
770 registry.lock().unwrap().register(SubagentHandle::new(
771 "sa_expired".into(),
772 1,
773 "test".into(),
774 "task".into(),
775 "anthropic/claude-sonnet-4-6".into(),
776 "system".into(),
777 30,
778 state,
779 Some(steer_tx),
780 Some(shutdown_tx),
781 Some(result_rx),
782 ));
783 let foreground =
784 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
785 let orchestration = Arc::new(
786 crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
787 );
788 orchestration
789 .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
790 .unwrap();
791 let gate_before = orchestration.completion_gate();
792 reap_finished_with_ttl(®istry, Some(orchestration.as_ref()), Duration::ZERO);
793 assert!(registry
794 .lock()
795 .unwrap()
796 .get("sa_expired")
797 .unwrap()
798 .is_tombstone());
799 let count_before = registry.lock().unwrap().list_active().len();
800
801 let mut ctx = create_tool_context();
802 ctx.capabilities.subagent_registry = Some(registry.clone());
803 ctx.capabilities.orchestration = Some(orchestration.clone());
804 let error = SubagentResumeTool
805 .execute(
806 json!({"handle_id": "sa_expired", "instructions": "continue"}),
807 ctx,
808 )
809 .await
810 .unwrap_err()
811 .to_string();
812 assert!(error.contains("expired resumable context"));
813 assert!(!error.contains("No subagent found"));
814 assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
815 assert_eq!(orchestration.completion_gate(), gate_before);
816 }
817}