- 584
pub input: Value, - 585
pub content: String, - 586
pub is_error: bool, - 587
} - 588
- 589
/// Drops every ```` ```vak ```` fence from `narration` whose `payload` - 590
/// digest matches one of this turn's presentation entries: the card is - 591
/// already shown through the entry, and replaying the fence would teach the - 592
/// model the duplicate it is told never to produce (docs/design/68 §10). - 593
fn strip_duplicate_fences(narration: &str, presentations: &[PresentationRecord]) -> String { - 594
let known: HashSet<&str> = presentations - 595
.iter() - 596
.map(|record| record.payload_digest.as_str()) - 597
.collect(); - 598
let mut out = String::with_capacity(narration.len()); - 599
let mut lines = narration.lines().peekable(); - 600
while let Some(line) = lines.next() { - 601
let trimmed = line.trim_start(); - 602
let opens_fence = trimmed - 603
.strip_prefix("```") - 604
.or_else(|| trimmed.strip_prefix("~~~")) - 605
.map(|rest| rest.trim() == "vak") - 606
.unwrap_or(false); - 607
if !opens_fence { - 608
out.push_str(line); - 609
out.push('\n'); - 610
continue; - 611
} - 612
let fence_marker = &trimmed[..3]; - 613
let mut body = String::new(); - 614
let mut closed = false; - 615
for inner in lines.by_ref() { - 616
if inner.trim_start().starts_with(fence_marker) { - 617
closed = true; - 618
break; - 619
} - 620
body.push_str(inner); - 621
body.push('\n'); - 622
} - 623
let duplicate = serde_json::from_str::<Value>(&body) - 624
.ok() - 625
.and_then(|value| value.get("payload").cloned()) - 626
.map(|payload| crate::types::payload_digest(&payload)) - 627
.map(|digest| known.contains(digest.as_str())) - 628
.unwrap_or(false); - 629
if !duplicate { - 630
out.push_str(line); - 631
out.push('\n'); - 632
out.push_str(&body); - 633
if closed { - 634
out.push_str(fence_marker); - 635
out.push('\n'); - 636
} - 637
} - 638
} - 639
out - 640
} - 641
- 642
/// A content-aware summary of one result, ending with the fixed - 643
/// `[evidence:<id> — <n> chars; call recall to expand]` tag (docs/design/68 - 644
/// §3). Never a character-count cut: the body is shape-driven, and only the - 645
/// bash/text digests select by *line* count. - 646
pub fn evidence_digest(id: &str, evidence: &Evidence) -> String { - 647
let chars = evidence.content.chars().count(); - 648
let tag = format!("[evidence:{id} \u{2014} {chars} chars; call recall to expand]"); - 649
if evidence.is_error { - 650
return format!("{}\n{tag}", evidence.content); - 651
} - 652
let body = if evidence.tool == "bash" { - 653
bash_digest(&evidence.content) - 654
} else if let Ok(value) = serde_json::from_str::<Value>(&evidence.content) { - 655
json_digest(&value) - 656
} else { - 657
text_digest(&evidence.content) - 658
}; - 659
format!("{body}\n{tag}") - 660
} - 661
- 662
/// A tool result as a summariser's transcript shows it (compaction, - 663
/// handoff): its schema-driven digest, tagged with the evidence id `recall` - 664
/// reopens, when the call that produced it is in `messages`; else the result - 665
/// itself. Never a character cut. - 666
pub fn transcript_result( - 667
messages: &[Message], - 668
tool_use_id: &str, - 669
content: &str, - 670
is_error: bool, - 671
) -> String { - 672
let call = messages - 673
.iter() - 674
.flat_map(|message| message.content.iter()) - 675
.find_map(|block| match block { - 676
ContentBlock::ToolUse { id, name, input } if id == tool_use_id => Some((name, input)), - 677
_ => None, - 678
}); - 679
match call { - 680
Some((tool, input)) => evidence_digest( - 681
tool_use_id, - 682
&Evidence { - 683
tool: tool.clone(), - 684
input: input.clone(), - 685
content: content.to_string(), - 686
is_error, - 687
}, - 688
), - 689
None => content.to_string(), - 690
} - 691
} - 692
- 693
/// The short parenthetical a `TraceLine` carries (`"8 results, 14.2k - 694
/// chars"`) — a summary, not the full digest. - 695
pub fn evidence_shape(evidence: &Evidence) -> String { - 696
let chars = evidence.content.chars().count(); - 697
if evidence.is_error { - 698
return "error".to_string(); - 699
} - 700
if evidence.tool == "bash" { - 701
let exit = evidence.content.lines().find_map(|line| { - 702
line.to_ascii_lowercase() - 703
.contains("exit code") - 704
.then(|| line.trim().to_string()) - 705
}); - 706
return match exit { - 707
Some(exit) => format!("{exit}, {chars} chars"), - 708
None => format!("{chars} chars"), - 709
}; - 710
} - 711
if let Ok(value) = serde_json::from_str::<Value>(&evidence.content) { - 712
return match &value { - 713
Value::Array(items) if !items.is_empty() && items.iter().all(is_link_like) => { - 714
format!("{} results, {chars} chars", items.len()) - 715
} - 716
Value::Array(items) => format!("array[{}], {chars} chars", items.len()), - 717
Value::Object(map) => format!("{} keys, {chars} chars", map.len()), - 718
_ => format!("{chars} chars"), - 719
}; - 720
} - 721
let lines = evidence.content.lines().count(); - 722
format!("{lines} lines, {chars} chars") - 723
} - 724
- 725
fn is_link_like(value: &Value) -> bool { - 726
value.is_object() && (value.get("url").is_some() || value.get("title").is_some()) - 727
} - 728
- 729
fn json_digest(value: &Value) -> String { - 730
match value { - 731
Value::Array(items) => array_digest(items), - 732
Value::Object(map) => object_digest(map), - 733
other => other.to_string(), - 734
} - 735
} - 736
- 737
fn array_digest(items: &[Value]) -> String { - 738
if !items.is_empty() && items.iter().all(is_link_like) { - 739
let mut out = format!("{} results:\n", items.len()); - 740
for item in items { - 741
let title = item.get("title").and_then(Value::as_str).unwrap_or(""); - 742
let url = item.get("url").and_then(Value::as_str).unwrap_or(""); - 743
out.push_str(&format!("- {title} ({url})\n")); - 744
} - 745
return out.trim_end().to_string(); - 746
} - 747
let mut out = format!("array, {} items", items.len()); - 748
if let Some(first) = items.first() { - 749
out.push_str(&format!( - 750
"; first: {}", - 751
serde_json::to_string(first).unwrap_or_default() - 752
)); - 753
} - 754
out - 755
} - 756
- 757
fn object_digest(map: &serde_json::Map<String, Value>) -> String { - 758
let keys: Vec<&str> = map.keys().map(String::as_str).collect(); - 759
let mut lines = vec![format!("object, keys: {}", keys.join(", "))]; - 760
for (key, value) in map { - 761
if let Value::Array(items) = value { - 762
if !items.is_empty() && items.iter().all(is_link_like) { - 763
lines.push(format!("{key}: {}", array_digest(items))); - 764
} else { - 765
lines.push(format!("{key}: array[{}]", items.len())); - 766
} - 767
} - 768
} - 769
lines.join("\n") - 770
} - 771
- 772
fn text_digest(content: &str) -> String { - 773
let headings: Vec<&str> = content - 774
.lines() - 775
.map(str::trim_start) - 776
.filter(|line| line.starts_with('#')) - 777
.collect(); - 778
// The opening of the first paragraph: its first sentence, bounded by - 779
// words. A paragraph with no whitespace at all is not prose (a blob, a - 780
// token, base64) and has no opening worth quoting — it would "digest" to - 781
// itself — so only its counts are reported. - 782
let first_paragraph = content - 783
.split("\n\n") - 784
.map(str::trim) - 785
.find(|paragraph| !paragraph.is_empty() && !paragraph.starts_with('#')) - 786
.filter(|paragraph| paragraph.contains(char::is_whitespace)) - 787
.map(|paragraph| truncate_words(first_sentence(paragraph), 60)) - 788
.unwrap_or_default(); - 789
let lines = content.lines().count(); - 790
let bytes = content.len(); - 791
let mut out = String::new(); - 792
if !headings.is_empty() { - 793
out.push_str("headings: "); - 794
out.push_str(&headings.join(" | ")); - 795
out.push('\n'); - 796
} - 797
if !first_paragraph.is_empty() { - 798
out.push_str(&first_paragraph); - 799
out.push('\n'); - 800
} - 801
out.push_str(&format!("({lines} lines, {bytes} bytes)")); - 802
out - 803
} - 804
- 805
/// Command output as its exit line and first and last ten lines, saying how - 806
/// many lines between them it leaves out. - 807
pub fn bash_digest(content: &str) -> String { - 808
let lines: Vec<&str> = content.lines().collect(); - 809
let mut out = String::new(); - 810
if let Some(exit) = lines - 811
.iter() - 812
.find(|line| line.to_ascii_lowercase().contains("exit code")) - 813
{ - 814
out.push_str(exit); - 815
out.push('\n'); - 816
} - 817
if lines.len() <= 20 { - 818
out.push_str(&lines.join("\n")); - 819
} else { - 820
out.push_str(&lines[..10].join("\n")); - 821
out.push_str(&format!("\n[{} lines omitted]\n", lines.len() - 20)); - 822
out.push_str(&lines[lines.len() - 10..].join("\n")); - 823
} - 824
out - 825
} - 826
- 827
/// The input JSON with string values longer than one sentence replaced by - 828
/// their first sentence — never for `bash`/`edit`/`write`, whose inputs stay - 829
/// whole (docs/design/68 §10's `TraceLine.args_digest`). - 830
fn args_digest(tool: &str, input: &Value) -> Value { - 831
if matches!(tool, "bash" | "edit" | "write") { - 832
return input.clone(); - 833
} - 834
shorten_strings(input) - 835
} - 836
- 837
fn shorten_strings(value: &Value) -> Value { - 838
match value { - 839
Value::String(s) => Value::String(first_sentence(s).to_string()), - 840
Value::Array(items) => Value::Array(items.iter().map(shorten_strings).collect()), - 841
Value::Object(map) => Value::Object( - 842
map.iter() - 843
.map(|(key, value)| (key.clone(), shorten_strings(value))) - 844
.collect(), - 845
), - 846
other => other.clone(), - 847
} - 848
} - 849
- 850
/// The text up to and including its first sentence-ending punctuation - 851
/// (`.`, `!`, `?`) followed by a space, newline, or end of string. Returns - 852
/// the whole trimmed text unchanged when it has none — never a character - 853
/// count, always a semantic boundary. - 854
pub(crate) fn first_sentence(text: &str) -> &str { - 855
let trimmed = text.trim(); - 856
let bytes = trimmed.as_bytes(); - 857
for (i, b) in bytes.iter().enumerate() { - 858
if matches!(b, b'.' | b'!' | b'?') { - 859
let after = i + 1; - 860
if after >= bytes.len() || matches!(bytes[after], b' ' | b'\n') { - 861
return &trimmed[..after]; - 862
} - 863
} - 864
} - 865
trimmed - 866
} - 867
- 868
fn first_sentence_by_words(text: &str, max_words: usize) -> String { - 869
let trimmed = text.trim(); - 870
if trimmed.split_whitespace().count() <= max_words { - 871
return trimmed.to_string(); - 872
} - 873
first_sentence(trimmed).to_string() - 874
} - 875
- 876
fn truncate_words(text: &str, max_words: usize) -> String { - 877
let words: Vec<&str> = text.split_whitespace().collect(); - 878
if words.len() <= max_words { - 879
return text.trim().to_string(); - 880
} - 881
format!("{}\u{2026}", words[..max_words].join(" ")) - 882
} - 883
- 884
/// Act/domains/modalities distilled from a turn's `vak_intent::Reading` — - 885
/// enough to group and filter turns without carrying the full reading (which - 886
/// includes provenance and confidence irrelevant to a card). - 887
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] - 888
pub struct ReadingKey { - 889
pub act: String, - 890
pub domains: Vec<String>, - 891
pub modalities: Vec<String>, - 892
/// The engagement's context profile (`minimal` | `recall` | `working` - 893
/// | `full`), which steers the working-set planner - 894
/// (docs/design/47-commitment-kernel.md). Empty means `recall`. - 895
#[serde(default)] - 896
pub context: String, - 897
} - 898
- 899
impl ReadingKey { - 900
/// From a full intent entry: the reading plus the context profile. - 901
pub fn from_record(record: &crate::types::IntentRecord) -> Self { - 902
let mut key = Self::from_reading(&record.reading); - 903
key.context = record.engagement.posture.context.as_str().to_string(); - 904
key - 905
} - 906
- 907
/// Whether the planner should keep history to the bare minimum. - 908
pub fn is_minimal(&self) -> bool { - 909
self.context == "minimal" - 910
} - 911
- 912
/// Whether the turn wants the workspace delta since the session began. - 913
pub fn wants_workspace_delta(&self) -> bool { - 914
matches!(self.context.as_str(), "working" | "full") - 915
} - 916
- 917
pub fn from_reading(reading: &vak_intent::Reading) -> Self { - 918
let mut modalities: Vec<String> = reading - 919
.input_modalities - 920
.iter() - 921
.chain(reading.output_modalities.iter()) - 922
.map(|m| m.as_str().to_string()) - 923
.collect(); - 924
modalities.sort(); - 925
modalities.dedup(); - 926
ReadingKey { - 927
act: reading.act.as_str().to_string(), - 928
domains: reading.domains.iter().cloned().collect(), - 929
modalities, - 930
context: String::new(), - 931
} - 932
} - 933
} - 934
- 935
/// One non-presentation tool call in a turn's trace (docs/design/68 §10). - 936
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 937
pub struct TraceLine { - 938
pub tool: String, - 939
pub args_digest: Value, - 940
pub evidence_id: String, - 941
pub shape: String, - 942
} - 943
- 944
/// A card emitted in this turn, as referenced from its `TurnCard`. - 945
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 946
pub struct PresentationRef { - 947
pub id: String, - 948
pub semantic_type: String, - 949
pub title: String, - 950
/// Schema-driven digest (`PresentationRecord::identity_digest`). - 951
pub digest: String, - 952
pub derived_from: Vec<String>, - 953
} - 954
- 955
/// The turn's answer: the presentations it emitted (the answer itself) and - 956
/// the narration around them. - 957
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] - 958
pub struct Answer { - 959
pub presentations: Vec<PresentationRef>, - 960
pub narration: String, - 961
} - 962
- 963
/// A turn's closing card (docs/design/68-context-engine.md §10): the - 964
/// three-layer answer (evidence trace, presentations, narration) plus enough - 965
/// bookkeeping to render it as a `<turns>` line or promote it to a full - 966
/// record. Written once at turn close (`EntryPayload::TurnCard`) and never - 967
/// rewritten. - 968
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 969
pub struct TurnCard { - 970
pub turn_id: String, - 971
pub asked: String, - 972
pub did: Vec<TraceLine>, - 973
pub answered: Answer, - 974
pub outcome: String, - 975
pub reading: ReadingKey, - 976
pub tokens_full: u64, - 977
pub tokens_card: u64, - 978
} - 979
- 980
impl TurnCard { - 981
/// The text BM25 indexes this card by: `asked` + every presentation's - 982
/// title/digest + narration + trace tool names and args (docs/design/68 - 983
/// §10). - 984
pub fn index_text(&self) -> String { - 985
let mut parts = vec![self.asked.clone()]; - 986
for presentation in &self.answered.presentations { - 987
parts.push(presentation.title.clone()); - 988
parts.push(presentation.digest.clone()); - 989
} - 990
parts.push(self.answered.narration.clone()); - 991
for trace in &self.did { - 992
parts.push(trace.tool.clone()); - 993
parts.push(trace.args_digest.to_string()); - 994
} - 995
parts.push(self.reading.act.clone()); - 996
parts.extend(self.reading.domains.iter().cloned()); - 997
parts.join(" ") - 998
} - 999
- 1000
/// The one-line `<turns>` rendering: `#<n> asked: … → did: search×2 → - 1001
/// research.synthesis "Sensex 15 Sep" [pres:a1; ev:9f2,9f3]`. - 1002
pub fn line(&self, n: usize) -> String { - 1003
let did_summary = summarize_trace(&self.did); - 1004
let outcome_part = match self.answered.presentations.first() { - 1005
Some(presentation) => { - 1006
let evidence_ids: Vec<&str> = self - 1007
.did - 1008
.iter() - 1009
.map(|trace| trace.evidence_id.as_str()) - 1010
.collect(); - 1011
let evidence_part = if evidence_ids.is_empty() { - 1012
String::new() - 1013
} else { - 1014
format!("; ev:{}", evidence_ids.join(",")) - 1015
}; - 1016
format!( - 1017
"{} \"{}\" [pres:{}{evidence_part}]", - 1018
presentation.semantic_type, presentation.title, presentation.id - 1019
) - 1020
} - 1021
None => format!("\"{}\"", truncate_words(&self.answered.narration, 12)), - 1022
}; - 1023
format!( - 1024
"#{n} asked: {} \u{2192} did: {did_summary} \u{2192} {outcome_part}", - 1025
self.asked - 1026
) - 1027
} - 1028
} - 1029
- 1030
fn summarize_trace(did: &[TraceLine]) -> String { - 1031
if did.is_empty() { - 1032
return "nothing".to_string(); - 1033
} - 1034
let mut counts: Vec<(String, usize)> = Vec::new(); - 1035
for trace in did { - 1036
match counts.iter_mut().find(|(name, _)| *name == trace.tool) { - 1037
Some(existing) => existing.1 += 1, - 1038
None => counts.push((trace.tool.clone(), 1)), - 1039
} - 1040
} - 1041
counts - 1042
.into_iter() - 1043
.map(|(name, n)| { - 1044
if n > 1 { - 1045
format!("{name}\u{d7}{n}") - 1046
} else { - 1047
name - 1048
} - 1049
}) - 1050
.collect::<Vec<_>>() - 1051
.join(", ") - 1052
} - 1053
- 1054
#[cfg(test)] - 1055
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1056
mod tests { - 1057
use super::*; - 1058
use crate::types::{ - 1059
ActivityKind, ActivityRecord, ActivityStatus, CompactionEntry, Entry, FrozenContract, - 1060
IntentRecord, MessageRecord, PresentationRecord, PresentationSource, SessionHeader, - 1061
TurnCardRecord, - 1062
}; - 1063
use crate::{SessionLog, SessionPath}; - 1064
use vak_llm::{ContentBlock as CB, Message as M, Role as R}; - 1065
- 1066
fn header(cwd: &std::path::Path) -> SessionHeader { - 1067
SessionHeader { - 1068
agent: None, - 1069
session_id: "s1".into(), - 1070
created_at: chrono::Utc::now(), - 1071
cwd: cwd.to_path_buf(), - 1072
parent_session_id: None, - 1073
contract_id: None, - 1074
work_item_id: None, - 1075
conversation: None, - 1076
contract: FrozenContract { - 1077
app_version: "test".into(), - 1078
provider: "scripted".into(), - 1079
model: "m".into(), - 1080
route_ladder: Vec::new(), - 1081
route_objective: String::new(), - 1082
route_annotations: Vec::new(), - 1083
system_prompt: String::new(), - 1084
permission_mode: "workspace-write".into(), - 1085
capabilities: Vec::new(), - 1086
prompt_layers: Vec::new(), - 1087
}, - 1088
} - 1089
} - 1090
- 1091
fn open_log(dir: &std::path::Path) -> SessionLog { - 1092
let path = SessionPath::new_session_file(dir, dir, "s1"); - 1093
SessionLog::create(path, header(dir)).unwrap() - 1094
} - 1095
- 1096
fn user_text(text: &str) -> MessageRecord { - 1097
MessageRecord { - 1098
message: M::user_text(text), - 1099
meta: None, - 1100
} - 1101
} - 1102
- 1103
fn assistant_tool_call(id: &str, name: &str, input: Value) -> MessageRecord { - 1104
MessageRecord { - 1105
message: M::assistant(vec![CB::ToolUse { - 1106
id: id.into(), - 1107
name: name.into(), - 1108
input, - 1109
}]), - 1110
meta: None, - 1111
} - 1112
} - 1113
- 1114
fn tool_result(id: &str, content: &str) -> MessageRecord { - 1115
MessageRecord { - 1116
message: M { - 1117
role: R::User, - 1118
content: vec![CB::tool_result(id, content)], - 1119
}, - 1120
meta: None, - 1121
} - 1122
} - 1123
- 1124
fn assistant_text(text: &str) -> MessageRecord { - 1125
MessageRecord { - 1126
message: M::assistant(vec![CB::text(text)]), - 1127
meta: None, - 1128
} - 1129
} - 1130
- 1131
/// Builds a fixture ledger with two closed turns (one plain, one with a - 1132
/// tool call and a presentation) and a third turn left open (a tool call - 1133
/// with no final answer yet). - 1134
fn two_closed_one_open(dir: &std::path::Path) -> (SessionLog, String, String, String) { - 1135
let mut log = open_log(dir); - 1136
let t1 = log.append_message(user_text("hello there")).unwrap().id; - 1137
log.append_message(assistant_text("hi, how can I help?")) - 1138
.unwrap(); - 1139
- 1140
let t2 = log - 1141
.append_message(user_text("search for rust news")) - 1142
.unwrap() - 1143
.id; - 1144
log.append_message(assistant_tool_call( - 1145
"call-1", - 1146
"search", - 1147
serde_json::json!({"query": "rust news"}), - 1148
)) - 1149
.unwrap(); - 1150
log.append_message(tool_result( - 1151
"call-1", - 1152
r#"[{"title":"Rust 2.0","url":"https://example.com/a"}]"#, - 1153
)) - 1154
.unwrap(); - 1155
log.append_message(assistant_tool_call( - 1156
"card-1", - 1157
"emit_research_card", - 1158
serde_json::json!({"semantic_type": "research.synthesis"}), - 1159
)) - 1160
.unwrap(); - 1161
log.append_message(tool_result( - 1162
"card-1", - 1163
r#"{"presentation":"pres-1","ok":true}"#, - 1164
)) - 1165
.unwrap(); - 1166
log.append_message(assistant_text("Here is what I found.")) - 1167
.unwrap(); - 1168
log.append_presentation(PresentationRecord { - 1169
turn_id: t2.clone(), - 1170
source: PresentationSource::ToolCall { - 1171
tool_use_id: "card-1".into(), - 1172
}, - 1173
semantic_type: "research.synthesis".into(), - 1174
skill_id: "skill".into(), - 1175
skill_version: "1".into(), - 1176
schema_version: 1, - 1177
payload: serde_json::json!({"takeaways": ["Rust 2.0 shipped"]}), - 1178
payload_digest: "digest1".into(), - 1179
derived_from: vec!["call-1".into()], - 1180
title: "Rust news".into(), - 1181
identity_digest: "Rust news: Rust 2.0 shipped".into(), - 1182
}) - 1183
.unwrap(); - 1184
- 1185
let t3 = log - 1186
.append_message(user_text("now check the changelog")) - 1187
.unwrap() - 1188
.id; - 1189
log.append_message(assistant_tool_call( - 1190
"call-2", - 1191
"webfetch", - 1192
serde_json::json!({"url": "https://example.com/changelog"}), - 1193
)) - 1194
.unwrap(); - 1195
(log, t1, t2, t3) - 1196
} - 1197
- 1198
#[test] - 1199
fn index_builds_turns_from_a_fixture_ledger_with_two_closed_and_one_open() { - 1200
let dir = tempfile::tempdir().unwrap(); - 1201
let (log, t1, t2, t3) = two_closed_one_open(dir.path()); - 1202
let index = TurnIndex::from_log(&log); - 1203
assert_eq!(index.turns.len(), 3); - 1204
assert_eq!(index.turns[0].id, t1); - 1205
assert!(index.turns[0].closed); - 1206
assert_eq!(index.turns[1].id, t2); - 1207
assert!(index.turns[1].closed); - 1208
assert_eq!(index.turns[2].id, t3); - 1209
assert!( - 1210
!index.turns[2].closed, - 1211
"the last turn has no final answer yet" - 1212
); - 1213
} - 1214
- 1215
#[test] - 1216
fn a_turn_is_never_split_across_the_index() { - 1217
let dir = tempfile::tempdir().unwrap(); - 1218
let (log, _, t2, _) = two_closed_one_open(dir.path()); - 1219
let index = TurnIndex::from_log(&log); - 1220
let turn2 = index.turn_by_id(&t2).unwrap(); - 1221
assert_eq!(turn2.steps.len(), 2); - 1222
assert_eq!( - 1223
turn2.evidence, - 1224
vec!["call-1".to_string(), "card-1".to_string()] - 1225
); - 1226
assert_eq!(turn2.presentations.len(), 1); - 1227
} - 1228
- 1229
#[test] - 1230
fn full_record_keeps_the_call_pattern_with_digested_results() { - 1231
let dir = tempfile::tempdir().unwrap(); - 1232
let (log, _, t2, _) = two_closed_one_open(dir.path()); - 1233
let index = TurnIndex::from_log(&log); - 1234
let turn2 = index.turn_by_id(&t2).unwrap(); - 1235
let record = turn2.full_record(); - 1236
// directive, search call, digested result, card call, ack, narration - 1237
let roles: Vec<R> = record.iter().map(|m| m.role).collect(); - 1238
assert_eq!( - 1239
roles, - 1240
vec![ - 1241
R::User, - 1242
R::Assistant, - 1243
R::User, - 1244
R::Assistant, - 1245
R::User, - 1246
R::Assistant - 1247
] - 1248
); - 1249
let search_result = match &record[2].content[0] { - 1250
CB::ToolResult { content, .. } => content.clone(), - 1251
other => panic!("expected a tool result, got {other:?}"), - 1252
}; - 1253
assert!( - 1254
search_result.contains("[evidence:call-1"), - 1255
"{search_result}" - 1256
); - 1257
assert!(search_result.contains("https://example.com/a")); - 1258
assert!( - 1259
!search_result.contains("Rust 2.0\",\"url"), - 1260
"raw JSON must be digested, not replayed: {search_result}" - 1261
); - 1262
assert!(matches!( - 1263
&record[3].content[0], - 1264
CB::ToolUse { name, .. } if name == "emit_research_card" - 1265
)); - 1266
assert!(matches!( - 1267
&record[4].content[0], - 1268
CB::ToolResult { tool_use_id, content, .. } - 1269
if tool_use_id == "card-1" && content.contains("\"ok\":true") - 1270
)); - 1271
assert!( - 1272
!record - 1273
.iter() - 1274
.any(|m| m.content.iter().any(|b| matches!(b, CB::Thinking { .. }))) - 1275
); - 1276
assert_eq!(record[5].text_content(), "Here is what I found."); - 1277
} - 1278
- 1279
#[test] - 1280
fn full_record_drops_a_fence_that_duplicates_an_emitted_card() { - 1281
let payload = serde_json::json!({"label": "Temperature", "value": 29.1, "unit": "C"}); - 1282
let record = PresentationRecord { - 1283
turn_id: "t".into(), - 1284
source: PresentationSource::ToolCall { - 1285
tool_use_id: "card-9".into(), - 1286
}, - 1287
semantic_type: "metric".into(), - 1288
skill_id: "core".into(), - 1289
skill_version: "1.0.0".into(), - 1290
schema_version: 2, - 1291
payload: crate::types::canonicalize_json(&payload), - 1292
payload_digest: crate::types::payload_digest(&payload), - 1293
derived_from: vec![], - 1294
title: "Temperature".into(), - 1295
identity_digest: String::new(), - 1296
}; - 1297
let narration = format!( - 1298
"It is 29.1°C.\n```vak\n{}\n```\nStay hydrated.", - 1299
serde_json::json!({"semantic_type": "metric", "payload": payload}) - 1300
); - 1301
let kept = strip_duplicate_fences(&narration, std::slice::from_ref(&record)); - 1302
assert_eq!(kept.trim(), "It is 29.1°C.\nStay hydrated."); - 1303
let other = "```vak\n{\"semantic_type\":\"metric\",\"payload\":{\"label\":\"Wind\"}}\n```"; - 1304
assert_eq!( - 1305
strip_duplicate_fences(other, std::slice::from_ref(&record)).trim(), - 1306
other - 1307
); - 1308
} - 1309
- 1310
#[test] - 1311
fn card_line_format() { - 1312
let dir = tempfile::tempdir().unwrap(); - 1313
let (log, _, t2, _) = two_closed_one_open(dir.path()); - 1314
let index = TurnIndex::from_log(&log); - 1315
let turn2 = index.turn_by_id(&t2).unwrap(); - 1316
let card = turn2.build_card("completed", "Here is what I found.".to_string(), &|s| { - 1317
s.len() as u64 / 4 - 1318
}); - 1319
let line = card.line(2); - 1320
assert!(line.starts_with("#2 asked:")); - 1321
assert!(line.contains("did: search")); - 1322
assert!(line.contains("research.synthesis")); - 1323
assert!(line.contains("Rust news")); - 1324
assert!(line.contains("ev:call-1")); - 1325
} - 1326
- 1327
#[test] - 1328
fn digest_per_shape() { - 1329
let search_ev = Evidence { - 1330
tool: "search".into(), - 1331
input: serde_json::json!({}), - 1332
content: r#"[{"title":"A","url":"https://a"},{"title":"B","url":"https://b"}]"#.into(), - 1333
is_error: false, - 1334
}; - 1335
let digest = evidence_digest("ev1", &search_ev); - 1336
assert!(digest.contains("https://a")); - 1337
assert!(digest.contains("https://b")); - 1338
assert!(digest.ends_with("[evidence:ev1 \u{2014} 65 chars; call recall to expand]")); - 1339
- 1340
let bash_ev = Evidence { - 1341
tool: "bash".into(), - 1342
input: serde_json::json!({"command": "echo hi"}), - 1343
content: "hi\nexit code: 0".into(), - 1344
is_error: false, - 1345
}; - 1346
let bash_digest_text = evidence_digest("ev2", &bash_ev); - 1347
assert!(bash_digest_text.contains("exit code: 0")); - 1348
- 1349
let text_ev = Evidence { - 1350
tool: "read".into(), - 1351
input: serde_json::json!({}), - 1352
content: "# Heading\n\nFirst paragraph text.\n\nMore.".into(), - 1353
is_error: false, - 1354
}; - 1355
let text_digest_text = evidence_digest("ev3", &text_ev); - 1356
assert!(text_digest_text.contains("# Heading")); - 1357
assert!(text_digest_text.contains("First paragraph text.")); - 1358
- 1359
let err_ev = Evidence { - 1360
tool: "bash".into(), - 1361
input: serde_json::json!({}), - 1362
content: "boom: permission denied".into(), - 1363
is_error: true, - 1364
}; - 1365
let err_digest = evidence_digest("ev4", &err_ev); - 1366
assert!(err_digest.starts_with("boom: permission denied")); - 1367
} - 1368
- 1369
#[test] - 1370
fn bm25_returns_the_matching_turn_first() { - 1371
let dir = tempfile::tempdir().unwrap(); - 1372
let (mut log, _, t2, _) = two_closed_one_open(dir.path()); - 1373
let card = TurnIndex::from_log(&log) - 1374
.turn_by_id(&t2) - 1375
.unwrap() - 1376
.build_card("completed", "Here is what I found.".to_string(), &|s| { - 1377
s.len() as u64 / 4 - 1378
}); - 1379
log.append_turn_card(TurnCardRecord { - 1380
turn_id: t2.clone(), - 1381
card, - 1382
}) - 1383
.unwrap(); - 1384
let index = TurnIndex::from_log(&log); - 1385
let hits = index.search("rust news"); - 1386
assert!(!hits.is_empty()); - 1387
assert_eq!(hits[0].0, t2); - 1388
} - 1389
- 1390
#[test] - 1391
fn control_messages_are_not_turns_or_steps() { - 1392
let dir = tempfile::tempdir().unwrap(); - 1393
let mut log = open_log(dir.path()); - 1394
let t1 = log.append_message(user_text("do a thing")).unwrap().id; - 1395
log.append_message(assistant_tool_call( - 1396
"c1", - 1397
"bash", - 1398
serde_json::json!({"command": "ls"}), - 1399
)) - 1400
.unwrap(); - 1401
log.append_message(tool_result("c1", "ok")).unwrap(); - 1402
log.append_message(MessageRecord::control( - 1403
vak_intent::control::ControlKind::StopHook, - 1404
"[stop-hook]: keep going", - 1405
)) - 1406
.unwrap(); - 1407
log.append_message(assistant_text("done")).unwrap(); - 1408
let index = TurnIndex::from_log(&log); - 1409
assert_eq!(index.turns.len(), 1); - 1410
let turn = index.turn_by_id(&t1).unwrap(); - 1411
assert_eq!(turn.steps.len(), 1, "the nudge must not become a step"); - 1412
assert!(turn.closed); - 1413
} - 1414
- 1415
/// A gate that redoes a text-only draft appends a control nudge AFTER - 1416
/// it without any further assistant reply yet: the turn must stay open - 1417
/// (never rendered as a closed `full_record`, which would drop the - 1418
/// nudge entirely) and `current_verbatim` must end with the nudge, so - 1419
/// the next request's last message is a real user-role message rather - 1420
/// than the assistant's own draft (invariant: current Claude models - 1421
/// reject a request with no trailing user turn). - 1422
#[test] - 1423
fn a_draft_followed_by_a_control_nudge_with_no_reply_yet_stays_open() { - 1424
let dir = tempfile::tempdir().unwrap(); - 1425
let mut log = open_log(dir.path()); - 1426
let t1 = log.append_message(user_text("do a thing")).unwrap().id; - 1427
log.append_message(assistant_text("draft answer")).unwrap(); - 1428
log.append_message(MessageRecord::control( - 1429
vak_intent::control::ControlKind::StopHook, - 1430
"[stop-hook]: keep going", - 1431
)) - 1432
.unwrap(); - 1433
let index = TurnIndex::from_log(&log); - 1434
assert_eq!(index.turns.len(), 1); - 1435
let turn = index.turn_by_id(&t1).unwrap(); - 1436
assert!( - 1437
!turn.closed, - 1438
"a nudge with no reply after it must keep the turn open" - 1439
); - 1440
let verbatim = turn.current_verbatim(); - 1441
let last = verbatim.last().expect("at least the directive"); - 1442
assert_eq!( - 1443
last.text_content(), - 1444
"[stop-hook]: keep going", - 1445
"the nudge must be the last message so it reaches the model" - 1446
); - 1447
assert_eq!(last.role, R::User); - 1448
} - 1449
- 1450
/// The same shape, but a tool result (rather than a nudge) is the last - 1451
/// thing appended after the draft: also open, for the same reason. - 1452
#[test] - 1453
fn a_draft_followed_by_a_tool_result_with_no_reply_yet_stays_open() { - 1454
let dir = tempfile::tempdir().unwrap(); - 1455
let mut log = open_log(dir.path()); - 1456
let t1 = log.append_message(user_text("do a thing")).unwrap().id; - 1457
log.append_message(assistant_tool_call( - 1458
"c1", - 1459
"bash", - 1460
serde_json::json!({"command": "ls"}), - 1461
)) - 1462
.unwrap(); - 1463
log.append_message(tool_result("c1", "ok")).unwrap(); - 1464
let index = TurnIndex::from_log(&log); - 1465
let turn = index.turn_by_id(&t1).unwrap(); - 1466
assert!(!turn.closed, "a fresh tool result must keep the turn open"); - 1467
} - 1468
- 1469
#[test] - 1470
fn intent_reading_attaches_to_its_turn() { - 1471
let dir = tempfile::tempdir().unwrap(); - 1472
let mut log = open_log(dir.path()); - 1473
let t1 = log - 1474
.append_message(user_text("find the weather")) - 1475
.unwrap() - 1476
.id; - 1477
let reading = vak_intent::Reading::general(); - 1478
log.append_intent(IntentRecord { - 1479
reading, - 1480
engagement: vak_intent::Engagement::general(), - 1481
provenance: vak_intent::Provenance::new(vak_intent::Tier::General, 1, Vec::new()), - 1482
outcome: None, - 1483
model_visible: None, - 1484
commitment_id: None, - 1485
strands: Vec::new(), - 1486
strand_commitments: Default::default(), - 1487
}) - 1488
.unwrap(); - 1489
log.append_message(assistant_text("it is sunny")).unwrap(); - 1490
let index = TurnIndex::from_log(&log); - 1491
let turn = index.turn_by_id(&t1).unwrap(); - 1492
let card = turn.build_card("completed", "it is sunny".to_string(), &|s| { - 1493
s.len() as u64 / 4 - 1494
}); - 1495
assert_eq!(card.reading.act, "answer"); - 1496
} - 1497
- 1498
#[test] - 1499
fn every_ledger_activity_entry_is_ignored_by_turn_structure() { - 1500
let dir = tempfile::tempdir().unwrap(); - 1501
let mut log = open_log(dir.path()); - 1502
log.append_message(user_text("hi")).unwrap(); - 1503
log.append_activity(ActivityRecord { - 1504
activity_id: "a1".into(), - 1505
turn: None, - 1506
kind: ActivityKind::Diagnostic, - 1507
status: ActivityStatus::Succeeded, - 1508
label: "note".into(), - 1509
detail: None, - 1510
data: Default::default(), - 1511
}) - 1512
.unwrap(); - 1513
log.append_message(assistant_text("hello")).unwrap(); - 1514
let index = TurnIndex::from_log(&log); - 1515
assert_eq!(index.turns.len(), 1); - 1516
} - 1517
- 1518
#[test] - 1519
fn packets_collect_compaction_entries() { - 1520
let dir = tempfile::tempdir().unwrap(); - 1521
let mut log = open_log(dir.path()); - 1522
let t1 = log.append_message(user_text("first")).unwrap().id; - 1523
log.append_message(assistant_text("first answer")).unwrap(); - 1524
log.append(Entry::new( - 1525
log.tail_id().cloned(), - 1526
EntryPayload::Compaction(CompactionEntry { - 1527
summary: "summary text".into(), - 1528
first_turn_id: t1.clone(), - 1529
last_turn_id: t1, - 1530
model: "fixture-model".into(), - 1531
tokens_before: 100, - 1532
reset_all: false, - 1533
}), - 1534
)) - 1535
.unwrap(); - 1536
let index = TurnIndex::from_log(&log); - 1537
assert_eq!(index.packets.len(), 1); - 1538
assert_eq!(index.packets[0].summary, "summary text"); - 1539
} - 1540
} - 1541
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.