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    // v0.10.3: newest evidence first (ULID DESC). Matches the
64    // decision-ordering fix so truncation prefers older rows.
65    let mut stmt = conn.prepare(
66        "SELECT text, strength FROM evidence WHERE task_id=?1 ORDER BY evidence_id DESC",
67    )?;
68    let rows = stmt.query_map(rusqlite::params![task_id], |r| {
69        let t: String = r.get(0)?;
70        let s: String = r.get(1)?;
71        Ok((t, s))
72    })?;
73    let mut count = 0;
74    for row in rows {
75        let (t, s) = row?;
76        out.push_str(&format!("- {t} ({s})\n"));
77        count += 1;
78    }
79    if count == 0 {
80        out.push_str("- (none)\n");
81    }
82    out.push('\n');
83    Ok(out)
84}
85
86fn render_rejected(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
87    let mut out = String::from("## Rejected\n");
88    // v0.10.3: newest-first so end-of-pack truncation drops the
89    // OLDEST rejections, not the latest decision the agent recorded.
90    let mut id_stmt = conn.prepare(
91        "SELECT event_id FROM events_index
92         WHERE task_id=?1 AND type='rejection'
93         ORDER BY timestamp DESC",
94    )?;
95    let mut text_stmt = conn.prepare("SELECT text FROM search_fts WHERE event_id=?1 LIMIT 1")?;
96    let event_ids: Vec<String> = id_stmt
97        .query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?
98        .collect::<Result<_, _>>()?;
99    let mut count = 0;
100    for eid in event_ids {
101        let text: String = text_stmt.query_row(rusqlite::params![eid], |r| r.get(0))?;
102        out.push_str(&format!("- {text}\n"));
103        count += 1;
104    }
105    if count == 0 {
106        out.push_str("- (none)\n");
107    }
108    out.push('\n');
109    Ok(out)
110}
111
112fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
113    let mut out = String::from("## Active decisions\n");
114    // v0.10.3: newest decision first. `decision_id` is a ULID so DESC
115    // gives reverse-chronological order. The summary/final-decision
116    // event the agent records just before close is now the FIRST line
117    // of this section, surviving end-of-pack truncation.
118    let mut stmt = conn.prepare(
119        "SELECT text FROM decisions WHERE task_id=?1 AND status='active' ORDER BY decision_id DESC",
120    )?;
121    let rows = stmt.query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?;
122    let mut count = 0;
123    for row in rows {
124        out.push_str(&format!("- {}\n", row?));
125        count += 1;
126    }
127    if count == 0 {
128        out.push_str("- (none)\n");
129    }
130    out.push('\n');
131    Ok(out)
132}
133
134fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
135    let mut out = String::from("## Lifecycle\n");
136    let mut stmt = conn.prepare(
137        "SELECT timestamp, type FROM events_index
138         WHERE task_id=?1 AND type IN ('open','close','reopen','supersede','redirect')
139         ORDER BY timestamp ASC",
140    )?;
141    let rows = stmt.query_map(rusqlite::params![task_id], |r| {
142        let ts: String = r.get(0)?;
143        let ty: String = r.get(1)?;
144        Ok((ts, ty))
145    })?;
146    let mut count = 0;
147    for row in rows {
148        let (ts, ty) = row?;
149        let verb = match ty.as_str() {
150            "open" => "opened",
151            "close" => "closed",
152            "reopen" => "reopened",
153            "supersede" => "superseded",
154            "redirect" => "redirected",
155            _ => &ty,
156        };
157        out.push_str(&format!("- {ts} {verb}\n"));
158        count += 1;
159    }
160    if count == 0 {
161        out.push_str("- (none)\n");
162    }
163    out.push('\n');
164    Ok(out)
165}
166
167pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Result<TaskPack> {
168    let mode_str = match mode {
169        PackMode::Compact => "compact",
170        PackMode::Full => "full",
171    };
172
173    // Read-through cache: if we have a stored pack with the same mode, return it.
174    let cached: Option<(String, String, i64)> = conn
175        .query_row(
176            "SELECT text, generated_at, source_event_count FROM task_pack_cache
177         WHERE task_id=?1 AND mode=?2",
178            rusqlite::params![task_id, mode_str],
179            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
180        )
181        .ok();
182    if let Some((cached_text, cached_at, cached_count)) = cached {
183        // Detect truncation by re-checking for the marker.
184        let was_truncated = cached_text.contains("_(truncated to fit pack budget)_");
185        return Ok(TaskPack {
186            task_id: task_id.to_string(),
187            mode,
188            schema_version: crate::SCHEMA_VERSION.into(),
189            text: cached_text,
190            metadata: PackMetadata {
191                generated_at: cached_at,
192                source_event_count: cached_count as usize,
193                cache_hit: true,
194                truncated: was_truncated,
195            },
196        });
197    }
198
199    let (title, status, goal, outcome, outcome_tag, external): (
200        String,
201        String,
202        Option<String>,
203        Option<String>,
204        Option<String>,
205        Option<String>,
206    ) = conn
207        .query_row(
208            "SELECT title, status, goal, outcome, outcome_tag, external FROM tasks WHERE task_id=?1",
209            rusqlite::params![task_id],
210            |r| {
211                Ok((
212                    r.get(0)?,
213                    r.get(1)?,
214                    r.get(2)?,
215                    r.get(3)?,
216                    r.get(4)?,
217                    r.get(5)?,
218                ))
219            },
220        )
221        .with_context(|| format!("task not found: {task_id}"))?;
222
223    let event_count: usize = conn.query_row(
224        "SELECT COUNT(*) FROM events_index WHERE task_id=?1",
225        rusqlite::params![task_id],
226        |r| r.get::<_, i64>(0).map(|n| n as usize),
227    )?;
228
229    let mut text = format!("# {title}  [status: {status}]\n\n");
230
231    // v0.4.0 task-as-goal block. Goal renders even when empty so the
232    // shape is consistent and the absence is visible. Outcome only
233    // when closed (avoids "(open)" noise on every active task).
234    // External only when populated.
235    let goal_str = goal.as_deref().unwrap_or("(not set)");
236    text.push_str(&format!("**Goal**: {goal_str}\n"));
237    if status == "closed" {
238        let outcome_str = outcome.as_deref().unwrap_or("(not recorded)");
239        let tag = outcome_tag
240            .as_deref()
241            .map(|t| format!(" [{t}]"))
242            .unwrap_or_default();
243        text.push_str(&format!("**Outcome**{tag}: {outcome_str}\n"));
244    }
245    if let Some(ext) = external.as_deref().filter(|s| !s.is_empty()) {
246        // Split out `linked:tj-xxx` entries so the user sees the
247        // task-graph dimension separately from PRs / commit hashes /
248        // beads-ids. Other refs stay in External; linked entries get
249        // their own block annotated with the live status of each
250        // pointer.
251        let (linked, other): (Vec<_>, Vec<_>) = ext
252            .split(',')
253            .map(|s| s.trim())
254            .partition(|s| s.starts_with("linked:") || s.starts_with("linked: "));
255        if !other.is_empty() {
256            text.push_str(&format!(
257                "**External**: {}\n",
258                other
259                    .iter()
260                    .filter(|s| !s.is_empty())
261                    .copied()
262                    .collect::<Vec<_>>()
263                    .join(",")
264            ));
265        }
266        if !linked.is_empty() {
267            text.push_str("**Linked**:\n");
268            for entry in linked {
269                let id = entry.trim_start_matches("linked:").trim();
270                let st: Option<String> = conn
271                    .query_row(
272                        "SELECT status FROM tasks WHERE task_id = ?1",
273                        rusqlite::params![id],
274                        |r| r.get(0),
275                    )
276                    .ok();
277                match st {
278                    Some(s) => text.push_str(&format!("- {id} [{s}]\n")),
279                    None => text.push_str(&format!("- {id} [unknown]\n")),
280                }
281            }
282        }
283    }
284
285    // v0.5.0 Phase B: artifacts auto-extracted from event text. Render
286    // only categories that have entries — empty groups are noise on a
287    // 30-event task. Order is stable so packs diff cleanly.
288    let arts = crate::db::task_artifacts(conn, task_id)?;
289    if !arts.is_empty() {
290        text.push_str("**Artifacts**:\n");
291        if !arts.commit_hashes.is_empty() {
292            text.push_str(&format!("- commits: {}\n", arts.commit_hashes.join(", ")));
293        }
294        if !arts.pr_urls.is_empty() {
295            text.push_str(&format!("- PRs: {}\n", arts.pr_urls.join(", ")));
296        }
297        if !arts.linked_issues.is_empty() {
298            text.push_str(&format!("- issues: {}\n", arts.linked_issues.join(", ")));
299        }
300        if !arts.files.is_empty() {
301            text.push_str(&format!("- files: {}\n", arts.files.join(", ")));
302        }
303        if !arts.branch_names.is_empty() {
304            text.push_str(&format!("- branches: {}\n", arts.branch_names.join(", ")));
305        }
306    }
307    text.push('\n');
308
309    if matches!(mode, PackMode::Full) {
310        text.push_str(&render_lifecycle(conn, task_id)?);
311    }
312    text.push_str(&render_active_decisions(conn, task_id)?);
313    if matches!(mode, PackMode::Full) {
314        text.push_str(&render_rejected(conn, task_id)?);
315        text.push_str(&render_evidence(conn, task_id)?);
316    }
317    let recent_limit = match mode {
318        PackMode::Compact => 3,
319        PackMode::Full => 10,
320    };
321    text.push_str(&render_recent_events(conn, task_id, recent_limit)?);
322
323    // Token-budget truncation: cap pack size so it always fits an LLM context window.
324    // v0.10.3: full bumped 10K → 24K. Real tasks accumulate 50-100 events
325    // and the prior cap clipped final-summary decisions even after the
326    // ORDER BY DESC reshuffle. 24K still fits comfortably inside any
327    // modern LLM context budget.
328    const FULL_BUDGET: usize = 24 * 1024;
329    const COMPACT_BUDGET: usize = 2 * 1024;
330    const TRUNC_MARKER: &str = "\n\n_(truncated to fit pack budget)_\n";
331    let budget = match mode {
332        PackMode::Full => FULL_BUDGET,
333        PackMode::Compact => COMPACT_BUDGET,
334    };
335    let truncated = text.len() > budget;
336    if truncated {
337        let cutoff = text[..budget].rfind('\n').unwrap_or(budget);
338        text.truncate(cutoff);
339        text.push_str(TRUNC_MARKER);
340    }
341
342    let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
343
344    // Write-through cache.
345    conn.execute(
346        "INSERT OR REPLACE INTO task_pack_cache(task_id, mode, text, generated_at, source_event_count)
347         VALUES (?1, ?2, ?3, ?4, ?5)",
348        rusqlite::params![task_id, mode_str, text, generated_at, event_count as i64],
349    )?;
350
351    Ok(TaskPack {
352        task_id: task_id.to_string(),
353        mode,
354        schema_version: crate::SCHEMA_VERSION.into(),
355        text,
356        metadata: PackMetadata {
357            generated_at,
358            source_event_count: event_count,
359            cache_hit: false,
360            truncated,
361        },
362    })
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn pack_mode_round_trips_via_serde() {
371        let s = serde_json::to_string(&PackMode::Compact).unwrap();
372        assert_eq!(s, "\"Compact\"");
373    }
374
375    #[test]
376    fn cache_is_invalidated_on_new_event() {
377        use crate::db;
378        use crate::event::*;
379        use tempfile::TempDir;
380
381        let d = TempDir::new().unwrap();
382        let conn = db::open(d.path().join("s.sqlite")).unwrap();
383        let mut open_e = Event::new(
384            "tj-inv",
385            EventType::Open,
386            Author::User,
387            Source::Cli,
388            "x".into(),
389        );
390        open_e.meta = serde_json::json!({"title": "Inv"});
391        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
392        db::index_event(&conn, &open_e).unwrap();
393
394        let _ = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
395        let p2 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
396        assert!(p2.metadata.cache_hit);
397
398        let dec = Event::new(
399            "tj-inv",
400            EventType::Decision,
401            Author::Agent,
402            Source::Chat,
403            "D".into(),
404        );
405        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
406        db::index_event(&conn, &dec).unwrap();
407
408        let p3 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
409        assert!(
410            !p3.metadata.cache_hit,
411            "new event must invalidate the cache"
412        );
413    }
414
415    #[test]
416    fn pack_cache_hits_after_incremental_ingest_with_no_new_events() {
417        // Reproduces the MCP hot loop: client calls task_pack(X), the server
418        // runs ingest_new_events (which now reads only the JSONL tail), then
419        // calls assemble(X). After B2 the second call must hit the cache —
420        // before B2, full rebuild_state replayed every event through index_
421        // event() which DELETEd the cache row, so we always missed.
422        use crate::db;
423        use crate::event::*;
424        use std::io::Write;
425        use tempfile::TempDir;
426
427        let d = TempDir::new().unwrap();
428        let jsonl = d.path().join("events.jsonl");
429        let project = "cafef00dcafef00d";
430
431        let mut open_e = Event::new(
432            "tj-cmcp",
433            EventType::Open,
434            Author::User,
435            Source::Cli,
436            "x".into(),
437        );
438        open_e.meta = serde_json::json!({"title": "Cached"});
439        let dec = Event::new(
440            "tj-cmcp",
441            EventType::Decision,
442            Author::Agent,
443            Source::Chat,
444            "Adopt Rust".into(),
445        );
446
447        let mut f = std::fs::File::create(&jsonl).unwrap();
448        writeln!(f, "{}", serde_json::to_string(&open_e).unwrap()).unwrap();
449        writeln!(f, "{}", serde_json::to_string(&dec).unwrap()).unwrap();
450        drop(f);
451
452        let conn = db::open(d.path().join("s.sqlite")).unwrap();
453
454        // First MCP call: ingest, then pack.
455        db::ingest_new_events(&conn, &jsonl, project).unwrap();
456        let first = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
457        assert!(
458            !first.metadata.cache_hit,
459            "first assemble must populate cache"
460        );
461
462        // Second MCP call: ingest again (zero new events in JSONL), then pack.
463        let n_new = db::ingest_new_events(&conn, &jsonl, project).unwrap();
464        assert_eq!(n_new, 0, "no new events should be ingested");
465        let second = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
466        assert!(
467            second.metadata.cache_hit,
468            "repeat assemble after a no-op ingest must hit the cache"
469        );
470        assert_eq!(first.text, second.text);
471    }
472
473    #[test]
474    fn pack_cache_returns_cached_text_on_second_call() {
475        use crate::db;
476        use crate::event::*;
477        use tempfile::TempDir;
478
479        let d = TempDir::new().unwrap();
480        let conn = db::open(d.path().join("s.sqlite")).unwrap();
481        let mut open_e = Event::new(
482            "tj-c",
483            EventType::Open,
484            Author::User,
485            Source::Cli,
486            "x".into(),
487        );
488        open_e.meta = serde_json::json!({"title": "Cache"});
489        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
490        db::index_event(&conn, &open_e).unwrap();
491
492        let p1 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
493        assert!(!p1.metadata.cache_hit);
494        let p2 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
495        assert!(p2.metadata.cache_hit, "second call should hit cache");
496        assert_eq!(p1.text, p2.text);
497    }
498
499    #[test]
500    fn compact_mode_omits_optional_sections() {
501        use crate::db;
502        use crate::event::*;
503        use tempfile::TempDir;
504
505        let d = TempDir::new().unwrap();
506        let conn = db::open(d.path().join("s.sqlite")).unwrap();
507        let mut open_e = Event::new(
508            "tj-cm",
509            EventType::Open,
510            Author::User,
511            Source::Cli,
512            "x".into(),
513        );
514        open_e.meta = serde_json::json!({"title": "Compact"});
515        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
516        db::index_event(&conn, &open_e).unwrap();
517        let dec = Event::new(
518            "tj-cm",
519            EventType::Decision,
520            Author::Agent,
521            Source::Chat,
522            "D1".into(),
523        );
524        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
525        db::index_event(&conn, &dec).unwrap();
526
527        let pack = assemble(&conn, "tj-cm", PackMode::Compact).unwrap();
528        assert!(pack.text.contains("# Compact"));
529        assert!(pack.text.contains("Active decisions"));
530        assert!(pack.text.contains("Recent events"));
531        assert!(
532            !pack.text.contains("Lifecycle"),
533            "compact should omit Lifecycle: {}",
534            pack.text
535        );
536        assert!(
537            !pack.text.contains("Rejected"),
538            "compact should omit Rejected: {}",
539            pack.text
540        );
541        assert!(
542            !pack.text.contains("Evidence"),
543            "compact should omit Evidence: {}",
544            pack.text
545        );
546    }
547
548    #[test]
549    fn full_mode_truncates_when_exceeding_budget() {
550        use crate::db;
551        use crate::event::*;
552        use tempfile::TempDir;
553
554        let d = TempDir::new().unwrap();
555        let conn = db::open(d.path().join("s.sqlite")).unwrap();
556        let mut open_e = Event::new(
557            "tj-big",
558            EventType::Open,
559            Author::User,
560            Source::Cli,
561            "x".into(),
562        );
563        open_e.meta = serde_json::json!({"title": "Big"});
564        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
565        db::index_event(&conn, &open_e).unwrap();
566        for i in 0..100 {
567            let ev = Event::new(
568                "tj-big",
569                EventType::Evidence,
570                Author::Agent,
571                Source::Chat,
572                format!("Evidence #{i}: {}", "lorem ipsum ".repeat(50)),
573            );
574            db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
575            db::index_event(&conn, &ev).unwrap();
576        }
577        let pack = assemble(&conn, "tj-big", PackMode::Full).unwrap();
578        // v0.10.3: FULL_BUDGET bumped 10K → 24K + truncation slack.
579        assert!(
580            pack.text.len() <= 26 * 1024,
581            "pack must stay under ~26KB; got {} bytes",
582            pack.text.len()
583        );
584        assert!(pack.metadata.truncated, "metadata.truncated must be true");
585        assert!(pack.text.contains("truncated to fit pack budget"));
586    }
587
588    #[test]
589    fn corrected_events_appear_with_correction_event_type() {
590        use crate::db;
591        use crate::event::*;
592        use tempfile::TempDir;
593
594        let d = TempDir::new().unwrap();
595        let conn = db::open(d.path().join("s.sqlite")).unwrap();
596        let mut open_e = Event::new(
597            "tj-co",
598            EventType::Open,
599            Author::User,
600            Source::Cli,
601            "x".into(),
602        );
603        open_e.meta = serde_json::json!({"title": "Corr"});
604        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
605        db::index_event(&conn, &open_e).unwrap();
606
607        let bad = Event::new(
608            "tj-co",
609            EventType::Finding,
610            Author::Classifier,
611            Source::Hook,
612            "Migration done (wrong)".into(),
613        );
614        db::upsert_task_from_event(&conn, &bad, "feedface").unwrap();
615        db::index_event(&conn, &bad).unwrap();
616
617        let mut corr = Event::new(
618            "tj-co",
619            EventType::Correction,
620            Author::User,
621            Source::Cli,
622            "Migration NOT done; finding was wrong".into(),
623        );
624        corr.corrects = Some(bad.event_id.clone());
625        db::upsert_task_from_event(&conn, &corr, "feedface").unwrap();
626        db::index_event(&conn, &corr).unwrap();
627
628        let pack = assemble(&conn, "tj-co", PackMode::Full).unwrap();
629        assert!(pack.text.contains("[correction]"));
630        assert!(pack.text.contains("Migration NOT done"));
631    }
632
633    #[test]
634    fn suggested_events_get_question_mark_marker_in_pack() {
635        use crate::db;
636        use crate::event::*;
637        use tempfile::TempDir;
638
639        let d = TempDir::new().unwrap();
640        let conn = db::open(d.path().join("s.sqlite")).unwrap();
641        let mut open_e = Event::new(
642            "tj-q",
643            EventType::Open,
644            Author::User,
645            Source::Cli,
646            "x".into(),
647        );
648        open_e.meta = serde_json::json!({"title": "Q"});
649        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
650        db::index_event(&conn, &open_e).unwrap();
651
652        let mut suggested = Event::new(
653            "tj-q",
654            EventType::Decision,
655            Author::Classifier,
656            Source::Hook,
657            "Adopt Rust".into(),
658        );
659        suggested.status = EventStatus::Suggested;
660        db::upsert_task_from_event(&conn, &suggested, "feedface").unwrap();
661        db::index_event(&conn, &suggested).unwrap();
662
663        let pack = assemble(&conn, "tj-q", PackMode::Full).unwrap();
664        let recent_pos = pack.text.find("## Recent events").unwrap();
665        let recent_section = &pack.text[recent_pos..];
666        assert!(
667            recent_section.contains("[?]"),
668            "suggested event must show [?] marker in Recent events:\n{recent_section}"
669        );
670    }
671
672    #[test]
673    fn pack_renders_recent_events_full_mode() {
674        use crate::db;
675        use crate::event::*;
676        use tempfile::TempDir;
677
678        let d = TempDir::new().unwrap();
679        let conn = db::open(d.path().join("s.sqlite")).unwrap();
680        let mut open_e = Event::new(
681            "tj-re",
682            EventType::Open,
683            Author::User,
684            Source::Cli,
685            "x".into(),
686        );
687        open_e.meta = serde_json::json!({"title": "Recent"});
688        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
689        db::index_event(&conn, &open_e).unwrap();
690        for i in 0..6 {
691            let e = Event::new(
692                "tj-re",
693                EventType::Hypothesis,
694                Author::Agent,
695                Source::Chat,
696                format!("hypothesis {i}"),
697            );
698            db::upsert_task_from_event(&conn, &e, "feedface").unwrap();
699            db::index_event(&conn, &e).unwrap();
700        }
701
702        let pack = assemble(&conn, "tj-re", PackMode::Full).unwrap();
703        assert!(pack.text.contains("## Recent events"));
704        let count = pack.text.matches("[hypothesis]").count();
705        assert!(
706            count >= 5,
707            "expected >=5 hypotheses, got {count} in {}",
708            pack.text
709        );
710    }
711
712    #[test]
713    fn pack_renders_evidence_section() {
714        use crate::db;
715        use crate::event::*;
716        use tempfile::TempDir;
717
718        let d = TempDir::new().unwrap();
719        let conn = db::open(d.path().join("s.sqlite")).unwrap();
720        let mut open_e = Event::new(
721            "tj-ev",
722            EventType::Open,
723            Author::User,
724            Source::Cli,
725            "x".into(),
726        );
727        open_e.meta = serde_json::json!({"title": "Ev"});
728        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
729        db::index_event(&conn, &open_e).unwrap();
730
731        let mut ev = Event::new(
732            "tj-ev",
733            EventType::Evidence,
734            Author::Agent,
735            Source::Chat,
736            "Hook startup at 12ms vs 380ms node".into(),
737        );
738        ev.evidence_strength = Some(EvidenceStrength::Strong);
739        db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
740        db::index_event(&conn, &ev).unwrap();
741
742        let pack = assemble(&conn, "tj-ev", PackMode::Full).unwrap();
743        assert!(pack.text.contains("## Evidence"));
744        assert!(pack.text.contains("12ms"));
745        assert!(pack.text.contains("(strong)"));
746    }
747
748    #[test]
749    fn pack_renders_rejected_options() {
750        use crate::db;
751        use crate::event::*;
752        use tempfile::TempDir;
753
754        let d = TempDir::new().unwrap();
755        let conn = db::open(d.path().join("s.sqlite")).unwrap();
756        let mut open_e = Event::new(
757            "tj-r",
758            EventType::Open,
759            Author::User,
760            Source::Cli,
761            "x".into(),
762        );
763        open_e.meta = serde_json::json!({"title": "Rej"});
764        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
765        db::index_event(&conn, &open_e).unwrap();
766
767        let rej = Event::new(
768            "tj-r",
769            EventType::Rejection,
770            Author::Agent,
771            Source::Chat,
772            "TypeScript: loses single-binary distribution".into(),
773        );
774        db::upsert_task_from_event(&conn, &rej, "feedface").unwrap();
775        db::index_event(&conn, &rej).unwrap();
776
777        let pack = assemble(&conn, "tj-r", PackMode::Full).unwrap();
778        assert!(pack.text.contains("## Rejected"));
779        assert!(pack.text.contains("TypeScript"));
780    }
781
782    #[test]
783    fn pack_renders_active_decisions() {
784        use crate::db;
785        use crate::event::*;
786        use tempfile::TempDir;
787
788        let d = TempDir::new().unwrap();
789        let conn = db::open(d.path().join("s.sqlite")).unwrap();
790        let mut open_e = Event::new(
791            "tj-ad",
792            EventType::Open,
793            Author::User,
794            Source::Cli,
795            "x".into(),
796        );
797        open_e.meta = serde_json::json!({"title": "Decisions test"});
798        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
799        db::index_event(&conn, &open_e).unwrap();
800
801        let dec = Event::new(
802            "tj-ad",
803            EventType::Decision,
804            Author::Agent,
805            Source::Chat,
806            "Adopt Rust".into(),
807        );
808        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
809        db::index_event(&conn, &dec).unwrap();
810
811        let pack = assemble(&conn, "tj-ad", PackMode::Full).unwrap();
812        assert!(
813            pack.text.contains("## Active decisions"),
814            "missing section: {}",
815            pack.text
816        );
817        assert!(
818            pack.text.contains("Adopt Rust"),
819            "decision text missing: {}",
820            pack.text
821        );
822    }
823
824    #[test]
825    fn assemble_includes_lifecycle_history() {
826        use crate::db;
827        use crate::event::*;
828        use tempfile::TempDir;
829
830        let d = TempDir::new().unwrap();
831        let conn = db::open(d.path().join("s.sqlite")).unwrap();
832
833        let mut open_e = Event::new(
834            "tj-l",
835            EventType::Open,
836            Author::User,
837            Source::Cli,
838            "x".into(),
839        );
840        open_e.meta = serde_json::json!({"title": "Lifecycle"});
841        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
842        db::index_event(&conn, &open_e).unwrap();
843
844        let close_e = Event::new(
845            "tj-l",
846            EventType::Close,
847            Author::User,
848            Source::Cli,
849            "done".into(),
850        );
851        db::upsert_task_from_event(&conn, &close_e, "feedface").unwrap();
852        db::index_event(&conn, &close_e).unwrap();
853
854        let pack = assemble(&conn, "tj-l", PackMode::Full).unwrap();
855        assert!(pack.text.contains("## Lifecycle"));
856        assert!(pack.text.contains("opened"));
857        assert!(pack.text.contains("closed"));
858    }
859
860    #[test]
861    fn assemble_header_only_compact() {
862        use crate::db;
863        use crate::event::*;
864        use tempfile::TempDir;
865
866        let d = TempDir::new().unwrap();
867        let conn = db::open(d.path().join("s.sqlite")).unwrap();
868
869        let mut open_e = Event::new(
870            "tj-h",
871            EventType::Open,
872            Author::User,
873            Source::Cli,
874            "x".into(),
875        );
876        open_e.meta = serde_json::json!({"title": "Header test"});
877        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
878        db::index_event(&conn, &open_e).unwrap();
879
880        let pack = assemble(&conn, "tj-h", PackMode::Compact).unwrap();
881        assert!(
882            pack.text.contains("# Header test"),
883            "header missing: {}",
884            pack.text
885        );
886        assert!(
887            pack.text.contains("status: open"),
888            "status missing: {}",
889            pack.text
890        );
891        assert_eq!(pack.metadata.source_event_count, 1);
892        assert!(!pack.metadata.cache_hit);
893    }
894}