Skip to main content

recursive/session/
writer.rs

1//! Session writer for appending messages to JSONL session files and
2//! the `SessionPersistenceSink` event-sink bridge.
3//!
4//! Split from `session.rs` during the Goal 221 module refactor.
5
6use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use uuid::Uuid;
10
11use super::lifecycle::{read_last_message_uuid, SessionLock};
12use super::serialize::{CompactBoundaryEntry, TranscriptEntry};
13use super::{
14    chrono_lite_now, hash_tool_specs, workspace_slug, SessionMeta, SessionStatus, UsageMeta,
15};
16
17use crate::event::{AgentEvent, EventSink};
18use crate::llm::ToolSpec;
19use crate::message::Message;
20
21/// Writer for appending messages to a JSONL session file.
22///
23/// Opens (or creates) a `.jsonl` file in append mode and writes one
24/// JSON object per line. A companion `.meta.json` file tracks session
25/// metadata and is updated on `finish()`.
26pub struct SessionWriter {
27    session_id: String,
28    session_dir: PathBuf,
29    writer: BufWriter<std::fs::File>,
30    message_count: u64,
31    /// UUID of the last-appended message; used as `parent_uuid` for the
32    /// next message in the chain (g155).
33    last_uuid: Option<String>,
34    /// Accumulated token usage across all appended messages (g156).
35    cumulative_usage: UsageMeta,
36    /// First user prompt, truncated to 200 chars (g157).
37    first_prompt: Option<String>,
38    /// Most recent user prompt, truncated to 200 chars (g157).
39    last_prompt: Option<String>,
40    /// Held for the lifetime of the writer so a second
41    /// `SessionWriter::open_existing` (or `create`) on the same
42    /// `session_dir` is refused. Cleaned up on `Drop`.
43    _lock: Option<SessionLock>,
44    /// Optional human-readable display name for this session.
45    name: Option<String>,
46}
47
48impl SessionWriter {
49    /// Create a new session in the given workspace directory.
50    ///
51    /// The session directory is `<workspace>/.recursive/sessions/<workspace-slug>/<session-id>/`.
52    /// The `.jsonl` file and `.meta.json` are placed inside that directory.
53    ///
54    /// Equivalent to `create_with_tools(workspace, goal, model, provider, &[])`;
55    /// no `tool_registry_hash` is stamped in the meta. Prefer
56    /// `create_with_tools` whenever the caller has a `ToolRegistry`,
57    /// so that `recursive resume` (g151) can detect tool drift.
58    pub fn create(
59        workspace: &Path,
60        goal: &str,
61        model: &str,
62        provider: &str,
63    ) -> std::io::Result<Self> {
64        Self::create_with_tools(workspace, goal, model, provider, &[], None)
65    }
66
67    /// Create a new session, stamping a BLAKE3 hash of `tool_specs`
68    /// into `.meta.json` as `tool_registry_hash`. The hash is what
69    /// `recursive resume` validates against the current registry —
70    /// if they differ, resume aborts (g151).
71    ///
72    /// Pass `&[]` for `tool_specs` if the caller has no registry
73    /// (e.g. tests, ad-hoc tools), in which case the hash is `None`
74    /// and resume will warn but not abort.
75    ///
76    /// `preset` is the resolved provider preset id (e.g. "deepseek")
77    /// from the active `Config`. Pass `None` for pre-preset-config
78    /// callers or when the user did not opt in; the field is
79    /// `Option<String>` and `skip_serializing_if = "Option::is_none"`
80    /// keeps the on-disk format clean.
81    pub fn create_with_tools(
82        workspace: &Path,
83        goal: &str,
84        model: &str,
85        provider: &str,
86        tool_specs: &[ToolSpec],
87        preset: Option<&str>,
88    ) -> std::io::Result<Self> {
89        let slug = workspace_slug(workspace);
90        let session_id = format!("{}-{}", super::filesystem_safe_timestamp(), slug);
91        // Sessions live under the per-user data dir, not the project,
92        // so they don't pollute the user's `git status`.
93        let sessions_root = crate::paths::user_sessions_dir(workspace)
94            .map_err(|e| std::io::Error::other(format!("user_sessions_dir: {e}")))?;
95        let session_dir = sessions_root.join(&slug).join(&session_id);
96
97        std::fs::create_dir_all(&session_dir)?;
98
99        // Acquire the per-session lock before opening any files for
100        // writing — guards against two `recursive resume <id>`
101        // invocations clobbering the same transcript.
102        let lock = SessionLock::acquire(&session_dir)?;
103
104        let jsonl_path = session_dir.join("transcript.jsonl");
105        let file = std::fs::OpenOptions::new()
106            .create(true)
107            .append(true)
108            .open(&jsonl_path)?;
109
110        let now = chrono_lite_now();
111        let tool_registry_hash = if tool_specs.is_empty() {
112            None
113        } else {
114            Some(hash_tool_specs(tool_specs))
115        };
116        let meta = SessionMeta {
117            schema_version: super::SUPPORTED_SESSION_SCHEMA_VERSION,
118            session_id: session_id.clone(),
119            goal: goal.to_string(),
120            model: model.to_string(),
121            provider: provider.to_string(),
122            created_at: now.clone(),
123            updated_at: now.clone(),
124            message_count: 0,
125            status: SessionStatus::Active,
126            tool_registry_hash,
127            first_prompt: None,
128            last_prompt: None,
129            cost: None,
130            preset: preset.map(|s| s.to_string()),
131            name: None,
132        };
133
134        // Write initial meta file (atomic: temp + rename to prevent corruption).
135        let meta_path = session_dir.join(".meta.json");
136        let meta_json = serde_json::to_string_pretty(&meta)
137            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
138        crate::atomic::atomic_write(&meta_path, meta_json.as_bytes())?;
139
140        Ok(Self {
141            session_id,
142            session_dir,
143            writer: BufWriter::new(file),
144            message_count: 0,
145            last_uuid: None,
146            cumulative_usage: UsageMeta::default(),
147            first_prompt: None,
148            last_prompt: None,
149            _lock: Some(lock),
150            name: None,
151        })
152    }
153
154    /// Re-open an existing session directory for appending.
155    ///
156    /// Reads the existing `.meta.json` to recover `message_count`,
157    /// `created_at`, `goal`, `model`, and `provider`. The
158    /// `tool_registry_hash` is **not** re-validated here — the
159    /// caller (typically the `Cmd::Resume` handler) must have done
160    /// that already.
161    ///
162    /// Acquires `SessionLock::acquire(session_dir)` so a second
163    /// resume on the same session is refused while this writer is
164    /// alive.
165    ///
166    /// Continues the `msg_NNN` sequence from where the existing
167    /// transcript left off (so the first `append()` after
168    /// `open_existing` does not collide with prior messages).
169    pub fn open_existing(session_dir: &Path) -> std::io::Result<Self> {
170        let meta = super::reader::SessionReader::load_meta(session_dir)?;
171
172        let lock = SessionLock::acquire(session_dir)?;
173
174        let jsonl_path = session_dir.join("transcript.jsonl");
175
176        // Recover last_uuid from the last message entry in the JSONL so the
177        // UUID chain can continue from where the previous run left off (g155).
178        let last_uuid = read_last_message_uuid(&jsonl_path);
179
180        let file = std::fs::OpenOptions::new()
181            .create(true)
182            .append(true)
183            .open(&jsonl_path)?;
184
185        Ok(Self {
186            session_id: meta.session_id,
187            session_dir: session_dir.to_path_buf(),
188            writer: BufWriter::new(file),
189            message_count: meta.message_count,
190            last_uuid,
191            cumulative_usage: UsageMeta::default(),
192            first_prompt: meta.first_prompt,
193            last_prompt: meta.last_prompt,
194            _lock: Some(lock),
195            name: meta.name,
196        })
197    }
198
199    /// Append a message to the JSONL file.
200    ///
201    /// Returns the UUID assigned to this message entry. Bumps
202    /// `SessionMeta.updated_at` on every `assistant` or `user`
203    /// message (skipping per-tool-result writes is an
204    /// optimisation — a turn with N tool calls would otherwise
205    /// rewrite the meta file 2N times). The "most-recent" shortcut
206    /// in `recursive resume` (g151) relies on `updated_at` being
207    /// live for crashed sessions.
208    ///
209    /// `parent_uuid_override` — if `Some`, this UUID is used as `parent_uuid`
210    /// for the new entry instead of `self.last_uuid`. Use this for subagent
211    /// messages that branch off a specific parent agent message (g155).
212    ///
213    /// `usage` — token usage to attach to this entry (non-None for assistant
214    /// messages, g156).
215    pub fn append(
216        &mut self,
217        msg: &Message,
218        parent_uuid_override: Option<&str>,
219        usage: Option<&UsageMeta>,
220    ) -> std::io::Result<String> {
221        self.append_with_audit(msg, None, parent_uuid_override, usage)
222    }
223
224    /// Append a message with optional audit metadata (Goal 153).
225    /// `audit` should only be `Some` for `Role::Tool` messages.
226    pub fn append_with_audit(
227        &mut self,
228        msg: &Message,
229        audit: Option<crate::tools::AuditMeta>,
230        parent_uuid_override: Option<&str>,
231        usage: Option<&UsageMeta>,
232    ) -> std::io::Result<String> {
233        self.message_count += 1;
234        let msg_id = format!("msg_{:03}", self.message_count);
235
236        let parent_id = if self.message_count > 1 {
237            Some(format!("msg_{:03}", self.message_count - 1))
238        } else {
239            None
240        };
241
242        // g155: generate a stable UUID for this entry.
243        let new_uuid = Uuid::new_v4().to_string();
244        let parent_uuid = parent_uuid_override
245            .map(|s| s.to_string())
246            .or_else(|| self.last_uuid.clone());
247
248        // g155: for tool results, track which assistant message issued the call.
249        let source_tool_assistant_uuid = if matches!(msg.role, crate::message::Role::Tool) {
250            // The parent_uuid points to the assistant that issued this tool call.
251            parent_uuid.clone()
252        } else {
253            None
254        };
255
256        let role_str = match msg.role {
257            crate::message::Role::System => "system",
258            crate::message::Role::User => "user",
259            crate::message::Role::Assistant => "assistant",
260            crate::message::Role::Tool => "tool",
261        };
262
263        // g157: track first/last user prompt in memory (written to meta on bump).
264        if matches!(msg.role, crate::message::Role::User) {
265            let prompt: String = msg.content.chars().take(200).collect();
266            if self.first_prompt.is_none() {
267                self.first_prompt = Some(prompt.clone());
268                // Auto-populate `name` from the first user message when no
269                // explicit --name was set.  Truncate to 60 visible chars so
270                // the session list stays readable.  `name` can be overridden
271                // later with `SessionWriter::set_name`.
272                if self.name.is_none() {
273                    let title: String = prompt.chars().take(60).collect();
274                    self.name = Some(title);
275                }
276            }
277            self.last_prompt = Some(prompt);
278        }
279
280        // g156: accumulate usage if provided.
281        if let Some(u) = usage {
282            self.cumulative_usage.accumulate(u);
283        }
284
285        let entry = TranscriptEntry {
286            uuid: new_uuid.clone(),
287            parent_uuid,
288            source_tool_assistant_uuid,
289            id: msg_id,
290            parent_id,
291            role: role_str.to_string(),
292            content: msg.content.clone(),
293            tool_calls: msg.tool_calls.clone(),
294            tool_call_id: msg.tool_call_id.clone(),
295            reasoning_content: msg.reasoning_content.clone(),
296            usage: usage.cloned(),
297            timestamp: chrono_lite_now(),
298            audit,
299        };
300
301        let line = serde_json::to_string(&entry)
302            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
303        self.writer.write_all(line.as_bytes())?;
304        self.writer.write_all(b"\n")?;
305        self.writer.flush()?;
306
307        // g155: advance the chain pointer.
308        self.last_uuid = Some(new_uuid.clone());
309
310        // Bump updated_at on user/assistant messages so the
311        // "most-recent" shortcut on resume picks crashed sessions
312        // correctly. Skip on tool results to avoid 2N writes per
313        // tool-call-heavy turn.
314        if matches!(
315            msg.role,
316            crate::message::Role::User | crate::message::Role::Assistant
317        ) {
318            let _ = self.bump_updated_at();
319        }
320
321        Ok(new_uuid)
322    }
323
324    /// Write a compact_boundary system entry directly to the JSONL (g157).
325    ///
326    /// Called by `SessionPersistenceSink` when it receives a
327    /// `AgentEvent::CompactionBoundary` event. The entry is written outside
328    /// the normal `append` flow so it does not increment `message_count` or
329    /// advance the UUID chain.
330    pub fn write_compact_boundary(
331        &mut self,
332        turn: u32,
333        compacted_count: usize,
334        summary_uuid: Option<&str>,
335    ) -> std::io::Result<()> {
336        let entry = CompactBoundaryEntry {
337            entry_type: "system".to_string(),
338            subtype: "compact_boundary".to_string(),
339            turn: Some(turn),
340            compacted_count: Some(compacted_count),
341            summary_uuid: summary_uuid.map(|s| s.to_string()),
342            timestamp: chrono_lite_now(),
343        };
344        let line = serde_json::to_string(&entry)
345            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
346        self.writer.write_all(line.as_bytes())?;
347        self.writer.write_all(b"\n")?;
348        self.writer.flush()
349    }
350
351    /// Update `updated_at`, `message_count`, `first_prompt`, and `last_prompt`
352    /// in `.meta.json`, preserving everything else (goal, model, status, tool hash).
353    /// Best-effort: errors are returned but `append()` swallows them
354    /// so a transient meta-write failure does not abort the run.
355    fn bump_updated_at(&self) -> std::io::Result<()> {
356        let meta_path = self.session_dir.join(".meta.json");
357        let bytes = std::fs::read(&meta_path)?;
358        let mut meta: SessionMeta = serde_json::from_slice(&bytes)
359            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
360        meta.updated_at = chrono_lite_now();
361        meta.message_count = self.message_count;
362        // g157: persist first/last prompt so session picker can show them
363        // without reading the full JSONL.
364        if self.first_prompt.is_some() {
365            meta.first_prompt = self.first_prompt.clone();
366        }
367        if self.last_prompt.is_some() {
368            meta.last_prompt = self.last_prompt.clone();
369        }
370        // Persist auto-generated or explicitly set display name on every bump
371        // so the session list shows the title as soon as the first message lands.
372        if self.name.is_some() && meta.name.is_none() {
373            meta.name = self.name.clone();
374        }
375        let json = serde_json::to_string_pretty(&meta)
376            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
377        crate::atomic::atomic_write(&meta_path, json.as_bytes())
378    }
379
380    /// Finalise the session: flush the writer and update the meta file
381    /// with the final message count, status, prompts, and cumulative cost.
382    ///
383    /// `status` is a [`SessionStatus`] enum value. Callers that have a
384    /// `FinishReason` in hand should prefer
385    /// `SessionStatus::for_finish(&reason)` so the mapping stays
386    /// exhaustive — adding a new `FinishReason` variant will not
387    /// silently fall back to `Crashed`.
388    pub fn finish(&mut self, status: SessionStatus) -> std::io::Result<()> {
389        self.writer.flush()?;
390
391        // Read-modify-write so we preserve fields we don't own here
392        // (notably `tool_registry_hash`).
393        let meta_path = self.session_dir.join(".meta.json");
394        let bytes = std::fs::read(&meta_path)?;
395        let mut meta: SessionMeta = serde_json::from_slice(&bytes)
396            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
397        meta.updated_at = chrono_lite_now();
398        meta.message_count = self.message_count;
399        meta.status = status;
400        // g157: final prompt snapshot.
401        if self.first_prompt.is_some() {
402            meta.first_prompt = self.first_prompt.clone();
403        }
404        if self.last_prompt.is_some() {
405            meta.last_prompt = self.last_prompt.clone();
406        }
407        // g156: write cumulative cost.
408        if !self.cumulative_usage.is_zero() {
409            let mut cost = meta.cost.take().unwrap_or_default();
410            cost.accumulate(&self.cumulative_usage);
411            meta.cost = Some(cost);
412        }
413        // Persist the display name if one was set.
414        if self.name.is_some() {
415            meta.name = self.name.clone();
416        }
417
418        let meta_json = serde_json::to_string_pretty(&meta)
419            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
420        crate::atomic::atomic_write(&meta_path, meta_json.as_bytes())
421    }
422
423    /// Set an optional human-readable display name for this session.
424    /// The name is persisted to `.meta.json` on the next `finish()` call.
425    pub fn set_name(&mut self, name: impl Into<String>) {
426        self.name = Some(name.into());
427    }
428
429    /// Return the session ID.
430    pub fn session_id(&self) -> &str {
431        &self.session_id
432    }
433
434    /// Return the session directory path.
435    pub fn session_dir(&self) -> &Path {
436        &self.session_dir
437    }
438
439    /// Return the number of messages written so far.
440    pub fn message_count(&self) -> u64 {
441        self.message_count
442    }
443
444    /// Return the UUID of the last appended message (g155).
445    pub fn last_uuid(&self) -> Option<&str> {
446        self.last_uuid.as_deref()
447    }
448}
449
450// ---------------------------------------------------------------------------
451// SessionPersistenceSink
452// ---------------------------------------------------------------------------
453
454/// An [`EventSink`] that persists every [`AgentEvent::MessageAppended`] event
455/// to the session transcript file.
456///
457/// Wraps an `Arc<Mutex<SessionWriter>>` and calls
458/// [`SessionWriter::append`] on every `MessageAppended` event. All other
459/// event variants are silently ignored.
460///
461/// Persistence failures are non-fatal for the agent run but are logged at
462/// `error` level because a missing line on disk would silently break
463/// downstream orphan detection (g153).
464///
465/// # Locking notes
466///
467/// `SessionWriter` is not `Send` across `.await` points, so we keep the
468/// mutex non-`async` (`std::sync::Mutex`). The critical section inside
469/// `emit` is purely synchronous I/O — one `serde_json::to_string` +
470/// `write_all` + `flush` per message — matching every other consumer of
471/// `Arc<Mutex<SessionWriter>>` in the codebase.
472pub struct SessionPersistenceSink {
473    writer: Arc<std::sync::Mutex<SessionWriter>>,
474}
475
476impl SessionPersistenceSink {
477    /// Create a new `SessionPersistenceSink` backed by the given writer.
478    pub fn new(writer: Arc<std::sync::Mutex<SessionWriter>>) -> Self {
479        Self { writer }
480    }
481}
482
483#[async_trait::async_trait]
484impl EventSink for SessionPersistenceSink {
485    async fn emit(&self, event: AgentEvent) {
486        match event {
487            AgentEvent::MessageAppended { message, usage } => {
488                let result = {
489                    match self.writer.lock() {
490                        Ok(mut w) => w.append_with_audit(&message, None, None, usage.as_ref()),
491                        Err(poisoned) => {
492                            let mut w = poisoned.into_inner();
493                            w.append_with_audit(&message, None, None, usage.as_ref())
494                        }
495                    }
496                };
497                if let Err(e) = result {
498                    tracing::error!("session persistence: failed to append message: {e}");
499                }
500            }
501            AgentEvent::MessageAppendedWithAudit { message, audit } => {
502                // Goal 153: tool result with audit metadata.
503                let result = {
504                    match self.writer.lock() {
505                        Ok(mut w) => w.append_with_audit(&message, Some(audit), None, None),
506                        Err(poisoned) => {
507                            let mut w = poisoned.into_inner();
508                            w.append_with_audit(&message, Some(audit), None, None)
509                        }
510                    }
511                };
512                if let Err(e) = result {
513                    tracing::error!("session persistence: failed to append audited message: {e}");
514                }
515            }
516            AgentEvent::CompactionBoundary {
517                turn,
518                compacted_count,
519                summary_uuid,
520                ..
521            } => {
522                // g157: write a compact_boundary system entry so resume can
523                // skip the pre-compaction messages.
524                let result = match self.writer.lock() {
525                    Ok(mut w) => {
526                        w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
527                    }
528                    Err(poisoned) => {
529                        let mut w = poisoned.into_inner();
530                        w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
531                    }
532                };
533                if let Err(e) = result {
534                    tracing::error!("session persistence: failed to write compact_boundary: {e}");
535                }
536            }
537            _ => {}
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::message::{Message, Role};
546    use crate::session::SessionReader;
547
548    #[test]
549    fn session_writer_creates_meta_and_jsonl() {
550        let tmp = crate::test_util::IsolatedWorkspace::new();
551        let ws = tmp.path();
552
553        let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
554
555        let session_dir = writer.session_dir().to_path_buf();
556        assert!(session_dir.join("transcript.jsonl").is_file());
557        assert!(session_dir.join(".meta.json").is_file());
558
559        // Verify meta
560        let meta = SessionReader::load_meta(&session_dir).unwrap();
561        assert_eq!(meta.goal, "test goal");
562        assert_eq!(meta.model, "gpt-4o");
563        assert_eq!(meta.provider, "openai");
564        assert_eq!(meta.message_count, 0);
565        assert_eq!(meta.status, SessionStatus::Active);
566
567        writer.finish(SessionStatus::Completed).unwrap();
568
569        let meta = SessionReader::load_meta(&session_dir).unwrap();
570        assert_eq!(meta.message_count, 0);
571        assert_eq!(meta.status, SessionStatus::Completed);
572    }
573
574    #[test]
575    fn session_writer_appends_lines() {
576        let tmp = crate::test_util::IsolatedWorkspace::new();
577        let ws = tmp.path();
578
579        let mut writer = SessionWriter::create(ws, "test", "gpt-4o", "openai").unwrap();
580
581        let id1 = writer.append(&Message::user("hello"), None, None).unwrap();
582        // append now returns a UUID v4 (g155); just verify it's unique and non-empty
583        assert_eq!(id1.len(), 36, "uuid should be 36 chars");
584
585        let id2 = writer
586            .append(&Message::assistant("hi there"), None, None)
587            .unwrap();
588        assert_eq!(id2.len(), 36);
589        assert_ne!(id1, id2, "each message gets a unique uuid");
590
591        let session_dir = writer.session_dir().to_path_buf();
592        writer.finish(SessionStatus::Completed).unwrap();
593
594        // Load and verify — sequential id/parent_id are still written (g155 compat)
595        let entries = SessionReader::load_transcript(&session_dir).unwrap();
596        assert_eq!(entries.len(), 2);
597        assert_eq!(entries[0].id, "msg_001");
598        assert_eq!(entries[0].parent_id, None);
599        assert_eq!(entries[0].parent_uuid, None, "root has no parent_uuid");
600        assert!(!entries[0].uuid.is_empty(), "uuid must be present");
601        assert_eq!(entries[0].role, "user");
602        assert_eq!(entries[0].content, "hello");
603
604        assert_eq!(entries[1].id, "msg_002");
605        assert_eq!(entries[1].parent_id, Some("msg_001".to_string()));
606        assert_eq!(
607            entries[1].parent_uuid,
608            Some(entries[0].uuid.clone()),
609            "parent_uuid points to first entry"
610        );
611        assert_eq!(entries[1].role, "assistant");
612        assert_eq!(entries[1].content, "hi there");
613    }
614
615    #[test]
616    fn session_writer_finish_updates_meta() {
617        let tmp = crate::test_util::IsolatedWorkspace::new();
618        let ws = tmp.path();
619
620        let mut writer = SessionWriter::create(ws, "meta test", "gpt-4o", "openai").unwrap();
621        writer.append(&Message::user("msg1"), None, None).unwrap();
622        writer
623            .append(&Message::assistant("msg2"), None, None)
624            .unwrap();
625        let session_dir = writer.session_dir().to_path_buf();
626        writer.finish(SessionStatus::Completed).unwrap();
627
628        let meta = SessionReader::load_meta(&session_dir).unwrap();
629        assert_eq!(meta.message_count, 2);
630        assert_eq!(meta.status, SessionStatus::Completed);
631    }
632
633    /// Preset-config goal: the `preset` field is recorded on
634    /// `SessionMeta` so a future reader can see "this run was on
635    /// deepseek" without re-deriving from `api_base`. Verifies the
636    /// round-trip: create with `Some("deepseek")`, reload, assert
637    /// the field is preserved. Also confirms the default (None)
638    /// path is not serialized at all (`skip_serializing_if`) so
639    /// pre-preset-config session files stay byte-compat on read.
640    #[test]
641    fn session_meta_preserves_preset_field() {
642        let tmp = crate::test_util::IsolatedWorkspace::new();
643        let ws = tmp.path();
644
645        // With a preset — should round-trip. The writer is dropped
646        // before the second writer is created so the per-session
647        // lock is released; otherwise the second `create_with_tools`
648        // would fail with SessionLockBusy when both session_ids
649        // collide on the same second.
650        let (_session_dir, preset_after) = {
651            let mut writer = SessionWriter::create_with_tools(
652                ws,
653                "preset run",
654                "deepseek-chat",
655                "openai",
656                &[],
657                Some("deepseek"),
658            )
659            .unwrap();
660            let dir = writer.session_dir().to_path_buf();
661            writer.finish(SessionStatus::Completed).unwrap();
662            let meta = SessionReader::load_meta(&dir).unwrap();
663            (dir, meta.preset)
664        };
665        assert_eq!(preset_after.as_deref(), Some("deepseek"));
666
667        // Without a preset — `None` is kept on read, and the JSON
668        // should not include a `preset` key (skip_serializing_if).
669        let writer2 =
670            SessionWriter::create_with_tools(ws, "no preset run", "gpt-4o", "openai", &[], None)
671                .unwrap();
672        let session_dir2 = writer2.session_dir().to_path_buf();
673        drop(writer2);
674        let meta2 = SessionReader::load_meta(&session_dir2).unwrap();
675        assert!(meta2.preset.is_none());
676        let raw = std::fs::read_to_string(session_dir2.join(".meta.json")).unwrap();
677        assert!(
678            !raw.contains("\"preset\""),
679            "preset key should be absent when None, got: {raw}"
680        );
681    }
682
683    /// `name` is auto-filled from the first user prompt when no explicit
684    /// `--name` was supplied.
685    #[test]
686    fn session_name_autofills_from_first_prompt() {
687        let tmp = crate::test_util::IsolatedWorkspace::new();
688        let ws = tmp.path();
689
690        let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
691        let session_dir = writer.session_dir().to_path_buf();
692
693        // No --name set; name should be None before any message.
694        assert!(writer.name.is_none());
695
696        // After the first user message the writer should have auto-populated name.
697        writer
698            .append(&Message::user("hello world".to_string()), None, None)
699            .unwrap();
700        assert_eq!(writer.name.as_deref(), Some("hello world"));
701
702        writer.finish(SessionStatus::Completed).unwrap();
703
704        // name should be persisted in meta.json.
705        let meta = SessionReader::load_meta(&session_dir).unwrap();
706        assert_eq!(meta.name.as_deref(), Some("hello world"));
707    }
708
709    /// When --name is set explicitly, auto-fill must NOT overwrite it.
710    #[test]
711    fn explicit_name_not_overwritten_by_autofill() {
712        let tmp = crate::test_util::IsolatedWorkspace::new();
713        let ws = tmp.path();
714
715        let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
716        writer.set_name("my custom name".to_string());
717        let session_dir = writer.session_dir().to_path_buf();
718
719        writer
720            .append(&Message::user("hello world".to_string()), None, None)
721            .unwrap();
722        // Auto-fill should not replace the explicitly set name.
723        assert_eq!(writer.name.as_deref(), Some("my custom name"));
724
725        writer.finish(SessionStatus::Completed).unwrap();
726
727        let meta = SessionReader::load_meta(&session_dir).unwrap();
728        assert_eq!(meta.name.as_deref(), Some("my custom name"));
729    }
730
731    /// Long prompts are truncated to 60 visible chars in the auto-filled name.
732    #[test]
733    fn autofill_name_truncates_long_prompt() {
734        let tmp = crate::test_util::IsolatedWorkspace::new();
735        let ws = tmp.path();
736
737        let long_prompt = "a".repeat(100);
738        let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
739        writer
740            .append(&Message::user(long_prompt.clone()), None, None)
741            .unwrap();
742        writer.finish(SessionStatus::Completed).unwrap();
743
744        let session_dir = writer.session_dir().to_path_buf();
745        let meta = SessionReader::load_meta(&session_dir).unwrap();
746        assert_eq!(
747            meta.name.as_deref(),
748            Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") // 60 'a's
749        );
750    }
751
752    // -- SessionPersistenceSink tests --------------------------------------
753
754    #[test]
755    fn session_writer_accessor_methods_return_correct_values() {
756        // kills function-level replacements for session_id(), session_dir(),
757        // message_count(), and last_uuid()
758        let tmp = crate::test_util::IsolatedWorkspace::new();
759        let writer = SessionWriter::create(tmp.path(), "goal", "gpt-4o", "openai").unwrap();
760
761        // session_id must be non-empty
762        assert!(
763            !writer.session_id().is_empty(),
764            "session_id must be non-empty"
765        );
766        // session_dir must end with the session_id directory
767        assert!(writer.session_dir().exists());
768        assert!(writer.session_dir().ends_with(writer.session_id()));
769        // initial message_count must be 0
770        assert_eq!(writer.message_count(), 0, "initial message_count must be 0");
771        // initial last_uuid must be None
772        assert!(
773            writer.last_uuid().is_none(),
774            "last_uuid must be None initially"
775        );
776    }
777
778    #[test]
779    fn write_compact_boundary_appends_line_to_transcript() {
780        // kills function-level replacement of write_compact_boundary
781        let tmp = crate::test_util::IsolatedWorkspace::new();
782        let mut writer = SessionWriter::create(tmp.path(), "g", "gpt-4o", "openai").unwrap();
783        let session_dir = writer.session_dir().to_path_buf();
784        writer
785            .write_compact_boundary(5, 10, Some("uuid-summary-1"))
786            .unwrap();
787        let transcript_path = session_dir.join("transcript.jsonl");
788        let content = std::fs::read_to_string(&transcript_path).unwrap();
789        assert!(
790            content.contains("compact_boundary"),
791            "transcript must contain 'compact_boundary' marker"
792        );
793        assert!(
794            content.contains("uuid-summary-1"),
795            "transcript must contain the summary UUID"
796        );
797    }
798
799    fn make_isolated_writer() -> (
800        crate::test_util::IsolatedWorkspace,
801        Arc<std::sync::Mutex<SessionWriter>>,
802    ) {
803        let ws = crate::test_util::IsolatedWorkspace::new();
804        let writer = SessionWriter::create(ws.path(), "test goal", "gpt-4o", "openai").unwrap();
805        (ws, Arc::new(std::sync::Mutex::new(writer)))
806    }
807
808    /// `SessionPersistenceSink` appends a message with all fields to disk,
809    /// and the round-trip preserves content, tool_calls, and reasoning_content.
810    #[tokio::test]
811    async fn message_appended_round_trips_through_sink() {
812        use crate::event::{AgentEvent, EventSink};
813        use crate::llm::ToolCall as LlmToolCall;
814
815        let (_ws, sw) = make_isolated_writer();
816        let sink = SessionPersistenceSink::new(sw.clone());
817
818        let tc = LlmToolCall {
819            id: "call_1".into(),
820            name: "my_tool".into(),
821            arguments: serde_json::json!({"x": 1}),
822        };
823        let msg = Message {
824            role: Role::Assistant,
825            content: "response text".into(),
826            tool_calls: vec![tc],
827            tool_call_id: None,
828            reasoning_content: Some("I thought about it".into()),
829            is_compaction_summary: false,
830        };
831        sink.emit(AgentEvent::MessageAppended {
832            message: msg.clone(),
833            usage: None,
834        })
835        .await;
836
837        // Other events are silently ignored.
838        sink.emit(AgentEvent::PlanConfirmed).await;
839
840        let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
841        drop(sink);
842        drop(sw);
843
844        let transcript = SessionReader::load_messages(&session_dir).unwrap();
845        assert_eq!(transcript.len(), 1, "exactly one message written");
846        let loaded = &transcript[0];
847        assert_eq!(loaded.content, "response text");
848        assert_eq!(
849            loaded.reasoning_content.as_deref(),
850            Some("I thought about it")
851        );
852        assert_eq!(loaded.tool_calls.len(), 1);
853        assert_eq!(loaded.tool_calls[0].name, "my_tool");
854    }
855
856    /// A poisoned mutex is recovered gracefully: subsequent `emit` calls
857    /// still append and do not panic.
858    #[tokio::test]
859    async fn sink_recovers_from_poisoned_mutex() {
860        use crate::event::{AgentEvent, EventSink};
861
862        let (_ws, sw) = make_isolated_writer();
863        let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
864
865        // Poison the mutex by panicking inside a lock guard on another thread.
866        let sw2 = sw.clone();
867        let _ = std::panic::catch_unwind(move || {
868            let _guard = sw2.lock().unwrap();
869            panic!("intentional poison");
870        });
871        assert!(sw.is_poisoned(), "mutex must be poisoned after the panic");
872
873        let sink = SessionPersistenceSink::new(sw);
874        // This must not panic even though the mutex is poisoned.
875        sink.emit(AgentEvent::MessageAppended {
876            message: Message::user("after poison"),
877            usage: None,
878        })
879        .await;
880
881        let transcript = SessionReader::load_messages(&session_dir).unwrap();
882        assert_eq!(transcript.len(), 1);
883        assert_eq!(transcript[0].content, "after poison");
884    }
885
886    #[test]
887    fn open_existing_resumes_message_count_and_uuid_chain() {
888        // kills `message_count: meta.message_count` and `last_uuid` field removal mutations
889        let tmp = crate::test_util::IsolatedWorkspace::new();
890        let session_dir = {
891            let mut w = SessionWriter::create(tmp.path(), "goal", "gpt-4o", "openai").unwrap();
892            w.append(&Message::user("first".to_string()), None, None)
893                .unwrap();
894            w.append(&Message::assistant("second".to_string()), None, None)
895                .unwrap();
896            let d = w.session_dir().to_path_buf();
897            w.finish(SessionStatus::Completed).unwrap();
898            d
899        };
900
901        // Re-open and check that message_count is recovered from meta
902        let mut w2 = SessionWriter::open_existing(&session_dir).unwrap();
903        assert_eq!(
904            w2.message_count(),
905            2,
906            "open_existing must recover message_count from meta"
907        );
908        // last_uuid must be non-None (chain continues)
909        assert!(
910            w2.last_uuid().is_some(),
911            "open_existing must recover last_uuid from transcript"
912        );
913
914        // Append another message and verify count advances
915        w2.append(&Message::user("third".to_string()), None, None)
916            .unwrap();
917        assert_eq!(
918            w2.message_count(),
919            3,
920            "appending after open_existing must increment count"
921        );
922    }
923
924    #[test]
925    fn append_with_audit_includes_audit_fields_in_jsonl() {
926        // kills `audit: None` → `audit: Some(...)` field mutation in append_with_audit;
927        // specifically tests that the audit field is serialized when provided.
928        let tmp = crate::test_util::IsolatedWorkspace::new();
929        let mut w = SessionWriter::create(tmp.path(), "goal", "gpt-4o", "openai").unwrap();
930        let session_dir = w.session_dir().to_path_buf();
931
932        // Use the synthetic factory to build a valid AuditMeta without
933        // constructing all fields manually.
934        let audit = crate::tools::AuditMeta::synthetic_unknown_tool("Read");
935        let tool_msg = Message::tool_result("tc-audit-1", "file contents");
936        w.append_with_audit(&tool_msg, Some(audit), None, None)
937            .unwrap();
938        w.finish(SessionStatus::Completed).unwrap();
939
940        // Verify the audit data was written to the JSONL
941        let raw = std::fs::read_to_string(session_dir.join("transcript.jsonl")).unwrap();
942        assert!(
943            raw.contains("\"audit\""),
944            "audit field must appear in transcript when provided; raw={raw}"
945        );
946    }
947}