Skip to main content

tj_core/
pack.rs

1//! Pack assembler: turns events + derived state into compact resume Markdown.
2
3use serde::Serialize;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
6pub enum PackMode {
7    Compact,
8    Full,
9}
10
11#[derive(Debug, Clone, Serialize)]
12pub struct TaskPack {
13    pub task_id: String,
14    pub mode: PackMode,
15    pub schema_version: String,
16    pub text: String,
17    pub metadata: PackMetadata,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct PackMetadata {
22    pub generated_at: String,
23    pub source_event_count: usize,
24    pub cache_hit: bool,
25    pub truncated: bool,
26}
27
28use anyhow::Context;
29use rusqlite::Connection;
30
31fn render_recent_events(conn: &Connection, task_id: &str, limit: usize) -> anyhow::Result<String> {
32    let mut out = format!("## Recent events (last {limit})\n");
33    let mut stmt = conn.prepare(
34        "SELECT ei.timestamp, ei.type, ei.status, sf.text FROM events_index ei
35         LEFT JOIN search_fts sf ON sf.event_id = ei.event_id
36         WHERE ei.task_id=?1 ORDER BY ei.timestamp DESC LIMIT ?2",
37    )?;
38    let rows = stmt.query_map(rusqlite::params![task_id, limit as i64], |r| {
39        let ts: String = r.get(0)?;
40        let ty: String = r.get(1)?;
41        let st: String = r.get(2)?;
42        let txt: Option<String> = r.get(3)?;
43        Ok((ts, ty, st, txt.unwrap_or_default()))
44    })?;
45    for row in rows {
46        let (ts, ty, st, txt) = row?;
47        let one_line = txt
48            .lines()
49            .next()
50            .unwrap_or("")
51            .chars()
52            .take(120)
53            .collect::<String>();
54        let marker = if st == "suggested" { " [?]" } else { "" };
55        out.push_str(&format!("- {ts} [{ty}]{marker} {one_line}\n"));
56    }
57    out.push('\n');
58    Ok(out)
59}
60
61fn render_evidence(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
62    let mut out = String::from("## Evidence\n");
63    let mut stmt = conn
64        .prepare("SELECT text, strength FROM evidence WHERE task_id=?1 ORDER BY evidence_id ASC")?;
65    let rows = stmt.query_map(rusqlite::params![task_id], |r| {
66        let t: String = r.get(0)?;
67        let s: String = r.get(1)?;
68        Ok((t, s))
69    })?;
70    let mut count = 0;
71    for row in rows {
72        let (t, s) = row?;
73        out.push_str(&format!("- {t} ({s})\n"));
74        count += 1;
75    }
76    if count == 0 {
77        out.push_str("- (none)\n");
78    }
79    out.push('\n');
80    Ok(out)
81}
82
83fn render_rejected(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
84    let mut out = String::from("## Rejected\n");
85    let mut id_stmt = conn.prepare(
86        "SELECT event_id FROM events_index
87         WHERE task_id=?1 AND type='rejection'
88         ORDER BY timestamp ASC",
89    )?;
90    let mut text_stmt = conn.prepare("SELECT text FROM search_fts WHERE event_id=?1 LIMIT 1")?;
91    let event_ids: Vec<String> = id_stmt
92        .query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?
93        .collect::<Result<_, _>>()?;
94    let mut count = 0;
95    for eid in event_ids {
96        let text: String = text_stmt.query_row(rusqlite::params![eid], |r| r.get(0))?;
97        out.push_str(&format!("- {text}\n"));
98        count += 1;
99    }
100    if count == 0 {
101        out.push_str("- (none)\n");
102    }
103    out.push('\n');
104    Ok(out)
105}
106
107fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
108    let mut out = String::from("## Active decisions\n");
109    let mut stmt = conn.prepare(
110        "SELECT text FROM decisions WHERE task_id=?1 AND status='active' ORDER BY decision_id ASC",
111    )?;
112    let rows = stmt.query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?;
113    let mut count = 0;
114    for row in rows {
115        out.push_str(&format!("- {}\n", row?));
116        count += 1;
117    }
118    if count == 0 {
119        out.push_str("- (none)\n");
120    }
121    out.push('\n');
122    Ok(out)
123}
124
125fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
126    let mut out = String::from("## Lifecycle\n");
127    let mut stmt = conn.prepare(
128        "SELECT timestamp, type FROM events_index
129         WHERE task_id=?1 AND type IN ('open','close','reopen','supersede','redirect')
130         ORDER BY timestamp ASC",
131    )?;
132    let rows = stmt.query_map(rusqlite::params![task_id], |r| {
133        let ts: String = r.get(0)?;
134        let ty: String = r.get(1)?;
135        Ok((ts, ty))
136    })?;
137    let mut count = 0;
138    for row in rows {
139        let (ts, ty) = row?;
140        let verb = match ty.as_str() {
141            "open" => "opened",
142            "close" => "closed",
143            "reopen" => "reopened",
144            "supersede" => "superseded",
145            "redirect" => "redirected",
146            _ => &ty,
147        };
148        out.push_str(&format!("- {ts} {verb}\n"));
149        count += 1;
150    }
151    if count == 0 {
152        out.push_str("- (none)\n");
153    }
154    out.push('\n');
155    Ok(out)
156}
157
158pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Result<TaskPack> {
159    let mode_str = match mode {
160        PackMode::Compact => "compact",
161        PackMode::Full => "full",
162    };
163
164    // Read-through cache: if we have a stored pack with the same mode, return it.
165    let cached: Option<(String, String, i64)> = conn
166        .query_row(
167            "SELECT text, generated_at, source_event_count FROM task_pack_cache
168         WHERE task_id=?1 AND mode=?2",
169            rusqlite::params![task_id, mode_str],
170            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
171        )
172        .ok();
173    if let Some((cached_text, cached_at, cached_count)) = cached {
174        // Detect truncation by re-checking for the marker.
175        let was_truncated = cached_text.contains("_(truncated to fit pack budget)_");
176        return Ok(TaskPack {
177            task_id: task_id.to_string(),
178            mode,
179            schema_version: crate::SCHEMA_VERSION.into(),
180            text: cached_text,
181            metadata: PackMetadata {
182                generated_at: cached_at,
183                source_event_count: cached_count as usize,
184                cache_hit: true,
185                truncated: was_truncated,
186            },
187        });
188    }
189
190    let (title, status): (String, String) = conn
191        .query_row(
192            "SELECT title, status FROM tasks WHERE task_id=?1",
193            rusqlite::params![task_id],
194            |r| Ok((r.get(0)?, r.get(1)?)),
195        )
196        .with_context(|| format!("task not found: {task_id}"))?;
197
198    let event_count: usize = conn.query_row(
199        "SELECT COUNT(*) FROM events_index WHERE task_id=?1",
200        rusqlite::params![task_id],
201        |r| r.get::<_, i64>(0).map(|n| n as usize),
202    )?;
203
204    let mut text = format!("# {title}  [status: {status}]\n\n");
205    if matches!(mode, PackMode::Full) {
206        text.push_str(&render_lifecycle(conn, task_id)?);
207    }
208    text.push_str(&render_active_decisions(conn, task_id)?);
209    if matches!(mode, PackMode::Full) {
210        text.push_str(&render_rejected(conn, task_id)?);
211        text.push_str(&render_evidence(conn, task_id)?);
212    }
213    let recent_limit = match mode {
214        PackMode::Compact => 3,
215        PackMode::Full => 10,
216    };
217    text.push_str(&render_recent_events(conn, task_id, recent_limit)?);
218
219    // Token-budget truncation: cap pack size so it always fits an LLM context window.
220    const FULL_BUDGET: usize = 10 * 1024;
221    const COMPACT_BUDGET: usize = 2 * 1024;
222    const TRUNC_MARKER: &str = "\n\n_(truncated to fit pack budget)_\n";
223    let budget = match mode {
224        PackMode::Full => FULL_BUDGET,
225        PackMode::Compact => COMPACT_BUDGET,
226    };
227    let truncated = text.len() > budget;
228    if truncated {
229        let cutoff = text[..budget].rfind('\n').unwrap_or(budget);
230        text.truncate(cutoff);
231        text.push_str(TRUNC_MARKER);
232    }
233
234    let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
235
236    // Write-through cache.
237    conn.execute(
238        "INSERT OR REPLACE INTO task_pack_cache(task_id, mode, text, generated_at, source_event_count)
239         VALUES (?1, ?2, ?3, ?4, ?5)",
240        rusqlite::params![task_id, mode_str, text, generated_at, event_count as i64],
241    )?;
242
243    Ok(TaskPack {
244        task_id: task_id.to_string(),
245        mode,
246        schema_version: crate::SCHEMA_VERSION.into(),
247        text,
248        metadata: PackMetadata {
249            generated_at,
250            source_event_count: event_count,
251            cache_hit: false,
252            truncated,
253        },
254    })
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn pack_mode_round_trips_via_serde() {
263        let s = serde_json::to_string(&PackMode::Compact).unwrap();
264        assert_eq!(s, "\"Compact\"");
265    }
266
267    #[test]
268    fn cache_is_invalidated_on_new_event() {
269        use crate::db;
270        use crate::event::*;
271        use tempfile::TempDir;
272
273        let d = TempDir::new().unwrap();
274        let conn = db::open(d.path().join("s.sqlite")).unwrap();
275        let mut open_e = Event::new(
276            "tj-inv",
277            EventType::Open,
278            Author::User,
279            Source::Cli,
280            "x".into(),
281        );
282        open_e.meta = serde_json::json!({"title": "Inv"});
283        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
284        db::index_event(&conn, &open_e).unwrap();
285
286        let _ = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
287        let p2 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
288        assert!(p2.metadata.cache_hit);
289
290        let dec = Event::new(
291            "tj-inv",
292            EventType::Decision,
293            Author::Agent,
294            Source::Chat,
295            "D".into(),
296        );
297        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
298        db::index_event(&conn, &dec).unwrap();
299
300        let p3 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
301        assert!(
302            !p3.metadata.cache_hit,
303            "new event must invalidate the cache"
304        );
305    }
306
307    #[test]
308    fn pack_cache_hits_after_incremental_ingest_with_no_new_events() {
309        // Reproduces the MCP hot loop: client calls task_pack(X), the server
310        // runs ingest_new_events (which now reads only the JSONL tail), then
311        // calls assemble(X). After B2 the second call must hit the cache —
312        // before B2, full rebuild_state replayed every event through index_
313        // event() which DELETEd the cache row, so we always missed.
314        use crate::db;
315        use crate::event::*;
316        use std::io::Write;
317        use tempfile::TempDir;
318
319        let d = TempDir::new().unwrap();
320        let jsonl = d.path().join("events.jsonl");
321        let project = "cafef00dcafef00d";
322
323        let mut open_e = Event::new(
324            "tj-cmcp",
325            EventType::Open,
326            Author::User,
327            Source::Cli,
328            "x".into(),
329        );
330        open_e.meta = serde_json::json!({"title": "Cached"});
331        let dec = Event::new(
332            "tj-cmcp",
333            EventType::Decision,
334            Author::Agent,
335            Source::Chat,
336            "Adopt Rust".into(),
337        );
338
339        let mut f = std::fs::File::create(&jsonl).unwrap();
340        writeln!(f, "{}", serde_json::to_string(&open_e).unwrap()).unwrap();
341        writeln!(f, "{}", serde_json::to_string(&dec).unwrap()).unwrap();
342        drop(f);
343
344        let conn = db::open(d.path().join("s.sqlite")).unwrap();
345
346        // First MCP call: ingest, then pack.
347        db::ingest_new_events(&conn, &jsonl, project).unwrap();
348        let first = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
349        assert!(
350            !first.metadata.cache_hit,
351            "first assemble must populate cache"
352        );
353
354        // Second MCP call: ingest again (zero new events in JSONL), then pack.
355        let n_new = db::ingest_new_events(&conn, &jsonl, project).unwrap();
356        assert_eq!(n_new, 0, "no new events should be ingested");
357        let second = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
358        assert!(
359            second.metadata.cache_hit,
360            "repeat assemble after a no-op ingest must hit the cache"
361        );
362        assert_eq!(first.text, second.text);
363    }
364
365    #[test]
366    fn pack_cache_returns_cached_text_on_second_call() {
367        use crate::db;
368        use crate::event::*;
369        use tempfile::TempDir;
370
371        let d = TempDir::new().unwrap();
372        let conn = db::open(d.path().join("s.sqlite")).unwrap();
373        let mut open_e = Event::new(
374            "tj-c",
375            EventType::Open,
376            Author::User,
377            Source::Cli,
378            "x".into(),
379        );
380        open_e.meta = serde_json::json!({"title": "Cache"});
381        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
382        db::index_event(&conn, &open_e).unwrap();
383
384        let p1 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
385        assert!(!p1.metadata.cache_hit);
386        let p2 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
387        assert!(p2.metadata.cache_hit, "second call should hit cache");
388        assert_eq!(p1.text, p2.text);
389    }
390
391    #[test]
392    fn compact_mode_omits_optional_sections() {
393        use crate::db;
394        use crate::event::*;
395        use tempfile::TempDir;
396
397        let d = TempDir::new().unwrap();
398        let conn = db::open(d.path().join("s.sqlite")).unwrap();
399        let mut open_e = Event::new(
400            "tj-cm",
401            EventType::Open,
402            Author::User,
403            Source::Cli,
404            "x".into(),
405        );
406        open_e.meta = serde_json::json!({"title": "Compact"});
407        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
408        db::index_event(&conn, &open_e).unwrap();
409        let dec = Event::new(
410            "tj-cm",
411            EventType::Decision,
412            Author::Agent,
413            Source::Chat,
414            "D1".into(),
415        );
416        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
417        db::index_event(&conn, &dec).unwrap();
418
419        let pack = assemble(&conn, "tj-cm", PackMode::Compact).unwrap();
420        assert!(pack.text.contains("# Compact"));
421        assert!(pack.text.contains("Active decisions"));
422        assert!(pack.text.contains("Recent events"));
423        assert!(
424            !pack.text.contains("Lifecycle"),
425            "compact should omit Lifecycle: {}",
426            pack.text
427        );
428        assert!(
429            !pack.text.contains("Rejected"),
430            "compact should omit Rejected: {}",
431            pack.text
432        );
433        assert!(
434            !pack.text.contains("Evidence"),
435            "compact should omit Evidence: {}",
436            pack.text
437        );
438    }
439
440    #[test]
441    fn full_mode_truncates_when_exceeding_budget() {
442        use crate::db;
443        use crate::event::*;
444        use tempfile::TempDir;
445
446        let d = TempDir::new().unwrap();
447        let conn = db::open(d.path().join("s.sqlite")).unwrap();
448        let mut open_e = Event::new(
449            "tj-big",
450            EventType::Open,
451            Author::User,
452            Source::Cli,
453            "x".into(),
454        );
455        open_e.meta = serde_json::json!({"title": "Big"});
456        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
457        db::index_event(&conn, &open_e).unwrap();
458        for i in 0..100 {
459            let ev = Event::new(
460                "tj-big",
461                EventType::Evidence,
462                Author::Agent,
463                Source::Chat,
464                format!("Evidence #{i}: {}", "lorem ipsum ".repeat(50)),
465            );
466            db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
467            db::index_event(&conn, &ev).unwrap();
468        }
469        let pack = assemble(&conn, "tj-big", PackMode::Full).unwrap();
470        assert!(
471            pack.text.len() <= 12 * 1024,
472            "pack must stay under ~12KB; got {} bytes",
473            pack.text.len()
474        );
475        assert!(pack.metadata.truncated, "metadata.truncated must be true");
476        assert!(pack.text.contains("truncated to fit pack budget"));
477    }
478
479    #[test]
480    fn corrected_events_appear_with_correction_event_type() {
481        use crate::db;
482        use crate::event::*;
483        use tempfile::TempDir;
484
485        let d = TempDir::new().unwrap();
486        let conn = db::open(d.path().join("s.sqlite")).unwrap();
487        let mut open_e = Event::new(
488            "tj-co",
489            EventType::Open,
490            Author::User,
491            Source::Cli,
492            "x".into(),
493        );
494        open_e.meta = serde_json::json!({"title": "Corr"});
495        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
496        db::index_event(&conn, &open_e).unwrap();
497
498        let bad = Event::new(
499            "tj-co",
500            EventType::Finding,
501            Author::Classifier,
502            Source::Hook,
503            "Migration done (wrong)".into(),
504        );
505        db::upsert_task_from_event(&conn, &bad, "feedface").unwrap();
506        db::index_event(&conn, &bad).unwrap();
507
508        let mut corr = Event::new(
509            "tj-co",
510            EventType::Correction,
511            Author::User,
512            Source::Cli,
513            "Migration NOT done; finding was wrong".into(),
514        );
515        corr.corrects = Some(bad.event_id.clone());
516        db::upsert_task_from_event(&conn, &corr, "feedface").unwrap();
517        db::index_event(&conn, &corr).unwrap();
518
519        let pack = assemble(&conn, "tj-co", PackMode::Full).unwrap();
520        assert!(pack.text.contains("[correction]"));
521        assert!(pack.text.contains("Migration NOT done"));
522    }
523
524    #[test]
525    fn suggested_events_get_question_mark_marker_in_pack() {
526        use crate::db;
527        use crate::event::*;
528        use tempfile::TempDir;
529
530        let d = TempDir::new().unwrap();
531        let conn = db::open(d.path().join("s.sqlite")).unwrap();
532        let mut open_e = Event::new(
533            "tj-q",
534            EventType::Open,
535            Author::User,
536            Source::Cli,
537            "x".into(),
538        );
539        open_e.meta = serde_json::json!({"title": "Q"});
540        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
541        db::index_event(&conn, &open_e).unwrap();
542
543        let mut suggested = Event::new(
544            "tj-q",
545            EventType::Decision,
546            Author::Classifier,
547            Source::Hook,
548            "Adopt Rust".into(),
549        );
550        suggested.status = EventStatus::Suggested;
551        db::upsert_task_from_event(&conn, &suggested, "feedface").unwrap();
552        db::index_event(&conn, &suggested).unwrap();
553
554        let pack = assemble(&conn, "tj-q", PackMode::Full).unwrap();
555        let recent_pos = pack.text.find("## Recent events").unwrap();
556        let recent_section = &pack.text[recent_pos..];
557        assert!(
558            recent_section.contains("[?]"),
559            "suggested event must show [?] marker in Recent events:\n{recent_section}"
560        );
561    }
562
563    #[test]
564    fn pack_renders_recent_events_full_mode() {
565        use crate::db;
566        use crate::event::*;
567        use tempfile::TempDir;
568
569        let d = TempDir::new().unwrap();
570        let conn = db::open(d.path().join("s.sqlite")).unwrap();
571        let mut open_e = Event::new(
572            "tj-re",
573            EventType::Open,
574            Author::User,
575            Source::Cli,
576            "x".into(),
577        );
578        open_e.meta = serde_json::json!({"title": "Recent"});
579        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
580        db::index_event(&conn, &open_e).unwrap();
581        for i in 0..6 {
582            let e = Event::new(
583                "tj-re",
584                EventType::Hypothesis,
585                Author::Agent,
586                Source::Chat,
587                format!("hypothesis {i}"),
588            );
589            db::upsert_task_from_event(&conn, &e, "feedface").unwrap();
590            db::index_event(&conn, &e).unwrap();
591        }
592
593        let pack = assemble(&conn, "tj-re", PackMode::Full).unwrap();
594        assert!(pack.text.contains("## Recent events"));
595        let count = pack.text.matches("[hypothesis]").count();
596        assert!(
597            count >= 5,
598            "expected >=5 hypotheses, got {count} in {}",
599            pack.text
600        );
601    }
602
603    #[test]
604    fn pack_renders_evidence_section() {
605        use crate::db;
606        use crate::event::*;
607        use tempfile::TempDir;
608
609        let d = TempDir::new().unwrap();
610        let conn = db::open(d.path().join("s.sqlite")).unwrap();
611        let mut open_e = Event::new(
612            "tj-ev",
613            EventType::Open,
614            Author::User,
615            Source::Cli,
616            "x".into(),
617        );
618        open_e.meta = serde_json::json!({"title": "Ev"});
619        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
620        db::index_event(&conn, &open_e).unwrap();
621
622        let mut ev = Event::new(
623            "tj-ev",
624            EventType::Evidence,
625            Author::Agent,
626            Source::Chat,
627            "Hook startup at 12ms vs 380ms node".into(),
628        );
629        ev.evidence_strength = Some(EvidenceStrength::Strong);
630        db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
631        db::index_event(&conn, &ev).unwrap();
632
633        let pack = assemble(&conn, "tj-ev", PackMode::Full).unwrap();
634        assert!(pack.text.contains("## Evidence"));
635        assert!(pack.text.contains("12ms"));
636        assert!(pack.text.contains("(strong)"));
637    }
638
639    #[test]
640    fn pack_renders_rejected_options() {
641        use crate::db;
642        use crate::event::*;
643        use tempfile::TempDir;
644
645        let d = TempDir::new().unwrap();
646        let conn = db::open(d.path().join("s.sqlite")).unwrap();
647        let mut open_e = Event::new(
648            "tj-r",
649            EventType::Open,
650            Author::User,
651            Source::Cli,
652            "x".into(),
653        );
654        open_e.meta = serde_json::json!({"title": "Rej"});
655        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
656        db::index_event(&conn, &open_e).unwrap();
657
658        let rej = Event::new(
659            "tj-r",
660            EventType::Rejection,
661            Author::Agent,
662            Source::Chat,
663            "TypeScript: loses single-binary distribution".into(),
664        );
665        db::upsert_task_from_event(&conn, &rej, "feedface").unwrap();
666        db::index_event(&conn, &rej).unwrap();
667
668        let pack = assemble(&conn, "tj-r", PackMode::Full).unwrap();
669        assert!(pack.text.contains("## Rejected"));
670        assert!(pack.text.contains("TypeScript"));
671    }
672
673    #[test]
674    fn pack_renders_active_decisions() {
675        use crate::db;
676        use crate::event::*;
677        use tempfile::TempDir;
678
679        let d = TempDir::new().unwrap();
680        let conn = db::open(d.path().join("s.sqlite")).unwrap();
681        let mut open_e = Event::new(
682            "tj-ad",
683            EventType::Open,
684            Author::User,
685            Source::Cli,
686            "x".into(),
687        );
688        open_e.meta = serde_json::json!({"title": "Decisions test"});
689        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
690        db::index_event(&conn, &open_e).unwrap();
691
692        let dec = Event::new(
693            "tj-ad",
694            EventType::Decision,
695            Author::Agent,
696            Source::Chat,
697            "Adopt Rust".into(),
698        );
699        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
700        db::index_event(&conn, &dec).unwrap();
701
702        let pack = assemble(&conn, "tj-ad", PackMode::Full).unwrap();
703        assert!(
704            pack.text.contains("## Active decisions"),
705            "missing section: {}",
706            pack.text
707        );
708        assert!(
709            pack.text.contains("Adopt Rust"),
710            "decision text missing: {}",
711            pack.text
712        );
713    }
714
715    #[test]
716    fn assemble_includes_lifecycle_history() {
717        use crate::db;
718        use crate::event::*;
719        use tempfile::TempDir;
720
721        let d = TempDir::new().unwrap();
722        let conn = db::open(d.path().join("s.sqlite")).unwrap();
723
724        let mut open_e = Event::new(
725            "tj-l",
726            EventType::Open,
727            Author::User,
728            Source::Cli,
729            "x".into(),
730        );
731        open_e.meta = serde_json::json!({"title": "Lifecycle"});
732        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
733        db::index_event(&conn, &open_e).unwrap();
734
735        let close_e = Event::new(
736            "tj-l",
737            EventType::Close,
738            Author::User,
739            Source::Cli,
740            "done".into(),
741        );
742        db::upsert_task_from_event(&conn, &close_e, "feedface").unwrap();
743        db::index_event(&conn, &close_e).unwrap();
744
745        let pack = assemble(&conn, "tj-l", PackMode::Full).unwrap();
746        assert!(pack.text.contains("## Lifecycle"));
747        assert!(pack.text.contains("opened"));
748        assert!(pack.text.contains("closed"));
749    }
750
751    #[test]
752    fn assemble_header_only_compact() {
753        use crate::db;
754        use crate::event::*;
755        use tempfile::TempDir;
756
757        let d = TempDir::new().unwrap();
758        let conn = db::open(d.path().join("s.sqlite")).unwrap();
759
760        let mut open_e = Event::new(
761            "tj-h",
762            EventType::Open,
763            Author::User,
764            Source::Cli,
765            "x".into(),
766        );
767        open_e.meta = serde_json::json!({"title": "Header test"});
768        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
769        db::index_event(&conn, &open_e).unwrap();
770
771        let pack = assemble(&conn, "tj-h", PackMode::Compact).unwrap();
772        assert!(
773            pack.text.contains("# Header test"),
774            "header missing: {}",
775            pack.text
776        );
777        assert!(
778            pack.text.contains("status: open"),
779            "status missing: {}",
780            pack.text
781        );
782        assert_eq!(pack.metadata.source_event_count, 1);
783        assert!(!pack.metadata.cache_hit);
784    }
785}