1use crate::exec::events::{
3 CommandActivity, ExecutionContext, InputOrigin, ItemContext, ThreadEvent, ThreadItemDetails,
4};
5use crate::tools::tool_intent::{ShellActivity, classify_shell_activity};
6use std::collections::VecDeque;
7
8const MAX_TRACKED_EXEC_LAUNCHES: usize = 256;
9const MAX_EXEC_ID_BYTES: usize = 256;
10
11struct ExecSessionLaunch {
12 session_id: Option<String>,
13 call_item_id: String,
14 tool_call_id: Option<String>,
15 context: Box<ItemContext>,
16}
17
18pub fn validate_decision_input(args: serde_json::Value) -> anyhow::Result<crate::exec::events::DecisionItem> {
20 use anyhow::{Context, bail};
21 crate::tools::output_limits::max_output_tokens(&args)?;
22 let args = crate::tools::output_limits::args_without_output_metadata(&args);
23 if args.as_object().is_none_or(|o| {
24 o.keys()
25 .any(|k| !matches!(k.as_str(), "summary" | "rationale" | "alternatives" | "evidence_ids"))
26 }) {
27 bail!("invalid decision fields");
28 }
29 let d: crate::exec::events::DecisionItem = serde_json::from_value(args).context("invalid public decision")?;
30 if d.summary.trim().is_empty()
31 || d.summary.chars().count() > 240
32 || d.rationale.trim().is_empty()
33 || d.rationale.chars().count() > 1000
34 || d.alternatives.len() > 3
35 || d.evidence_ids.len() > 8
36 || d.alternatives.iter().any(|a| a.chars().count() > 1000)
37 || d.evidence_ids.iter().any(|id| id.is_empty() || id.chars().count() > 240)
38 {
39 bail!("public decision exceeds input bounds");
40 }
41 Ok(d)
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47 use serde_json::json;
48
49 fn track_launch(tracker: &mut ExecutionContextTracker, item: &str, session: &str) {
50 let args = json!({"cmd":"cargo check --locked"});
51 let mut started = super::super::tool_started_event(item.into(), "exec_command", Some(&args), Some(item));
52 tracker.annotate(&mut started);
53 let pending = tracker
54 .exec_session_output_event(item, "exec_command", &args, &json!({"session_id":session}))
55 .unwrap();
56 let ThreadEvent::ItemCompleted(completed) = pending else {
57 panic!("canonical output expected")
58 };
59 let ThreadItemDetails::ToolOutput(output) = completed.item.details else {
60 panic!("tool output expected")
61 };
62 assert_eq!(output.call_id, item);
63 assert_eq!(output.status, crate::exec::events::ToolCallStatus::InProgress);
64 }
65
66 fn background(session: &str, task: &str, exit_code: Option<i32>) -> ThreadEvent {
67 ThreadEvent::ItemCompleted(crate::exec::events::ItemCompletedEvent { item: crate::exec::events::ThreadItem {
68 id: "background".into(), context: None,
69 details: ThreadItemDetails::Harness(Box::new(serde_json::from_value(json!({
70 "event":crate::exec::events::HarnessEventKind::BackgroundSubprocessCompleted,
71 "task_id":task,"session_id":session,"exec_session_id":session,"exit_code":exit_code,"message":"observed exit",
72 })).unwrap())),
73 }})
74 }
75
76 #[test]
77 fn native_delegation_status_is_captured_only_for_its_recorded_parent() {
78 let mut tracker = ExecutionContextTracker::default();
79 let current = tracker.begin("parent", "turn", "Delegate", InputOrigin::User);
80 let output = json!({
81 "id":"agent-1", "session_id":"child", "parent_thread_id":"parent", "agent_name":"reviewer", "display_label":"Review", "description":"Check storage", "source":"builtin", "status":"running", "background":true, "depth":1, "created_at":"2026-10-03T00:00:00Z", "updated_at":"2026-10-03T00:00:01Z",
82 });
83 let event = tracker.delegation_status_event("call", "agent", &output).unwrap();
84 let ThreadEvent::ItemCompleted(completed) = event else {
85 panic!("status event expected")
86 };
87 let context = completed.item.context.unwrap();
88 assert_eq!(context.task_id, current.task_id);
89 assert_eq!(context.actor_id, "child");
90 assert_eq!(context.parent_actor_id.as_deref(), Some("parent"));
91 let ThreadItemDetails::Harness(status) = completed.item.details else {
92 panic!("harness status expected")
93 };
94 assert_eq!(status.status.as_deref(), Some("running"));
95 assert!(tracker.delegation_status_event("call", "exec_command", &output).is_none());
96 assert!(
97 tracker
98 .delegation_status_event("call", "agent", &json!({"session_id":"child"}))
99 .is_none()
100 );
101 let mut unrelated = output.clone();
102 unrelated["parent_thread_id"] = json!("unrelated");
103 assert!(tracker.delegation_status_event("call", "agent", &unrelated).is_none());
104 assert!(
105 tracker
106 .delegation_status_event("wait", "agent", &json!({"completed":true,"entry":output}))
107 .is_some()
108 );
109 }
110
111 #[test]
112 fn exec_session_mapping_is_bounded_and_isolates_actors_and_unrelated_sessions() {
113 let mut tracker = ExecutionContextTracker::default();
114 let original = tracker.begin("root", "turn-1", "Verify", InputOrigin::User);
115 for index in 0..=MAX_TRACKED_EXEC_LAUNCHES {
116 track_launch(&mut tracker, &format!("launch-{index}"), &format!("session-{index}"));
117 }
118 assert_eq!(tracker.exec_launches.len(), MAX_TRACKED_EXEC_LAUNCHES);
119 assert!(
120 tracker
121 .exec_session_output_event(
122 "poll",
123 "write_stdin",
124 &json!({"session_id":"session-0","chars":""}),
125 &json!({"exit_code":0})
126 )
127 .is_none()
128 );
129 assert!(
130 tracker
131 .exec_session_output_event(
132 "poll",
133 "write_stdin",
134 &json!({"session_id":"unknown","chars":""}),
135 &json!({"exit_code":0})
136 )
137 .is_none()
138 );
139 tracker.begin("other-actor", "turn-2", "Unrelated actor", InputOrigin::User);
140 assert!(
141 tracker
142 .exec_session_output_event(
143 "poll",
144 "write_stdin",
145 &json!({"session_id":"session-1","chars":""}),
146 &json!({"exit_code":0})
147 )
148 .is_none()
149 );
150 tracker.begin("root", "turn-3", "Another task", InputOrigin::User);
151 let completed = tracker
152 .exec_session_output_event(
153 "poll",
154 "write_stdin",
155 &json!({"session_id":"session-1","chars":""}),
156 &json!({"exit_code":0}),
157 )
158 .unwrap();
159 let ThreadEvent::ItemCompleted(completed) = completed else {
160 panic!("completion expected")
161 };
162 let context = completed.item.context.unwrap();
163 assert_eq!(context.task_id, original.task_id);
164 assert_eq!(context.turn_id, "turn-1");
165 assert_eq!(context.actor_id, "root");
166 assert_eq!(tracker.exec_launches.len(), MAX_TRACKED_EXEC_LAUNCHES - 1);
167 }
168
169 #[test]
170 fn raw_background_exit_correlates_to_the_original_verifier_only() {
171 let mut tracker = ExecutionContextTracker::default();
172 let original = tracker.begin("root", "turn", "Verify", InputOrigin::User);
173 track_launch(&mut tracker, "original-call", "session");
174 assert!(
175 tracker
176 .background_exec_output_event(&mut background("other", "exec:other", Some(0)))
177 .is_none()
178 );
179 assert!(
180 tracker
181 .background_exec_output_event(&mut background("session", "managed-task", Some(0)))
182 .is_none()
183 );
184 let pending = tracker
185 .background_exec_output_event(&mut background("session", "exec:session", None))
186 .unwrap();
187 let ThreadEvent::ItemCompleted(pending) = pending else {
188 panic!("pending output expected")
189 };
190 let ThreadItemDetails::ToolOutput(output) = pending.item.details else {
191 panic!("tool output expected")
192 };
193 assert_eq!(output.status, crate::exec::events::ToolCallStatus::InProgress);
194 tracker.begin("root", "new-turn", "Another task", InputOrigin::User);
195 let mut raw = background("session", "exec:session", Some(7));
196 tracker.annotate(&mut raw);
197 let terminal = tracker.background_exec_output_event(&mut raw).unwrap();
198 tracker.annotate(&mut raw);
199 let ThreadEvent::ItemCompleted(raw) = raw else {
200 panic!("raw completion expected")
201 };
202 assert_eq!(raw.item.context.unwrap().task_id, original.task_id);
203 assert_eq!(tracker.current.as_ref().unwrap().turn_id, "new-turn");
204 let ThreadEvent::ItemCompleted(terminal) = terminal else {
205 panic!("terminal output expected")
206 };
207 assert_eq!(terminal.item.context.unwrap().task_id, original.task_id);
208 let ThreadItemDetails::ToolOutput(output) = terminal.item.details else {
209 panic!("tool output expected")
210 };
211 assert_eq!(output.call_id, "original-call");
212 assert_eq!(output.status, crate::exec::events::ToolCallStatus::Failed);
213 assert_eq!(output.exit_code, Some(7));
214 assert!(
215 tracker
216 .background_exec_output_event(&mut background("session", "exec:session", Some(0)))
217 .is_none()
218 );
219 }
220
221 #[test]
222 fn execution_task_identity_survives_corrections_and_handoffs() {
223 let mut tracker = ExecutionContextTracker::default();
224 let first = tracker.begin("root", "turn-1", "Fix login", InputOrigin::User);
225 for (turn, origin) in [
226 ("turn-2", InputOrigin::Correction),
227 ("turn-3", InputOrigin::PlanApproval),
228 ("turn-4", InputOrigin::Continuation),
229 ("turn-5", InputOrigin::Retry),
230 ] {
231 let current = tracker.begin("root", turn, "changed input", origin);
232 assert_eq!(current.task_id, first.task_id);
233 assert_eq!(current.goal.as_deref(), Some("Fix login"));
234 assert_eq!(current.turn_id, turn);
235 }
236 let next = tracker.begin("root", "turn-6", "Add logging", InputOrigin::User);
237 assert_ne!(next.task_id, first.task_id);
238 assert_eq!(next.goal.as_deref(), Some("Add logging"));
239 }
240
241 #[test]
242 fn public_decision_bounds_are_validated_in_characters_and_fail_closed() {
243 assert!(validate_decision_input(json!({"summary":"é".repeat(240),"rationale":"r".repeat(1000),"alternatives":["a","b","c"],"evidence_ids":vec!["id";8]})).is_ok());
244 for invalid in [
245 json!({"summary":"a".repeat(241),"rationale":"why"}),
246 json!({"summary":"choice","rationale":"a".repeat(1001)}),
247 json!({"summary":"choice","rationale":"why","alternatives":["a","b","c","d"]}),
248 json!({"summary":"choice","rationale":"why","evidence_ids":[""]}),
249 json!({"summary":"choice","rationale":"why","private_reasoning":"hidden"}),
250 json!({"summary":" ","rationale":"why"}),
251 ] {
252 assert!(validate_decision_input(invalid).is_err());
253 }
254 }
255
256 #[test]
257 fn public_decision_accepts_valid_common_output_metadata() {
258 let args = json!({"summary":"Reuse parser","rationale":"Preserve validation","max_output_tokens":100});
259 let decision = validate_decision_input(args.clone()).unwrap();
260 assert_eq!(decision.summary, "Reuse parser");
261 let event = super::super::tool_invocation_completed_event(
262 "decision-call".into(),
263 "record_decision",
264 Some(&args),
265 None,
266 crate::exec::events::ToolCallStatus::Completed,
267 crate::exec::events::ToolOutcome::Success,
268 );
269 assert!(decision_completed_event(&event).is_some());
270 for invalid in [json!(0), json!(50_001), json!("100"), json!(1.5)] {
271 let mut args = args.clone();
272 args["max_output_tokens"] = invalid;
273 assert!(validate_decision_input(args).is_err());
274 }
275 }
276}
277
278pub fn decision_completed_event(event: &ThreadEvent) -> Option<ThreadEvent> {
280 let ThreadEvent::ItemCompleted(e) = event else {
281 return None;
282 };
283 let ThreadItemDetails::ToolInvocation(t) = &e.item.details else {
284 return None;
285 };
286 if t.tool_name != crate::config::constants::tools::RECORD_DECISION
287 || t.status != crate::exec::events::ToolCallStatus::Completed
288 || t.outcome != Some(crate::exec::events::ToolOutcome::Success)
289 {
290 return None;
291 }
292 let decision = validate_decision_input(t.arguments.clone()?).ok()?;
293 Some(ThreadEvent::ItemCompleted(crate::exec::events::ItemCompletedEvent {
294 item: crate::exec::events::ThreadItem {
295 id: format!("{}:decision", e.item.id),
296 context: e.item.context.clone(),
297 details: ThreadItemDetails::Decision(Box::new(decision)),
298 },
299 }))
300}
301
302#[derive(Default)]
303pub struct ExecutionContextTracker {
304 current: Option<ExecutionContext>,
305 exec_launches: VecDeque<ExecSessionLaunch>,
306}
307
308impl ExecutionContextTracker {
309 pub fn delegation_status_event(
311 &self,
312 call_item_id: &str,
313 tool_name: &str,
314 output: &serde_json::Value,
315 ) -> Option<ThreadEvent> {
316 use crate::config::constants::tools;
317 use crate::exec::events::{HarnessEventItem, HarnessEventKind, ItemCompletedEvent, ThreadItem};
318 if !matches!(
319 tool_name,
320 tools::AGENT
321 | tools::SPAWN_AGENT
322 | tools::WAIT_AGENT
323 | tools::RESUME_AGENT
324 | tools::CLOSE_AGENT
325 | tools::SEND_INPUT
326 ) {
327 return None;
328 }
329 let value = output.get("entry").unwrap_or(output);
330 let entry: crate::subagents::SubagentStatusEntry = serde_json::from_value(value.clone()).ok()?;
331 let current = self.current.as_ref()?;
332 if entry.session_id.is_empty()
333 || entry.session_id.len() > MAX_EXEC_ID_BYTES
334 || entry.parent_thread_id != current.actor_id
335 {
336 return None;
337 }
338 let item: HarnessEventItem = serde_json::from_value(serde_json::json!({
339 "event": HarnessEventKind::DelegatedAgentStatus,
340 "message": format!("Delegated {}: {}", entry.display_label, entry.description),
341 "task_id": entry.id,
342 "session_id": entry.session_id,
343 "status": entry.status.as_str(),
344 }))
345 .ok()?;
346 Some(ThreadEvent::ItemCompleted(ItemCompletedEvent {
347 item: ThreadItem {
348 id: format!("{call_item_id}:delegated-agent"),
349 context: Some(Box::new(ItemContext {
350 task_id: current.task_id.clone(),
351 turn_id: current.turn_id.clone(),
352 actor_id: entry.session_id,
353 parent_actor_id: Some(entry.parent_thread_id),
354 timestamp: entry.updated_at.to_rfc3339(),
355 activity: None,
356 })),
357 details: ThreadItemDetails::Harness(Box::new(item)),
358 },
359 }))
360 }
361
362 pub fn begin(&mut self, actor: &str, turn: &str, input: &str, origin: InputOrigin) -> ExecutionContext {
364 let previous = self.current.as_ref().filter(|_| origin != InputOrigin::User);
365 let context = ExecutionContext {
366 task_id: previous.map_or_else(|| format!("task-{}", uuid::Uuid::new_v4()), |c| c.task_id.clone()),
367 turn_id: turn.to_owned(),
368 actor_id: actor.to_owned(),
369 parent_actor_id: None,
370 origin,
371 timestamp: chrono::Utc::now().to_rfc3339(),
372 goal: previous.and_then(|c| c.goal.clone()).or_else(|| Some(input.to_owned())),
373 };
374 self.current = Some(context.clone());
375 context
376 }
377
378 pub fn annotate(&mut self, event: &mut ThreadEvent) {
380 let now = chrono::Utc::now().to_rfc3339();
381 match event {
382 ThreadEvent::TurnStarted(e) => {
383 if let Some(context) = e.context.as_deref() {
384 self.current = Some(context.clone());
385 } else {
386 e.context = self.current.clone().map(Box::new);
387 }
388 }
389 ThreadEvent::TurnCompleted(e) => {
390 e.completed_at.get_or_insert_with(|| Box::new(now));
391 }
392 ThreadEvent::TurnFailed(e) => {
393 e.completed_at.get_or_insert_with(|| Box::new(now));
394 }
395 ThreadEvent::ThreadCompleted(e) => {
396 e.completed_at.get_or_insert(now);
397 }
398 ThreadEvent::TurnBlocked(e) => {
399 e.completed_at.get_or_insert(now);
400 }
401 ThreadEvent::ItemStarted(e) => self.annotate_item(&mut e.item, now),
402 ThreadEvent::ItemUpdated(e) => self.annotate_item(&mut e.item, now),
403 ThreadEvent::ItemCompleted(e) => self.annotate_item(&mut e.item, now),
404 _ => {}
405 }
406 }
407
408 fn annotate_item(&mut self, item: &mut crate::exec::events::ThreadItem, timestamp: String) {
409 let Some(c) = &self.current else {
410 return;
411 };
412 let activity = match &item.details {
413 ThreadItemDetails::ToolInvocation(t) => t
414 .arguments
415 .as_ref()
416 .filter(|_| crate::tools::tool_intent::is_command_session_tool(&t.tool_name))
417 .map(|args| classify_shell_activity(&t.tool_name, args)),
418 ThreadItemDetails::CommandExecution(t) => {
419 Some(classify_shell_activity("exec_command", &serde_json::json!({"cmd": t.command})))
420 }
421 _ => None,
422 }
423 .map(|a| match a {
424 ShellActivity::Inspection => CommandActivity::Inspection,
425 ShellActivity::Verification => CommandActivity::Verification,
426 ShellActivity::Mutation => CommandActivity::Mutation,
427 });
428 item.context.get_or_insert_with(|| {
429 Box::new(ItemContext {
430 task_id: c.task_id.clone(),
431 turn_id: c.turn_id.clone(),
432 actor_id: c.actor_id.clone(),
433 parent_actor_id: c.parent_actor_id.clone(),
434 timestamp,
435 activity,
436 })
437 });
438 let ThreadItemDetails::ToolInvocation(invocation) = &item.details else {
439 return;
440 };
441 let Some(args) = invocation.arguments.as_ref() else {
442 return;
443 };
444 let Some(context) = item.context.as_ref() else { return };
445 if invocation.status == crate::exec::events::ToolCallStatus::Failed {
446 self.exec_launches.retain(|launch| {
447 launch.call_item_id != item.id
448 || launch.context.task_id != context.task_id
449 || launch.context.actor_id != context.actor_id
450 });
451 return;
452 }
453 if context.activity != Some(CommandActivity::Verification)
454 || !crate::tools::tool_intent::is_command_run_tool_call(&invocation.tool_name, args)
455 || item.id.len() > MAX_EXEC_ID_BYTES
456 || invocation.tool_call_id.as_ref().is_some_and(|id| id.len() > MAX_EXEC_ID_BYTES)
457 || [
458 &context.task_id,
459 &context.turn_id,
460 &context.actor_id,
461 &context.timestamp,
462 ]
463 .into_iter()
464 .any(|id| id.len() > MAX_EXEC_ID_BYTES)
465 || context.parent_actor_id.as_ref().is_some_and(|id| id.len() > MAX_EXEC_ID_BYTES)
466 || self.exec_launches.iter().any(|launch| {
467 launch.call_item_id == item.id
468 && launch.context.task_id == context.task_id
469 && launch.context.actor_id == context.actor_id
470 })
471 {
472 return;
473 }
474 if self.exec_launches.len() == MAX_TRACKED_EXEC_LAUNCHES {
475 self.exec_launches.pop_front();
476 }
477 self.exec_launches.push_back(ExecSessionLaunch {
478 session_id: None,
479 call_item_id: item.id.clone(),
480 tool_call_id: invocation.tool_call_id.clone(),
481 context: context.clone(),
482 });
483 }
484
485 pub fn exec_session_output_event(
488 &mut self,
489 call_item_id: &str,
490 tool_name: &str,
491 args: &serde_json::Value,
492 output: &serde_json::Value,
493 ) -> Option<ThreadEvent> {
494 crate::tools::tool_intent::canonical_command_session_tool_name(tool_name)?;
495 let current = self.current.as_ref()?;
496 let is_launch = crate::tools::tool_intent::is_command_run_tool_call(tool_name, args);
497 let response_session = crate::tools::command_args::session_id_text(output);
498 let requested_session = crate::tools::command_args::session_id_text(args);
499 if response_session.is_some_and(|id| id.len() > MAX_EXEC_ID_BYTES)
500 || (!is_launch && response_session.is_some() && response_session != requested_session)
501 {
502 return None;
503 }
504 let position = self.exec_launches.iter().position(|launch| {
505 launch.context.actor_id == current.actor_id
506 && if is_launch {
507 launch.context.task_id == current.task_id && launch.call_item_id == call_item_id
508 } else {
509 requested_session.is_some() && launch.session_id.as_deref() == requested_session
510 }
511 })?;
512 let exit_code = output
513 .get("exit_code")
514 .and_then(serde_json::Value::as_i64)
515 .and_then(|code| i32::try_from(code).ok());
516 if exit_code == Some(0)
517 && (output.get("success").and_then(serde_json::Value::as_bool) == Some(false)
518 || output.get("error").is_some_and(|error| !error.is_null()))
519 {
520 return None;
521 }
522 if is_launch {
523 if exit_code.is_some() || response_session.is_none() {
524 self.exec_launches.remove(position);
525 return None;
526 }
527 self.exec_launches.get_mut(position)?.session_id = response_session.map(str::to_owned);
528 }
529 let payload = super::tool_output_payload_from_value(output);
530 self.exec_session_completion_event(
531 position,
532 exit_code,
533 payload.spool_path.as_deref(),
534 payload.aggregated_output,
535 )
536 }
537
538 pub fn background_exec_output_event(&mut self, event: &mut ThreadEvent) -> Option<ThreadEvent> {
540 let ThreadEvent::ItemCompleted(completed) = event else {
541 return None;
542 };
543 let ThreadItemDetails::Harness(harness) = &completed.item.details else {
544 return None;
545 };
546 if harness.event != crate::exec::events::HarnessEventKind::BackgroundSubprocessCompleted {
547 return None;
548 }
549 let session_id = harness.exec_session_id.as_deref()?;
550 if harness.task_id.as_deref() != Some(format!("exec:{session_id}").as_str())
553 || harness.session_id.as_deref() != Some(session_id)
554 {
555 return None;
556 }
557 let current = self.current.as_ref()?;
558 let position = self.exec_launches.iter().position(|launch| {
559 launch.context.actor_id == current.actor_id && launch.session_id.as_deref() == Some(session_id)
560 })?;
561 let output = self.exec_session_completion_event(
562 position,
563 harness.exit_code,
564 None,
565 harness.message.clone().unwrap_or_default(),
566 )?;
567 if let ThreadEvent::ItemCompleted(output) = &output {
568 completed.item.context = output.item.context.clone();
569 }
570 Some(output)
571 }
572
573 fn exec_session_completion_event(
574 &mut self,
575 position: usize,
576 exit_code: Option<i32>,
577 spool_path: Option<&str>,
578 output: String,
579 ) -> Option<ThreadEvent> {
580 let launch = self.exec_launches.get(position)?;
581 let status = match exit_code {
582 Some(0) => crate::exec::events::ToolCallStatus::Completed,
583 Some(_) => crate::exec::events::ToolCallStatus::Failed,
584 None => crate::exec::events::ToolCallStatus::InProgress,
585 };
586 let mut event = super::tool_output_completed_event(
587 launch.call_item_id.clone(),
588 launch.tool_call_id.as_deref(),
589 status,
590 exit_code,
591 spool_path,
592 output,
593 );
594 if let ThreadEvent::ItemCompleted(completed) = &mut event {
595 completed.item.id = format!("{}:exec-session-output", launch.call_item_id);
596 let mut context = launch.context.clone();
597 context.timestamp = chrono::Utc::now().to_rfc3339();
598 completed.item.context = Some(context);
599 }
600 if exit_code.is_some() {
601 self.exec_launches.remove(position);
602 }
603 Some(event)
604 }
605}