Skip to main content

git_internal/internal/object/
plan.rs

1//! AI Plan Definition
2//!
3//! A [`Plan`] is a sequence of [`PlanStep`]s derived from an
4//! [`Intent`](super::intent::Intent)'s analyzed content. It defines
5//! *what* to do — the strategy and decomposition — while
6//! [`Run`](super::run::Run) handles *how* to execute it. The Plan is
7//! step ③ in the end-to-end flow described in [`mod.rs`](super).
8//!
9//! # Position in Lifecycle
10//!
11//! ```text
12//!  ②  Intent (Active)       ← content analyzed
13//!       │
14//!       ├──▶ ContextPipeline ← seeded with IntentAnalysis frame
15//!       │
16//!       ▼
17//!  ③  Plan (pipeline, fwindow, steps)
18//!       │
19//!       ├─ PlanStep₀ (inline)
20//!       ├─ PlanStep₁ ──task──▶ sub-Task (recursive)
21//!       └─ PlanStep₂ (inline)
22//!       │
23//!       ▼
24//!  ④  Task ──runs──▶ Run ──plan──▶ Plan (snapshot reference)
25//! ```
26//!
27//! # Revision Chain
28//!
29//! When the agent encounters obstacles or learns new information, it
30//! creates a revised Plan via [`new_revision`](Plan::new_revision).
31//! Each revision links back to its predecessor via `previous`, forming
32//! a singly-linked revision chain. The [`Intent`](super::intent::Intent)
33//! always points to the **latest** revision:
34//!
35//! ```text
36//! Intent.plan ──▶ Plan_v3 (latest)
37//!                   │ previous
38//!                   ▼
39//!                 Plan_v2
40//!                   │ previous
41//!                   ▼
42//!                 Plan_v1 (original, previous = None)
43//! ```
44//!
45//! Each [`Run`](super::run::Run) records the specific Plan version it
46//! executed via a **snapshot reference** (`Run.plan`), which never
47//! changes after creation.
48//!
49//! # Context Range
50//!
51//! A Plan references a [`ContextPipeline`](super::pipeline::ContextPipeline)
52//! via `pipeline` and records the visible frame range `fwindow = (start,
53//! end)` — the half-open range `[start..end)` of frames that were
54//! visible when this Plan was created. This enables retrospective
55//! analysis: given the context the agent saw, was the plan a reasonable
56//! decomposition?
57//!
58//! ```text
59//! ContextPipeline.frames:  [F₀, F₁, F₂, F₃, F₄, F₅, ...]
60//!                           ^^^^^^^^^^^^^^^^
61//!                           fwindow = (0, 4)
62//! ```
63//!
64//! When replanning occurs, a new Plan is created with an updated frame
65//! range that includes frames accumulated since the previous plan.
66//!
67//! # Steps
68//!
69//! Each [`PlanStep`] has a `description` (what to do) and a status
70//! history (`statuses`) tracking every lifecycle transition with
71//! timestamps and optional reasons, following the same append-only
72//! pattern used by [`Intent`](super::intent::Intent).
73//!
74//! ## Step Context Tracking
75//!
76//! Each step tracks its relationship to pipeline frames via two
77//! ID vectors:
78//!
79//! - `iframes` — stable `frame_id`s of frames the step **consumed**
80//!   as context.
81//! - `oframes` — stable `frame_id`s of frames the step **produced**
82//!   (e.g. `StepSummary`, `CodeChange`).
83//!
84//! Frame IDs are monotonic integers assigned by
85//! [`ContextPipeline::push_frame`](super::pipeline::ContextPipeline::push_frame).
86//! Unlike Vec indices, IDs survive eviction — a step's `iframes`
87//! remain valid even after older frames are evicted from the pipeline.
88//! Look up frames via
89//! [`ContextPipeline::frame_by_id`](super::pipeline::ContextPipeline::frame_by_id).
90//!
91//! All context association is owned by the step side;
92//! [`ContextFrame`](super::pipeline::ContextFrame) itself is a passive
93//! data record with no back-references.
94//!
95//! ```text
96//! ContextPipeline.frames:  [F₀, F₁, F₂, F₃, F₄, F₅]
97//!                            │    │         ▲
98//!                            ╰────╯         │
99//!                         iframes=[0,1]  oframes=[4]
100//!                              ╰── Step₀ ──╯
101//! ```
102//!
103//! ## Recursive Decomposition
104//!
105//! A step can optionally spawn a sub-[`Task`](super::task::Task) via
106//! its `task` field. When set, the step delegates execution to an
107//! independent Task with its own Run / Intent / Plan lifecycle,
108//! enabling recursive work breakdown:
109//!
110//! ```text
111//! Plan
112//!  ├─ Step₀  (inline — executed by current Run)
113//!  ├─ Step₁  ──task──▶ Task₁
114//!  │                     └─ Run → Plan
115//!  │                          ├─ Step₁₋₀
116//!  │                          └─ Step₁₋₁
117//!  └─ Step₂  (inline)
118//! ```
119//!
120//! # Purpose
121//!
122//! - **Decomposition**: Breaks a complex Intent into manageable,
123//!   ordered steps that an agent can execute sequentially.
124//! - **Context Scoping**: `pipeline` + `fwindow` record exactly what
125//!   context the Plan was derived from. Step-level `iframes`/`oframes`
126//!   track fine-grained context flow.
127//! - **Versioning**: The `previous` revision chain preserves the full
128//!   planning history, enabling comparison of strategies across
129//!   attempts.
130//! - **Recursive Delegation**: Steps can spawn sub-Tasks for complex
131//!   sub-problems, enabling divide-and-conquer workflows.
132
133use std::fmt;
134
135use chrono::{DateTime, Utc};
136use serde::{Deserialize, Serialize};
137use uuid::Uuid;
138
139use crate::{
140    errors::GitError,
141    hash::ObjectHash,
142    internal::object::{
143        ObjectTrait,
144        types::{ActorRef, Header, ObjectType},
145    },
146};
147
148/// Lifecycle status of a [`PlanStep`].
149///
150/// Valid transitions:
151/// ```text
152/// Pending ──▶ Progressing ──▶ Completed
153///   │             │
154///   ├─────────────┴──▶ Failed
155///   └──────────────────▶ Skipped
156/// ```
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "snake_case")]
159pub enum StepStatus {
160    /// Step is waiting to be executed. Initial state.
161    Pending,
162    /// Step is currently being executed by the agent.
163    Progressing,
164    /// Step finished successfully. Outputs and `oframes` should be set.
165    Completed,
166    /// Step encountered an unrecoverable error. A reason should be
167    /// recorded in the [`StepStatusEntry`] that carries this status.
168    Failed,
169    /// Step was skipped (e.g. no longer necessary after replanning,
170    /// or pre-condition not met). Not an error — the Plan continues.
171    Skipped,
172}
173
174impl StepStatus {
175    pub fn as_str(&self) -> &'static str {
176        match self {
177            StepStatus::Pending => "pending",
178            StepStatus::Progressing => "progressing",
179            StepStatus::Completed => "completed",
180            StepStatus::Failed => "failed",
181            StepStatus::Skipped => "skipped",
182        }
183    }
184}
185
186impl fmt::Display for StepStatus {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(f, "{}", self.as_str())
189    }
190}
191
192/// A single entry in a step's status history.
193///
194/// Mirrors [`StatusEntry`](super::intent::StatusEntry) in Intent.
195/// Each transition appends a new entry; entries are never removed
196/// or mutated, forming an append-only audit log.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198pub struct StepStatusEntry {
199    /// The [`StepStatus`] that was entered by this transition.
200    status: StepStatus,
201    /// UTC timestamp of when this transition occurred.
202    changed_at: DateTime<Utc>,
203    /// Optional human-readable reason for the transition.
204    ///
205    /// Recommended for `Failed` (error details) and `Skipped`
206    /// (why the step was deemed unnecessary).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    reason: Option<String>,
209}
210
211impl StepStatusEntry {
212    pub fn new(status: StepStatus, reason: Option<String>) -> Self {
213        Self {
214            status,
215            changed_at: Utc::now(),
216            reason,
217        }
218    }
219
220    pub fn status(&self) -> &StepStatus {
221        &self.status
222    }
223
224    pub fn changed_at(&self) -> DateTime<Utc> {
225        self.changed_at
226    }
227
228    pub fn reason(&self) -> Option<&str> {
229        self.reason.as_deref()
230    }
231}
232
233/// Default for [`PlanStep::statuses`] when deserializing legacy data
234/// that lacks the `statuses` field.
235fn default_step_statuses() -> Vec<StepStatusEntry> {
236    vec![StepStatusEntry::new(StepStatus::Pending, None)]
237}
238
239/// A single step within a [`Plan`], describing one unit of work.
240///
241/// Steps are executed in order by the agent. Each step can be either
242/// **inline** (executed directly by the current Run) or **delegated**
243/// (spawning a sub-Task via the `task` field). See module documentation
244/// for context tracking and recursive decomposition details.
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246pub struct PlanStep {
247    /// Human-readable description of what this step should accomplish.
248    ///
249    /// Set once at creation. The `alias = "intent"` supports legacy
250    /// serialized data where this field was named `intent`.
251    #[serde(alias = "intent")]
252    description: String,
253    /// Expected inputs for this step as a JSON value.
254    ///
255    /// Schema is step-dependent. For example, a "refactor" step might
256    /// list `{"files": ["src/auth.rs"]}`. `None` when the step has no
257    /// explicit inputs (e.g. a discovery step).
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    inputs: Option<serde_json::Value>,
260    /// Expected outputs for this step as a JSON value.
261    ///
262    /// Populated after execution completes. For example,
263    /// `{"files_modified": ["src/auth.rs", "src/lib.rs"]}`. `None`
264    /// while the step is `Pending` or `Progressing`, or when the step
265    /// produces no structured output.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    outputs: Option<serde_json::Value>,
268    /// Validation criteria for this step as a JSON value.
269    ///
270    /// Defines what must pass for the step to be considered successful.
271    /// For example, `{"tests": "cargo test", "lint": "cargo clippy"}`.
272    /// `None` when no explicit checks are defined.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    checks: Option<serde_json::Value>,
275    /// Indices into the pipeline's frame list that this step **consumed**
276    /// as input context.
277    ///
278    /// Set when the step begins execution. Values are stable
279    /// [`ContextFrame::frame_id`](super::pipeline::ContextFrame::frame_id)s
280    /// (not Vec indices), so they survive pipeline eviction. Look up
281    /// frames via
282    /// [`ContextPipeline::frame_by_id`](super::pipeline::ContextPipeline::frame_by_id).
283    /// Empty when no prior context was consumed.
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    iframes: Vec<u64>,
286    /// Stable frame IDs in the pipeline that this step **produced**
287    /// as output context.
288    ///
289    /// Set after the step completes. The step pushes new frames (e.g.
290    /// `StepSummary`, `CodeChange`) to the pipeline and records the
291    /// returned `frame_id`s here. Empty when the step produced no
292    /// context frames.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    oframes: Vec<u64>,
295    /// Optional sub-[`Task`](super::task::Task) spawned for this step.
296    ///
297    /// When set, the step delegates execution to an independent Task
298    /// with its own Run / Intent / Plan lifecycle (recursive
299    /// decomposition). The sub-Task's `parent` field points back to
300    /// the owning Task. When `None`, the step is executed inline by
301    /// the current Run.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    task: Option<Uuid>,
304    /// Append-only chronological history of status transitions.
305    ///
306    /// Initialized with a single `Pending` entry at creation. The
307    /// current status is always `statuses.last().status`.
308    ///
309    /// `#[serde(default)]` ensures backward compatibility with the
310    /// legacy schema that used a single `status: PlanStatus` field.
311    /// When deserializing old data that lacks `statuses`, the default
312    /// produces a single `Pending` entry.
313    #[serde(default = "default_step_statuses")]
314    statuses: Vec<StepStatusEntry>,
315}
316
317impl PlanStep {
318    pub fn new(description: impl Into<String>) -> Self {
319        Self {
320            description: description.into(),
321            inputs: None,
322            outputs: None,
323            checks: None,
324            iframes: Vec::new(),
325            oframes: Vec::new(),
326            task: None,
327            statuses: vec![StepStatusEntry::new(StepStatus::Pending, None)],
328        }
329    }
330
331    pub fn description(&self) -> &str {
332        &self.description
333    }
334
335    pub fn inputs(&self) -> Option<&serde_json::Value> {
336        self.inputs.as_ref()
337    }
338
339    pub fn outputs(&self) -> Option<&serde_json::Value> {
340        self.outputs.as_ref()
341    }
342
343    pub fn checks(&self) -> Option<&serde_json::Value> {
344        self.checks.as_ref()
345    }
346
347    /// Returns the current step status (last entry in the history).
348    ///
349    /// Returns `None` only if `statuses` is empty, which should not
350    /// happen for objects created via [`PlanStep::new`] (seeds with
351    /// `Pending`), but may occur for malformed deserialized data.
352    pub fn status(&self) -> Option<&StepStatus> {
353        self.statuses.last().map(|e| &e.status)
354    }
355
356    /// Returns the full chronological status history.
357    pub fn statuses(&self) -> &[StepStatusEntry] {
358        &self.statuses
359    }
360
361    /// Transitions the step to a new status, appending to the history.
362    pub fn set_status(&mut self, status: StepStatus) {
363        self.statuses.push(StepStatusEntry::new(status, None));
364    }
365
366    /// Transitions the step to a new status with a reason.
367    pub fn set_status_with_reason(&mut self, status: StepStatus, reason: impl Into<String>) {
368        self.statuses
369            .push(StepStatusEntry::new(status, Some(reason.into())));
370    }
371
372    pub fn set_inputs(&mut self, inputs: Option<serde_json::Value>) {
373        self.inputs = inputs;
374    }
375
376    pub fn set_outputs(&mut self, outputs: Option<serde_json::Value>) {
377        self.outputs = outputs;
378    }
379
380    pub fn set_checks(&mut self, checks: Option<serde_json::Value>) {
381        self.checks = checks;
382    }
383
384    /// Returns the pipeline frame IDs this step consumed as input context.
385    pub fn iframes(&self) -> &[u64] {
386        &self.iframes
387    }
388
389    /// Returns the pipeline frame IDs this step produced as output context.
390    pub fn oframes(&self) -> &[u64] {
391        &self.oframes
392    }
393
394    /// Records the pipeline frame IDs this step consumed as input.
395    pub fn set_iframes(&mut self, ids: Vec<u64>) {
396        self.iframes = ids;
397    }
398
399    /// Records the pipeline frame IDs this step produced as output.
400    pub fn set_oframes(&mut self, ids: Vec<u64>) {
401        self.oframes = ids;
402    }
403
404    /// Returns the sub-Task ID if this step has been elevated to an
405    /// independent Task.
406    pub fn task(&self) -> Option<Uuid> {
407        self.task
408    }
409
410    /// Elevates this step to an independent sub-Task, or clears the
411    /// association by passing `None`.
412    pub fn set_task(&mut self, task: Option<Uuid>) {
413        self.task = task;
414    }
415}
416
417/// A sequence of steps derived from an Intent's analyzed content.
418///
419/// A Plan is a pure planning artifact — it defines *what* to do, not
420/// *how* to execute. It is step ③ in the end-to-end flow. A
421/// [`Run`](super::run::Run) then references the Plan via `plan` to
422/// execute it. See module documentation for revision chain, context
423/// range, and recursive decomposition details.
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
425pub struct Plan {
426    /// Common header (object ID, type, timestamps, creator, etc.).
427    #[serde(flatten)]
428    header: Header,
429    /// Link to the predecessor Plan in the revision chain.
430    ///
431    /// Forms a singly-linked list from newest to oldest: each revised
432    /// Plan points to the Plan it supersedes. `None` for the initial
433    /// (first) Plan. The [`Intent`](super::intent::Intent) always
434    /// points to the latest revision via `Intent.plan`.
435    ///
436    /// Use [`new_revision`](Plan::new_revision) to create a successor
437    /// that automatically sets this field.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    previous: Option<Uuid>,
440    /// The [`ContextPipeline`](super::pipeline::ContextPipeline) that
441    /// served as the context basis for this Plan.
442    ///
443    /// Set when the Plan is created from an Intent's analyzed content.
444    /// The pipeline contains the [`ContextFrame`](super::pipeline::ContextFrame)s
445    /// that informed this Plan's decomposition. `None` when no pipeline
446    /// was used (e.g. a manually created Plan).
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pipeline: Option<Uuid>,
449    /// Frame visibility window `(start, end)`.
450    ///
451    /// A half-open range `[start..end)` into the pipeline's frame list
452    /// that was visible when this Plan was created. Enables
453    /// retrospective analysis: given the context the agent saw, was the
454    /// decomposition reasonable? `None` when `pipeline` is not set or
455    /// when the entire pipeline was visible.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    fwindow: Option<(u32, u32)>,
458    /// Ordered sequence of steps to execute.
459    ///
460    /// Steps are executed in order (index 0 first). Each step can be
461    /// inline (executed by the current Run) or delegated (spawning a
462    /// sub-Task via `PlanStep.task`). Empty when the Plan has just been
463    /// created and steps haven't been added yet.
464    #[serde(default)]
465    steps: Vec<PlanStep>,
466}
467
468impl Plan {
469    /// Create a new initial plan (no predecessor).
470    pub fn new(created_by: ActorRef) -> Result<Self, String> {
471        Ok(Self {
472            header: Header::new(ObjectType::Plan, created_by)?,
473            previous: None,
474            pipeline: None,
475            fwindow: None,
476            steps: Vec::new(),
477        })
478    }
479
480    /// Create a revised plan that links back to this one.
481    pub fn new_revision(&self, created_by: ActorRef) -> Result<Self, String> {
482        Ok(Self {
483            header: Header::new(ObjectType::Plan, created_by)?,
484            previous: Some(self.header.object_id()),
485            pipeline: None,
486            fwindow: None,
487            steps: Vec::new(),
488        })
489    }
490
491    pub fn header(&self) -> &Header {
492        &self.header
493    }
494
495    pub fn previous(&self) -> Option<Uuid> {
496        self.previous
497    }
498
499    pub fn steps(&self) -> &[PlanStep] {
500        &self.steps
501    }
502
503    pub fn add_step(&mut self, step: PlanStep) {
504        self.steps.push(step);
505    }
506
507    pub fn set_previous(&mut self, previous: Option<Uuid>) {
508        self.previous = previous;
509    }
510
511    /// Returns the pipeline that served as context basis for this plan.
512    pub fn pipeline(&self) -> Option<Uuid> {
513        self.pipeline
514    }
515
516    /// Sets the pipeline that serves as the context basis for this plan.
517    pub fn set_pipeline(&mut self, pipeline: Option<Uuid>) {
518        self.pipeline = pipeline;
519    }
520
521    /// Returns the frame window `(start, end)` — the half-open range
522    /// `[start..end)` of pipeline frames visible when this plan was created.
523    pub fn fwindow(&self) -> Option<(u32, u32)> {
524        self.fwindow
525    }
526
527    /// Sets the frame window `(start, end)` — the half-open range
528    /// `[start..end)` of pipeline frames visible when this plan was created.
529    pub fn set_fwindow(&mut self, fwindow: Option<(u32, u32)>) {
530        self.fwindow = fwindow;
531    }
532}
533
534impl fmt::Display for Plan {
535    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536        write!(f, "Plan: {}", self.header.object_id())
537    }
538}
539
540impl ObjectTrait for Plan {
541    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
542    where
543        Self: Sized,
544    {
545        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
546    }
547
548    fn get_type(&self) -> ObjectType {
549        ObjectType::Plan
550    }
551
552    fn get_size(&self) -> usize {
553        match serde_json::to_vec(self) {
554            Ok(v) => v.len(),
555            Err(e) => {
556                tracing::warn!("failed to compute Plan size: {}", e);
557                0
558            }
559        }
560    }
561
562    fn to_data(&self) -> Result<Vec<u8>, GitError> {
563        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use serde_json::json;
570
571    use super::*;
572
573    #[test]
574    fn test_plan_revision_chain() {
575        let actor = ActorRef::human("jackie").expect("actor");
576
577        let plan_v1 = Plan::new(actor.clone()).expect("plan");
578        let plan_v2 = plan_v1.new_revision(actor.clone()).expect("plan");
579        let plan_v3 = plan_v2.new_revision(actor.clone()).expect("plan");
580
581        // Initial plan has no predecessor
582        assert!(plan_v1.previous().is_none());
583
584        // Revision chain links back correctly
585        assert_eq!(plan_v2.previous(), Some(plan_v1.header().object_id()));
586        assert_eq!(plan_v3.previous(), Some(plan_v2.header().object_id()));
587
588        // Chronological ordering via header timestamps
589        assert!(plan_v2.header().created_at() >= plan_v1.header().created_at());
590        assert!(plan_v3.header().created_at() >= plan_v2.header().created_at());
591    }
592
593    #[test]
594    fn test_plan_pipeline_and_fwindow() {
595        let actor = ActorRef::human("jackie").expect("actor");
596        let mut plan = Plan::new(actor).expect("plan");
597
598        assert!(plan.pipeline().is_none());
599        assert!(plan.fwindow().is_none());
600
601        let pipeline_id = Uuid::from_u128(0x42);
602        plan.set_pipeline(Some(pipeline_id));
603        plan.set_fwindow(Some((0, 3)));
604
605        assert_eq!(plan.pipeline(), Some(pipeline_id));
606        assert_eq!(plan.fwindow(), Some((0, 3)));
607    }
608
609    #[test]
610    fn test_plan_step_statuses() {
611        let mut step = PlanStep::new("run tests");
612
613        // Initial state: one Pending entry
614        assert_eq!(step.statuses().len(), 1);
615        assert_eq!(step.status(), Some(&StepStatus::Pending));
616
617        // Transition to Progressing
618        step.set_status(StepStatus::Progressing);
619        assert_eq!(step.status(), Some(&StepStatus::Progressing));
620        assert_eq!(step.statuses().len(), 2);
621
622        // Transition to Completed with reason
623        step.set_status_with_reason(StepStatus::Completed, "all checks passed");
624        assert_eq!(step.status(), Some(&StepStatus::Completed));
625        assert_eq!(step.statuses().len(), 3);
626
627        // Verify full history
628        let history = step.statuses();
629        assert_eq!(history[0].status(), &StepStatus::Pending);
630        assert!(history[0].reason().is_none());
631        assert_eq!(history[1].status(), &StepStatus::Progressing);
632        assert!(history[1].reason().is_none());
633        assert_eq!(history[2].status(), &StepStatus::Completed);
634        assert_eq!(history[2].reason(), Some("all checks passed"));
635
636        // Timestamps are ordered
637        assert!(history[1].changed_at() >= history[0].changed_at());
638        assert!(history[2].changed_at() >= history[1].changed_at());
639    }
640
641    #[test]
642    fn test_plan_step_deserializes_legacy_intent_field() {
643        let step: PlanStep = serde_json::from_value(json!({
644            "intent": "run tests",
645            "statuses": [{"status": "pending", "changed_at": "2026-01-01T00:00:00Z"}]
646        }))
647        .expect("deserialize legacy step");
648
649        assert_eq!(step.description(), "run tests");
650    }
651
652    #[test]
653    fn test_plan_step_serializes_description_field() {
654        let step = PlanStep::new("run tests");
655        let value = serde_json::to_value(&step).expect("serialize step");
656
657        assert_eq!(
658            value.get("description").and_then(|v| v.as_str()),
659            Some("run tests")
660        );
661        assert!(value.get("intent").is_none());
662    }
663
664    #[test]
665    fn test_plan_step_context_frames() {
666        let mut step = PlanStep::new("refactor auth module");
667
668        // Initially empty
669        assert!(step.iframes().is_empty());
670        assert!(step.oframes().is_empty());
671
672        // Step consumed frames 0 and 1 as input context
673        step.set_iframes(vec![0, 1]);
674        // Step produced frame 2 as output
675        step.set_oframes(vec![2]);
676
677        assert_eq!(step.iframes(), &[0, 1]);
678        assert_eq!(step.oframes(), &[2]);
679    }
680
681    #[test]
682    fn test_plan_step_context_frames_serde_roundtrip() {
683        let mut step = PlanStep::new("deploy");
684        step.set_iframes(vec![0, 3]);
685        step.set_oframes(vec![4, 5]);
686
687        let value = serde_json::to_value(&step).expect("serialize");
688        let restored: PlanStep = serde_json::from_value(value).expect("deserialize");
689
690        assert_eq!(restored.iframes(), &[0, 3]);
691        assert_eq!(restored.oframes(), &[4, 5]);
692    }
693
694    #[test]
695    fn test_plan_step_empty_frames_omitted_in_json() {
696        let step = PlanStep::new("noop");
697        let value = serde_json::to_value(&step).expect("serialize");
698
699        // Empty vecs should be omitted (skip_serializing_if = "Vec::is_empty")
700        assert!(value.get("iframes").is_none());
701        assert!(value.get("oframes").is_none());
702    }
703
704    #[test]
705    fn test_plan_fwindow_serde_roundtrip() {
706        let actor = ActorRef::human("jackie").expect("actor");
707        let mut plan = Plan::new(actor).expect("plan");
708        plan.set_pipeline(Some(Uuid::from_u128(0x99)));
709        plan.set_fwindow(Some((2, 7)));
710
711        let mut step = PlanStep::new("step 0");
712        step.set_iframes(vec![2, 3]);
713        step.set_oframes(vec![7]);
714        plan.add_step(step);
715
716        let data = plan.to_data().expect("serialize");
717        let restored = Plan::from_bytes(&data, ObjectHash::default()).expect("deserialize");
718
719        assert_eq!(restored.fwindow(), Some((2, 7)));
720        assert_eq!(restored.steps()[0].iframes(), &[2, 3]);
721        assert_eq!(restored.steps()[0].oframes(), &[7]);
722    }
723
724    #[test]
725    fn test_plan_step_subtask() {
726        let mut step = PlanStep::new("design OAuth flow");
727
728        // Initially no sub-task
729        assert!(step.task().is_none());
730
731        // Elevate to independent sub-Task
732        let sub_task_id = Uuid::from_u128(0xAB);
733        step.set_task(Some(sub_task_id));
734        assert_eq!(step.task(), Some(sub_task_id));
735
736        // Clear association
737        step.set_task(None);
738        assert!(step.task().is_none());
739    }
740
741    #[test]
742    fn test_plan_step_subtask_serde_roundtrip() {
743        let mut step = PlanStep::new("implement auth module");
744        let sub_task_id = Uuid::from_u128(0xCD);
745        step.set_task(Some(sub_task_id));
746
747        let value = serde_json::to_value(&step).expect("serialize");
748        assert!(value.get("task").is_some());
749
750        let restored: PlanStep = serde_json::from_value(value).expect("deserialize");
751        assert_eq!(restored.task(), Some(sub_task_id));
752    }
753
754    #[test]
755    fn test_plan_step_no_subtask_omitted_in_json() {
756        let step = PlanStep::new("inline step");
757        let value = serde_json::to_value(&step).expect("serialize");
758
759        // None task should be omitted (skip_serializing_if)
760        assert!(value.get("task").is_none());
761    }
762}