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, goal, outcome, outcome_tag, external): (
191        String,
192        String,
193        Option<String>,
194        Option<String>,
195        Option<String>,
196        Option<String>,
197    ) = conn
198        .query_row(
199            "SELECT title, status, goal, outcome, outcome_tag, external FROM tasks WHERE task_id=?1",
200            rusqlite::params![task_id],
201            |r| {
202                Ok((
203                    r.get(0)?,
204                    r.get(1)?,
205                    r.get(2)?,
206                    r.get(3)?,
207                    r.get(4)?,
208                    r.get(5)?,
209                ))
210            },
211        )
212        .with_context(|| format!("task not found: {task_id}"))?;
213
214    let event_count: usize = conn.query_row(
215        "SELECT COUNT(*) FROM events_index WHERE task_id=?1",
216        rusqlite::params![task_id],
217        |r| r.get::<_, i64>(0).map(|n| n as usize),
218    )?;
219
220    let mut text = format!("# {title}  [status: {status}]\n\n");
221
222    // v0.4.0 task-as-goal block. Goal renders even when empty so the
223    // shape is consistent and the absence is visible. Outcome only
224    // when closed (avoids "(open)" noise on every active task).
225    // External only when populated.
226    let goal_str = goal.as_deref().unwrap_or("(not set)");
227    text.push_str(&format!("**Goal**: {goal_str}\n"));
228    if status == "closed" {
229        let outcome_str = outcome.as_deref().unwrap_or("(not recorded)");
230        let tag = outcome_tag
231            .as_deref()
232            .map(|t| format!(" [{t}]"))
233            .unwrap_or_default();
234        text.push_str(&format!("**Outcome**{tag}: {outcome_str}\n"));
235    }
236    if let Some(ext) = external.as_deref().filter(|s| !s.is_empty()) {
237        text.push_str(&format!("**External**: {ext}\n"));
238    }
239    text.push('\n');
240
241    if matches!(mode, PackMode::Full) {
242        text.push_str(&render_lifecycle(conn, task_id)?);
243    }
244    text.push_str(&render_active_decisions(conn, task_id)?);
245    if matches!(mode, PackMode::Full) {
246        text.push_str(&render_rejected(conn, task_id)?);
247        text.push_str(&render_evidence(conn, task_id)?);
248    }
249    let recent_limit = match mode {
250        PackMode::Compact => 3,
251        PackMode::Full => 10,
252    };
253    text.push_str(&render_recent_events(conn, task_id, recent_limit)?);
254
255    // Token-budget truncation: cap pack size so it always fits an LLM context window.
256    const FULL_BUDGET: usize = 10 * 1024;
257    const COMPACT_BUDGET: usize = 2 * 1024;
258    const TRUNC_MARKER: &str = "\n\n_(truncated to fit pack budget)_\n";
259    let budget = match mode {
260        PackMode::Full => FULL_BUDGET,
261        PackMode::Compact => COMPACT_BUDGET,
262    };
263    let truncated = text.len() > budget;
264    if truncated {
265        let cutoff = text[..budget].rfind('\n').unwrap_or(budget);
266        text.truncate(cutoff);
267        text.push_str(TRUNC_MARKER);
268    }
269
270    let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
271
272    // Write-through cache.
273    conn.execute(
274        "INSERT OR REPLACE INTO task_pack_cache(task_id, mode, text, generated_at, source_event_count)
275         VALUES (?1, ?2, ?3, ?4, ?5)",
276        rusqlite::params![task_id, mode_str, text, generated_at, event_count as i64],
277    )?;
278
279    Ok(TaskPack {
280        task_id: task_id.to_string(),
281        mode,
282        schema_version: crate::SCHEMA_VERSION.into(),
283        text,
284        metadata: PackMetadata {
285            generated_at,
286            source_event_count: event_count,
287            cache_hit: false,
288            truncated,
289        },
290    })
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn pack_mode_round_trips_via_serde() {
299        let s = serde_json::to_string(&PackMode::Compact).unwrap();
300        assert_eq!(s, "\"Compact\"");
301    }
302
303    #[test]
304    fn cache_is_invalidated_on_new_event() {
305        use crate::db;
306        use crate::event::*;
307        use tempfile::TempDir;
308
309        let d = TempDir::new().unwrap();
310        let conn = db::open(d.path().join("s.sqlite")).unwrap();
311        let mut open_e = Event::new(
312            "tj-inv",
313            EventType::Open,
314            Author::User,
315            Source::Cli,
316            "x".into(),
317        );
318        open_e.meta = serde_json::json!({"title": "Inv"});
319        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
320        db::index_event(&conn, &open_e).unwrap();
321
322        let _ = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
323        let p2 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
324        assert!(p2.metadata.cache_hit);
325
326        let dec = Event::new(
327            "tj-inv",
328            EventType::Decision,
329            Author::Agent,
330            Source::Chat,
331            "D".into(),
332        );
333        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
334        db::index_event(&conn, &dec).unwrap();
335
336        let p3 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
337        assert!(
338            !p3.metadata.cache_hit,
339            "new event must invalidate the cache"
340        );
341    }
342
343    #[test]
344    fn pack_cache_hits_after_incremental_ingest_with_no_new_events() {
345        // Reproduces the MCP hot loop: client calls task_pack(X), the server
346        // runs ingest_new_events (which now reads only the JSONL tail), then
347        // calls assemble(X). After B2 the second call must hit the cache —
348        // before B2, full rebuild_state replayed every event through index_
349        // event() which DELETEd the cache row, so we always missed.
350        use crate::db;
351        use crate::event::*;
352        use std::io::Write;
353        use tempfile::TempDir;
354
355        let d = TempDir::new().unwrap();
356        let jsonl = d.path().join("events.jsonl");
357        let project = "cafef00dcafef00d";
358
359        let mut open_e = Event::new(
360            "tj-cmcp",
361            EventType::Open,
362            Author::User,
363            Source::Cli,
364            "x".into(),
365        );
366        open_e.meta = serde_json::json!({"title": "Cached"});
367        let dec = Event::new(
368            "tj-cmcp",
369            EventType::Decision,
370            Author::Agent,
371            Source::Chat,
372            "Adopt Rust".into(),
373        );
374
375        let mut f = std::fs::File::create(&jsonl).unwrap();
376        writeln!(f, "{}", serde_json::to_string(&open_e).unwrap()).unwrap();
377        writeln!(f, "{}", serde_json::to_string(&dec).unwrap()).unwrap();
378        drop(f);
379
380        let conn = db::open(d.path().join("s.sqlite")).unwrap();
381
382        // First MCP call: ingest, then pack.
383        db::ingest_new_events(&conn, &jsonl, project).unwrap();
384        let first = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
385        assert!(
386            !first.metadata.cache_hit,
387            "first assemble must populate cache"
388        );
389
390        // Second MCP call: ingest again (zero new events in JSONL), then pack.
391        let n_new = db::ingest_new_events(&conn, &jsonl, project).unwrap();
392        assert_eq!(n_new, 0, "no new events should be ingested");
393        let second = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
394        assert!(
395            second.metadata.cache_hit,
396            "repeat assemble after a no-op ingest must hit the cache"
397        );
398        assert_eq!(first.text, second.text);
399    }
400
401    #[test]
402    fn pack_cache_returns_cached_text_on_second_call() {
403        use crate::db;
404        use crate::event::*;
405        use tempfile::TempDir;
406
407        let d = TempDir::new().unwrap();
408        let conn = db::open(d.path().join("s.sqlite")).unwrap();
409        let mut open_e = Event::new(
410            "tj-c",
411            EventType::Open,
412            Author::User,
413            Source::Cli,
414            "x".into(),
415        );
416        open_e.meta = serde_json::json!({"title": "Cache"});
417        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
418        db::index_event(&conn, &open_e).unwrap();
419
420        let p1 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
421        assert!(!p1.metadata.cache_hit);
422        let p2 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
423        assert!(p2.metadata.cache_hit, "second call should hit cache");
424        assert_eq!(p1.text, p2.text);
425    }
426
427    #[test]
428    fn compact_mode_omits_optional_sections() {
429        use crate::db;
430        use crate::event::*;
431        use tempfile::TempDir;
432
433        let d = TempDir::new().unwrap();
434        let conn = db::open(d.path().join("s.sqlite")).unwrap();
435        let mut open_e = Event::new(
436            "tj-cm",
437            EventType::Open,
438            Author::User,
439            Source::Cli,
440            "x".into(),
441        );
442        open_e.meta = serde_json::json!({"title": "Compact"});
443        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
444        db::index_event(&conn, &open_e).unwrap();
445        let dec = Event::new(
446            "tj-cm",
447            EventType::Decision,
448            Author::Agent,
449            Source::Chat,
450            "D1".into(),
451        );
452        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
453        db::index_event(&conn, &dec).unwrap();
454
455        let pack = assemble(&conn, "tj-cm", PackMode::Compact).unwrap();
456        assert!(pack.text.contains("# Compact"));
457        assert!(pack.text.contains("Active decisions"));
458        assert!(pack.text.contains("Recent events"));
459        assert!(
460            !pack.text.contains("Lifecycle"),
461            "compact should omit Lifecycle: {}",
462            pack.text
463        );
464        assert!(
465            !pack.text.contains("Rejected"),
466            "compact should omit Rejected: {}",
467            pack.text
468        );
469        assert!(
470            !pack.text.contains("Evidence"),
471            "compact should omit Evidence: {}",
472            pack.text
473        );
474    }
475
476    #[test]
477    fn full_mode_truncates_when_exceeding_budget() {
478        use crate::db;
479        use crate::event::*;
480        use tempfile::TempDir;
481
482        let d = TempDir::new().unwrap();
483        let conn = db::open(d.path().join("s.sqlite")).unwrap();
484        let mut open_e = Event::new(
485            "tj-big",
486            EventType::Open,
487            Author::User,
488            Source::Cli,
489            "x".into(),
490        );
491        open_e.meta = serde_json::json!({"title": "Big"});
492        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
493        db::index_event(&conn, &open_e).unwrap();
494        for i in 0..100 {
495            let ev = Event::new(
496                "tj-big",
497                EventType::Evidence,
498                Author::Agent,
499                Source::Chat,
500                format!("Evidence #{i}: {}", "lorem ipsum ".repeat(50)),
501            );
502            db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
503            db::index_event(&conn, &ev).unwrap();
504        }
505        let pack = assemble(&conn, "tj-big", PackMode::Full).unwrap();
506        assert!(
507            pack.text.len() <= 12 * 1024,
508            "pack must stay under ~12KB; got {} bytes",
509            pack.text.len()
510        );
511        assert!(pack.metadata.truncated, "metadata.truncated must be true");
512        assert!(pack.text.contains("truncated to fit pack budget"));
513    }
514
515    #[test]
516    fn corrected_events_appear_with_correction_event_type() {
517        use crate::db;
518        use crate::event::*;
519        use tempfile::TempDir;
520
521        let d = TempDir::new().unwrap();
522        let conn = db::open(d.path().join("s.sqlite")).unwrap();
523        let mut open_e = Event::new(
524            "tj-co",
525            EventType::Open,
526            Author::User,
527            Source::Cli,
528            "x".into(),
529        );
530        open_e.meta = serde_json::json!({"title": "Corr"});
531        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
532        db::index_event(&conn, &open_e).unwrap();
533
534        let bad = Event::new(
535            "tj-co",
536            EventType::Finding,
537            Author::Classifier,
538            Source::Hook,
539            "Migration done (wrong)".into(),
540        );
541        db::upsert_task_from_event(&conn, &bad, "feedface").unwrap();
542        db::index_event(&conn, &bad).unwrap();
543
544        let mut corr = Event::new(
545            "tj-co",
546            EventType::Correction,
547            Author::User,
548            Source::Cli,
549            "Migration NOT done; finding was wrong".into(),
550        );
551        corr.corrects = Some(bad.event_id.clone());
552        db::upsert_task_from_event(&conn, &corr, "feedface").unwrap();
553        db::index_event(&conn, &corr).unwrap();
554
555        let pack = assemble(&conn, "tj-co", PackMode::Full).unwrap();
556        assert!(pack.text.contains("[correction]"));
557        assert!(pack.text.contains("Migration NOT done"));
558    }
559
560    #[test]
561    fn suggested_events_get_question_mark_marker_in_pack() {
562        use crate::db;
563        use crate::event::*;
564        use tempfile::TempDir;
565
566        let d = TempDir::new().unwrap();
567        let conn = db::open(d.path().join("s.sqlite")).unwrap();
568        let mut open_e = Event::new(
569            "tj-q",
570            EventType::Open,
571            Author::User,
572            Source::Cli,
573            "x".into(),
574        );
575        open_e.meta = serde_json::json!({"title": "Q"});
576        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
577        db::index_event(&conn, &open_e).unwrap();
578
579        let mut suggested = Event::new(
580            "tj-q",
581            EventType::Decision,
582            Author::Classifier,
583            Source::Hook,
584            "Adopt Rust".into(),
585        );
586        suggested.status = EventStatus::Suggested;
587        db::upsert_task_from_event(&conn, &suggested, "feedface").unwrap();
588        db::index_event(&conn, &suggested).unwrap();
589
590        let pack = assemble(&conn, "tj-q", PackMode::Full).unwrap();
591        let recent_pos = pack.text.find("## Recent events").unwrap();
592        let recent_section = &pack.text[recent_pos..];
593        assert!(
594            recent_section.contains("[?]"),
595            "suggested event must show [?] marker in Recent events:\n{recent_section}"
596        );
597    }
598
599    #[test]
600    fn pack_renders_recent_events_full_mode() {
601        use crate::db;
602        use crate::event::*;
603        use tempfile::TempDir;
604
605        let d = TempDir::new().unwrap();
606        let conn = db::open(d.path().join("s.sqlite")).unwrap();
607        let mut open_e = Event::new(
608            "tj-re",
609            EventType::Open,
610            Author::User,
611            Source::Cli,
612            "x".into(),
613        );
614        open_e.meta = serde_json::json!({"title": "Recent"});
615        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
616        db::index_event(&conn, &open_e).unwrap();
617        for i in 0..6 {
618            let e = Event::new(
619                "tj-re",
620                EventType::Hypothesis,
621                Author::Agent,
622                Source::Chat,
623                format!("hypothesis {i}"),
624            );
625            db::upsert_task_from_event(&conn, &e, "feedface").unwrap();
626            db::index_event(&conn, &e).unwrap();
627        }
628
629        let pack = assemble(&conn, "tj-re", PackMode::Full).unwrap();
630        assert!(pack.text.contains("## Recent events"));
631        let count = pack.text.matches("[hypothesis]").count();
632        assert!(
633            count >= 5,
634            "expected >=5 hypotheses, got {count} in {}",
635            pack.text
636        );
637    }
638
639    #[test]
640    fn pack_renders_evidence_section() {
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-ev",
649            EventType::Open,
650            Author::User,
651            Source::Cli,
652            "x".into(),
653        );
654        open_e.meta = serde_json::json!({"title": "Ev"});
655        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
656        db::index_event(&conn, &open_e).unwrap();
657
658        let mut ev = Event::new(
659            "tj-ev",
660            EventType::Evidence,
661            Author::Agent,
662            Source::Chat,
663            "Hook startup at 12ms vs 380ms node".into(),
664        );
665        ev.evidence_strength = Some(EvidenceStrength::Strong);
666        db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
667        db::index_event(&conn, &ev).unwrap();
668
669        let pack = assemble(&conn, "tj-ev", PackMode::Full).unwrap();
670        assert!(pack.text.contains("## Evidence"));
671        assert!(pack.text.contains("12ms"));
672        assert!(pack.text.contains("(strong)"));
673    }
674
675    #[test]
676    fn pack_renders_rejected_options() {
677        use crate::db;
678        use crate::event::*;
679        use tempfile::TempDir;
680
681        let d = TempDir::new().unwrap();
682        let conn = db::open(d.path().join("s.sqlite")).unwrap();
683        let mut open_e = Event::new(
684            "tj-r",
685            EventType::Open,
686            Author::User,
687            Source::Cli,
688            "x".into(),
689        );
690        open_e.meta = serde_json::json!({"title": "Rej"});
691        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
692        db::index_event(&conn, &open_e).unwrap();
693
694        let rej = Event::new(
695            "tj-r",
696            EventType::Rejection,
697            Author::Agent,
698            Source::Chat,
699            "TypeScript: loses single-binary distribution".into(),
700        );
701        db::upsert_task_from_event(&conn, &rej, "feedface").unwrap();
702        db::index_event(&conn, &rej).unwrap();
703
704        let pack = assemble(&conn, "tj-r", PackMode::Full).unwrap();
705        assert!(pack.text.contains("## Rejected"));
706        assert!(pack.text.contains("TypeScript"));
707    }
708
709    #[test]
710    fn pack_renders_active_decisions() {
711        use crate::db;
712        use crate::event::*;
713        use tempfile::TempDir;
714
715        let d = TempDir::new().unwrap();
716        let conn = db::open(d.path().join("s.sqlite")).unwrap();
717        let mut open_e = Event::new(
718            "tj-ad",
719            EventType::Open,
720            Author::User,
721            Source::Cli,
722            "x".into(),
723        );
724        open_e.meta = serde_json::json!({"title": "Decisions test"});
725        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
726        db::index_event(&conn, &open_e).unwrap();
727
728        let dec = Event::new(
729            "tj-ad",
730            EventType::Decision,
731            Author::Agent,
732            Source::Chat,
733            "Adopt Rust".into(),
734        );
735        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
736        db::index_event(&conn, &dec).unwrap();
737
738        let pack = assemble(&conn, "tj-ad", PackMode::Full).unwrap();
739        assert!(
740            pack.text.contains("## Active decisions"),
741            "missing section: {}",
742            pack.text
743        );
744        assert!(
745            pack.text.contains("Adopt Rust"),
746            "decision text missing: {}",
747            pack.text
748        );
749    }
750
751    #[test]
752    fn assemble_includes_lifecycle_history() {
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-l",
762            EventType::Open,
763            Author::User,
764            Source::Cli,
765            "x".into(),
766        );
767        open_e.meta = serde_json::json!({"title": "Lifecycle"});
768        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
769        db::index_event(&conn, &open_e).unwrap();
770
771        let close_e = Event::new(
772            "tj-l",
773            EventType::Close,
774            Author::User,
775            Source::Cli,
776            "done".into(),
777        );
778        db::upsert_task_from_event(&conn, &close_e, "feedface").unwrap();
779        db::index_event(&conn, &close_e).unwrap();
780
781        let pack = assemble(&conn, "tj-l", PackMode::Full).unwrap();
782        assert!(pack.text.contains("## Lifecycle"));
783        assert!(pack.text.contains("opened"));
784        assert!(pack.text.contains("closed"));
785    }
786
787    #[test]
788    fn assemble_header_only_compact() {
789        use crate::db;
790        use crate::event::*;
791        use tempfile::TempDir;
792
793        let d = TempDir::new().unwrap();
794        let conn = db::open(d.path().join("s.sqlite")).unwrap();
795
796        let mut open_e = Event::new(
797            "tj-h",
798            EventType::Open,
799            Author::User,
800            Source::Cli,
801            "x".into(),
802        );
803        open_e.meta = serde_json::json!({"title": "Header test"});
804        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
805        db::index_event(&conn, &open_e).unwrap();
806
807        let pack = assemble(&conn, "tj-h", PackMode::Compact).unwrap();
808        assert!(
809            pack.text.contains("# Header test"),
810            "header missing: {}",
811            pack.text
812        );
813        assert!(
814            pack.text.contains("status: open"),
815            "status missing: {}",
816            pack.text
817        );
818        assert_eq!(pack.metadata.source_event_count, 1);
819        assert!(!pack.metadata.cache_hit);
820    }
821}