diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 19c6c6a3d..1b8305f94 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -546,18 +546,27 @@ impl RunProjectionReducer for RunProjection { stage.state = StageState::from(outcome); } 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, stored.parent_session_id.as_deref()); + 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(()); }; @@ -681,25 +690,21 @@ impl RunProjectionReducer for RunProjection { } } -fn apply_todo_created( - stage: &mut StageProjection, - props: &TodoCreatedProps, - parent_session_id: Option<&str>, -) { - // OpenAI plan lists are scoped per agent session (`openai_plan:`). - // A child subagent must not displace a different list already in the slot - // (which would clobber the root agent's plan); but it may create the slot - // when empty, or extend its own list. Anthropic task lists are root-scoped - // and pass through unchanged. - if matches!(props.list_kind, TodoListKind::OpenAiPlan) - && parent_session_id.is_some() - && stage - .todos - .as_ref() - .is_some_and(|list| list.list_id != props.list_id) - { - return; - } +/// 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 child OpenAI plan +/// events do not belong in this projection. 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 .as_ref() @@ -4712,50 +4717,35 @@ mod tests { } #[test] - fn child_openai_plan_projects_when_no_root_plan_exists() { - // Regression: when a stage's root agent never calls update_plan but - // a subagent does, the subagent's plan must still surface in - // StageProjection.todos. Observed on run 01KSDXK5DJ61CFCK9YSDR8AETQ - // (implement@1), where a gpt-5.5 root delegated to a subagent that - // owned the only plan list — and the sidebar showed no todos. + fn child_openai_plan_does_not_project_when_root_has_no_plan() { let mut state = initialized_projection(); let stage_id = stage_id(); - let child_list = "openai_plan:child_session"; state - .apply_event(&child_stage_event( + .apply_event(&test_stage_event( 1, - created(child_list, TodoListKind::OpenAiPlan, "c-a", 0, "first"), + EventBody::StageStarted(started_props()), stage_id.clone(), )) .unwrap(); state .apply_event(&child_stage_event( 2, - created(child_list, TodoListKind::OpenAiPlan, "c-b", 1, "second"), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 3, - updated_status( - child_list, + created( + "openai_plan:child_session", TodoListKind::OpenAiPlan, "c-a", - TodoStatus::Completed, + 0, + "child work", ), stage_id.clone(), )) .unwrap(); - let projection = stage_todos(&state, &stage_id); - assert_eq!(projection.list_id, child_list); - assert_eq!(projection.kind, TodoListKind::OpenAiPlan); - assert_eq!(projection.items.len(), 2); - assert_eq!(projection.items[0].id, "c-a"); - assert_eq!(projection.items[0].status, TodoStatus::Completed); - assert_eq!(projection.items[1].id, "c-b"); - assert_eq!(projection.items[1].status, TodoStatus::Pending); + let stage = state.stage(&stage_id).expect("stage projection present"); + assert!( + stage.todos.is_none(), + "a child session's plan must not become the stage's root plan" + ); } #[test]