返回 CodeWhale
launch_receipt.rs
根目录 / crates / tui / src / tools / subagent / tests / launch_receipt.rs
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
430 lines RUST