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