Skip to main content

nika_engine/runtime/
chat_workflow.rs

1//! ChatWorkflow - DAG wrapper for chat conversations
2//!
3//! Turns every chat message into a traceable DAG node with stable NodeIndex.
4//! Foundation for @mention references where `@N` references stay valid after deletion.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ChatWorkflow {
10//!     dag: StableDag<ChatMessage>,
11//!     message_counter: u32,
12//!     id_to_index: HashMap<String, NodeIndex>,
13//! }
14//!
15//! Sequential Flow:
16//! [msg-001] ──► [msg-002] ──► [msg-003] ──► [msg-004]
17//!    User        Assistant      User        Assistant
18//! ```
19
20use crate::binding::{
21    has_parallel_marker, parse_mentions, resolve_mention, strip_parallel_marker, text_to_bindings,
22    BindingSpec, Mention, MentionResolutionError, ResolvedMention,
23};
24use crate::dag::StableDag;
25use chrono::{DateTime, Utc};
26use petgraph::stable_graph::NodeIndex;
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30/// Role of a chat message participant.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum Role {
33    User,
34    Assistant,
35    System,
36    Tool,
37}
38
39/// A chat message as a DAG node.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ChatMessage {
42    pub id: String,
43    pub content: String,
44    pub role: Role,
45    pub timestamp: DateTime<Utc>,
46}
47
48/// DAG wrapper for chat conversation.
49///
50/// Every message becomes a node in the DAG with a stable NodeIndex.
51/// Sequential edges are auto-created for linear conversation flow.
52pub struct ChatWorkflow {
53    /// The underlying DAG with stable node indices
54    pub dag: StableDag<ChatMessage>,
55    /// Counter for generating sequential message IDs
56    message_counter: u32,
57    /// Map from message ID to NodeIndex for fast lookup
58    id_to_index: HashMap<String, NodeIndex>,
59}
60
61impl ChatWorkflow {
62    /// Create a new empty ChatWorkflow.
63    pub fn new() -> Self {
64        Self {
65            dag: StableDag::new(),
66            message_counter: 0,
67            id_to_index: HashMap::new(),
68        }
69    }
70
71    /// Get the number of messages in the conversation.
72    pub fn message_count(&self) -> usize {
73        self.dag.node_count()
74    }
75
76    // ═══════════════════════════════════════════════════════════════════════════
77    // Task 1.2: add_message() → Node Creation
78    // ═══════════════════════════════════════════════════════════════════════════
79
80    /// Add a message to the conversation DAG.
81    /// Returns the stable NodeIndex of the new message.
82    ///
83    /// Automatically creates a sequential edge from the previous message
84    /// to maintain linear conversation flow.
85    pub fn add_message(&mut self, content: &str, role: Role) -> NodeIndex {
86        self.message_counter += 1;
87        let id = format!("msg-{:03}", self.message_counter);
88
89        let message = ChatMessage {
90            id: id.clone(),
91            content: content.to_string(),
92            role,
93            timestamp: Utc::now(),
94        };
95
96        let idx = self.dag.add_node(message);
97        self.id_to_index.insert(id, idx);
98
99        // Auto-create edge from previous message (sequential flow)
100        // First message (counter=1) has no previous, so skip
101        if self.message_counter > 1 {
102            if let Some(prev_idx) = self.get_index_by_number(self.message_counter - 1) {
103                self.dag.add_edge(prev_idx, idx);
104            }
105        }
106
107        idx
108    }
109
110    /// Get a message by its ID (e.g., "msg-001").
111    pub fn get_message_by_id(&self, id: &str) -> Option<&ChatMessage> {
112        self.id_to_index
113            .get(id)
114            .and_then(|idx| self.dag.node_weight(*idx))
115    }
116
117    // ═══════════════════════════════════════════════════════════════════════════
118    // Task 1.3: get_message_by_index() and get_message_by_number()
119    // ═══════════════════════════════════════════════════════════════════════════
120
121    /// Get a message by its NodeIndex.
122    pub fn get_message_by_index(&self, idx: NodeIndex) -> Option<&ChatMessage> {
123        self.dag.node_weight(idx)
124    }
125
126    /// Get a message by its number (for @N references).
127    /// @1 returns the first message (msg-001).
128    pub fn get_message_by_number(&self, n: u32) -> Option<&ChatMessage> {
129        let id = format!("msg-{:03}", n);
130        self.get_message_by_id(&id)
131    }
132
133    /// Get the NodeIndex by message number.
134    pub fn get_index_by_number(&self, n: u32) -> Option<NodeIndex> {
135        let id = format!("msg-{:03}", n);
136        self.id_to_index.get(&id).copied()
137    }
138
139    // ═══════════════════════════════════════════════════════════════════════════
140    // Task 1.5: Message Counter for @N References
141    // ═══════════════════════════════════════════════════════════════════════════
142
143    /// Get the current message number (for @N references).
144    /// Returns 0 if no messages have been added.
145    pub fn current_message_number(&self) -> u32 {
146        self.message_counter
147    }
148
149    /// Get the most recent message (last added).
150    pub fn last_message(&self) -> Option<&ChatMessage> {
151        if self.message_counter == 0 {
152            return None;
153        }
154        self.get_message_by_number(self.message_counter)
155    }
156
157    /// Get the NodeIndex of the most recent message.
158    pub fn last_message_index(&self) -> Option<NodeIndex> {
159        if self.message_counter == 0 {
160            return None;
161        }
162        self.get_index_by_number(self.message_counter)
163    }
164
165    // ═══════════════════════════════════════════════════════════════════════════
166    // Task 2.9: @mention Integration
167    // ═══════════════════════════════════════════════════════════════════════════
168
169    /// Add a message that is parallel (independent of previous message).
170    ///
171    /// Unlike `add_message()`, this does NOT create an auto-edge from the
172    /// previous message. Used for messages starting with `//` parallel marker.
173    pub fn add_message_parallel(&mut self, content: &str, role: Role) -> NodeIndex {
174        self.message_counter += 1;
175        let id = format!("msg-{:03}", self.message_counter);
176
177        // Strip the parallel marker from content if present
178        let clean_content = strip_parallel_marker(content);
179
180        let message = ChatMessage {
181            id: id.clone(),
182            content: clean_content.to_string(),
183            role,
184            timestamp: Utc::now(),
185        };
186
187        let idx = self.dag.add_node(message);
188        self.id_to_index.insert(id, idx);
189
190        // NO auto-edge for parallel messages
191        idx
192    }
193
194    /// Add a message, automatically detecting if it's parallel or sequential.
195    ///
196    /// Messages starting with `//` are parallel (no edge from previous).
197    /// All other messages get the standard sequential edge.
198    pub fn add_message_auto(&mut self, content: &str, role: Role) -> NodeIndex {
199        if has_parallel_marker(content) {
200            self.add_message_parallel(content, role)
201        } else {
202            self.add_message(content, role)
203        }
204    }
205
206    /// Parse @mentions from a message's content.
207    ///
208    /// Returns all mentions found in the message content.
209    pub fn get_mentions(&self, idx: NodeIndex) -> Vec<Mention> {
210        match self.get_message_by_index(idx) {
211            Some(msg) => parse_mentions(&msg.content),
212            None => vec![],
213        }
214    }
215
216    /// Resolve a mention using this conversation's message count.
217    pub fn resolve_mention(
218        &self,
219        mention: &Mention,
220    ) -> Result<ResolvedMention, MentionResolutionError> {
221        resolve_mention(mention, self.message_counter)
222    }
223
224    /// Get BindingSpec for all mentions in a message.
225    ///
226    /// Parses mentions from the message content and converts them to
227    /// BindingSpec bindings that can be used for DAG edges.
228    pub fn get_bindings_for_message(
229        &self,
230        idx: NodeIndex,
231    ) -> Result<BindingSpec, MentionResolutionError> {
232        match self.get_message_by_index(idx) {
233            Some(msg) => text_to_bindings(&msg.content, self.message_counter),
234            None => Ok(BindingSpec::default()),
235        }
236    }
237
238    /// Check if a message has the parallel marker.
239    ///
240    /// Returns true if the message content starts with `//`.
241    pub fn is_parallel_message(&self, idx: NodeIndex) -> bool {
242        match self.get_message_by_index(idx) {
243            Some(msg) => has_parallel_marker(&msg.content),
244            None => false,
245        }
246    }
247
248    // ═══════════════════════════════════════════════════════════════════════════
249    // Task 2.10: Edge Creation from @mentions
250    // ═══════════════════════════════════════════════════════════════════════════
251
252    /// Add edges from mentioned messages to the target message.
253    ///
254    /// For each @mention in the target message's content, creates an edge
255    /// from the mentioned message(s) to the target. This forms the DAG
256    /// dependency graph based on explicit references.
257    ///
258    /// Returns the number of edges added.
259    pub fn add_edges_from_mentions(
260        &mut self,
261        target_idx: NodeIndex,
262    ) -> Result<usize, MentionResolutionError> {
263        // Get the target message's content
264        let content = match self.get_message_by_index(target_idx) {
265            Some(msg) => msg.content.clone(),
266            None => return Ok(0),
267        };
268
269        // Parse mentions from content
270        let mentions = parse_mentions(&content);
271        let mut edges_added = 0;
272
273        for mention in mentions {
274            // Resolve the mention to message indices
275            let resolved = resolve_mention(&mention, self.message_counter)?;
276
277            match resolved {
278                ResolvedMention::Single(n) => {
279                    if let Some(source_idx) = self.get_index_by_number(n) {
280                        // Don't add self-loops
281                        if source_idx != target_idx {
282                            self.dag.add_edge(source_idx, target_idx);
283                            edges_added += 1;
284                        }
285                    }
286                }
287                ResolvedMention::Multiple(indices) => {
288                    for n in indices {
289                        if let Some(source_idx) = self.get_index_by_number(n) {
290                            // Don't add self-loops
291                            if source_idx != target_idx {
292                                self.dag.add_edge(source_idx, target_idx);
293                                edges_added += 1;
294                            }
295                        }
296                    }
297                }
298                ResolvedMention::Empty => {}
299            }
300        }
301
302        Ok(edges_added)
303    }
304
305    /// Add a message and create edges from any @mentions in its content.
306    ///
307    /// This combines `add_message_auto()` with `add_edges_from_mentions()`:
308    /// 1. Adds the message (parallel if `//`, sequential otherwise)
309    /// 2. Creates edges from all @mentioned messages
310    ///
311    /// Returns the NodeIndex of the new message.
312    pub fn add_message_with_mentions(
313        &mut self,
314        content: &str,
315        role: Role,
316    ) -> Result<NodeIndex, MentionResolutionError> {
317        let idx = self.add_message_auto(content, role);
318        self.add_edges_from_mentions(idx)?;
319        Ok(idx)
320    }
321
322    /// Get all incoming edges (dependencies) for a message.
323    ///
324    /// Returns the NodeIndices of messages that this message depends on.
325    pub fn get_dependencies(&self, idx: NodeIndex) -> Vec<NodeIndex> {
326        self.dag.incoming_neighbors(idx).collect()
327    }
328
329    /// Get all outgoing edges (dependents) for a message.
330    ///
331    /// Returns the NodeIndices of messages that depend on this message.
332    pub fn get_dependents(&self, idx: NodeIndex) -> Vec<NodeIndex> {
333        self.dag.outgoing_neighbors(idx).collect()
334    }
335
336    // ═══════════════════════════════════════════════════════════════════════════
337    // Iteration support for TUI sync
338    // ═══════════════════════════════════════════════════════════════════════════
339
340    /// Iterate over all messages in the workflow.
341    ///
342    /// Returns messages in insertion order (by message number).
343    pub fn all_messages(&self) -> Vec<(NodeIndex, &ChatMessage)> {
344        (1..=self.message_counter)
345            .filter_map(|n| {
346                let idx = self.get_index_by_number(n)?;
347                let msg = self.get_message_by_index(idx)?;
348                Some((idx, msg))
349            })
350            .collect()
351    }
352
353    /// Get the total message count (for iteration bounds).
354    pub fn total_messages(&self) -> u32 {
355        self.message_counter
356    }
357
358    // ═══════════════════════════════════════════════════════════════════════════
359    // YAML Export for /export yaml command
360    // ═══════════════════════════════════════════════════════════════════════════
361
362    /// Export the chat conversation to Nika workflow YAML.
363    ///
364    /// Converts User messages to `infer:` tasks, preserving @N dependencies
365    /// as `depends_on:` entries. Assistant/System/Tool messages are skipped.
366    ///
367    /// # Example Output
368    ///
369    /// ```yaml
370    /// schema: "nika/workflow@0.12"
371    /// provider: claude
372    ///
373    /// tasks:
374    ///   - id: msg-002
375    ///     infer: "What is Rust?"
376    ///
377    ///   - id: msg-004
378    ///     depends_on: [msg-002]
379    ///     infer: "@2 Tell me more about safety"
380    /// ```
381    pub fn to_yaml(&self) -> String {
382        let mut yaml = String::new();
383
384        // Header
385        yaml.push_str("# Auto-generated from Nika Chat session\n");
386        yaml.push_str(&format!(
387            "# Generated: {}\n",
388            Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
389        ));
390        yaml.push_str("# Use `nika run <file>` to execute\n\n");
391
392        yaml.push_str("schema: \"nika/workflow@0.12\"\n");
393        yaml.push_str("provider: claude\n\n");
394
395        // Collect tasks with their dependencies
396        // (id, content, deps: Vec<String>)
397        let mut tasks: Vec<(&str, &str, Vec<String>)> = Vec::new();
398
399        for (idx, msg) in self.all_messages() {
400            match msg.role {
401                Role::User => {
402                    // Collect @N dependencies (only User→User edges)
403                    let mut deps = Vec::new();
404                    for dep_idx in self.get_dependencies(idx) {
405                        if let Some(dep_msg) = self.get_message_by_index(dep_idx) {
406                            if dep_msg.role == Role::User {
407                                deps.push(dep_msg.id.clone());
408                            }
409                        }
410                    }
411                    tasks.push((&msg.id, &msg.content, deps));
412                }
413                Role::Assistant | Role::System | Role::Tool => {
414                    // Non-user messages are skipped in workflow export
415                }
416            }
417        }
418
419        // Tasks section
420        if tasks.is_empty() {
421            yaml.push_str("# No user messages to convert to tasks\n");
422            yaml.push_str("tasks: []\n");
423        } else {
424            yaml.push_str("tasks:\n");
425            for (id, content, deps) in &tasks {
426                let escaped = escape_yaml_string(content);
427                yaml.push_str(&format!("  - id: \"{}\"\n", id));
428                if !deps.is_empty() {
429                    let dep_list: Vec<String> = deps.iter().map(|d| format!("\"{}\"", d)).collect();
430                    yaml.push_str(&format!("    depends_on: [{}]\n", dep_list.join(", ")));
431                }
432                yaml.push_str(&format!("    infer: \"{}\"\n\n", escaped));
433            }
434        }
435
436        yaml
437    }
438}
439
440/// Escape a string for YAML output (double-quoted style).
441fn escape_yaml_string(s: &str) -> String {
442    s.replace('\\', "\\\\")
443        .replace('"', "\\\"")
444        .replace('\n', "\\n")
445        .replace('\r', "\\r")
446        .replace('\t', "\\t")
447}
448
449impl Default for ChatWorkflow {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455// ═══════════════════════════════════════════════════════════════════════════════
456// Compile-time assertion: ChatWorkflow must be Send + Sync for async usage
457// ═══════════════════════════════════════════════════════════════════════════════
458const _: () = {
459    const fn assert_send_sync<T: Send + Sync>() {}
460    assert_send_sync::<ChatWorkflow>();
461};
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    // ═══════════════════════════════════════════════════════════════════════════
468    // Task 1.1: Basic construction tests
469    // ═══════════════════════════════════════════════════════════════════════════
470
471    #[test]
472    fn test_chat_workflow_new_creates_empty_dag() {
473        let workflow = ChatWorkflow::new();
474
475        assert_eq!(workflow.message_count(), 0);
476        assert_eq!(workflow.dag.node_count(), 0);
477    }
478
479    #[test]
480    fn test_chat_workflow_default() {
481        let workflow = ChatWorkflow::default();
482        assert_eq!(workflow.message_count(), 0);
483    }
484
485    #[test]
486    fn test_chat_workflow_send_sync() {
487        fn assert_send<T: Send>() {}
488        fn assert_sync<T: Sync>() {}
489
490        // ChatWorkflow should be Send + Sync for async usage
491        assert_send::<ChatWorkflow>();
492        assert_sync::<ChatWorkflow>();
493    }
494
495    // ═══════════════════════════════════════════════════════════════════════════
496    // Role enum tests
497    // ═══════════════════════════════════════════════════════════════════════════
498
499    #[test]
500    fn test_all_role_variants() {
501        let roles = [Role::User, Role::Assistant, Role::System, Role::Tool];
502        assert_eq!(roles.len(), 4);
503    }
504
505    #[test]
506    fn test_role_equality() {
507        assert_eq!(Role::User, Role::User);
508        assert_ne!(Role::User, Role::Assistant);
509    }
510
511    #[test]
512    fn test_role_clone() {
513        let role = Role::Assistant;
514        let cloned = role;
515        assert_eq!(role, cloned);
516    }
517
518    #[test]
519    fn test_role_serialization() {
520        let role = Role::User;
521        let json = serde_json::to_string(&role).unwrap();
522        let restored: Role = serde_json::from_str(&json).unwrap();
523        assert_eq!(role, restored);
524    }
525
526    // ═══════════════════════════════════════════════════════════════════════════
527    // Task 1.2: add_message() tests
528    // ═══════════════════════════════════════════════════════════════════════════
529
530    #[test]
531    fn test_add_message_creates_node() {
532        let mut workflow = ChatWorkflow::new();
533
534        let idx = workflow.add_message("Hello!", Role::User);
535
536        assert_eq!(workflow.message_count(), 1);
537        assert_eq!(idx.index(), 0); // First message gets index 0
538    }
539
540    #[test]
541    fn test_add_message_generates_sequential_ids() {
542        let mut workflow = ChatWorkflow::new();
543
544        workflow.add_message("First", Role::User);
545        workflow.add_message("Second", Role::Assistant);
546        workflow.add_message("Third", Role::User);
547
548        let msg1 = workflow.get_message_by_id("msg-001");
549        let msg2 = workflow.get_message_by_id("msg-002");
550        let msg3 = workflow.get_message_by_id("msg-003");
551
552        assert!(msg1.is_some());
553        assert!(msg2.is_some());
554        assert!(msg3.is_some());
555
556        assert_eq!(msg1.unwrap().content, "First");
557        assert_eq!(msg2.unwrap().content, "Second");
558        assert_eq!(msg3.unwrap().content, "Third");
559    }
560
561    #[test]
562    fn test_add_message_assigns_correct_role() {
563        let mut workflow = ChatWorkflow::new();
564
565        workflow.add_message("User msg", Role::User);
566        workflow.add_message("Assistant msg", Role::Assistant);
567        workflow.add_message("System msg", Role::System);
568        workflow.add_message("Tool msg", Role::Tool);
569
570        assert_eq!(
571            workflow.get_message_by_id("msg-001").unwrap().role,
572            Role::User
573        );
574        assert_eq!(
575            workflow.get_message_by_id("msg-002").unwrap().role,
576            Role::Assistant
577        );
578        assert_eq!(
579            workflow.get_message_by_id("msg-003").unwrap().role,
580            Role::System
581        );
582        assert_eq!(
583            workflow.get_message_by_id("msg-004").unwrap().role,
584            Role::Tool
585        );
586    }
587
588    #[test]
589    fn test_get_message_by_id_nonexistent() {
590        let workflow = ChatWorkflow::new();
591        assert!(workflow.get_message_by_id("msg-999").is_none());
592    }
593
594    // ═══════════════════════════════════════════════════════════════════════════
595    // Task 1.3: get_message_by_index() and get_message_by_number() tests
596    // ═══════════════════════════════════════════════════════════════════════════
597
598    #[test]
599    fn test_get_message_by_index() {
600        let mut workflow = ChatWorkflow::new();
601
602        let idx = workflow.add_message("Test message", Role::User);
603        let msg = workflow.get_message_by_index(idx);
604
605        assert!(msg.is_some());
606        assert_eq!(msg.unwrap().content, "Test message");
607    }
608
609    #[test]
610    fn test_get_message_by_number() {
611        let mut workflow = ChatWorkflow::new();
612
613        workflow.add_message("First", Role::User);
614        workflow.add_message("Second", Role::Assistant);
615
616        // @1 → msg-001, @2 → msg-002
617        let msg1 = workflow.get_message_by_number(1);
618        let msg2 = workflow.get_message_by_number(2);
619        let msg3 = workflow.get_message_by_number(3);
620
621        assert_eq!(msg1.unwrap().content, "First");
622        assert_eq!(msg2.unwrap().content, "Second");
623        assert!(msg3.is_none());
624    }
625
626    #[test]
627    fn test_get_index_by_number() {
628        let mut workflow = ChatWorkflow::new();
629
630        let idx1 = workflow.add_message("First", Role::User);
631        let idx2 = workflow.add_message("Second", Role::Assistant);
632
633        assert_eq!(workflow.get_index_by_number(1), Some(idx1));
634        assert_eq!(workflow.get_index_by_number(2), Some(idx2));
635        assert_eq!(workflow.get_index_by_number(3), None);
636    }
637
638    #[test]
639    fn test_get_message_by_invalid_index() {
640        let workflow = ChatWorkflow::new();
641        let invalid_idx = NodeIndex::new(999);
642        assert!(workflow.get_message_by_index(invalid_idx).is_none());
643    }
644
645    // ═══════════════════════════════════════════════════════════════════════════
646    // Task 1.4: Auto-Edge Creation tests
647    // ═══════════════════════════════════════════════════════════════════════════
648
649    #[test]
650    fn test_auto_edge_sequential_messages() {
651        let mut workflow = ChatWorkflow::new();
652
653        let idx1 = workflow.add_message("First", Role::User);
654        let idx2 = workflow.add_message("Second", Role::Assistant);
655        let idx3 = workflow.add_message("Third", Role::User);
656
657        // Sequential edges: 1 → 2 → 3
658        assert!(workflow.dag.has_edge(idx1, idx2), "Should have edge 1 → 2");
659        assert!(workflow.dag.has_edge(idx2, idx3), "Should have edge 2 → 3");
660
661        // No reverse edges
662        assert!(
663            !workflow.dag.has_edge(idx2, idx1),
664            "Should NOT have edge 2 → 1"
665        );
666        assert!(
667            !workflow.dag.has_edge(idx3, idx2),
668            "Should NOT have edge 3 → 2"
669        );
670    }
671
672    #[test]
673    fn test_first_message_has_no_incoming_edge() {
674        let mut workflow = ChatWorkflow::new();
675
676        let idx1 = workflow.add_message("First message", Role::User);
677
678        // First message should have no incoming edges
679        assert_eq!(
680            workflow.dag.edge_count(),
681            0,
682            "First message should have no edges"
683        );
684
685        // Add second message - now we should have exactly one edge
686        let _idx2 = workflow.add_message("Second message", Role::Assistant);
687        assert_eq!(
688            workflow.dag.edge_count(),
689            1,
690            "Should have exactly 1 edge after 2 messages"
691        );
692
693        // The first message still has no incoming edge (it's the source)
694        assert!(workflow.dag.node_weight(idx1).is_some());
695    }
696
697    // ═══════════════════════════════════════════════════════════════════════════
698    // Task 1.5: Message Counter for @N References tests
699    // ═══════════════════════════════════════════════════════════════════════════
700
701    #[test]
702    fn test_current_message_number_empty() {
703        let workflow = ChatWorkflow::new();
704        assert_eq!(workflow.current_message_number(), 0);
705    }
706
707    #[test]
708    fn test_current_message_number_increments() {
709        let mut workflow = ChatWorkflow::new();
710
711        workflow.add_message("First", Role::User);
712        assert_eq!(workflow.current_message_number(), 1);
713
714        workflow.add_message("Second", Role::Assistant);
715        assert_eq!(workflow.current_message_number(), 2);
716
717        workflow.add_message("Third", Role::User);
718        assert_eq!(workflow.current_message_number(), 3);
719    }
720
721    #[test]
722    fn test_last_message_empty() {
723        let workflow = ChatWorkflow::new();
724        assert!(workflow.last_message().is_none());
725    }
726
727    #[test]
728    fn test_last_message_returns_most_recent() {
729        let mut workflow = ChatWorkflow::new();
730
731        workflow.add_message("First", Role::User);
732        assert_eq!(workflow.last_message().unwrap().content, "First");
733
734        workflow.add_message("Second", Role::Assistant);
735        assert_eq!(workflow.last_message().unwrap().content, "Second");
736
737        workflow.add_message("Third", Role::User);
738        assert_eq!(workflow.last_message().unwrap().content, "Third");
739    }
740
741    #[test]
742    fn test_last_message_index() {
743        let mut workflow = ChatWorkflow::new();
744
745        assert!(workflow.last_message_index().is_none());
746
747        let idx1 = workflow.add_message("First", Role::User);
748        assert_eq!(workflow.last_message_index(), Some(idx1));
749
750        let idx2 = workflow.add_message("Second", Role::Assistant);
751        assert_eq!(workflow.last_message_index(), Some(idx2));
752    }
753
754    // ═══════════════════════════════════════════════════════════════════════════
755    // Task 1.6: Thread-Safety with parking_lot::Mutex tests
756    // ═══════════════════════════════════════════════════════════════════════════
757
758    #[test]
759    fn test_concurrent_access_with_mutex() {
760        use parking_lot::Mutex;
761        use std::sync::Arc;
762        use std::thread;
763
764        let workflow = Arc::new(Mutex::new(ChatWorkflow::new()));
765        let mut handles = vec![];
766
767        // Spawn 4 threads, each adding messages
768        for i in 0..4 {
769            let wf = Arc::clone(&workflow);
770            handles.push(thread::spawn(move || {
771                for j in 0..5 {
772                    let mut guard = wf.lock();
773                    let role = if (i + j) % 2 == 0 {
774                        Role::User
775                    } else {
776                        Role::Assistant
777                    };
778                    guard.add_message(&format!("Thread {} msg {}", i, j), role);
779                }
780            }));
781        }
782
783        // Wait for all threads
784        for h in handles {
785            h.join().unwrap();
786        }
787
788        // Verify total messages: 4 threads × 5 messages = 20
789        let guard = workflow.lock();
790        assert_eq!(guard.message_count(), 20);
791        assert_eq!(guard.current_message_number(), 20);
792
793        // Verify sequential edges exist (linear flow maintained)
794        // Due to mutex, messages are added sequentially, so edges are 1→2, 2→3, etc.
795        assert_eq!(guard.dag.edge_count(), 19); // 20 nodes, 19 edges
796    }
797
798    #[test]
799    fn test_send_sync_bounds_with_arc() {
800        use std::sync::Arc;
801
802        // ChatWorkflow can be wrapped in Arc for sharing across threads
803        fn assert_send_sync<T: Send + Sync>() {}
804        assert_send_sync::<Arc<parking_lot::Mutex<ChatWorkflow>>>();
805
806        // Create and share
807        let workflow = Arc::new(parking_lot::Mutex::new(ChatWorkflow::new()));
808        let _cloned = Arc::clone(&workflow);
809    }
810
811    // ═══════════════════════════════════════════════════════════════════════════
812    // Task 2.9: @mention Integration Tests
813    // ═══════════════════════════════════════════════════════════════════════════
814
815    #[test]
816    fn test_add_message_parallel_no_auto_edge() {
817        let mut workflow = ChatWorkflow::new();
818
819        workflow.add_message("First", Role::User);
820        workflow.add_message_parallel("// Independent task", Role::User);
821
822        // 2 messages, but only 0 edges (parallel message has no edge)
823        assert_eq!(workflow.message_count(), 2);
824        assert_eq!(workflow.dag.edge_count(), 0);
825    }
826
827    #[test]
828    fn test_add_message_parallel_strips_marker() {
829        let mut workflow = ChatWorkflow::new();
830
831        let idx = workflow.add_message_parallel("// Independent task", Role::User);
832
833        // Content should have marker stripped
834        let msg = workflow.get_message_by_index(idx).unwrap();
835        assert_eq!(msg.content, "Independent task");
836    }
837
838    #[test]
839    fn test_add_message_auto_detects_parallel() {
840        let mut workflow = ChatWorkflow::new();
841
842        workflow.add_message_auto("First", Role::User);
843        workflow.add_message_auto("Second", Role::Assistant);
844        workflow.add_message_auto("// Parallel", Role::User);
845
846        // 3 messages: 2 sequential (1 edge), 1 parallel (0 edge)
847        assert_eq!(workflow.message_count(), 3);
848        assert_eq!(workflow.dag.edge_count(), 1); // Only 1→2 edge
849    }
850
851    #[test]
852    fn test_add_message_auto_sequential() {
853        let mut workflow = ChatWorkflow::new();
854
855        workflow.add_message_auto("First", Role::User);
856        workflow.add_message_auto("Second", Role::Assistant);
857
858        // 2 messages with 1 edge (sequential)
859        assert_eq!(workflow.message_count(), 2);
860        assert_eq!(workflow.dag.edge_count(), 1);
861    }
862
863    #[test]
864    fn test_get_mentions_parses_content() {
865        let mut workflow = ChatWorkflow::new();
866
867        workflow.add_message("First message", Role::User);
868        workflow.add_message("Second message", Role::Assistant);
869        let idx = workflow.add_message("Based on @1 and @2", Role::User);
870
871        let mentions = workflow.get_mentions(idx);
872        assert_eq!(mentions.len(), 2);
873        assert_eq!(mentions[0], Mention::Number(1));
874        assert_eq!(mentions[1], Mention::Number(2));
875    }
876
877    #[test]
878    fn test_get_mentions_empty_for_no_mentions() {
879        let mut workflow = ChatWorkflow::new();
880
881        let idx = workflow.add_message("No mentions here", Role::User);
882
883        let mentions = workflow.get_mentions(idx);
884        assert!(mentions.is_empty());
885    }
886
887    #[test]
888    fn test_resolve_mention_uses_message_count() {
889        let mut workflow = ChatWorkflow::new();
890
891        workflow.add_message("First", Role::User);
892        workflow.add_message("Second", Role::Assistant);
893        workflow.add_message("Third", Role::User);
894
895        // @last should resolve to 3
896        let result = workflow.resolve_mention(&Mention::Last);
897        assert_eq!(result, Ok(ResolvedMention::Single(3)));
898
899        // @all should resolve to [1, 2, 3]
900        let result = workflow.resolve_mention(&Mention::All);
901        assert_eq!(result, Ok(ResolvedMention::Multiple(vec![1, 2, 3])));
902    }
903
904    #[test]
905    fn test_get_bindings_for_message() {
906        let mut workflow = ChatWorkflow::new();
907
908        workflow.add_message("First", Role::User);
909        workflow.add_message("Second", Role::Assistant);
910        let idx = workflow.add_message("Based on @1", Role::User);
911
912        let spec = workflow.get_bindings_for_message(idx).unwrap();
913        assert_eq!(spec.len(), 1);
914        assert!(spec.contains_key("ref_1"));
915        assert_eq!(spec["ref_1"].path, "msg-001.output");
916    }
917
918    #[test]
919    fn test_get_bindings_for_message_with_last() {
920        let mut workflow = ChatWorkflow::new();
921
922        workflow.add_message("First", Role::User);
923        workflow.add_message("Second", Role::Assistant);
924        let idx = workflow.add_message("Continue from @last", Role::User);
925
926        // @last resolves to current message count (3) at resolution time
927        let spec = workflow.get_bindings_for_message(idx).unwrap();
928        assert_eq!(spec.len(), 1);
929        assert!(spec.contains_key("ref_3")); // @last = message 3 (current count)
930    }
931
932    #[test]
933    fn test_is_parallel_message() {
934        let mut workflow = ChatWorkflow::new();
935
936        let idx1 = workflow.add_message("Normal", Role::User);
937        let idx2 = workflow.add_message("// Parallel", Role::User);
938
939        assert!(!workflow.is_parallel_message(idx1));
940        assert!(workflow.is_parallel_message(idx2));
941    }
942
943    // ═══════════════════════════════════════════════════════════════════════════
944    // Task 2.10: Edge Creation from @mentions Tests
945    // ═══════════════════════════════════════════════════════════════════════════
946
947    #[test]
948    fn test_add_edges_from_mentions_single() {
949        let mut workflow = ChatWorkflow::new();
950
951        workflow.add_message("First", Role::User);
952        workflow.add_message("Second", Role::Assistant);
953        let idx3 = workflow.add_message("Based on @1", Role::User);
954
955        let edges_added = workflow.add_edges_from_mentions(idx3).unwrap();
956        assert_eq!(edges_added, 1);
957
958        // Should have edge from msg-001 to msg-003
959        let deps = workflow.get_dependencies(idx3);
960        assert_eq!(deps.len(), 2); // 1 from auto-edge + 1 from mention
961    }
962
963    #[test]
964    fn test_add_edges_from_mentions_multiple() {
965        let mut workflow = ChatWorkflow::new();
966
967        workflow.add_message("First", Role::User);
968        workflow.add_message("Second", Role::Assistant);
969        let idx3 = workflow.add_message("Based on @1 and @2", Role::User);
970
971        let edges_added = workflow.add_edges_from_mentions(idx3).unwrap();
972        assert_eq!(edges_added, 2);
973    }
974
975    #[test]
976    fn test_add_edges_from_mentions_range() {
977        let mut workflow = ChatWorkflow::new();
978
979        workflow.add_message("First", Role::User);
980        workflow.add_message("Second", Role::Assistant);
981        workflow.add_message("Third", Role::User);
982        let idx4 = workflow.add_message("Summarize @1..3", Role::User);
983
984        let edges_added = workflow.add_edges_from_mentions(idx4).unwrap();
985        assert_eq!(edges_added, 3); // Edges from @1, @2, @3
986    }
987
988    #[test]
989    fn test_add_edges_from_mentions_no_self_loop() {
990        let mut workflow = ChatWorkflow::new();
991
992        workflow.add_message("First", Role::User);
993        workflow.add_message("Second", Role::Assistant);
994        // @3 references itself (message count is 3 after adding this)
995        let idx3 = workflow.add_message("Self reference @3", Role::User);
996
997        // Note: @3 resolves to 3, but idx3 IS message 3, so it would be a self-loop
998        // However, the message says "@3" which means it wants to reference message 3
999        // At the time of resolution, message_counter is 3, so @3 is valid
1000        // But we prevent self-loops
1001        let edges_added = workflow.add_edges_from_mentions(idx3).unwrap();
1002        assert_eq!(edges_added, 0); // Self-loop prevented
1003    }
1004
1005    #[test]
1006    fn test_add_edges_from_mentions_no_mentions() {
1007        let mut workflow = ChatWorkflow::new();
1008
1009        workflow.add_message("First", Role::User);
1010        let idx2 = workflow.add_message("No mentions here", Role::User);
1011
1012        let edges_added = workflow.add_edges_from_mentions(idx2).unwrap();
1013        assert_eq!(edges_added, 0);
1014    }
1015
1016    #[test]
1017    fn test_add_message_with_mentions() {
1018        let mut workflow = ChatWorkflow::new();
1019
1020        workflow.add_message("First", Role::User);
1021        workflow.add_message("Second", Role::Assistant);
1022        let idx3 = workflow
1023            .add_message_with_mentions("Based on @1", Role::User)
1024            .unwrap();
1025
1026        // Should have auto-edge (2→3) + mention edge (1→3)
1027        let deps = workflow.get_dependencies(idx3);
1028        assert_eq!(deps.len(), 2);
1029    }
1030
1031    #[test]
1032    fn test_add_message_with_mentions_parallel() {
1033        let mut workflow = ChatWorkflow::new();
1034
1035        workflow.add_message("First", Role::User);
1036        workflow.add_message("Second", Role::Assistant);
1037        let idx3 = workflow
1038            .add_message_with_mentions("// Based on @1", Role::User)
1039            .unwrap();
1040
1041        // Parallel message: no auto-edge, only mention edge (1→3)
1042        let deps = workflow.get_dependencies(idx3);
1043        assert_eq!(deps.len(), 1);
1044    }
1045
1046    #[test]
1047    fn test_add_message_with_mentions_error() {
1048        let mut workflow = ChatWorkflow::new();
1049
1050        workflow.add_message("First", Role::User);
1051
1052        // @10 doesn't exist
1053        let result = workflow.add_message_with_mentions("Reference @10", Role::User);
1054        assert!(result.is_err());
1055    }
1056
1057    #[test]
1058    fn test_get_dependencies() {
1059        let mut workflow = ChatWorkflow::new();
1060
1061        let idx1 = workflow.add_message("First", Role::User);
1062        let idx2 = workflow.add_message("Second", Role::Assistant);
1063        let idx3 = workflow.add_message("Third", Role::User);
1064
1065        // idx3 depends on idx2 (auto-edge)
1066        let deps = workflow.get_dependencies(idx3);
1067        assert_eq!(deps.len(), 1);
1068        assert_eq!(deps[0], idx2);
1069
1070        // idx1 has no dependencies (first message)
1071        let deps = workflow.get_dependencies(idx1);
1072        assert!(deps.is_empty());
1073    }
1074
1075    #[test]
1076    fn test_get_dependents() {
1077        let mut workflow = ChatWorkflow::new();
1078
1079        let idx1 = workflow.add_message("First", Role::User);
1080        let idx2 = workflow.add_message("Second", Role::Assistant);
1081        workflow.add_message("Third", Role::User);
1082
1083        // idx1 has idx2 as dependent (auto-edge 1→2)
1084        let dependents = workflow.get_dependents(idx1);
1085        assert_eq!(dependents.len(), 1);
1086        assert_eq!(dependents[0], idx2);
1087    }
1088
1089    #[test]
1090    fn test_complex_mention_dag() {
1091        let mut workflow = ChatWorkflow::new();
1092
1093        // Build a DAG:
1094        // @1 USER: What is Rust?
1095        // @2 ASSISTANT: Rust is a systems programming language...
1096        // @3 USER: // What is Go?  (parallel, no edge from @2)
1097        // @4 ASSISTANT: Go is a programming language...
1098        // @5 USER: Compare @2 and @4  (depends on both @2 and @4)
1099
1100        workflow
1101            .add_message_with_mentions("What is Rust?", Role::User)
1102            .unwrap();
1103        workflow
1104            .add_message_with_mentions("Rust is a systems programming language...", Role::Assistant)
1105            .unwrap();
1106        workflow
1107            .add_message_with_mentions("// What is Go?", Role::User)
1108            .unwrap();
1109        workflow
1110            .add_message_with_mentions("Go is a programming language...", Role::Assistant)
1111            .unwrap();
1112        let idx5 = workflow
1113            .add_message_with_mentions("Compare @2 and @4", Role::User)
1114            .unwrap();
1115
1116        // Message 5 should depend on messages 2, 4, and 4 (auto-edge)
1117        let deps = workflow.get_dependencies(idx5);
1118        assert_eq!(deps.len(), 3); // auto-edge from @4 + mention edges from @2 and @4
1119
1120        // Total edges: 1→2, 3→4, 4→5, 2→5, 4→5 (duplicate)
1121        // Actually: 1→2, 3→4, 4→5 (auto), 2→5 (mention), 4→5 (mention but duplicate)
1122        // StableGraph allows parallel edges, so we might have 5 edges
1123        assert!(workflow.dag.edge_count() >= 4);
1124    }
1125
1126    // ═══════════════════════════════════════════════════════════════════════════
1127    // Task 2.4: to_yaml() export tests
1128    // ═══════════════════════════════════════════════════════════════════════════
1129
1130    #[test]
1131    fn test_to_yaml_empty_workflow() {
1132        let workflow = ChatWorkflow::new();
1133        let yaml = workflow.to_yaml();
1134
1135        // Header should be present
1136        assert!(yaml.contains("# Auto-generated from Nika Chat session"));
1137        assert!(yaml.contains("schema: \"nika/workflow@0.12\""));
1138        assert!(yaml.contains("provider: claude"));
1139
1140        // No tasks
1141        assert!(yaml.contains("tasks: []"));
1142        // No flows section at all (uses depends_on per-task instead)
1143        assert!(!yaml.contains("flows:"));
1144    }
1145
1146    #[test]
1147    fn test_to_yaml_single_user_message() {
1148        let mut workflow = ChatWorkflow::new();
1149        workflow.add_message("What is Rust?", Role::User);
1150
1151        let yaml = workflow.to_yaml();
1152
1153        // Should have one task
1154        assert!(yaml.contains("tasks:"));
1155        assert!(yaml.contains("- id: \"msg-001\""));
1156        assert!(yaml.contains("infer: \"What is Rust?\""));
1157    }
1158
1159    #[test]
1160    fn test_to_yaml_skips_non_user_messages() {
1161        let mut workflow = ChatWorkflow::new();
1162        workflow.add_message("What is Rust?", Role::User); // msg-001
1163        workflow.add_message("Rust is a systems language", Role::Assistant); // msg-002
1164        workflow.add_message("System initialized", Role::System); // msg-003
1165        workflow.add_message("Tool output", Role::Tool); // msg-004
1166        workflow.add_message("Tell me more", Role::User); // msg-005
1167
1168        let yaml = workflow.to_yaml();
1169
1170        // Only User messages become tasks
1171        assert!(yaml.contains("- id: \"msg-001\""));
1172        assert!(yaml.contains("- id: \"msg-005\""));
1173
1174        // Assistant/System/Tool should NOT appear as task IDs
1175        assert!(!yaml.contains("- id: \"msg-002\""));
1176        assert!(!yaml.contains("- id: \"msg-003\""));
1177        assert!(!yaml.contains("- id: \"msg-004\""));
1178    }
1179
1180    #[test]
1181    fn test_to_yaml_escapes_quotes() {
1182        let mut workflow = ChatWorkflow::new();
1183        workflow.add_message("Generate \"hello world\" program", Role::User);
1184
1185        let yaml = workflow.to_yaml();
1186
1187        // Quotes should be escaped
1188        assert!(yaml.contains(r#"infer: "Generate \"hello world\" program""#));
1189    }
1190
1191    #[test]
1192    fn test_to_yaml_escapes_multiline() {
1193        let mut workflow = ChatWorkflow::new();
1194        workflow.add_message("Line 1\nLine 2", Role::User);
1195
1196        let yaml = workflow.to_yaml();
1197
1198        // Newlines should be escaped as \n in the output
1199        assert!(yaml.contains("Line 1\\nLine 2"));
1200    }
1201
1202    #[test]
1203    fn test_to_yaml_with_mention_creates_depends_on() {
1204        let mut workflow = ChatWorkflow::new();
1205        workflow
1206            .add_message_with_mentions("What is Rust?", Role::User)
1207            .unwrap(); // msg-001
1208        workflow
1209            .add_message_with_mentions("Rust is...", Role::Assistant)
1210            .unwrap(); // msg-002
1211        workflow
1212            .add_message_with_mentions("Expand on @1", Role::User)
1213            .unwrap(); // msg-003
1214
1215        let yaml = workflow.to_yaml();
1216
1217        // msg-003 should have depends_on referencing msg-001
1218        assert!(yaml.contains("depends_on:"));
1219        assert!(yaml.contains("\"msg-001\""));
1220        // No separate flows section
1221        assert!(!yaml.contains("flows:"));
1222    }
1223
1224    #[test]
1225    fn test_to_yaml_multiple_mentions_create_depends_on() {
1226        let mut workflow = ChatWorkflow::new();
1227        workflow
1228            .add_message_with_mentions("First question", Role::User)
1229            .unwrap(); // msg-001
1230        workflow
1231            .add_message_with_mentions("Answer 1", Role::Assistant)
1232            .unwrap(); // msg-002
1233        workflow
1234            .add_message_with_mentions("Second question", Role::User)
1235            .unwrap(); // msg-003
1236        workflow
1237            .add_message_with_mentions("Answer 2", Role::Assistant)
1238            .unwrap(); // msg-004
1239        workflow
1240            .add_message_with_mentions("Compare @1 and @3", Role::User)
1241            .unwrap(); // msg-005
1242
1243        let yaml = workflow.to_yaml();
1244
1245        // msg-005 should have depends_on with both msg-001 and msg-003
1246        assert!(yaml.contains("depends_on:"));
1247        assert!(yaml.contains("\"msg-001\""));
1248        assert!(yaml.contains("\"msg-003\""));
1249        // No separate flows section
1250        assert!(!yaml.contains("flows:"));
1251    }
1252
1253    #[test]
1254    fn test_escape_yaml_string_simple() {
1255        assert_eq!(escape_yaml_string("hello"), "hello");
1256    }
1257
1258    #[test]
1259    fn test_escape_yaml_string_with_quotes() {
1260        assert_eq!(escape_yaml_string(r#"say "hi""#), r#"say \"hi\""#);
1261    }
1262
1263    #[test]
1264    fn test_escape_yaml_string_with_newline() {
1265        assert_eq!(escape_yaml_string("line1\nline2"), "line1\\nline2");
1266    }
1267
1268    #[test]
1269    fn test_escape_yaml_string_with_backslash() {
1270        assert_eq!(escape_yaml_string(r"path\to\file"), r"path\\to\\file");
1271    }
1272
1273    #[test]
1274    fn test_escape_yaml_string_complex() {
1275        let input = "He said \"hello\"\nThen walked away";
1276        let expected = r#"He said \"hello\"\nThen walked away"#;
1277        assert_eq!(escape_yaml_string(input), expected);
1278    }
1279
1280    #[test]
1281    fn test_to_yaml_round_trip_parseable() {
1282        // Verify the generated YAML is syntactically valid
1283        let mut workflow = ChatWorkflow::new();
1284        workflow.add_message("Question 1", Role::User);
1285        workflow.add_message("Answer 1", Role::Assistant);
1286        workflow.add_message("Question 2", Role::User);
1287
1288        let yaml = workflow.to_yaml();
1289
1290        // Should be parseable as YAML (not panic)
1291        // Note: Using serde_json::Value as target since serde-saphyr doesn't export Value type
1292        let parsed: serde_json::Value = crate::serde_yaml::from_str(&yaml).expect("Valid YAML");
1293
1294        // Verify structure
1295        assert!(parsed["schema"].as_str().is_some());
1296        assert!(parsed["tasks"].is_array()); // serde_json uses is_array()
1297                                             // No flows section — dependencies expressed via depends_on per-task
1298        assert!(parsed.get("flows").is_none());
1299    }
1300}