1use async_trait::async_trait;
2use cookie_agent_engine::{
3 DelegateInvocation, Engine, PreparedExecutor, PreparedTool, PromptSection, SessionToolContext,
4 ToolCall, ToolError, ToolExecutionContext, ToolPreparationContext, ToolProvider, ToolSpec,
5};
6use cookie_agent_protocol::{
7 AgentId, ApprovalResourceSource, PermissionAction, PersistedToolResult as ToolResult,
8 PreparedBindingLifetime,
9};
10use serde::{Deserialize, Serialize};
11
12use crate::{fs_cap, prepared_operation, prepared_resource, safe_title};
13
14const DEFAULT_RESULT_LIMIT: u32 = 2_000;
15
16pub(crate) fn result_truncation_policy(
17 tool_name: &str,
18) -> cookie_agent_engine::ToolResultTruncationPolicy {
19 if matches!(tool_name, "get_subagent_result" | "delegate_subagent") {
20 cookie_agent_engine::ToolResultTruncationPolicy::OptOut
21 } else {
22 cookie_agent_engine::ToolResultTruncationPolicy::Bounded
23 }
24}
25
26pub struct DelegateToolProvider {
27 engine: Engine,
28}
29
30#[derive(Debug, Deserialize, Serialize)]
31#[serde(deny_unknown_fields)]
32struct DelegateArgs {
33 description: String,
34 prompt: String,
35 agent_type: AgentId,
36 #[serde(default)]
37 background: bool,
38 resume_session_id: Option<String>,
40 #[serde(default)]
41 inherit_context: bool,
42}
43
44#[derive(Debug, Deserialize, Serialize)]
45#[serde(deny_unknown_fields)]
46struct GetResultArgs {
47 session_id: String,
49 #[serde(default)]
50 offset: u32,
51 limit: Option<u32>,
52}
53
54#[derive(Debug, Deserialize, Serialize)]
55#[serde(deny_unknown_fields)]
56struct CancelArgs {
57 session_id: String,
59 reason: Option<String>,
60}
61
62enum DelegateExecutor {
63 Invoke {
64 engine: Engine,
65 call_id: cookie_agent_protocol::ToolCallId,
66 args: DelegateArgs,
67 },
68 GetResult {
69 engine: Engine,
70 args: GetResultArgs,
71 },
72 Cancel {
73 engine: Engine,
74 args: CancelArgs,
75 },
76}
77
78impl DelegateToolProvider {
79 #[must_use]
80 pub fn new(engine: Engine) -> Self {
81 Self { engine }
82 }
83
84 fn targets(
85 &self,
86 session: cookie_agent_protocol::SessionId,
87 ) -> Result<Vec<AgentId>, ToolError> {
88 self.engine
89 .delegate_targets(session)
90 .map_err(|error| ToolError::execution(error.to_string()))
91 }
92
93 fn operation(
94 ctx: &ToolPreparationContext,
95 name: &str,
96 args: &impl Serialize,
97 operation_name: &str,
98 agent_type: &AgentId,
99 ) -> Result<PreparedToolParts, ToolError> {
100 let resource = prepared_resource(
101 PermissionAction::Delegate,
102 "agent",
103 agent_type.as_str().as_bytes(),
104 agent_type.as_str().as_bytes(),
105 PreparedBindingLifetime::RestartStable,
106 ApprovalResourceSource::PrimaryOperation,
107 )?;
108 let cwd = fs_cap::cwd_context_bytes(&ctx.cwd)?;
109 Ok(PreparedToolParts {
110 operation: prepared_operation(
111 name,
112 args,
113 vec![(PermissionAction::Delegate, operation_name)],
114 vec![resource],
115 &cwd,
116 )?,
117 policy_label: agent_type.to_string(),
118 })
119 }
120}
121
122struct PreparedToolParts {
123 operation: cookie_agent_protocol::PreparedOperationIdentity,
124 policy_label: String,
125}
126
127#[async_trait]
128impl ToolProvider for DelegateToolProvider {
129 fn provider_id(&self) -> &'static str {
130 "builtin.delegate"
131 }
132
133 fn prompt_sections(&self, ctx: &SessionToolContext) -> Result<Vec<PromptSection>, ToolError> {
134 let Some(targets) = ctx.prompt_delegate_targets() else {
135 return Ok(Vec::new());
136 };
137 let targets = targets.collect::<Vec<_>>();
138 if targets.is_empty() {
139 return Ok(Vec::new());
140 }
141 let mut body = String::from("Available subagents:");
142 for (id, description) in targets {
143 body.push_str("\n- ");
144 body.push_str(id.as_str());
145 body.push_str(": ");
146 body.push_str(description);
147 }
148 body.push_str(
149 "\n\nDelegation behavior:\n\
150 - Foreground delegation waits for the child and returns its result.\n\
151 - background=true returns a handle immediately; when the child finishes, this parent session receives a completion notification.\n\
152 - If you need to wait for a background child, tell the user that you are waiting and end your turn; the completion notification will wake this session.\n\
153 - Use get_subagent_result only to read the available result after notification; it does not wait.",
154 );
155 Ok(vec![PromptSection {
156 title: "Available subagents".into(),
157 body,
158 }])
159 }
160
161 fn tools_for_session(&self, ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
162 let targets = self.targets(ctx.session)?;
163 Ok(if targets.is_empty() {
164 Vec::new()
165 } else {
166 vec![
167 ToolSpec {
168 output: Default::default(),
169 concurrency: cookie_agent_engine::ToolConcurrency::Parallel,
170 result_truncation: result_truncation_policy("delegate_subagent"),
171 name: "delegate_subagent".into(),
172 permission_name: Self::get_permission_name("delegate_subagent")?.into(),
173 description: "Delegate a self-contained task to a specialist agent. Foreground (default) blocks until done. background=true returns immediately with a session handle; the result is pushed back automatically as a <subagent_notification>. If you need to wait for completion, tell the user and end your turn; the notification will wake this session. Use get_subagent_result only to read the available result after notification. To continue an existing subagent, pass its resume_session_id (see the handle in its start/completion notice).".into(),
174 parameters: serde_json::json!({
175 "type":"object","additionalProperties":false,
176 "properties":{
177 "description":{"type":"string","description":"Short (3-5 words) summary of the task"},
178 "prompt":{"type":"string","description":"Full task brief with objective, context, and deliverable"},
179 "agent_type":{"type":"string","enum":targets},
180 "background":{"type":"boolean","default":false},
181 "resume_session_id":{
182 "type":"string",
183 "description":"Optional. Handle or UUID of an existing subagent of yours to resume, e.g. \"explore_1a2b3c4d\". Only subagents you delegated are valid."
184 },
185 "inherit_context":{"type":"boolean","default":false}
186 },
187 "required":["description","prompt","agent_type"]
188 }),
189 },
190 ToolSpec {
191 output: Default::default(),
192 concurrency: Default::default(),
193 result_truncation: result_truncation_policy("get_subagent_result"),
194 name: "get_subagent_result".into(),
195 permission_name: Self::get_permission_name("get_subagent_result")?.into(),
196 description: "Read the current status and latest result of a subagent you delegated. This call returns immediately; it never waits. A background delegation sends a completion notification to this parent session when it ends. Use the handle from the subagent's start or completion notice, e.g. \"explore_1a2b3c4d\". Only your own subagents are visible.".into(),
197 parameters: serde_json::json!({
198 "type":"object","additionalProperties":false,
199 "properties":{
200 "session_id":{
201 "type":"string",
202 "description":"Handle (agent_type + 8 hex, e.g. \"coder_9f8e7d6b\") or full UUID of one of your subagents."
203 },
204 "offset":{"type":"integer","minimum":0,"default":0},
205 "limit":{"type":"integer","minimum":1,"maximum":4_294_967_295_u64,"default":2000}
206 },
207 "required":["session_id"]
208 }),
209 },
210 ToolSpec {
211 output: Default::default(),
212 concurrency: Default::default(),
213 result_truncation: result_truncation_policy("cancel_subagent"),
214 name: "cancel_subagent".into(),
215 permission_name: Self::get_permission_name("cancel_subagent")?.into(),
216 description: "Cancel a subagent you delegated. Accepts the same handle or UUID forms as get_subagent_result.".into(),
217 parameters: serde_json::json!({
218 "type":"object","additionalProperties":false,
219 "properties":{
220 "session_id":{"type":"string","description":"Handle or full UUID of one of your subagents."},
221 "reason":{"type":"string"}
222 },
223 "required":["session_id"]
224 }),
225 },
226 ]
227 })
228 }
229
230 fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
231 match tool_name {
232 "delegate_subagent" | "get_subagent_result" | "cancel_subagent" => Ok("delegate"),
233 _ => Err(ToolError::execution(
234 "delegate provider received another tool",
235 )),
236 }
237 }
238
239 fn get_permission_resource(
240 &self,
241 name: &str,
242 arguments: &serde_json::Value,
243 ) -> Result<(&'static str, Option<String>), ToolError> {
244 let permission_name = Self::get_permission_name(name)?;
245 let resource = delegate_permission_resource(name, arguments)?;
246 Ok((permission_name, resource))
247 }
248
249 fn get_display_argument(
250 &self,
251 name: &str,
252 arguments: &serde_json::Value,
253 ) -> Result<String, ToolError> {
254 match name {
255 "delegate_subagent" => Ok(parse_delegate(arguments)?.description),
256 "get_subagent_result" => Ok(parse_result(arguments)?.session_id.to_string()),
257 "cancel_subagent" => Ok(parse_cancel(arguments)?.session_id.to_string()),
258 _ => Err(ToolError::execution(
259 "delegate provider received another tool",
260 )),
261 }
262 }
263
264 async fn prepare(
265 &self,
266 ctx: ToolPreparationContext,
267 call: ToolCall,
268 ) -> Result<PreparedTool, ToolError> {
269 let (parts, normalized, executor) = match call.name.as_str() {
270 "delegate_subagent" => {
271 let args = parse_delegate(&call.arguments)?;
272 if args.description.trim().is_empty() || args.prompt.trim().is_empty() {
273 return Err(ToolError::execution(
274 "description and prompt must not be empty",
275 ));
276 }
277 if args.resume_session_id.is_some() && args.inherit_context {
278 return Err(ToolError::execution(
279 "resume_session_id and inherit_context cannot both be set",
280 ));
281 }
282 if !self.targets(ctx.session)?.contains(&args.agent_type) {
283 return Err(ToolError::execution("delegate target is not allowed"));
284 }
285 let parts =
286 Self::operation(&ctx, "delegate_subagent", &args, "spawn", &args.agent_type)?;
287 let normalized = serde_json::to_value(&args)
288 .map_err(|error| ToolError::execution(error.to_string()))?;
289 let executor = DelegateExecutor::Invoke {
290 engine: self.engine.clone(),
291 call_id: call.id,
292 args,
293 };
294 (parts, normalized, executor)
295 }
296 "get_subagent_result" => {
297 let mut args = parse_result(&call.arguments)?;
298 let limit = args.limit.unwrap_or(DEFAULT_RESULT_LIMIT);
299 if limit == 0 {
300 return Err(ToolError::execution("limit must be positive"));
301 }
302 args.limit = Some(limit);
303 let target = self
304 .engine
305 .resolve_subagent_target(
306 ctx.session,
307 &args.session_id,
308 cookie_agent_engine::SubagentScope::DirectChildren,
309 )
310 .map_err(|error| ToolError::execution(error.to_string()))?;
311 let agent_type = self
312 .engine
313 .subagent_agent_type(ctx.session, target)
314 .map_err(|error| ToolError::execution(error.to_string()))?;
315 let parts =
316 Self::operation(&ctx, "get_subagent_result", &args, "read", &agent_type)?;
317 let normalized = serde_json::to_value(&args)
318 .map_err(|error| ToolError::execution(error.to_string()))?;
319 let executor = DelegateExecutor::GetResult {
320 engine: self.engine.clone(),
321 args,
322 };
323 (parts, normalized, executor)
324 }
325 "cancel_subagent" => {
326 let args = parse_cancel(&call.arguments)?;
327 let target = self
328 .engine
329 .resolve_subagent_target(
330 ctx.session,
331 &args.session_id,
332 cookie_agent_engine::SubagentScope::DirectChildren,
333 )
334 .map_err(|error| ToolError::execution(error.to_string()))?;
335 let agent_type = self
336 .engine
337 .subagent_agent_type(ctx.session, target)
338 .map_err(|error| ToolError::execution(error.to_string()))?;
339 let parts = Self::operation(&ctx, "cancel_subagent", &args, "cancel", &agent_type)?;
340 let normalized = serde_json::to_value(&args)
341 .map_err(|error| ToolError::execution(error.to_string()))?;
342 let executor = DelegateExecutor::Cancel {
343 engine: self.engine.clone(),
344 args,
345 };
346 (parts, normalized, executor)
347 }
348 _ => {
349 return Err(ToolError::execution(
350 "delegate provider received another tool",
351 ));
352 }
353 };
354 PreparedTool::new(parts.operation, normalized, None, Box::new(executor))?
355 .with_policy_labels(vec![parts.policy_label])
356 }
357}
358
359fn delegate_permission_resource(
360 name: &str,
361 arguments: &serde_json::Value,
362) -> Result<Option<String>, ToolError> {
363 match name {
364 "delegate_subagent" => Ok(Some(parse_delegate(arguments)?.agent_type.to_string())),
365 "get_subagent_result" | "cancel_subagent" => Ok(None),
366 _ => Err(ToolError::execution(
367 "delegate provider received another tool",
368 )),
369 }
370}
371
372#[async_trait]
373impl PreparedExecutor for DelegateExecutor {
374 async fn revalidate(&self) -> Result<(), ToolError> {
375 Ok(())
376 }
377
378 async fn execute(
379 self: Box<Self>,
380 context: ToolExecutionContext,
381 ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
382 let result: Result<ToolResult, ToolError> = async move {
383 if context.cancellation.is_cancelled() {
384 return Err(ToolError::execution(
385 "prepared subagent operation cancelled",
386 ));
387 }
388 match *self {
389 Self::Invoke {
390 engine,
391 call_id,
392 args,
393 } => {
394 let background = args.background;
395 let handle = engine
396 .delegate_invoke(DelegateInvocation {
397 parent_session_id: context.session,
398 parent_run_id: context.run,
399 parent_tool_call_id: call_id,
400 agent_type: args.agent_type,
401 description: args.description,
402 prompt: args.prompt,
403 background,
404 resume_session_id: args.resume_session_id,
405 inherit_context: args.inherit_context,
406 })
407 .await
408 .map_err(|error| ToolError::execution(error.to_string()))?;
409 if background {
410 let short_id = engine
411 .get_session(handle.child_session_id)
412 .ok()
413 .and_then(|meta| meta.short_id);
414 Ok(background_start_result(handle.child_session_id, short_id))
415 } else {
416 engine
417 .await_delegate(handle)
418 .await
419 .map_err(|error| ToolError::execution(error.to_string()))
420 }
421 }
422 Self::GetResult { engine, args } => engine
423 .get_subagent_result(
424 context.session,
425 &args.session_id,
426 false,
427 args.offset,
428 args.limit.expect("normalized result limit"),
429 context.cancellation,
430 )
431 .await
432 .map_err(|error| ToolError::execution(error.to_string())),
433 Self::Cancel { engine, args } => engine
434 .cancel_subagent(context.session, &args.session_id, args.reason)
435 .await
436 .map_err(|error| ToolError::execution(error.to_string())),
437 }
438 }
439 .await;
440 result.map(cookie_agent_engine::ToolCompletion::single)
441 }
442}
443
444fn background_start_result(
450 child_session_id: cookie_agent_protocol::SessionId,
451 short_id: Option<String>,
452) -> ToolResult {
453 let label = short_id
454 .clone()
455 .unwrap_or_else(|| child_session_id.to_string());
456 let metadata = serde_json::json!({
457 "session_id": child_session_id,
458 "handle": short_id,
459 });
460 ToolResult {
461 display: None,
462 retained_output: None,
463 title: safe_title("Subagent started"),
464 output: format!(
465 "Subagent started. [subagent session {label}]\nuse get_subagent_result with session_id \"{label}\""
466 ),
467 metadata,
468 truncation: None,
469 attachments: Vec::new(),
470 additional_messages: Vec::new(),
471 }
472}
473
474fn parse_delegate(arguments: &serde_json::Value) -> Result<DelegateArgs, ToolError> {
475 let args: DelegateArgs = serde_json::from_value(arguments.clone())
476 .map_err(|error| ToolError::execution(error.to_string()))?;
477 if Engine::is_reserved_staged_skill_prompt(&args.prompt) {
478 return Err(ToolError::execution(
479 "delegate prompt uses a reserved staged-skill prefix",
480 ));
481 }
482 if args.resume_session_id.is_some() && args.inherit_context {
483 return Err(ToolError::execution(
484 "resume_session_id and inherit_context cannot both be set",
485 ));
486 }
487 Ok(args)
488}
489
490fn parse_result(arguments: &serde_json::Value) -> Result<GetResultArgs, ToolError> {
491 serde_json::from_value(arguments.clone())
492 .map_err(|error| ToolError::execution(error.to_string()))
493}
494
495fn parse_cancel(arguments: &serde_json::Value) -> Result<CancelArgs, ToolError> {
496 serde_json::from_value(arguments.clone())
497 .map_err(|error| ToolError::execution(error.to_string()))
498}
499
500#[cfg(test)]
501mod tests {
502 use cookie_agent_engine::{
503 ToolError, ToolPreparationContext, ToolProvider, permissions::ApprovalStore,
504 };
505 use cookie_agent_protocol::{
506 AgentId, ApprovalId, ApprovalResourceSource, OperationFingerprint, PermissionAction,
507 PreparedBindingLifetime, RunId, SessionId, TreeApprovalGrant, TreeApprovalGrantId,
508 };
509 use serde::Serialize;
510
511 use super::{
512 CancelArgs, DelegateToolProvider, GetResultArgs, background_start_result,
513 delegate_permission_resource, parse_delegate, parse_result,
514 };
515
516 fn assert_legacy_grant_does_not_match(
517 name: &str,
518 operation_name: &str,
519 args: &impl Serialize,
520 old_resource_kind: &str,
521 old_binding: &[u8],
522 ) {
523 let cwd = tempfile::tempdir().expect("cwd");
524 let context = ToolPreparationContext {
525 session: SessionId::new_v7(),
526 run: RunId::new_v7(),
527 cwd: cwd.path().to_owned(),
528 workspace_root: cwd.path().to_owned(),
529 turn_context: crate::test_turn_context(),
530 };
531 let agent_type = AgentId::new("reviewer").expect("agent type");
532 let current =
533 DelegateToolProvider::operation(&context, name, args, operation_name, &agent_type)
534 .expect("current agent-scoped operation")
535 .operation;
536 let old_resource = crate::prepared_resource(
537 PermissionAction::Delegate,
538 old_resource_kind,
539 old_binding,
540 old_binding,
541 PreparedBindingLifetime::RestartStable,
542 ApprovalResourceSource::PrimaryOperation,
543 )
544 .expect("legacy scoped resource");
545 let cwd = crate::fs_cap::cwd_context_bytes(&context.cwd).expect("cwd context");
546 let old = crate::prepared_operation(
547 name,
548 args,
549 vec![(PermissionAction::Delegate, operation_name)],
550 vec![old_resource],
551 &cwd,
552 )
553 .expect("legacy scoped operation");
554 let root = SessionId::new_v7();
555 let store = ApprovalStore::default();
556 store.grant(TreeApprovalGrant {
557 grant_id: TreeApprovalGrantId::new_v7(),
558 root_session_id: root,
559 approval_id: ApprovalId::new_v7(),
560 operation_fingerprint: OperationFingerprint::from_prepared_operation(&old),
561 capabilities: old.capabilities().to_vec(),
562 resources: old.resources().to_vec(),
563 created_at: "2026-01-01T00:00:00Z".parse().expect("timestamp"),
564 });
565 assert!(store.matching(root, ¤t).is_none());
566 }
567
568 #[test]
569 fn legacy_scoped_delegate_grants_do_not_match_current_session_tools() {
570 let session_id = SessionId::new_v7();
571 assert_legacy_grant_does_not_match(
572 "get_subagent_result",
573 "read",
574 &GetResultArgs {
575 session_id: session_id.to_string(),
576 offset: 0,
577 limit: Some(super::DEFAULT_RESULT_LIMIT),
578 },
579 "agent",
580 b"explorer",
581 );
582 assert_legacy_grant_does_not_match(
583 "cancel_subagent",
584 "cancel",
585 &CancelArgs {
586 session_id: session_id.to_string(),
587 reason: None,
588 },
589 "agent",
590 b"explorer",
591 );
592 }
593
594 #[test]
595 fn delegate_permission_metadata_distinguishes_spawn_from_session_tools() {
596 for name in [
597 "delegate_subagent",
598 "get_subagent_result",
599 "cancel_subagent",
600 ] {
601 assert_eq!(
602 DelegateToolProvider::get_permission_name(name).expect("permission name"),
603 "delegate"
604 );
605 }
606 assert_eq!(
607 delegate_permission_resource(
608 "delegate_subagent",
609 &serde_json::json!({
610 "description":"Review",
611 "prompt":"Review this.",
612 "agent_type":"reviewer"
613 })
614 )
615 .expect("spawn resource"),
616 Some("reviewer".into())
617 );
618 let session_id = cookie_agent_protocol::SessionId::new_v7();
619 assert_eq!(
620 delegate_permission_resource(
621 "cancel_subagent",
622 &serde_json::json!({"session_id":session_id}),
623 )
624 .expect("provider-managed label resource"),
625 None
626 );
627 }
628
629 #[test]
630 fn only_paginated_subagent_results_opt_out_of_truncation() {
631 assert_eq!(
632 super::result_truncation_policy("cancel_subagent"),
633 cookie_agent_engine::ToolResultTruncationPolicy::Bounded
634 );
635 for name in ["delegate_subagent", "get_subagent_result"] {
636 assert_eq!(
637 super::result_truncation_policy(name),
638 cookie_agent_engine::ToolResultTruncationPolicy::OptOut
639 );
640 }
641 }
642
643 #[test]
644 fn background_start_result_surfaces_the_exact_handle_fragment() {
645 let session_id = cookie_agent_protocol::SessionId::new_v7();
646 let result = background_start_result(session_id, Some("explore_1a2b3c4d".into()));
647 assert_eq!(
648 result.output,
649 "Subagent started. [subagent session explore_1a2b3c4d]\nuse get_subagent_result with session_id \"explore_1a2b3c4d\""
650 );
651 assert_eq!(
652 result.metadata,
653 serde_json::json!({"session_id": session_id, "handle": "explore_1a2b3c4d"})
654 );
655 assert!(!result.output.contains(&session_id.to_string()));
656
657 let legacy = background_start_result(session_id, None);
658 assert_eq!(
659 legacy.output,
660 format!(
661 "Subagent started. [subagent session {session_id}]\nuse get_subagent_result with session_id \"{session_id}\""
662 )
663 );
664 assert_eq!(
665 legacy.metadata,
666 serde_json::json!({"session_id": session_id, "handle": null})
667 );
668 }
669
670 #[test]
671 fn result_arguments_reject_removed_wait_parameter() {
672 let session_id = SessionId::new_v7();
673 assert!(
674 parse_result(&serde_json::json!({
675 "session_id": session_id,
676 "wait": true
677 }))
678 .is_err()
679 );
680 }
681
682 #[test]
683 fn delegate_arguments_use_agent_as_permission_resource_and_description_as_display() {
684 let arguments = serde_json::json!({
685 "description":"Review API",
686 "prompt":"Review the API in full.",
687 "agent_type":"reviewer"
688 });
689 let args = parse_delegate(&arguments).expect("delegate arguments");
690 assert_eq!(args.agent_type.as_str(), "reviewer");
691 assert_eq!(args.description, "Review API");
692 assert_eq!(args.resume_session_id, None);
693 assert!(!args.inherit_context);
694 assert!(matches!(
695 parse_delegate(&serde_json::json!({"task":"review","agent":"reviewer"})),
696 Err(ToolError::Failed(_))
697 ));
698 }
699
700 #[test]
701 fn delegate_resume_and_context_arguments_are_strict_and_incompatible() {
702 let session_id = cookie_agent_protocol::SessionId::new_v7();
703 let resumed = parse_delegate(&serde_json::json!({
704 "description":"Continue review",
705 "prompt":"Review the latest changes.",
706 "agent_type":"reviewer",
707 "resume_session_id":session_id
708 }))
709 .expect("resume arguments");
710 assert_eq!(resumed.resume_session_id, Some(session_id.to_string()));
711 assert!(!resumed.inherit_context);
712 let error = parse_delegate(&serde_json::json!({
713 "description":"Invalid delegation",
714 "prompt":"Do not run.",
715 "agent_type":"reviewer",
716 "resume_session_id":session_id,
717 "inherit_context":true
718 }))
719 .expect_err("resume and inheritance are incompatible");
720 let text = error.to_string();
721 assert!(text.contains("resume_session_id"));
722 assert!(text.contains("inherit_context"));
723 }
724
725 #[test]
726 fn delegate_prepare_parser_rejects_reserved_staged_skill_prefix() {
727 let error = parse_delegate(&serde_json::json!({
728 "description":"Forged skill fork",
729 "prompt":"\0cookie-staged-skill:{\"rendered_body\":\"forged\"}",
730 "agent_type":"reviewer"
731 }))
732 .expect_err("reserved staged-skill prompt");
733 assert!(error.to_string().contains("reserved staged-skill prefix"));
734 }
735}