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