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, alternatives 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| {
122        Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
123    })?;
124    let mut count = 0;
125    for row in rows {
126        let (text, alternatives) = row?;
127        out.push_str(&format!("- {text}\n"));
128        // v0.12.0: structured alternatives render under the decision so the
129        // pack shows "considered A/B/C, chose X" without reconstructing it
130        // from the hypothesis+rejection chain.
131        if let Some(block) = render_alternatives(alternatives.as_deref()) {
132            out.push_str(&block);
133        }
134        count += 1;
135    }
136    if count == 0 {
137        out.push_str("- (none)\n");
138    }
139    out.push('\n');
140    Ok(out)
141}
142
143/// Render a decision's `meta.alternatives` JSON as indented bullet lines.
144/// Returns `None` when absent or malformed (the decision still renders
145/// without the block — a bad alternatives payload never hides the choice).
146/// Each entry is `{option, chosen?, rationale?}`; the chosen option is
147/// marked so the final choice is unambiguous.
148fn render_alternatives(raw: Option<&str>) -> Option<String> {
149    let raw = raw?;
150    let parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
151    let arr = parsed.as_array()?;
152    if arr.is_empty() {
153        return None;
154    }
155    let mut block = String::from("  - considered:\n");
156    for entry in arr {
157        let option = entry.get("option").and_then(|v| v.as_str())?;
158        let chosen = entry
159            .get("chosen")
160            .and_then(|v| v.as_bool())
161            .unwrap_or(false);
162        let marker = if chosen { "✓ chose" } else { "✗" };
163        let rationale = entry.get("rationale").and_then(|v| v.as_str());
164        match rationale {
165            Some(r) => block.push_str(&format!("    - {marker} {option} — {r}\n")),
166            None => block.push_str(&format!("    - {marker} {option}\n")),
167        }
168    }
169    Some(block)
170}
171
172fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
173    let mut out = String::from("## Lifecycle\n");
174    let mut stmt = conn.prepare(
175        "SELECT timestamp, type FROM events_index
176         WHERE task_id=?1 AND type IN ('open','close','reopen','supersede','redirect')
177         ORDER BY timestamp ASC",
178    )?;
179    let rows = stmt.query_map(rusqlite::params![task_id], |r| {
180        let ts: String = r.get(0)?;
181        let ty: String = r.get(1)?;
182        Ok((ts, ty))
183    })?;
184    let mut count = 0;
185    for row in rows {
186        let (ts, ty) = row?;
187        let verb = match ty.as_str() {
188            "open" => "opened",
189            "close" => "closed",
190            "reopen" => "reopened",
191            "supersede" => "superseded",
192            "redirect" => "redirected",
193            _ => &ty,
194        };
195        out.push_str(&format!("- {ts} {verb}\n"));
196        count += 1;
197    }
198    if count == 0 {
199        out.push_str("- (none)\n");
200    }
201    out.push('\n');
202    Ok(out)
203}
204
205/// Truncate `text` to at most `budget` bytes, cutting at a UTF-8 char
206/// boundary and preferring the last newline within the kept prefix, then
207/// append `marker`. Char-boundary-safe: a raw `text[..budget]` byte slice
208/// panics when `budget` lands inside a multibyte char (Cyrillic/CJK/emoji).
209/// Render a compact one-level roll-up of a task's direct children, or None
210/// when it has no children. Each child: status, id, title. Bounded.
211fn render_subtasks(conn: &Connection, task_id: &str) -> anyhow::Result<Option<String>> {
212    let kids = crate::db::children_of(conn, task_id)?;
213    if kids.is_empty() {
214        return Ok(None);
215    }
216    let mut s = format!("\n## Subtasks ({})\n", kids.len());
217    for k in &kids {
218        s.push_str(&format!("- [{}] {} — {}\n", k.status, k.task_id, k.title));
219    }
220    Ok(Some(s))
221}
222
223fn truncate_to_budget(text: &mut String, budget: usize, marker: &str) {
224    if text.len() <= budget {
225        return;
226    }
227    let mut end = budget;
228    while end > 0 && !text.is_char_boundary(end) {
229        end -= 1;
230    }
231    let cutoff = text[..end].rfind('\n').unwrap_or(end);
232    text.truncate(cutoff);
233    text.push_str(marker);
234}
235
236pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Result<TaskPack> {
237    let mode_str = match mode {
238        PackMode::Compact => "compact",
239        PackMode::Full => "full",
240    };
241
242    // Read-through cache: if we have a stored pack with the same mode, return it.
243    let cached: Option<(String, String, i64)> = conn
244        .query_row(
245            "SELECT text, generated_at, source_event_count FROM task_pack_cache
246         WHERE task_id=?1 AND mode=?2",
247            rusqlite::params![task_id, mode_str],
248            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
249        )
250        .ok();
251    if let Some((cached_text, cached_at, cached_count)) = cached {
252        // Detect truncation by re-checking for the marker.
253        let was_truncated = cached_text.contains("_(truncated to fit pack budget)_");
254        return Ok(TaskPack {
255            task_id: task_id.to_string(),
256            mode,
257            schema_version: crate::SCHEMA_VERSION.into(),
258            text: cached_text,
259            metadata: PackMetadata {
260                generated_at: cached_at,
261                source_event_count: cached_count as usize,
262                cache_hit: true,
263                truncated: was_truncated,
264            },
265        });
266    }
267
268    let (title, status, goal, outcome, outcome_tag, external): (
269        String,
270        String,
271        Option<String>,
272        Option<String>,
273        Option<String>,
274        Option<String>,
275    ) = conn
276        .query_row(
277            "SELECT title, status, goal, outcome, outcome_tag, external FROM tasks WHERE task_id=?1",
278            rusqlite::params![task_id],
279            |r| {
280                Ok((
281                    r.get(0)?,
282                    r.get(1)?,
283                    r.get(2)?,
284                    r.get(3)?,
285                    r.get(4)?,
286                    r.get(5)?,
287                ))
288            },
289        )
290        .with_context(|| format!("task not found: {task_id}"))?;
291
292    let event_count: usize = conn.query_row(
293        "SELECT COUNT(*) FROM events_index WHERE task_id=?1",
294        rusqlite::params![task_id],
295        |r| r.get::<_, i64>(0).map(|n| n as usize),
296    )?;
297
298    let mut text = format!("# {title}  [status: {status}]\n\n");
299
300    // v0.4.0 task-as-goal block. Goal renders even when empty so the
301    // shape is consistent and the absence is visible. Outcome only
302    // when closed (avoids "(open)" noise on every active task).
303    // External only when populated.
304    let goal_str = goal.as_deref().unwrap_or("(not set)");
305    text.push_str(&format!("**Goal**: {goal_str}\n"));
306    if status == "closed" {
307        let outcome_str = outcome.as_deref().unwrap_or("(not recorded)");
308        let tag = outcome_tag
309            .as_deref()
310            .map(|t| format!(" [{t}]"))
311            .unwrap_or_default();
312        text.push_str(&format!("**Outcome**{tag}: {outcome_str}\n"));
313    }
314    if let Some(ext) = external.as_deref().filter(|s| !s.is_empty()) {
315        // Split out `linked:tj-xxx` entries so the user sees the
316        // task-graph dimension separately from PRs / commit hashes /
317        // beads-ids. Other refs stay in External; linked entries get
318        // their own block annotated with the live status of each
319        // pointer.
320        let (linked, other): (Vec<_>, Vec<_>) = ext
321            .split(',')
322            .map(|s| s.trim())
323            .partition(|s| s.starts_with("linked:") || s.starts_with("linked: "));
324        if !other.is_empty() {
325            text.push_str(&format!(
326                "**External**: {}\n",
327                other
328                    .iter()
329                    .filter(|s| !s.is_empty())
330                    .copied()
331                    .collect::<Vec<_>>()
332                    .join(",")
333            ));
334        }
335        if !linked.is_empty() {
336            text.push_str("**Linked**:\n");
337            for entry in linked {
338                let id = entry.trim_start_matches("linked:").trim();
339                let st: Option<String> = conn
340                    .query_row(
341                        "SELECT status FROM tasks WHERE task_id = ?1",
342                        rusqlite::params![id],
343                        |r| r.get(0),
344                    )
345                    .ok();
346                match st {
347                    Some(s) => text.push_str(&format!("- {id} [{s}]\n")),
348                    None => text.push_str(&format!("- {id} [unknown]\n")),
349                }
350            }
351        }
352    }
353
354    // v0.5.0 Phase B: artifacts auto-extracted from event text. Render
355    // only categories that have entries — empty groups are noise on a
356    // 30-event task. Order is stable so packs diff cleanly.
357    let arts = crate::db::task_artifacts(conn, task_id)?;
358    if !arts.is_empty() {
359        text.push_str("**Artifacts**:\n");
360        if !arts.commit_hashes.is_empty() {
361            text.push_str(&format!("- commits: {}\n", arts.commit_hashes.join(", ")));
362        }
363        if !arts.pr_urls.is_empty() {
364            text.push_str(&format!("- PRs: {}\n", arts.pr_urls.join(", ")));
365        }
366        if !arts.linked_issues.is_empty() {
367            text.push_str(&format!("- issues: {}\n", arts.linked_issues.join(", ")));
368        }
369        if !arts.files.is_empty() {
370            text.push_str(&format!("- files: {}\n", arts.files.join(", ")));
371        }
372        if !arts.branch_names.is_empty() {
373            text.push_str(&format!("- branches: {}\n", arts.branch_names.join(", ")));
374        }
375    }
376    text.push('\n');
377
378    if matches!(mode, PackMode::Full) {
379        text.push_str(&render_lifecycle(conn, task_id)?);
380    }
381    text.push_str(&render_active_decisions(conn, task_id)?);
382    if matches!(mode, PackMode::Full) {
383        text.push_str(&render_rejected(conn, task_id)?);
384        text.push_str(&render_evidence(conn, task_id)?);
385    }
386    let recent_limit = match mode {
387        PackMode::Compact => 3,
388        PackMode::Full => 10,
389    };
390    text.push_str(&render_recent_events(conn, task_id, recent_limit)?);
391
392    let report = crate::completeness::assess(conn, task_id, crate::completeness::pending_count())?;
393    if let Some(section) = crate::completeness::render_section(&report) {
394        text.push_str(&section);
395    }
396
397    // One-level roll-up of direct children (parents only). Appended before
398    // truncation so it shares the pack budget. Task 5 busts the parent cache
399    // when a child changes, so the next assemble regenerates fresh.
400    if let Some(subtasks) = render_subtasks(conn, task_id)? {
401        text.push_str(&subtasks);
402    }
403
404    // Token-budget truncation: cap pack size so it always fits an LLM context window.
405    // v0.10.3: full bumped 10K → 24K. Real tasks accumulate 50-100 events
406    // and the prior cap clipped final-summary decisions even after the
407    // ORDER BY DESC reshuffle. 24K still fits comfortably inside any
408    // modern LLM context budget.
409    const FULL_BUDGET: usize = 24 * 1024;
410    const COMPACT_BUDGET: usize = 2 * 1024;
411    const TRUNC_MARKER: &str = "\n\n_(truncated to fit pack budget)_\n";
412    let budget = match mode {
413        PackMode::Full => FULL_BUDGET,
414        PackMode::Compact => COMPACT_BUDGET,
415    };
416    let truncated = text.len() > budget;
417    if truncated {
418        truncate_to_budget(&mut text, budget, TRUNC_MARKER);
419    }
420
421    let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
422
423    // Write-through cache.
424    conn.execute(
425        "INSERT OR REPLACE INTO task_pack_cache(task_id, mode, text, generated_at, source_event_count)
426         VALUES (?1, ?2, ?3, ?4, ?5)",
427        rusqlite::params![task_id, mode_str, text, generated_at, event_count as i64],
428    )?;
429
430    Ok(TaskPack {
431        task_id: task_id.to_string(),
432        mode,
433        schema_version: crate::SCHEMA_VERSION.into(),
434        text,
435        metadata: PackMetadata {
436            generated_at,
437            source_event_count: event_count,
438            cache_hit: false,
439            truncated,
440        },
441    })
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn pack_mode_round_trips_via_serde() {
450        let s = serde_json::to_string(&PackMode::Compact).unwrap();
451        assert_eq!(s, "\"Compact\"");
452    }
453
454    #[test]
455    fn parent_pack_contains_subtasks_section() {
456        let d = tempfile::TempDir::new().unwrap();
457        let conn = crate::db::open(d.path().join("s.sqlite")).unwrap();
458
459        // Parent + one child, each with an open event.
460        let mut p = crate::event::Event::new(
461            "p",
462            crate::event::EventType::Open,
463            crate::event::Author::User,
464            crate::event::Source::Cli,
465            "Parent".into(),
466        );
467        p.meta = serde_json::json!({"title": "Parent"});
468        crate::db::upsert_task_from_event(&conn, &p, "ph").unwrap();
469        crate::db::index_event(&conn, &p).unwrap();
470
471        let mut c = crate::event::Event::new(
472            "c",
473            crate::event::EventType::Open,
474            crate::event::Author::User,
475            crate::event::Source::Cli,
476            "Child".into(),
477        );
478        c.meta = serde_json::json!({"title": "Child", "parent_id": "p"});
479        crate::db::upsert_task_from_event(&conn, &c, "ph").unwrap();
480        crate::db::index_event(&conn, &c).unwrap();
481
482        let parent_pack = assemble(&conn, "p", PackMode::Compact).unwrap();
483        assert!(parent_pack.text.contains("Subtasks"));
484        assert!(parent_pack.text.contains("Child"));
485        assert!(parent_pack.text.contains("c")); // child id
486
487        let child_pack = assemble(&conn, "c", PackMode::Compact).unwrap();
488        assert!(!child_pack.text.contains("Subtasks"));
489    }
490
491    #[test]
492    fn pack_shows_completeness_section_when_gaps() {
493        let d = tempfile::TempDir::new().unwrap();
494        let conn = crate::db::open(d.path().join("s.sqlite")).unwrap();
495        // Task with no goal → NoGoal gap.
496        let e = crate::event::Event::new(
497            "g1",
498            crate::event::EventType::Open,
499            crate::event::Author::User,
500            crate::event::Source::Cli,
501            "T".into(),
502        );
503        crate::db::upsert_task_from_event(&conn, &e, "ph").unwrap();
504        crate::db::index_event(&conn, &e).unwrap();
505
506        let pack = assemble(&conn, "g1", PackMode::Compact).unwrap();
507        assert!(pack.text.contains("Completeness"));
508        assert!(pack.text.contains("no goal recorded"));
509    }
510
511    #[test]
512    fn pack_no_completeness_section_when_complete() {
513        let d = tempfile::TempDir::new().unwrap();
514        let conn = crate::db::open(d.path().join("s.sqlite")).unwrap();
515        let mut e = crate::event::Event::new(
516            "g2",
517            crate::event::EventType::Open,
518            crate::event::Author::User,
519            crate::event::Source::Cli,
520            "T".into(),
521        );
522        e.meta = serde_json::json!({"title": "T"});
523        crate::db::upsert_task_from_event(&conn, &e, "ph").unwrap();
524        crate::db::index_event(&conn, &e).unwrap();
525        // Give it a goal so NoGoal doesn't fire; open + no decisions → complete.
526        conn.execute("UPDATE tasks SET goal='g' WHERE task_id='g2'", [])
527            .unwrap();
528        // pending_count() resolves `<data_dir>/pending`. Point the data dir at
529        // the isolated tempdir (no pending/ child) so the PendingLeak rule
530        // stays silent regardless of the real dev environment.
531        std::env::set_var("TASK_JOURNAL_DATA_DIR", d.path());
532
533        let pack = assemble(&conn, "g2", PackMode::Compact).unwrap();
534        std::env::remove_var("TASK_JOURNAL_DATA_DIR");
535        assert!(!pack.text.contains("## Completeness"));
536    }
537
538    #[test]
539    fn cache_is_invalidated_on_new_event() {
540        use crate::db;
541        use crate::event::*;
542        use tempfile::TempDir;
543
544        let d = TempDir::new().unwrap();
545        let conn = db::open(d.path().join("s.sqlite")).unwrap();
546        let mut open_e = Event::new(
547            "tj-inv",
548            EventType::Open,
549            Author::User,
550            Source::Cli,
551            "x".into(),
552        );
553        open_e.meta = serde_json::json!({"title": "Inv"});
554        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
555        db::index_event(&conn, &open_e).unwrap();
556
557        let _ = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
558        let p2 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
559        assert!(p2.metadata.cache_hit);
560
561        let dec = Event::new(
562            "tj-inv",
563            EventType::Decision,
564            Author::Agent,
565            Source::Chat,
566            "D".into(),
567        );
568        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
569        db::index_event(&conn, &dec).unwrap();
570
571        let p3 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
572        assert!(
573            !p3.metadata.cache_hit,
574            "new event must invalidate the cache"
575        );
576    }
577
578    #[test]
579    fn pack_cache_hits_after_incremental_ingest_with_no_new_events() {
580        // Reproduces the MCP hot loop: client calls task_pack(X), the server
581        // runs ingest_new_events (which now reads only the JSONL tail), then
582        // calls assemble(X). After B2 the second call must hit the cache —
583        // before B2, full rebuild_state replayed every event through index_
584        // event() which DELETEd the cache row, so we always missed.
585        use crate::db;
586        use crate::event::*;
587        use std::io::Write;
588        use tempfile::TempDir;
589
590        let d = TempDir::new().unwrap();
591        let jsonl = d.path().join("events.jsonl");
592        let project = "cafef00dcafef00d";
593
594        let mut open_e = Event::new(
595            "tj-cmcp",
596            EventType::Open,
597            Author::User,
598            Source::Cli,
599            "x".into(),
600        );
601        open_e.meta = serde_json::json!({"title": "Cached"});
602        let dec = Event::new(
603            "tj-cmcp",
604            EventType::Decision,
605            Author::Agent,
606            Source::Chat,
607            "Adopt Rust".into(),
608        );
609
610        let mut f = std::fs::File::create(&jsonl).unwrap();
611        writeln!(f, "{}", serde_json::to_string(&open_e).unwrap()).unwrap();
612        writeln!(f, "{}", serde_json::to_string(&dec).unwrap()).unwrap();
613        drop(f);
614
615        let conn = db::open(d.path().join("s.sqlite")).unwrap();
616
617        // First MCP call: ingest, then pack.
618        db::ingest_new_events(&conn, &jsonl, project).unwrap();
619        let first = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
620        assert!(
621            !first.metadata.cache_hit,
622            "first assemble must populate cache"
623        );
624
625        // Second MCP call: ingest again (zero new events in JSONL), then pack.
626        let n_new = db::ingest_new_events(&conn, &jsonl, project).unwrap();
627        assert_eq!(n_new, 0, "no new events should be ingested");
628        let second = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
629        assert!(
630            second.metadata.cache_hit,
631            "repeat assemble after a no-op ingest must hit the cache"
632        );
633        assert_eq!(first.text, second.text);
634    }
635
636    #[test]
637    fn pack_cache_returns_cached_text_on_second_call() {
638        use crate::db;
639        use crate::event::*;
640        use tempfile::TempDir;
641
642        let d = TempDir::new().unwrap();
643        let conn = db::open(d.path().join("s.sqlite")).unwrap();
644        let mut open_e = Event::new(
645            "tj-c",
646            EventType::Open,
647            Author::User,
648            Source::Cli,
649            "x".into(),
650        );
651        open_e.meta = serde_json::json!({"title": "Cache"});
652        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
653        db::index_event(&conn, &open_e).unwrap();
654
655        let p1 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
656        assert!(!p1.metadata.cache_hit);
657        let p2 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
658        assert!(p2.metadata.cache_hit, "second call should hit cache");
659        assert_eq!(p1.text, p2.text);
660    }
661
662    #[test]
663    fn compact_mode_omits_optional_sections() {
664        use crate::db;
665        use crate::event::*;
666        use tempfile::TempDir;
667
668        let d = TempDir::new().unwrap();
669        let conn = db::open(d.path().join("s.sqlite")).unwrap();
670        let mut open_e = Event::new(
671            "tj-cm",
672            EventType::Open,
673            Author::User,
674            Source::Cli,
675            "x".into(),
676        );
677        open_e.meta = serde_json::json!({"title": "Compact"});
678        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
679        db::index_event(&conn, &open_e).unwrap();
680        let dec = Event::new(
681            "tj-cm",
682            EventType::Decision,
683            Author::Agent,
684            Source::Chat,
685            "D1".into(),
686        );
687        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
688        db::index_event(&conn, &dec).unwrap();
689
690        let pack = assemble(&conn, "tj-cm", PackMode::Compact).unwrap();
691        assert!(pack.text.contains("# Compact"));
692        assert!(pack.text.contains("Active decisions"));
693        assert!(pack.text.contains("Recent events"));
694        assert!(
695            !pack.text.contains("Lifecycle"),
696            "compact should omit Lifecycle: {}",
697            pack.text
698        );
699        assert!(
700            !pack.text.contains("Rejected"),
701            "compact should omit Rejected: {}",
702            pack.text
703        );
704        assert!(
705            !pack.text.contains("Evidence"),
706            "compact should omit Evidence: {}",
707            pack.text
708        );
709    }
710
711    #[test]
712    fn full_mode_truncates_when_exceeding_budget() {
713        use crate::db;
714        use crate::event::*;
715        use tempfile::TempDir;
716
717        let d = TempDir::new().unwrap();
718        let conn = db::open(d.path().join("s.sqlite")).unwrap();
719        let mut open_e = Event::new(
720            "tj-big",
721            EventType::Open,
722            Author::User,
723            Source::Cli,
724            "x".into(),
725        );
726        open_e.meta = serde_json::json!({"title": "Big"});
727        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
728        db::index_event(&conn, &open_e).unwrap();
729        for i in 0..100 {
730            let ev = Event::new(
731                "tj-big",
732                EventType::Evidence,
733                Author::Agent,
734                Source::Chat,
735                format!("Evidence #{i}: {}", "lorem ipsum ".repeat(50)),
736            );
737            db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
738            db::index_event(&conn, &ev).unwrap();
739        }
740        let pack = assemble(&conn, "tj-big", PackMode::Full).unwrap();
741        // v0.10.3: FULL_BUDGET bumped 10K → 24K + truncation slack.
742        assert!(
743            pack.text.len() <= 26 * 1024,
744            "pack must stay under ~26KB; got {} bytes",
745            pack.text.len()
746        );
747        assert!(pack.metadata.truncated, "metadata.truncated must be true");
748        assert!(pack.text.contains("truncated to fit pack budget"));
749    }
750
751    #[test]
752    fn truncate_to_budget_handles_multibyte_boundary() {
753        // 1 ASCII byte shifts every 'я' (2 bytes) start to an ODD offset, so an
754        // EVEN budget lands INSIDE a char — a raw text[..budget] slice would panic.
755        let marker = "\n[cut]";
756        let mut s = String::from("x");
757        s.push_str(&"я".repeat(2000)); // total = 1 + 4000 = 4001 bytes
758        let budget = 100usize; // even → mid-char given the odd char starts
759        assert!(
760            !s.is_char_boundary(budget),
761            "precondition: budget must be mid-char"
762        );
763        truncate_to_budget(&mut s, budget, marker); // must NOT panic
764        assert!(s.ends_with(marker));
765        assert!(s.len() <= budget + marker.len());
766        assert!(
767            std::str::from_utf8(s.as_bytes()).is_ok(),
768            "result must be valid UTF-8"
769        );
770    }
771
772    #[test]
773    fn truncate_to_budget_noop_under_budget() {
774        let mut s = String::from("маленький текст");
775        let before = s.clone();
776        truncate_to_budget(&mut s, 10_000, "\n[cut]");
777        assert_eq!(s, before);
778    }
779
780    #[test]
781    fn corrected_events_appear_with_correction_event_type() {
782        use crate::db;
783        use crate::event::*;
784        use tempfile::TempDir;
785
786        let d = TempDir::new().unwrap();
787        let conn = db::open(d.path().join("s.sqlite")).unwrap();
788        let mut open_e = Event::new(
789            "tj-co",
790            EventType::Open,
791            Author::User,
792            Source::Cli,
793            "x".into(),
794        );
795        open_e.meta = serde_json::json!({"title": "Corr"});
796        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
797        db::index_event(&conn, &open_e).unwrap();
798
799        let bad = Event::new(
800            "tj-co",
801            EventType::Finding,
802            Author::Classifier,
803            Source::Hook,
804            "Migration done (wrong)".into(),
805        );
806        db::upsert_task_from_event(&conn, &bad, "feedface").unwrap();
807        db::index_event(&conn, &bad).unwrap();
808
809        let mut corr = Event::new(
810            "tj-co",
811            EventType::Correction,
812            Author::User,
813            Source::Cli,
814            "Migration NOT done; finding was wrong".into(),
815        );
816        corr.corrects = Some(bad.event_id.clone());
817        db::upsert_task_from_event(&conn, &corr, "feedface").unwrap();
818        db::index_event(&conn, &corr).unwrap();
819
820        let pack = assemble(&conn, "tj-co", PackMode::Full).unwrap();
821        assert!(pack.text.contains("[correction]"));
822        assert!(pack.text.contains("Migration NOT done"));
823    }
824
825    #[test]
826    fn suggested_events_get_question_mark_marker_in_pack() {
827        use crate::db;
828        use crate::event::*;
829        use tempfile::TempDir;
830
831        let d = TempDir::new().unwrap();
832        let conn = db::open(d.path().join("s.sqlite")).unwrap();
833        let mut open_e = Event::new(
834            "tj-q",
835            EventType::Open,
836            Author::User,
837            Source::Cli,
838            "x".into(),
839        );
840        open_e.meta = serde_json::json!({"title": "Q"});
841        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
842        db::index_event(&conn, &open_e).unwrap();
843
844        let mut suggested = Event::new(
845            "tj-q",
846            EventType::Decision,
847            Author::Classifier,
848            Source::Hook,
849            "Adopt Rust".into(),
850        );
851        suggested.status = EventStatus::Suggested;
852        db::upsert_task_from_event(&conn, &suggested, "feedface").unwrap();
853        db::index_event(&conn, &suggested).unwrap();
854
855        let pack = assemble(&conn, "tj-q", PackMode::Full).unwrap();
856        let recent_pos = pack.text.find("## Recent events").unwrap();
857        let recent_section = &pack.text[recent_pos..];
858        assert!(
859            recent_section.contains("[?]"),
860            "suggested event must show [?] marker in Recent events:\n{recent_section}"
861        );
862    }
863
864    #[test]
865    fn pack_renders_recent_events_full_mode() {
866        use crate::db;
867        use crate::event::*;
868        use tempfile::TempDir;
869
870        let d = TempDir::new().unwrap();
871        let conn = db::open(d.path().join("s.sqlite")).unwrap();
872        let mut open_e = Event::new(
873            "tj-re",
874            EventType::Open,
875            Author::User,
876            Source::Cli,
877            "x".into(),
878        );
879        open_e.meta = serde_json::json!({"title": "Recent"});
880        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
881        db::index_event(&conn, &open_e).unwrap();
882        for i in 0..6 {
883            let e = Event::new(
884                "tj-re",
885                EventType::Hypothesis,
886                Author::Agent,
887                Source::Chat,
888                format!("hypothesis {i}"),
889            );
890            db::upsert_task_from_event(&conn, &e, "feedface").unwrap();
891            db::index_event(&conn, &e).unwrap();
892        }
893
894        let pack = assemble(&conn, "tj-re", PackMode::Full).unwrap();
895        assert!(pack.text.contains("## Recent events"));
896        let count = pack.text.matches("[hypothesis]").count();
897        assert!(
898            count >= 5,
899            "expected >=5 hypotheses, got {count} in {}",
900            pack.text
901        );
902    }
903
904    #[test]
905    fn pack_renders_evidence_section() {
906        use crate::db;
907        use crate::event::*;
908        use tempfile::TempDir;
909
910        let d = TempDir::new().unwrap();
911        let conn = db::open(d.path().join("s.sqlite")).unwrap();
912        let mut open_e = Event::new(
913            "tj-ev",
914            EventType::Open,
915            Author::User,
916            Source::Cli,
917            "x".into(),
918        );
919        open_e.meta = serde_json::json!({"title": "Ev"});
920        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
921        db::index_event(&conn, &open_e).unwrap();
922
923        let mut ev = Event::new(
924            "tj-ev",
925            EventType::Evidence,
926            Author::Agent,
927            Source::Chat,
928            "Hook startup at 12ms vs 380ms node".into(),
929        );
930        ev.evidence_strength = Some(EvidenceStrength::Strong);
931        db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
932        db::index_event(&conn, &ev).unwrap();
933
934        let pack = assemble(&conn, "tj-ev", PackMode::Full).unwrap();
935        assert!(pack.text.contains("## Evidence"));
936        assert!(pack.text.contains("12ms"));
937        assert!(pack.text.contains("(strong)"));
938    }
939
940    #[test]
941    fn pack_renders_rejected_options() {
942        use crate::db;
943        use crate::event::*;
944        use tempfile::TempDir;
945
946        let d = TempDir::new().unwrap();
947        let conn = db::open(d.path().join("s.sqlite")).unwrap();
948        let mut open_e = Event::new(
949            "tj-r",
950            EventType::Open,
951            Author::User,
952            Source::Cli,
953            "x".into(),
954        );
955        open_e.meta = serde_json::json!({"title": "Rej"});
956        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
957        db::index_event(&conn, &open_e).unwrap();
958
959        let rej = Event::new(
960            "tj-r",
961            EventType::Rejection,
962            Author::Agent,
963            Source::Chat,
964            "TypeScript: loses single-binary distribution".into(),
965        );
966        db::upsert_task_from_event(&conn, &rej, "feedface").unwrap();
967        db::index_event(&conn, &rej).unwrap();
968
969        let pack = assemble(&conn, "tj-r", PackMode::Full).unwrap();
970        assert!(pack.text.contains("## Rejected"));
971        assert!(pack.text.contains("TypeScript"));
972    }
973
974    #[test]
975    fn pack_renders_active_decisions() {
976        use crate::db;
977        use crate::event::*;
978        use tempfile::TempDir;
979
980        let d = TempDir::new().unwrap();
981        let conn = db::open(d.path().join("s.sqlite")).unwrap();
982        let mut open_e = Event::new(
983            "tj-ad",
984            EventType::Open,
985            Author::User,
986            Source::Cli,
987            "x".into(),
988        );
989        open_e.meta = serde_json::json!({"title": "Decisions test"});
990        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
991        db::index_event(&conn, &open_e).unwrap();
992
993        let dec = Event::new(
994            "tj-ad",
995            EventType::Decision,
996            Author::Agent,
997            Source::Chat,
998            "Adopt Rust".into(),
999        );
1000        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
1001        db::index_event(&conn, &dec).unwrap();
1002
1003        let pack = assemble(&conn, "tj-ad", PackMode::Full).unwrap();
1004        assert!(
1005            pack.text.contains("## Active decisions"),
1006            "missing section: {}",
1007            pack.text
1008        );
1009        assert!(
1010            pack.text.contains("Adopt Rust"),
1011            "decision text missing: {}",
1012            pack.text
1013        );
1014    }
1015
1016    #[test]
1017    fn pack_renders_decision_alternatives() {
1018        use crate::db;
1019        use crate::event::*;
1020        use tempfile::TempDir;
1021
1022        let d = TempDir::new().unwrap();
1023        let conn = db::open(d.path().join("s.sqlite")).unwrap();
1024        let mut open_e = Event::new(
1025            "tj-alt",
1026            EventType::Open,
1027            Author::User,
1028            Source::Cli,
1029            "x".into(),
1030        );
1031        open_e.meta = serde_json::json!({"title": "Alt test"});
1032        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
1033        db::index_event(&conn, &open_e).unwrap();
1034
1035        let mut dec = Event::new(
1036            "tj-alt",
1037            EventType::Decision,
1038            Author::Agent,
1039            Source::Chat,
1040            "Use SQLite for storage".into(),
1041        );
1042        dec.meta = serde_json::json!({
1043            "alternatives": [
1044                {"option": "SQLite", "chosen": true, "rationale": "embedded, zero-ops"},
1045                {"option": "Postgres", "chosen": false, "rationale": "too heavy for a local tool"}
1046            ]
1047        });
1048        db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
1049        db::index_event(&conn, &dec).unwrap();
1050
1051        let pack = assemble(&conn, "tj-alt", PackMode::Full).unwrap();
1052        // The decision itself still renders.
1053        assert!(
1054            pack.text.contains("Use SQLite for storage"),
1055            "decision text missing: {}",
1056            pack.text
1057        );
1058        // Considered alternatives surface, both chosen and rejected, with rationale.
1059        assert!(
1060            pack.text.contains("considered"),
1061            "alternatives header missing: {}",
1062            pack.text
1063        );
1064        assert!(
1065            pack.text.contains("SQLite") && pack.text.contains("Postgres"),
1066            "both options missing: {}",
1067            pack.text
1068        );
1069        assert!(
1070            pack.text.contains("too heavy for a local tool"),
1071            "rejected rationale missing: {}",
1072            pack.text
1073        );
1074    }
1075
1076    #[test]
1077    fn assemble_includes_lifecycle_history() {
1078        use crate::db;
1079        use crate::event::*;
1080        use tempfile::TempDir;
1081
1082        let d = TempDir::new().unwrap();
1083        let conn = db::open(d.path().join("s.sqlite")).unwrap();
1084
1085        let mut open_e = Event::new(
1086            "tj-l",
1087            EventType::Open,
1088            Author::User,
1089            Source::Cli,
1090            "x".into(),
1091        );
1092        open_e.meta = serde_json::json!({"title": "Lifecycle"});
1093        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
1094        db::index_event(&conn, &open_e).unwrap();
1095
1096        let close_e = Event::new(
1097            "tj-l",
1098            EventType::Close,
1099            Author::User,
1100            Source::Cli,
1101            "done".into(),
1102        );
1103        db::upsert_task_from_event(&conn, &close_e, "feedface").unwrap();
1104        db::index_event(&conn, &close_e).unwrap();
1105
1106        let pack = assemble(&conn, "tj-l", PackMode::Full).unwrap();
1107        assert!(pack.text.contains("## Lifecycle"));
1108        assert!(pack.text.contains("opened"));
1109        assert!(pack.text.contains("closed"));
1110    }
1111
1112    #[test]
1113    fn assemble_header_only_compact() {
1114        use crate::db;
1115        use crate::event::*;
1116        use tempfile::TempDir;
1117
1118        let d = TempDir::new().unwrap();
1119        let conn = db::open(d.path().join("s.sqlite")).unwrap();
1120
1121        let mut open_e = Event::new(
1122            "tj-h",
1123            EventType::Open,
1124            Author::User,
1125            Source::Cli,
1126            "x".into(),
1127        );
1128        open_e.meta = serde_json::json!({"title": "Header test"});
1129        db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
1130        db::index_event(&conn, &open_e).unwrap();
1131
1132        let pack = assemble(&conn, "tj-h", PackMode::Compact).unwrap();
1133        assert!(
1134            pack.text.contains("# Header test"),
1135            "header missing: {}",
1136            pack.text
1137        );
1138        assert!(
1139            pack.text.contains("status: open"),
1140            "status missing: {}",
1141            pack.text
1142        );
1143        assert_eq!(pack.metadata.source_event_count, 1);
1144        assert!(!pack.metadata.cache_hit);
1145    }
1146}