Skip to main content

bamboo_domain/session/
task.rs

1//! Task list types for task tracking in sessions.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6
7/// Task item status.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
9pub enum TaskItemStatus {
10    #[serde(rename = "pending")]
11    #[default]
12    Pending,
13    #[serde(rename = "in_progress")]
14    InProgress,
15    #[serde(rename = "completed")]
16    Completed,
17    #[serde(rename = "blocked")]
18    Blocked,
19}
20
21/// High-level phase of a task item.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum TaskPhase {
25    Planning,
26    #[default]
27    Execution,
28    Verification,
29    Handoff,
30}
31
32/// Relative importance of a task item.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum TaskPriority {
36    Low,
37    #[default]
38    Medium,
39    High,
40    Critical,
41}
42
43/// Evidence kind attached to a task item.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45#[serde(rename_all = "snake_case")]
46pub enum TaskEvidenceKind {
47    #[default]
48    Note,
49    ToolCall,
50    File,
51    Command,
52    Test,
53    Observation,
54}
55
56/// Structured evidence supporting task status.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
58pub struct TaskEvidence {
59    #[serde(default)]
60    pub kind: TaskEvidenceKind,
61    pub summary: String,
62    #[serde(default, skip_serializing_if = "Option::is_none", alias = "ref")]
63    pub reference: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub tool_name: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub tool_call_id: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub round: Option<u32>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub success: Option<bool>,
72}
73
74/// Blocker kind attached to a task item.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
76#[serde(rename_all = "snake_case")]
77pub enum TaskBlockerKind {
78    UserInput,
79    Dependency,
80    ToolFailure,
81    External,
82    #[default]
83    Unknown,
84}
85
86/// Structured blocker details for a task item.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
88pub struct TaskBlocker {
89    #[serde(default)]
90    pub kind: TaskBlockerKind,
91    pub summary: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub waiting_on: Option<String>,
94}
95
96/// History entry for task state changes.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct TaskTransition {
99    pub from_status: TaskItemStatus,
100    pub to_status: TaskItemStatus,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub reason: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub round: Option<u32>,
105    pub changed_at: DateTime<Utc>,
106}
107
108/// Task item for task tracking.
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct TaskItem {
111    /// Unique identifier for the task item.
112    pub id: String,
113    /// Human-readable description of the task.
114    pub description: String,
115    /// Current status of the item.
116    pub status: TaskItemStatus,
117    /// IDs of other items this item depends on.
118    #[serde(default)]
119    pub depends_on: Vec<String>,
120    /// Additional notes or context.
121    #[serde(default)]
122    pub notes: String,
123    /// Present-progress phrasing for the active task.
124    #[serde(default, skip_serializing_if = "Option::is_none", alias = "activeForm")]
125    pub active_form: Option<String>,
126    /// Optional parent task ID when this item is part of a larger task tree.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub parent_id: Option<String>,
129    /// Phase of work for the task.
130    #[serde(default)]
131    pub phase: TaskPhase,
132    /// Relative priority of the task.
133    #[serde(default)]
134    pub priority: TaskPriority,
135    /// Explicit completion criteria for the task.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub completion_criteria: Vec<String>,
138    /// Structured evidence gathered while working on the task.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub evidence: Vec<TaskEvidence>,
141    /// Structured blocker information.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub blockers: Vec<TaskBlocker>,
144    /// Transition history for this task item.
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub transitions: Vec<TaskTransition>,
147}
148
149impl TaskItem {
150    /// Append notes while preserving previous content.
151    pub fn append_notes(&mut self, note: &str) {
152        let note = note.trim();
153        if note.is_empty() {
154            return;
155        }
156        if !self.notes.is_empty() {
157            self.notes.push('\n');
158        }
159        self.notes.push_str(note);
160    }
161
162    /// Return the most useful active-form display for the task.
163    pub fn effective_active_form(&self) -> Option<&str> {
164        self.active_form.as_deref().or_else(|| {
165            let notes = self.notes.trim();
166            if matches!(self.status, TaskItemStatus::InProgress) && !notes.is_empty() {
167                Some(notes)
168            } else {
169                None
170            }
171        })
172    }
173
174    /// Add structured evidence if it contains useful content.
175    pub fn push_evidence(&mut self, evidence: TaskEvidence) {
176        if evidence.summary.trim().is_empty() {
177            return;
178        }
179        self.evidence.push(evidence);
180    }
181
182    /// Add a blocker if it is not empty and not already recorded.
183    pub fn add_blocker(&mut self, blocker: TaskBlocker) {
184        if blocker.summary.trim().is_empty() {
185            return;
186        }
187        if self.blockers.iter().any(|existing| {
188            existing.kind == blocker.kind
189                && existing.summary == blocker.summary
190                && existing.waiting_on == blocker.waiting_on
191        }) {
192            return;
193        }
194        self.blockers.push(blocker);
195    }
196
197    /// Transition to a new status and record the change.
198    pub fn transition_to(
199        &mut self,
200        status: TaskItemStatus,
201        reason: Option<&str>,
202        round: Option<u32>,
203    ) -> bool {
204        let reason = reason.map(str::trim).filter(|value| !value.is_empty());
205
206        if self.status == status {
207            if let Some(reason) = reason {
208                self.append_notes(reason);
209            }
210            return false;
211        }
212
213        let transition = TaskTransition {
214            from_status: self.status.clone(),
215            to_status: status.clone(),
216            reason: reason.map(ToOwned::to_owned),
217            round,
218            changed_at: Utc::now(),
219        };
220        self.status = status;
221        if let Some(reason) = transition.reason.as_deref() {
222            self.append_notes(reason);
223        }
224        self.transitions.push(transition);
225        true
226    }
227
228    /// Whether all declared dependencies are already completed.
229    pub fn dependencies_ready(&self, completed_ids: &HashSet<String>) -> bool {
230        self.depends_on
231            .iter()
232            .all(|dependency_id| completed_ids.contains(dependency_id))
233    }
234}
235
236/// Task list for a session.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct TaskList {
239    /// Session ID this task list belongs to.
240    pub session_id: String,
241    /// Title of the task list.
242    pub title: String,
243    /// List of task items.
244    pub items: Vec<TaskItem>,
245    /// When the list was created.
246    pub created_at: DateTime<Utc>,
247    /// When the list was last updated.
248    pub updated_at: DateTime<Utc>,
249}
250
251pub fn task_status_label(status: &TaskItemStatus) -> &'static str {
252    match status {
253        TaskItemStatus::Pending => "pending",
254        TaskItemStatus::InProgress => "in_progress",
255        TaskItemStatus::Completed => "completed",
256        TaskItemStatus::Blocked => "blocked",
257    }
258}
259
260pub fn task_status_icon(status: &TaskItemStatus) -> &'static str {
261    match status {
262        TaskItemStatus::Pending => "[ ]",
263        TaskItemStatus::InProgress => "[/]",
264        TaskItemStatus::Completed => "[x]",
265        TaskItemStatus::Blocked => "[!]",
266    }
267}
268
269pub fn task_phase_label(phase: &TaskPhase) -> &'static str {
270    match phase {
271        TaskPhase::Planning => "planning",
272        TaskPhase::Execution => "execution",
273        TaskPhase::Verification => "verification",
274        TaskPhase::Handoff => "handoff",
275    }
276}
277
278pub fn task_priority_label(priority: &TaskPriority) -> &'static str {
279    match priority {
280        TaskPriority::Low => "low",
281        TaskPriority::Medium => "medium",
282        TaskPriority::High => "high",
283        TaskPriority::Critical => "critical",
284    }
285}
286
287pub fn task_evidence_kind_label(kind: &TaskEvidenceKind) -> &'static str {
288    match kind {
289        TaskEvidenceKind::Note => "note",
290        TaskEvidenceKind::ToolCall => "tool_call",
291        TaskEvidenceKind::File => "file",
292        TaskEvidenceKind::Command => "command",
293        TaskEvidenceKind::Test => "test",
294        TaskEvidenceKind::Observation => "observation",
295    }
296}
297
298pub fn task_blocker_kind_label(kind: &TaskBlockerKind) -> &'static str {
299    match kind {
300        TaskBlockerKind::UserInput => "user_input",
301        TaskBlockerKind::Dependency => "dependency",
302        TaskBlockerKind::ToolFailure => "tool_failure",
303        TaskBlockerKind::External => "external",
304        TaskBlockerKind::Unknown => "unknown",
305    }
306}
307
308impl TaskList {
309    /// Returns true if any task is currently in-progress at the Execution or
310    /// Verification phase — a signal that compression should be deferred when
311    /// context pressure is moderate.
312    pub fn has_active_execution_tasks(&self) -> bool {
313        self.items.iter().any(|item| {
314            matches!(item.status, TaskItemStatus::InProgress)
315                && matches!(item.phase, TaskPhase::Execution | TaskPhase::Verification)
316        })
317    }
318
319    /// Format task list for display in system prompt.
320    pub fn format_for_prompt(&self) -> String {
321        if self.items.is_empty() {
322            return String::new();
323        }
324
325        let mut output = format!("\n\n## Current Task List: {}\n", self.title);
326
327        for item in &self.items {
328            output.push_str(&format!(
329                "\n{} {}: {}",
330                task_status_icon(&item.status),
331                item.id,
332                item.description
333            ));
334
335            let mut tags = Vec::new();
336            if item.phase != TaskPhase::Execution {
337                tags.push(format!("phase={}", task_phase_label(&item.phase)));
338            }
339            if item.priority != TaskPriority::Medium {
340                tags.push(format!("priority={}", task_priority_label(&item.priority)));
341            }
342            if let Some(parent_id) = item.parent_id.as_deref().filter(|value| !value.is_empty()) {
343                tags.push(format!("parent={parent_id}"));
344            }
345            if !item.depends_on.is_empty() {
346                tags.push(format!("depends_on={}", item.depends_on.join(", ")));
347            }
348            if !tags.is_empty() {
349                output.push_str(&format!(" [{}]", tags.join(" | ")));
350            }
351
352            if matches!(item.status, TaskItemStatus::InProgress) {
353                if let Some(active_form) = item.effective_active_form() {
354                    output.push_str(&format!(
355                        "\n    Active: {}",
356                        truncate_for_prompt(active_form, 160)
357                    ));
358                }
359            }
360
361            if !item.completion_criteria.is_empty() {
362                let criteria = item
363                    .completion_criteria
364                    .iter()
365                    .take(3)
366                    .map(|criterion| truncate_for_prompt(criterion, 60))
367                    .collect::<Vec<_>>()
368                    .join(" | ");
369                output.push_str(&format!("\n    Criteria: {criteria}"));
370                if item.completion_criteria.len() > 3 {
371                    output.push_str(&format!(" | +{} more", item.completion_criteria.len() - 3));
372                }
373            }
374
375            if let Some(blocker) = item.blockers.last() {
376                let mut blocker_line = truncate_for_prompt(&blocker.summary, 140);
377                if let Some(waiting_on) = blocker
378                    .waiting_on
379                    .as_deref()
380                    .filter(|value| !value.is_empty())
381                {
382                    blocker_line.push_str(&format!(
383                        " (waiting_on: {})",
384                        truncate_for_prompt(waiting_on, 60)
385                    ));
386                }
387                output.push_str(&format!("\n    Blocked by: {blocker_line}"));
388            }
389
390            if let Some(evidence) = item.evidence.last() {
391                let mut evidence_line = truncate_for_prompt(&evidence.summary, 140);
392                if let Some(reference) = evidence
393                    .reference
394                    .as_deref()
395                    .filter(|value| !value.is_empty())
396                {
397                    evidence_line
398                        .push_str(&format!(" [ref: {}]", truncate_for_prompt(reference, 60)));
399                }
400                output.push_str(&format!(
401                    "\n    Latest evidence [{}]: {}",
402                    task_evidence_kind_label(&evidence.kind),
403                    evidence_line
404                ));
405            }
406
407            let notes = item.notes.trim();
408            let active_form = item.active_form.as_deref().map(str::trim);
409            if !notes.is_empty() && Some(notes) != active_form {
410                output.push_str(&format!("\n    Notes: {}", truncate_for_prompt(notes, 160)));
411            }
412        }
413
414        let completed = self
415            .items
416            .iter()
417            .filter(|item| item.status == TaskItemStatus::Completed)
418            .count();
419        let total = self.items.len();
420        output.push_str(&format!(
421            "\n\nProgress: {}/{} tasks completed",
422            completed, total
423        ));
424
425        output
426    }
427
428    /// Update a task item status.
429    pub fn update_item(
430        &mut self,
431        item_id: &str,
432        status: TaskItemStatus,
433        notes: Option<&str>,
434    ) -> Result<String, String> {
435        if let Some(item) = self.items.iter_mut().find(|item| item.id == item_id) {
436            item.transition_to(status, notes, None);
437            self.updated_at = Utc::now();
438            Ok(format!("Updated item '{}'", item_id))
439        } else {
440            Err(format!("Task item '{}' not found", item_id))
441        }
442    }
443}
444
445fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
446    let trimmed = value.trim().replace('\n', " ");
447    let char_count = trimmed.chars().count();
448    if char_count <= max_chars {
449        return trimmed;
450    }
451
452    let truncated: String = trimmed.chars().take(max_chars).collect();
453    format!("{}…", truncated.trim_end())
454}