diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 574478c0f..d5854291e 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -15,7 +15,7 @@ use fabro_types::{ RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, - StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, + StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, TodoProjection, WorkflowRef, first_event_seq, }; use fabro_util::error::render_compact_with_causes; @@ -485,18 +485,27 @@ impl RunProjectionReducer for RunProjection { stage.parallel_results = Some(parallel_results); } EventBody::TodoCreated(props) => { + if !should_project_stage_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; apply_todo_created(stage, props); } EventBody::TodoUpdated(props) => { + if !should_project_stage_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; apply_todo_updated(stage, props); } EventBody::TodoDeleted(props) => { + if !should_project_stage_todo_event(stored, props.list_kind) { + return Ok(()); + } let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { return Ok(()); }; @@ -622,6 +631,20 @@ impl RunProjectionReducer for RunProjection { } } +/// Decide whether a TODO event should mutate `StageProjection.todos`. +/// +/// OpenAI plan lists are scoped per agent session (`openai_plan:`), +/// so a child/subagent session emits its own list events on the same stage. +/// The stage sidebar represents the root stage agent, so we drop child OpenAI +/// plan events here to keep the projection on the root session's list. +/// Anthropic task lists are root-scoped (`anthropic_tasks:`) +/// and intentionally shared with subagents, so they always project. +fn should_project_stage_todo_event(stored: &RunEvent, list_kind: TodoListKind) -> bool { + let is_child_openai_plan_event = + matches!(list_kind, TodoListKind::OpenAiPlan) && stored.parent_session_id.is_some(); + !is_child_openai_plan_event +} + fn apply_todo_created(stage: &mut StageProjection, props: &TodoCreatedProps) { if stage .todos @@ -3985,6 +4008,13 @@ mod tests { .expect("stage todos present") } + fn child_stage_event(seq: u32, body: EventBody, stage_id: StageId) -> EventEnvelope { + let mut event = test_stage_event(seq, body, stage_id); + event.event.session_id = Some(format!("child-session-{seq}")); + event.event.parent_session_id = Some("root-session".to_string()); + event + } + fn created( list: &str, list_kind: TodoListKind, @@ -4144,6 +4174,163 @@ mod tests { assert_eq!(stage_todos(&state, &claude).items[0].subject, "claude task"); } + #[test] + fn root_openai_plan_remains_projected_after_child_plan_events() { + let mut state = initialized_projection(); + let stage_id = stage_id(); + let root_list = "openai_plan:root_session"; + let child_list = "openai_plan:child_session"; + state + .apply_event(&test_stage_event( + 1, + created( + root_list, + TodoListKind::OpenAiPlan, + "root-a", + 0, + "root first", + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 2, + created( + root_list, + TodoListKind::OpenAiPlan, + "root-b", + 1, + "root second", + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&child_stage_event( + 3, + created( + child_list, + TodoListKind::OpenAiPlan, + "child-a", + 0, + "child first", + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&child_stage_event( + 4, + created( + child_list, + TodoListKind::OpenAiPlan, + "child-b", + 1, + "child second", + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 5, + updated_status( + root_list, + TodoListKind::OpenAiPlan, + "root-a", + TodoStatus::Completed, + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 6, + updated_status( + root_list, + TodoListKind::OpenAiPlan, + "root-b", + TodoStatus::Completed, + ), + stage_id.clone(), + )) + .unwrap(); + + let projection = stage_todos(&state, &stage_id); + assert_eq!(projection.list_id, root_list); + assert_eq!(projection.kind, TodoListKind::OpenAiPlan); + assert_eq!(projection.items.len(), 2); + assert_eq!(projection.items[0].id, "root-a"); + assert_eq!(projection.items[0].status, TodoStatus::Completed); + assert_eq!(projection.items[1].id, "root-b"); + assert_eq!(projection.items[1].status, TodoStatus::Completed); + } + + #[test] + fn child_openai_plan_events_do_not_create_stage_todos() { + let mut state = initialized_projection(); + let stage_id = stage_id(); + state + .apply_event(&child_stage_event( + 1, + created( + "openai_plan:child_session", + TodoListKind::OpenAiPlan, + "child-a", + 0, + "child first", + ), + stage_id.clone(), + )) + .unwrap(); + + assert!( + state + .stage(&stage_id) + .is_none_or(|stage| stage.todos.is_none()) + ); + } + + #[test] + fn anthropic_child_session_task_events_still_project() { + let mut state = initialized_projection(); + let stage_id = stage_id(); + let list = "anthropic_tasks:root_session"; + state + .apply_event(&child_stage_event( + 1, + created( + list, + TodoListKind::AnthropicTasks, + "task-a", + 0, + "task first", + ), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&child_stage_event( + 2, + updated_status( + list, + TodoListKind::AnthropicTasks, + "task-a", + TodoStatus::Completed, + ), + stage_id.clone(), + )) + .unwrap(); + + let projection = stage_todos(&state, &stage_id); + assert_eq!(projection.list_id, list); + assert_eq!(projection.kind, TodoListKind::AnthropicTasks); + assert_eq!(projection.items.len(), 1); + assert_eq!(projection.items[0].id, "task-a"); + assert_eq!(projection.items[0].status, TodoStatus::Completed); + } + #[test] fn metadata_patch_merges_and_null_deletes() { let mut state = initialized_projection();