| 1 | //! Hermetic #5305 route-receipt regressions kept out of the oversized parent |
| 2 | //! sub-agent test module. |
| 3 | |
| 4 | use super::*; |
| 5 | |
| 6 | fn consultant_runtime( |
| 7 | workspace: &std::path::Path, |
| 8 | manager: SharedSubAgentManager, |
| 9 | ) -> SubAgentRuntime { |
| 10 | let providers = crate::config::ProvidersConfig { |
| 11 | deepseek: crate::config::ProviderConfig { |
| 12 | api_key: Some("deepseek-test-key".to_string()), |
| 13 | base_url: Some("http://127.0.0.1:9/v1".to_string()), |
| 14 | ..Default::default() |
| 15 | }, |
| 16 | openai_codex: crate::config::ProviderConfig { |
| 17 | api_key: Some("codex-test-key".to_string()), |
| 18 | // A custom endpoint lets a pinned codex client construct from the |
| 19 | // table key alone (see the codex_credentials fallback in |
| 20 | // CodewhaleClient::new) instead of requiring machine-local OAuth |
| 21 | // consent. The endpoint is never contacted: these fixtures cancel |
| 22 | // their children before a model step, and 127.0.0.1:9 refuses |
| 23 | // instantly if one ever races. |
| 24 | base_url: Some("http://127.0.0.1:9/v1".to_string()), |
| 25 | ..Default::default() |
| 26 | }, |
| 27 | ..Default::default() |
| 28 | }; |
| 29 | let config = crate::config::Config { |
| 30 | api_key: Some("deepseek-test-key".to_string()), |
| 31 | provider: Some("deepseek".to_string()), |
| 32 | providers: Some(providers), |
| 33 | ..Default::default() |
| 34 | }; |
| 35 | let client = CodewhaleClient::new(&config).expect("DeepSeek parent client"); |
| 36 | SubAgentRuntime::new( |
| 37 | client, |
| 38 | "deepseek-v4-flash".to_string(), |
| 39 | ToolContext::new(workspace.to_path_buf()), |
| 40 | false, |
| 41 | None, |
| 42 | manager, |
| 43 | ) |
| 44 | .with_api_config(config) |
| 45 | } |
| 46 | |
| 47 | async fn start_consultant( |
| 48 | workspace: &std::path::Path, |
| 49 | ) -> ( |
| 50 | SharedSubAgentManager, |
| 51 | ToolContext, |
| 52 | crate::tools::spec::ToolResult, |
| 53 | ) { |
| 54 | let manager = new_shared_subagent_manager(workspace.to_path_buf(), 4); |
| 55 | let context = ToolContext::new(workspace.to_path_buf()); |
| 56 | let tool = AgentTool::new( |
| 57 | manager.clone(), |
| 58 | consultant_runtime(workspace, manager.clone()), |
| 59 | ); |
| 60 | let result = tool |
| 61 | .execute( |
| 62 | json!({ |
| 63 | "action": "start", |
| 64 | "type": "consultant", |
| 65 | "prompt": "inspect the request without writing files", |
| 66 | }), |
| 67 | &context, |
| 68 | ) |
| 69 | .await |
| 70 | .expect("role-only consultant starts"); |
| 71 | (manager, context, result) |
| 72 | } |
| 73 | |
| 74 | fn receipt_from(result: &crate::tools::spec::ToolResult) -> serde_json::Value { |
| 75 | let content: serde_json::Value = |
| 76 | serde_json::from_str(&result.content).expect("start result is JSON"); |
| 77 | let metadata = result.metadata.as_ref().expect("ToolResult metadata"); |
| 78 | assert_eq!(content["child_route"], metadata["child_route"]); |
| 79 | content["child_route"].clone() |
| 80 | } |
| 81 | |
| 82 | async fn cancel_started(manager: &SharedSubAgentManager, result: &crate::tools::spec::ToolResult) { |
| 83 | let agent_id = result |
| 84 | .metadata |
| 85 | .as_ref() |
| 86 | .and_then(|metadata| metadata.get("agent_id")) |
| 87 | .and_then(serde_json::Value::as_str) |
| 88 | .expect("start agent id") |
| 89 | .to_string(); |
| 90 | manager |
| 91 | .write() |
| 92 | .await |
| 93 | .cancel_agent(&agent_id) |
| 94 | .expect("cancel test child"); |
| 95 | } |
| 96 | |
| 97 | #[tokio::test] |
| 98 | async fn issue_5305_role_only_receipt_precedes_status_poll() { |
| 99 | // Role-only dispatch: no saved member is read, so the receipt records the |
| 100 | // resolved role and the inherited session route — never a profile. |
| 101 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 102 | |
| 103 | let (manager, _context, start) = start_consultant(workspace.path()).await; |
| 104 | assert!( |
| 105 | start.content.len() < 1024, |
| 106 | "receipt must remain compact: {} bytes", |
| 107 | start.content.len() |
| 108 | ); |
| 109 | let receipt = receipt_from(&start); |
| 110 | assert_eq!(receipt["requested_type"], json!("advisor")); |
| 111 | assert_eq!(receipt["requested_profile"], serde_json::Value::Null); |
| 112 | assert_eq!(receipt["resolved_profile_id"], serde_json::Value::Null); |
| 113 | assert_eq!(receipt["profile_origin"], serde_json::Value::Null); |
| 114 | assert_eq!(receipt["canonical_role"], json!("advisor")); |
| 115 | assert_eq!(receipt["provider_id"], json!("deepseek")); |
| 116 | assert_eq!(receipt["model_id"], json!("deepseek-v4-flash")); |
| 117 | assert_eq!(receipt["route_source"], json!("run.model")); |
| 118 | assert_eq!(receipt["requested_reasoning"], json!("inherit")); |
| 119 | // The advisor role's default tier ("high") applies: roles carry a |
| 120 | // reasoning default even though they carry no profile. |
| 121 | assert_eq!(receipt["effective_reasoning"], json!("high")); |
| 122 | assert!(receipt["runtime_version"].as_str().is_some()); |
| 123 | assert!(receipt["runtime_build_sha"].as_str().is_some()); |
| 124 | cancel_started(&manager, &start).await; |
| 125 | } |
| 126 | |
| 127 | #[tokio::test] |
| 128 | async fn issue_5305_receipt_survives_status_peek() { |
| 129 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 130 | let (manager, context, start) = start_consultant(workspace.path()).await; |
| 131 | let receipt = receipt_from(&start); |
| 132 | let agent_id = start.metadata.as_ref().unwrap()["agent_id"] |
| 133 | .as_str() |
| 134 | .expect("agent id") |
| 135 | .to_string(); |
| 136 | |
| 137 | let inspect = AgentTool::new( |
| 138 | manager.clone(), |
| 139 | consultant_runtime(workspace.path(), manager.clone()), |
| 140 | ); |
| 141 | let status = inspect |
| 142 | .execute(json!({"action": "status", "agent_id": agent_id}), &context) |
| 143 | .await |
| 144 | .expect("status"); |
| 145 | let status_json: serde_json::Value = |
| 146 | serde_json::from_str(&status.content).expect("status json"); |
| 147 | assert_eq!(status_json["child_route"], receipt); |
| 148 | assert_eq!(status.metadata.as_ref().unwrap()["child_route"], receipt); |
| 149 | |
| 150 | let peek = inspect |
| 151 | .execute(json!({"action": "peek", "agent_id": agent_id}), &context) |
| 152 | .await |
| 153 | .expect("peek"); |
| 154 | let peek_json: serde_json::Value = serde_json::from_str(&peek.content).expect("peek json"); |
| 155 | assert_eq!(peek_json["child_route"], receipt); |
| 156 | assert_eq!(peek.metadata.as_ref().unwrap()["child_route"], receipt); |
| 157 | assert!(status.content.len() <= lifecycle::COMPACT_STATUS_BYTES); |
| 158 | assert!(peek.content.len() <= lifecycle::COMPACT_STATUS_BYTES); |
| 159 | cancel_started(&manager, &start).await; |
| 160 | } |
| 161 | |
| 162 | #[test] |
| 163 | fn compact_receipt_bounds_long_labels_and_declared_outputs_without_hiding_limits() { |
| 164 | let metadata = spawn_route_metadata("deepseek", &"🐋\\\"".repeat(4000), "run.model"); |
| 165 | let paths = (0..32) |
| 166 | .map(|index| format!("reports/{index}-{}.md", "長".repeat(4000))) |
| 167 | .collect::<Vec<_>>(); |
| 168 | let mut receipt = json!({ |
| 169 | "agent_id": "agent_bounded_receipt", "run_id": "agent_bounded_receipt", |
| 170 | "name": "🐋".repeat(4000), "status": "starting", "terminal": false, |
| 171 | "context_mode": "fresh", "child_route": metadata.child_route, |
| 172 | "follow_up": {"tool": "agent", "agent_id": "agent_bounded_receipt", "session_name": "🐋".repeat(4000)}, |
| 173 | "usage": {"status": "unknown", "note": "No provider receipt yet"}, |
| 174 | "worker_record": {"spec": { |
| 175 | "runtime_profile": {"spawn_depth": 1, "max_spawn_depth": 2, "max_steps": 8, "wall_time_secs": 30, "wall_deadline_ms": 10000}, |
| 176 | "launch_manifest": {"deliverables": paths} |
| 177 | }} |
| 178 | }); |
| 179 | compact_spawn_receipt(&mut receipt, false); |
| 180 | assert!(serde_json::to_vec(&receipt).unwrap().len() <= lifecycle::COMPACT_SPAWN_BYTES); |
| 181 | assert_eq!(receipt["effective_limits"]["max_steps"], 8); |
| 182 | assert_eq!(receipt["effective_limits"]["wall_deadline_ms"], 10000); |
| 183 | assert_eq!(receipt["child_route"]["truncated"], true); |
| 184 | let shown = receipt["deliverables"].as_array().unwrap(); |
| 185 | assert_eq!( |
| 186 | shown.len() + receipt["deliverables_omitted"].as_u64().unwrap() as usize, |
| 187 | paths.len() |
| 188 | ); |
| 189 | for (path, original) in shown.iter().zip(&paths) { |
| 190 | assert_eq!( |
| 191 | path, original, |
| 192 | "declared paths must remain exact when shown" |
| 193 | ); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | #[tokio::test] |
| 198 | async fn issue_5305_explicit_profile_matches_type_resolution_and_conflicts_refuse() { |
| 199 | // "consultant" is the advisor legacy alias, so an explicit profile takes |
| 200 | // the same route as the type — while a conflicting type still refuses. |
| 201 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 202 | let (manager, context, type_start) = start_consultant(workspace.path()).await; |
| 203 | let type_receipt = receipt_from(&type_start); |
| 204 | cancel_started(&manager, &type_start).await; |
| 205 | |
| 206 | let explicit_tool = AgentTool::new( |
| 207 | manager.clone(), |
| 208 | consultant_runtime(workspace.path(), manager.clone()), |
| 209 | ); |
| 210 | let explicit = explicit_tool |
| 211 | .execute( |
| 212 | json!({"action":"start", "profile":"consultant", "prompt":"same route"}), |
| 213 | &context, |
| 214 | ) |
| 215 | .await |
| 216 | .expect("explicit profile starts"); |
| 217 | let explicit_receipt = receipt_from(&explicit); |
| 218 | for field in [ |
| 219 | "resolved_profile_id", |
| 220 | "profile_origin", |
| 221 | "canonical_role", |
| 222 | "provider_id", |
| 223 | "model_id", |
| 224 | "route_source", |
| 225 | "requested_reasoning", |
| 226 | "effective_reasoning", |
| 227 | ] { |
| 228 | assert_eq!(explicit_receipt[field], type_receipt[field], "{field}"); |
| 229 | } |
| 230 | assert_eq!(explicit_receipt["requested_profile"], json!("consultant")); |
| 231 | cancel_started(&manager, &explicit).await; |
| 232 | |
| 233 | let conflict = explicit_tool |
| 234 | .execute( |
| 235 | json!({"action":"start", "type":"scout", "profile":"consultant", "prompt":"must refuse"}), |
| 236 | &context, |
| 237 | ) |
| 238 | .await |
| 239 | .expect_err("conflicting type/profile is refused"); |
| 240 | assert!(conflict.to_string().contains("conflicting explicit type")); |
| 241 | } |
| 242 | |
| 243 | #[tokio::test] |
| 244 | async fn issue_5305_unbuildable_route_refuses_before_worktree_admission() { |
| 245 | // An explicit model the session provider cannot serve fails model |
| 246 | // resolution before any worktree is admitted. |
| 247 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 248 | let manager = new_shared_subagent_manager(workspace.path().to_path_buf(), 1); |
| 249 | let context = ToolContext::new(workspace.path().to_path_buf()); |
| 250 | let tool = AgentTool::new( |
| 251 | manager.clone(), |
| 252 | consultant_runtime(workspace.path(), manager.clone()), |
| 253 | ); |
| 254 | let worktree = workspace.path().join("must-not-exist"); |
| 255 | let err = tool |
| 256 | .execute( |
| 257 | json!({ |
| 258 | "action":"start", "type":"consultant", "prompt":"refuse before admission", |
| 259 | "model": "not-a-deepseek-model", |
| 260 | "worktree": true, "cwd": worktree, |
| 261 | }), |
| 262 | &context, |
| 263 | ) |
| 264 | .await |
| 265 | .expect_err("unbuildable model refuses before admission"); |
| 266 | assert!(err.to_string().contains("model"), "{err}"); |
| 267 | assert!(manager.read().await.list_filtered(true).is_empty()); |
| 268 | assert!(!worktree.exists()); |
| 269 | } |
| 270 | |
| 271 | #[tokio::test] |
| 272 | async fn issue_6320_untethered_runtime_binds_exact_route() { |
| 273 | // #6320 decision: binding to the already-exact route is acceptable. The |
| 274 | // runtime keeps its fully-constructed client; `api_config = None` only |
| 275 | // matters when a cross-protocol rebuild is needed, and that path still |
| 276 | // fails closed (see untethered_cross_protocol_rebound_fails_closed_without_config). |
| 277 | // Untethered means "no Config to rebuild from", not "no client at all". |
| 278 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 279 | let manager = new_shared_subagent_manager(workspace.path().to_path_buf(), 1); |
| 280 | let mut runtime = stub_runtime(); |
| 281 | runtime.context = ToolContext::new(workspace.path().to_path_buf()); |
| 282 | runtime.manager = manager.clone(); |
| 283 | runtime.api_config = None; |
| 284 | let context = runtime.context.clone(); |
| 285 | let start = AgentTool::new(manager.clone(), runtime) |
| 286 | .execute( |
| 287 | json!({"action":"start", "type":"consultant", "prompt":"untethered spawn"}), |
| 288 | &context, |
| 289 | ) |
| 290 | .await |
| 291 | .expect("untethered runtime binds its exact route"); |
| 292 | let receipt = receipt_from(&start); |
| 293 | assert_eq!(receipt["model_id"], json!("deepseek-v4-flash")); |
| 294 | assert!(!manager.read().await.list_filtered(true).is_empty()); |
| 295 | cancel_started(&manager, &start).await; |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn issue_5305_builtin_inheritance_and_redaction_are_bounded() { |
| 300 | let request = |
| 301 | parse_spawn_request(&json!({"prompt":"x", "type":"consultant"})).expect("request"); |
| 302 | let mut runtime = stub_runtime(); |
| 303 | runtime.model = "deepseek-v4-flash".to_string(); |
| 304 | let requested_route = RequestedChildRoute { |
| 305 | requested_type: "consultant".to_string(), |
| 306 | requested_profile: None, |
| 307 | requested_reasoning: "inherit".to_string(), |
| 308 | }; |
| 309 | let receipt = mint_child_route_receipt( |
| 310 | &requested_route, |
| 311 | &request, |
| 312 | None, |
| 313 | &runtime, |
| 314 | "deepseek-v4-flash".to_string(), |
| 315 | "run.model", |
| 316 | None, |
| 317 | ) |
| 318 | .expect("bounded receipt"); |
| 319 | let encoded = serde_json::to_string(&receipt).expect("receipt json"); |
| 320 | assert!(encoded.len() <= CHILD_ROUTE_RECEIPT_MAX_BYTES); |
| 321 | assert_eq!(receipt.resolved_profile_id, None); |
| 322 | assert_eq!(receipt.profile_origin, None); |
| 323 | assert_eq!(receipt.canonical_role, "advisor"); |
| 324 | assert_eq!(receipt.route_source, "run.model"); |
| 325 | for forbidden in ["test-key", "127.0.0.1", "codewhale-test-stub", "/"] { |
| 326 | assert!( |
| 327 | !encoded.contains(forbidden), |
| 328 | "receipt leaked {forbidden}: {encoded}" |
| 329 | ); |
| 330 | } |
| 331 | // #5529 mode 2: the fallback note rides the receipt inside the same |
| 332 | // byte ceiling. |
| 333 | let fallback = mint_child_route_receipt( |
| 334 | &requested_route, |
| 335 | &request, |
| 336 | None, |
| 337 | &runtime, |
| 338 | "deepseek-v4-flash".to_string(), |
| 339 | "session.fallback", |
| 340 | Some("pinned provider 'xai' unavailable (no credentials); fell back to the session route"), |
| 341 | ) |
| 342 | .expect("bounded receipt"); |
| 343 | assert_eq!( |
| 344 | fallback.fallback_note.as_deref(), |
| 345 | Some("pinned provider 'xai' unavailable (no credentials); fell back to the session route") |
| 346 | ); |
| 347 | let fallback_encoded = serde_json::to_string(&fallback).expect("receipt json"); |
| 348 | assert!(fallback_encoded.len() <= CHILD_ROUTE_RECEIPT_MAX_BYTES); |
| 349 | } |
| 350 | |
| 351 | #[tokio::test] |
| 352 | async fn issue_5305_receipt_survives_ledger_interruption_completion_and_resume() { |
| 353 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 354 | let manager = new_shared_subagent_manager(workspace.path().to_path_buf(), 4); |
| 355 | let receipt = ChildRouteReceipt { |
| 356 | requested_type: "consultant".to_string(), |
| 357 | requested_profile: None, |
| 358 | resolved_profile_id: Some("consultant".to_string()), |
| 359 | profile_origin: Some("personal".to_string()), |
| 360 | canonical_role: "advisor".to_string(), |
| 361 | provider_id: "openai-codex".to_string(), |
| 362 | model_id: "gpt-5.6-sol".to_string(), |
| 363 | route_source: "agent_profile.model".to_string(), |
| 364 | fallback_note: None, |
| 365 | requested_reasoning: "inherit".to_string(), |
| 366 | effective_reasoning: Some("high".to_string()), |
| 367 | runtime_version: "test".to_string(), |
| 368 | runtime_build_sha: "test-build".to_string(), |
| 369 | }; |
| 370 | let agent_id = { |
| 371 | let mut guard = manager.write().await; |
| 372 | let (agent_id, _) = guard.insert_test_interrupted_continuable_agent( |
| 373 | "receipt-child", |
| 374 | workspace.path(), |
| 375 | vec![text_message("assistant", "checkpointed work")], |
| 376 | ); |
| 377 | guard |
| 378 | .worker_records |
| 379 | .get_mut(&agent_id) |
| 380 | .expect("worker record") |
| 381 | .spec |
| 382 | .child_route = Some(receipt.clone()); |
| 383 | let ledger = guard |
| 384 | .coordination_summary_for(&agent_id, 4) |
| 385 | .expect("ledger projection"); |
| 386 | assert_eq!(ledger.child_route, Some(receipt.clone())); |
| 387 | agent_id |
| 388 | }; |
| 389 | |
| 390 | let context = ToolContext::new(workspace.path().to_path_buf()); |
| 391 | let interrupt = AgentsInterruptTool::new(manager.clone()) |
| 392 | .execute( |
| 393 | json!({"agent_id": agent_id, "reason": "pause for review"}), |
| 394 | &context, |
| 395 | ) |
| 396 | .await |
| 397 | .expect("already interrupted child projects its receipt"); |
| 398 | let interrupt_json: serde_json::Value = |
| 399 | serde_json::from_str(&interrupt.content).expect("interrupt json"); |
| 400 | assert_eq!(interrupt_json["child_route"], json!(receipt)); |
| 401 | |
| 402 | let interrupted = manager |
| 403 | .read() |
| 404 | .await |
| 405 | .get_result(&agent_id) |
| 406 | .expect("interrupted snapshot"); |
| 407 | assert_eq!(interrupted.child_route.as_ref(), Some(&receipt)); |
| 408 | let completion = subagent_completion_from_result(&interrupted); |
| 409 | assert!(completion.payload.contains("gpt-5.6-sol")); |
| 410 | |
| 411 | // #6046: resume rebinds the receipt's provider pin, so the runtime must |
| 412 | // carry a config the pinned openai-codex client can be built from |
| 413 | // hermetically (api-key table, no machine-local OAuth consent), exactly |
| 414 | // like the fresh-spawn fixtures in this file. |
| 415 | let runtime = consultant_runtime(workspace.path(), manager.clone()); |
| 416 | let resumed = { |
| 417 | let mut guard = manager.write().await; |
| 418 | guard |
| 419 | .resume_from_checkpoint(manager.clone(), runtime, &agent_id, "continue") |
| 420 | .expect("resume preserves receipt") |
| 421 | }; |
| 422 | assert_ne!(resumed.agent_id, agent_id); |
| 423 | assert_eq!(resumed.child_route.as_ref(), Some(&receipt)); |
| 424 | manager |
| 425 | .write() |
| 426 | .await |
| 427 | .cancel_agent(&resumed.agent_id) |
| 428 | .expect("cancel resumed test child"); |
| 429 | } |
| 430 |