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}