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
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 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 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 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 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 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 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 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 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 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 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}