1use 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.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 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 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 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 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 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 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 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 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 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 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 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 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 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}