mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
Fix stage TODO projection ownership
This commit is contained in:
parent
b558af070f
commit
5c55b0c074
1 changed files with 37 additions and 47 deletions
|
|
@ -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:<session_id>`).
|
||||
// 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:<session_id>`),
|
||||
/// 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:<root_session_id>`) 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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue