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, 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 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
143fn 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
205fn 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 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 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 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 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 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(§ion);
395 }
396
397 if let Some(subtasks) = render_subtasks(conn, task_id)? {
401 text.push_str(&subtasks);
402 }
403
404 const FULL_BUDGET: usize = 32 * 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 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 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")); 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 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 conn.execute("UPDATE tasks SET goal='g' WHERE task_id='g2'", [])
527 .unwrap();
528 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 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 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 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 assert!(
743 pack.text.len() <= 34 * 1024,
744 "pack must stay under ~34KB; 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 let marker = "\n[cut]";
756 let mut s = String::from("x");
757 s.push_str(&"я".repeat(2000)); let budget = 100usize; assert!(
760 !s.is_char_boundary(budget),
761 "precondition: budget must be mid-char"
762 );
763 truncate_to_budget(&mut s, budget, marker); 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 assert!(
1054 pack.text.contains("Use SQLite for storage"),
1055 "decision text missing: {}",
1056 pack.text
1057 );
1058 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}