1use std::collections::{BTreeMap, HashMap};
15use std::path::Path;
16use std::sync::Arc;
17
18use anyhow::Result;
19use forge_primitives::git_diff::LayerState;
20use forge_primitives::plugins::{PluginUpdateRun, PluginsInventorySnapshot};
21use forge_primitives::review::{ReviewAnchor, ReviewSet, ReviewSide, ReviewThread};
22use forge_primitives::runtime::{AvailableAgent, AvailableCommand, MonitorRecord};
23use forge_primitives::slack::SlackSubscription;
24use forge_primitives::{ContentBlock, GotifySubscription, Message, SessionSlot, UserEnvelope};
25use forge_workspace::env::git_diff::content::{DiffContent, FileContent, scan as scan_content};
26use forge_workspace::env::git_diff::file::{merge_base, read_file_lines};
27use forge_workspace::env::git_diff::resolver::{
28 AnchorPlacement, OutdatedReason, place_anchor_in_content, place_anchor_in_lines_with,
29};
30use forge_workspace::env::processes::ProcessSnapshot;
31use forge_workspace::{McpServers, ProjectView};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35use crate::file_index::FileIndex;
36use crate::surface::{AgentRow, PendingAsk, ViewSurface};
37use crate::transcript::{Rendered, TaskEnding, TurnSpan};
38use crate::transport::TransportState;
39use crate::transport::conversation::{Conversation, Held};
40use crate::transport::envelope::Subject;
41use crate::work::{WorkState, git_work_view, work_from_scan};
42
43fn boot_id() -> &'static str {
48 static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
49 BOOT.get_or_init(|| {
50 let nanos = std::time::SystemTime::now()
51 .duration_since(std::time::UNIX_EPOCH)
52 .map_or(0, |held| held.as_nanos());
53 format!("{}-{nanos}", std::process::id())
54 })
55}
56
57pub fn fixture_seat() -> SessionSlot {
62 SessionSlot::lead("TestOrg", "proj")
63}
64
65#[derive(Serialize, Deserialize)]
69#[serde(tag = "outcome", content = "value", rename_all = "snake_case")]
70pub enum ReadWire<T> {
71 Read(T),
72 Failed(String),
73}
74
75impl<T> From<std::result::Result<T, String>> for ReadWire<T> {
76 fn from(read: std::result::Result<T, String>) -> Self {
77 match read {
78 Ok(records) => Self::Read(records),
79 Err(why) => Self::Failed(why),
80 }
81 }
82}
83
84#[derive(Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub struct HomeWire {
93 pub projects: Vec<ProjectWire>,
94 pub agents: Vec<AgentWire>,
95 pub fleet: Vec<forge_workspace::board::FleetRow>,
99 pub unseen: Vec<SessionSlot>,
106 pub accounts: forge_workspace::AccountPoolView,
109 pub connectors: ConnectorsWire,
110 pub dictate: DictateWire,
111 pub voice: VoiceWire,
114 pub cli_version: Option<Value>,
117 pub forge_version: String,
124 pub forge_version_short: String,
125 pub boot_id: String,
132 pub service_status: Option<Value>,
133 pub fatal_error: Option<Value>,
136 pub biometric: ReadWire<Vec<forge_primitives::biometric::BiometricEnrolment>>,
140 pub vault: Option<VaultWire>,
144}
145
146#[derive(Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub struct VaultWire {
150 pub locked: bool,
152 pub idle_minutes: Option<u64>,
154}
155
156#[derive(Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub struct ExtensionsWire {
169 pub snapshot: Option<PluginsInventorySnapshot>,
173 pub update_run: Option<PluginUpdateRun>,
174 pub lsp: Vec<LspCheckWire>,
175}
176
177#[derive(Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub struct LspCheckWire {
181 pub plugin: String,
182 pub server: String,
183 pub command: String,
184 pub on_path: bool,
185}
186
187#[derive(Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub struct ProjectWire {
197 pub project: ProjectView,
198 pub work: WorkState,
211 pub would_bind: bool,
215 pub chip: Option<forge_workspace::SessionChipInfo>,
222}
223
224#[derive(Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub struct BoardWire {
234 pub projects: Vec<ProjectBoardWire>,
235}
236
237#[derive(Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub struct ProjectBoardIdentity {
244 pub org: String,
245 pub name: String,
246}
247
248#[derive(Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub struct ProjectBoardWire {
252 pub project: ProjectBoardIdentity,
253 pub rows: Vec<forge_workspace::board::BoardRow>,
258 pub crons: Vec<forge_primitives::CronEntry>,
264 pub connectors: ProjectConnectorsWire,
271}
272
273#[derive(Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub struct AgentWire {
287 pub slot: SessionSlot,
288 pub label: String,
289 pub lifecycle: forge_primitives::SessionLifecycleState,
290 pub heal_attempt: Option<u32>,
293 pub heal_attempts: Option<u32>,
295 pub has_background_work: bool,
296 pub pending: Option<forge_workspace::PendingInteractionKind>,
297 pub pending_depth: usize,
298 pub pending_seq: u64,
302 pub last_activity: Option<std::time::SystemTime>,
303 pub reason: Option<String>,
304 pub failed_turn: Option<std::time::SystemTime>,
308 pub died: bool,
312 pub tree: Option<String>,
316 pub work: Option<WorkState>,
322}
323
324impl From<&AgentRow> for AgentWire {
325 fn from(row: &AgentRow) -> Self {
326 Self {
327 slot: row.slot.clone(),
328 label: row.label.clone(),
329 lifecycle: row.lifecycle,
330 heal_attempt: row.heal_attempt,
331 heal_attempts: row.heal_attempts,
332 has_background_work: row.has_background_work,
333 pending: row.pending,
334 pending_depth: row.pending_depth,
335 pending_seq: row.pending_seq,
336 last_activity: row.last_activity,
337 reason: row.reason.clone(),
338 failed_turn: row.failed_turn,
339 died: row.died,
340 tree: row.tree.clone(),
341 work: None,
342 }
343 }
344}
345
346#[derive(Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub struct ConnectorsWire {
355 pub gotify: GotifyWire,
356 pub slack: SlackWire,
357}
358
359#[derive(Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub struct GotifyWire {
362 pub connected: bool,
363}
364
365#[derive(Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367pub struct SlackWire {
368 pub connected_workspaces: Vec<(String, bool)>,
372 pub load_failed: bool,
373}
374
375#[derive(Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub struct ProjectConnectorsWire {
379 pub gotify: Vec<GotifySubscription>,
380 pub slack: Vec<SlackSubscription>,
381}
382
383#[derive(Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub struct DictateWire {
387 pub enabled: bool,
393 pub snapshot: Value,
394 pub models_dir: Option<std::path::PathBuf>,
395}
396
397#[derive(Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub struct VoiceWire {
401 pub enabled: bool,
402 pub models_dir: Option<std::path::PathBuf>,
403}
404
405#[derive(Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub struct SessionWire {
409 pub slot: SessionSlot,
410 pub state: SessionStateWire,
411 pub composer: ComposerWire,
413 pub header: SessionHeaderWire,
414 pub mcp: Option<McpServers>,
415 pub processes: Option<ProcessSnapshot>,
416 pub background_tasks: Vec<forge_workspace::BackgroundTask>,
427 pub monitors: Vec<MonitorRecord>,
428 pub terminal: Option<forge_primitives::terminal::TerminalSummary>,
433 pub pending_ask: Option<PendingAskWire>,
441 pub pending_asks: Vec<PendingAskWire>,
446 pub conversation: ConversationWire,
448 pub slash_commands: Vec<AvailableCommand>,
449 pub subagents: Vec<AvailableAgent>,
450 pub subagent_instances: Vec<forge_primitives::runtime::SubagentCard>,
454 pub file_index: Arc<FileIndex>,
459 pub resume_sessions: Vec<ResumableSessionWire>,
468 pub reviews: ReviewsWire,
469 pub tree_reviews: ReviewsWire,
474 pub work: WorkState,
478 pub git: forge_primitives::git_diff::GitWorkView,
487 pub pr: Option<forge_primitives::git::GitPrInfo>,
496 pub closes: Vec<forge_primitives::git::GitIssueRef>,
497 pub diff: DiffContent,
508}
509
510#[derive(Serialize, Deserialize)]
513#[serde(rename_all = "snake_case")]
514pub struct ResumableSessionWire {
515 pub session_id: String,
516 pub summary: String,
517}
518
519#[derive(Serialize, Deserialize)]
521#[serde(rename_all = "snake_case")]
522pub struct SessionStateWire {
523 pub slot: SessionSlot,
524 pub scan_cwd: std::path::PathBuf,
525 pub queue: Vec<forge_workspace::protocol::QueuedPrompt>,
532 pub voice_listeners: u32,
534}
535
536#[derive(Serialize, Deserialize)]
553#[serde(rename_all = "snake_case")]
554pub struct ComposerWire {
555 pub machine: Option<forge_primitives::runtime::MachineTurn>,
558 pub bind: String,
566 pub mode: String,
567 pub notice: Option<serde_json::Value>,
570}
571
572#[derive(Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub struct SessionHeaderWire {
578 pub session_id: Option<forge_primitives::SessionId>,
586 pub seq: u64,
593 pub model: Option<Value>,
594 pub effort: Value,
595 pub permission_mode: Option<Value>,
596 pub context: Value,
597 pub available_models: Vec<Value>,
598 pub turn_in_flight: bool,
599}
600
601#[derive(Serialize, Deserialize)]
605#[serde(tag = "kind", content = "request", rename_all = "snake_case")]
606pub enum PendingAskWire {
607 Permission(Value),
608 Question(Value),
609 SlackDraft(Value),
610 BrowserHandOff(Value),
611 TerminalHandOff(Value),
612 DocumentHandOff(Value),
613 SettingsWrite(Value),
614 Vault(Value),
615}
616
617impl From<&PendingAsk> for PendingAskWire {
618 fn from(ask: &PendingAsk) -> Self {
619 let encode = |body: Result<Value, serde_json::Error>| body.unwrap_or(Value::Null);
620 match ask {
621 PendingAsk::Permission(request) => {
622 Self::Permission(encode(serde_json::to_value(request.as_ref())))
623 }
624 PendingAsk::Question(request) => {
625 Self::Question(encode(serde_json::to_value(request.as_ref())))
626 }
627 PendingAsk::SlackDraft(draft) => {
628 Self::SlackDraft(encode(serde_json::to_value(draft.as_ref())))
629 }
630 PendingAsk::BrowserHandOff(handoff) => {
631 Self::BrowserHandOff(encode(serde_json::to_value(handoff.as_ref())))
632 }
633 PendingAsk::TerminalHandOff(handoff) => {
634 Self::TerminalHandOff(encode(serde_json::to_value(handoff.as_ref())))
635 }
636 PendingAsk::DocumentHandOff(ask) => {
637 Self::DocumentHandOff(encode(serde_json::to_value(ask.as_ref())))
638 }
639 PendingAsk::SettingsWrite(ask) => {
640 Self::SettingsWrite(encode(serde_json::to_value(ask.as_ref())))
641 }
642 PendingAsk::Vault(draft) => Self::Vault(encode(serde_json::to_value(draft.as_ref()))),
643 }
644 }
645}
646
647#[derive(Serialize, Deserialize)]
655#[serde(rename_all = "snake_case")]
656pub struct ConversationWire {
657 pub turns: Vec<TurnWire>,
658 pub cursor: Option<String>,
665 pub compaction_count: u32,
666 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
674 pub cron_names: BTreeMap<String, forge_primitives::cron::CronName>,
675}
676
677pub fn cron_names_of(
683 surface: &ViewSurface,
684 slot: &SessionSlot,
685 turns: &[TurnWire],
686) -> BTreeMap<String, forge_primitives::cron::CronName> {
687 let mut uuids: Vec<&str> = Vec::new();
688 for turn in turns {
689 for frame in &turn.messages {
690 if frame.get("type").and_then(Value::as_str) != Some("user") {
691 continue;
692 }
693 let Some(uuid) = frame.get("uuid").and_then(Value::as_str) else {
694 continue;
695 };
696 if !uuid.is_empty() && !uuids.contains(&uuid) {
697 uuids.push(uuid);
698 }
699 }
700 }
701 if uuids.is_empty() {
702 return BTreeMap::new();
703 }
704 surface.cron_names(slot, &uuids)
705}
706
707#[derive(Serialize, Deserialize)]
709#[serde(rename_all = "snake_case")]
710pub struct ReviewsWire {
711 pub threads: ReadWire<Vec<ReviewedThreadWire>>,
712 pub reviews: ReadWire<Vec<ReviewSet>>,
713}
714
715#[derive(Debug, Serialize, Deserialize)]
722#[serde(tag = "state", rename_all = "snake_case")]
723pub enum FileContentWire {
724 Read { lines: Vec<String>, truncated: bool },
726 Failed { why: String },
728}
729
730#[derive(Debug, Serialize, Deserialize)]
750#[serde(rename_all = "snake_case")]
751pub struct ReviewedThreadWire {
752 #[serde(flatten)]
753 pub thread: ReviewThread,
754 pub resolution: Option<ThreadResolutionWire>,
758}
759
760#[derive(Debug, Serialize, Deserialize)]
763#[serde(tag = "state", rename_all = "snake_case")]
764pub enum ThreadResolutionWire {
765 Placed { line: u32, moved_from: Option<u32> },
768 Outdated { reason: OutdatedReasonWire },
770}
771
772#[derive(Debug, Serialize, Deserialize)]
774#[serde(rename_all = "snake_case")]
775pub enum OutdatedReasonWire {
776 Gone,
777 Ambiguous { matches: usize },
778}
779
780fn reviewed_threads(
791 threads: Vec<ReviewThread>,
792 content: &DiffContent,
793 default_branch: Option<&str>,
794 whole: &WholeFiles,
795) -> Vec<ReviewedThreadWire> {
796 let files = match &content.branch_ahead {
797 LayerState::Populated(files) => Some(files.as_slice()),
798 LayerState::Clean | LayerState::ScanFailed => None,
799 };
800 threads
801 .into_iter()
802 .map(|thread| {
803 let home_base = default_branch == Some(thread.anchor.base_ref.as_str());
804 let resolution = match (&thread.commit, files) {
805 (_, Some(files)) if home_base => {
806 let trusted = thread.commit.is_none();
813 resolution_wire(&thread.anchor, files, trusted, trusted.then_some(whole))
814 }
815 _ => None,
816 };
817 ReviewedThreadWire { thread, resolution }
818 })
819 .collect()
820}
821
822struct WholeFile {
824 lines: Vec<String>,
825 truncated: bool,
826}
827
828type WholeFiles = std::collections::HashMap<(String, ReviewSide), WholeFile>;
830
831const WHOLE_FILE_READS_PER_RECORD: usize = 8;
837
838fn line_carried(anchor: &ReviewAnchor, files: &[FileContent]) -> bool {
845 files.iter().filter(|file| file.path == anchor.path).any(|file| {
846 file.hunks.iter().any(|hunk| {
847 hunk.lines.iter().any(|line| match anchor.side {
848 ReviewSide::New => line.new_line == Some(anchor.line),
849 ReviewSide::Old => line.old_line == Some(anchor.line),
850 })
851 })
852 })
853}
854
855async fn whole_files_for(
868 cwd: &std::path::Path,
869 ceiling: u64,
870 threads: &[ReviewThread],
871 content: &DiffContent,
872 default_branch: Option<&str>,
873 base_rev: Option<&str>,
878) -> WholeFiles {
879 let files = match &content.branch_ahead {
880 LayerState::Populated(files) => files.as_slice(),
881 LayerState::Clean | LayerState::ScanFailed => return WholeFiles::new(),
882 };
883 let mut whole = WholeFiles::new();
884 let mut old_rev: Option<Option<String>> = None;
889 let mut reads = 0_usize;
890 for thread in threads {
891 let anchor = &thread.anchor;
892 if thread.commit.is_some() || default_branch != Some(anchor.base_ref.as_str()) {
893 continue;
894 }
895 let Some(file) = files.iter().find(|file| file.path == anchor.path) else {
896 continue;
897 };
898 if line_carried(anchor, files) {
899 continue;
900 }
901 let key = (anchor.path.clone(), anchor.side);
902 if whole.contains_key(&key) || reads >= WHOLE_FILE_READS_PER_RECORD {
903 continue;
904 }
905 let (rev, path) = match anchor.side {
906 ReviewSide::New => ("HEAD".to_owned(), file.path.clone()),
907 ReviewSide::Old => {
908 let Some(base) = base_rev.or(default_branch) else {
909 continue;
910 };
911 if old_rev.is_none() {
912 old_rev = Some(merge_base(cwd, base).await);
913 }
914 let Some(found) = old_rev.as_ref().and_then(|held| held.as_deref()) else {
915 continue;
916 };
917 (found.to_owned(), file.old_path.clone().unwrap_or_else(|| file.path.clone()))
918 }
919 };
920 reads += 1;
921 if let Ok(read) = read_file_lines(cwd, &rev, &path, ceiling).await {
922 whole.insert(key, WholeFile { lines: read.lines, truncated: read.truncated });
923 }
924 }
925 whole
926}
927
928fn resolution_wire(
939 anchor: &ReviewAnchor,
940 files: &[FileContent],
941 trusted: bool,
942 whole: Option<&WholeFiles>,
943) -> Option<ThreadResolutionWire> {
944 let held = whole.and_then(|map| map.get(&(anchor.path.clone(), anchor.side)));
945 let placed = if line_carried(anchor, files) {
946 place_anchor_in_content(anchor, files, trusted)
947 } else {
948 match held {
949 Some(file) => {
950 let past_the_cut =
951 usize::try_from(anchor.line).is_ok_and(|line| line > file.lines.len());
952 if file.truncated && past_the_cut {
953 return None;
954 }
955 place_anchor_in_lines_with(anchor, &file.lines, trusted)
956 }
957 None => place_anchor_in_content(anchor, files, trusted),
960 }
961 };
962 Some(match placed {
963 AnchorPlacement::Placed { line, moved_from } => ThreadResolutionWire::Placed {
964 line,
965 moved_from: if trusted { moved_from } else { None },
970 },
971 AnchorPlacement::Outdated(reason) => ThreadResolutionWire::Outdated {
972 reason: match reason {
973 OutdatedReason::Gone => OutdatedReasonWire::Gone,
974 OutdatedReason::Ambiguous { matches } => OutdatedReasonWire::Ambiguous { matches },
975 },
976 },
977 })
978}
979
980pub(crate) fn placement_wire(placement: AnchorPlacement) -> ThreadResolutionWire {
985 match placement {
986 AnchorPlacement::Placed { line, moved_from } => {
987 ThreadResolutionWire::Placed { line, moved_from }
988 }
989 AnchorPlacement::Outdated(reason) => ThreadResolutionWire::Outdated {
990 reason: match reason {
991 OutdatedReason::Gone => OutdatedReasonWire::Gone,
992 OutdatedReason::Ambiguous { matches } => OutdatedReasonWire::Ambiguous { matches },
993 },
994 },
995 }
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize)]
1014#[serde(rename_all = "snake_case")]
1015pub struct TurnWire {
1016 pub key: Option<String>,
1019 pub messages: Vec<Value>,
1022}
1023
1024pub struct Page {
1027 pub turns: Vec<TurnWire>,
1028 pub cursor: Option<String>,
1031}
1032
1033fn opens_of(spans: &[TurnSpan]) -> Vec<(usize, Option<&str>)> {
1041 let mut opens: Vec<(usize, Option<&str>)> = Vec::with_capacity(spans.len());
1042 for span in spans {
1043 match opens.last_mut() {
1044 Some((at, key)) if *at == span.opens_at => {
1045 if key.is_none() {
1046 *key = span.key.as_deref();
1047 }
1048 }
1049 _ => opens.push((span.opens_at, span.key.as_deref())),
1050 }
1051 }
1052 opens
1053}
1054
1055fn turn_wire(
1066 messages: &[Message],
1067 from: usize,
1068 to: usize,
1069 key: Option<&str>,
1070 endings: &HashMap<String, TaskEnding>,
1071) -> TurnWire {
1072 let mut frames: Vec<Value> =
1073 messages[from..to].iter().map(|message| frame_of(message, message.session_id())).collect();
1074 for ending in owed_endings(messages, from, to, endings) {
1075 let forged = forged_ending(ending);
1076 frames.push(frame_of(&forged, forged.session_id()));
1077 }
1078 TurnWire { key: key.map(str::to_owned), messages: frames }
1079}
1080
1081fn frame_of<T: Serialize>(value: &T, session: Option<&str>) -> Value {
1090 match serde_json::to_value(value) {
1091 Ok(frame) => frame,
1092 Err(error) => {
1093 tracing::warn!(
1094 event_name = "conversation_frame_unsent",
1095 session = session.unwrap_or("unknown"),
1096 error = %error,
1097 "a conversation frame could not be serialised; drawn as a notice instead",
1098 );
1099 serde_json::json!({
1100 "type": "system",
1101 "subtype": "forge_notice",
1102 "severity": "warning",
1103 "text": format!("a conversation frame could not be serialised: {error}"),
1104 })
1105 }
1106 }
1107}
1108
1109fn compact_counters(frames: &mut Vec<Value>) {
1147 let held = std::mem::take(frames);
1148 let mut out: Vec<Value> = Vec::with_capacity(held.len());
1149 let mut leading = held.first().is_some_and(is_counter);
1152 for frame in held {
1153 if !is_counter(&frame) {
1154 leading = false;
1155 }
1156 if let Some(last) = out.last_mut()
1157 && !leading
1158 && is_counter(last)
1159 && is_counter(&frame)
1160 && same_run(last, &frame)
1161 && counter_stamp(last).is_some()
1162 && counter_stamp(&frame).is_some()
1163 {
1164 let previous = std::mem::replace(last, frame);
1170 let delta = grown(last).saturating_add(grown(&previous));
1171 let count = counted(&previous).saturating_add(1);
1172 let from = previous.get("folded_from").or_else(|| counter_stamp(&previous)).cloned();
1173 if let Some(object) = last.as_object_mut() {
1174 if let Some(from) = from {
1175 object.insert("folded_from".to_owned(), from);
1176 }
1177 object.insert("estimated_tokens_delta".to_owned(), Value::from(delta));
1178 object.insert("folded_frames".to_owned(), Value::from(count));
1179 }
1180 continue;
1181 }
1182 out.push(frame);
1183 }
1184 *frames = out;
1185}
1186
1187fn is_counter(frame: &Value) -> bool {
1189 frame["type"] == "system" && frame["subtype"] == "thinking_tokens"
1190}
1191
1192fn counter_dispatch(frame: &Value) -> Option<&str> {
1194 frame.get("parent_tool_use_id").and_then(Value::as_str).filter(|id| !id.trim().is_empty())
1195}
1196
1197fn same_run(last: &Value, next: &Value) -> bool {
1199 counter_dispatch(last) == counter_dispatch(next)
1200}
1201
1202fn counter_stamp(frame: &Value) -> Option<&Value> {
1204 frame.get("timestamp").filter(|stamp| stamp.is_string())
1205}
1206
1207fn grown(frame: &Value) -> i64 {
1209 frame["estimated_tokens_delta"].as_i64().unwrap_or(0)
1210}
1211
1212fn counted(frame: &Value) -> u64 {
1214 frame["folded_frames"].as_u64().unwrap_or(1)
1215}
1216
1217const DRAWN_RECORD_KEYS: [&str; 4] = ["structuredPatch", "userModified", "answers", "annotations"];
1221
1222#[derive(Clone)]
1224pub(crate) struct RecordCap {
1225 pub bytes: usize,
1227 pub kept: String,
1229}
1230
1231fn reduced_to(record: &Value, keys: &[&str]) -> Option<Value> {
1233 let held = record.as_object()?;
1234 let mut kept = serde_json::Map::new();
1235 for key in keys {
1236 if let Some(value) = held.get(*key) {
1237 kept.insert((*key).to_owned(), value.clone());
1238 }
1239 }
1240 (!kept.is_empty()).then_some(Value::Object(kept))
1241}
1242
1243fn fits(record: &Value, cap: usize) -> bool {
1245 serde_json::to_string(record).is_ok_and(|text| text.len() <= cap)
1246}
1247
1248pub(crate) fn cap_tool_record(record: &mut Value, cap: usize) -> Option<RecordCap> {
1261 if cap == usize::MAX {
1262 return None;
1263 }
1264 let Ok(text) = serde_json::to_string(&*record) else {
1265 return None;
1266 };
1267 if text.len() <= cap {
1268 return None;
1269 }
1270 let bytes = text.len();
1271 let stages: [&[&str]; 3] = [&DRAWN_RECORD_KEYS, &["answers"], &["structuredPatch"]];
1272 for keys in stages {
1273 let Some(reduced) = reduced_to(record, keys) else {
1274 continue;
1275 };
1276 if !fits(&reduced, cap) {
1277 continue;
1278 }
1279 let kept = reduced
1280 .as_object()
1281 .map(|held| held.keys().cloned().collect::<Vec<_>>().join(","))
1282 .unwrap_or_default();
1283 *record = reduced;
1284 return Some(RecordCap { bytes, kept });
1285 }
1286 let mut marker = serde_json::json!({ "omitted": true, "bytes": bytes });
1287 if record.get("userModified").and_then(Value::as_bool) == Some(true) {
1288 marker["userModified"] = Value::Bool(true);
1289 }
1290 *record = marker;
1291 Some(RecordCap { bytes, kept: String::new() })
1292}
1293
1294fn log_record_cap(seat: &SessionSlot, id: Option<&str>, cap: usize, report: &RecordCap) {
1300 tracing::debug!(
1301 event_name = "tool_record_capped",
1302 slot = %seat.display(),
1303 tool_use_id = id.unwrap_or(""),
1304 bytes = report.bytes,
1305 cap,
1306 kept = %report.kept,
1307 disposition = if report.kept.is_empty() { "omitted" } else { "reduced" },
1308 "a served page bounded an executed tool call's record; the call ran and the transcript \
1309 keeps the whole record, and [server] tool_record_cap_kb = 0 serves it whole",
1310 );
1311}
1312
1313pub(crate) fn cap_tool_records(turns: &mut [TurnWire], cap: usize, seat: &SessionSlot) {
1323 for turn in turns {
1324 for (id, report) in cap_turn(turn, cap) {
1325 log_record_cap(seat, id.as_deref(), cap, &report);
1326 }
1327 }
1328}
1329
1330fn cap_turn(turn: &mut TurnWire, cap: usize) -> Vec<(Option<String>, RecordCap)> {
1337 if cap == usize::MAX {
1338 return Vec::new();
1339 }
1340 let mut reports = Vec::new();
1341 for frame in &mut turn.messages {
1342 let id = frame
1345 .get("message")
1346 .and_then(|message| message.get("content"))
1347 .and_then(Value::as_array)
1348 .and_then(|blocks| {
1349 blocks.iter().find_map(|block| {
1350 block.get("tool_use_id").and_then(Value::as_str).map(str::to_owned)
1351 })
1352 });
1353 if let Some(result) = frame.get_mut("tool_use_result")
1354 && let Some(report) = cap_tool_record(result, cap)
1355 {
1356 reports.push((id, report));
1357 }
1358 }
1359 reports
1360}
1361
1362pub(crate) fn floor_cursor(page: &mut Page, dropped: usize) {
1370 if page.cursor.is_none() && dropped > 0 {
1371 page.cursor = Some(dropped.to_string());
1372 }
1373}
1374
1375pub(crate) fn cap_message_record(msg: &mut Message, cap: usize, seat: &SessionSlot) {
1378 if let Message::User { tool_use_result: Some(record), message, .. } = msg {
1379 let id = message.content.iter().find_map(|block| match block {
1380 ContentBlock::ToolResult { tool_use_id, .. }
1381 | ContentBlock::ServerToolResult { tool_use_id, .. } => Some(tool_use_id.to_owned()),
1382 _ => None,
1383 });
1384 if let Some(report) = cap_tool_record(record, cap) {
1385 log_record_cap(seat, id.as_deref(), cap, &report);
1386 }
1387 }
1388}
1389
1390fn owed_endings<'a>(
1393 messages: &[Message],
1394 from: usize,
1395 to: usize,
1396 endings: &'a HashMap<String, TaskEnding>,
1397) -> Vec<&'a TaskEnding> {
1398 endings
1399 .values()
1400 .filter(|ending| !(from..to).contains(&ending.at))
1401 .filter(|ending| turn_holds_call(&messages[from..to], &ending.call))
1402 .collect()
1403}
1404
1405fn turn_holds_call(messages: &[Message], id: &str) -> bool {
1407 messages.iter().any(|message| {
1408 let Message::Assistant { message: envelope, .. } = message else {
1409 return false;
1410 };
1411 envelope.content.iter().any(|block| {
1412 matches!(
1413 block,
1414 ContentBlock::ToolUse { id: call, .. }
1415 | ContentBlock::ServerToolUse { id: call, .. }
1416 if call == id
1417 )
1418 })
1419 })
1420}
1421
1422fn forged_ending(ending: &TaskEnding) -> Message {
1430 Message::User {
1431 message: UserEnvelope {
1432 role: "user".to_owned(),
1433 content: vec![crate::transcript::notice_block(&ending.text)],
1434 extras: serde_json::Map::new(),
1435 },
1436 session_id: String::new(),
1437 parent_tool_use_id: None,
1438 uuid: None,
1439 tool_use_result: None,
1440 timestamp: None,
1441 synthetic: false,
1442 extras: serde_json::Map::new(),
1443 }
1444}
1445
1446fn turn_ranges<'a>(
1455 messages: &[Message],
1456 spans: &'a [TurnSpan],
1457) -> Vec<(usize, usize, Option<&'a str>)> {
1458 let opens = opens_of(spans);
1459 if opens.is_empty() {
1466 return if messages.is_empty() { Vec::new() } else { vec![(0, messages.len(), None)] };
1467 }
1468 (0..opens.len())
1472 .map(|at| {
1473 let from = if at == 0 { 0 } else { opens[at].0 };
1474 let to = opens.get(at + 1).map_or(messages.len(), |&(open, _)| open);
1475 (from, to, opens[at].1)
1476 })
1477 .collect()
1478}
1479
1480pub fn all_turns(messages: &[Message], rendered: &Rendered) -> Vec<TurnWire> {
1485 turn_ranges(messages, &rendered.turns)
1486 .into_iter()
1487 .map(|(from, to, key)| {
1488 let mut turn = turn_wire(messages, from, to, key, &rendered.endings);
1489 compact_counters(&mut turn.messages);
1490 turn
1491 })
1492 .collect()
1493}
1494
1495pub fn page(
1521 messages: &[Message],
1522 rendered: &Rendered,
1523 dropped: usize,
1524 dropped_thinking: i64,
1525 before: Option<&str>,
1526 turns: u32,
1527) -> Page {
1528 page_within(
1529 messages,
1530 rendered,
1531 dropped,
1532 dropped_thinking,
1533 before,
1534 turns,
1535 usize::MAX,
1536 "page",
1537 None,
1538 )
1539}
1540
1541pub fn page_within(
1567 messages: &[Message],
1568 rendered: &Rendered,
1569 dropped: usize,
1570 dropped_thinking: i64,
1571 before: Option<&str>,
1572 turns: u32,
1573 budget: usize,
1574 budget_key: &'static str,
1575 cap: Option<(usize, &SessionSlot)>,
1576) -> Page {
1577 let turns = turns.max(1) as usize;
1581 let ranges = turn_ranges(messages, &rendered.turns);
1582 let opens = opens_of(&rendered.turns);
1583
1584 let ends_at = match before.and_then(|cursor| cursor.parse::<usize>().ok()) {
1595 None => ranges.len(),
1596 Some(named) => match named.checked_sub(dropped) {
1597 None => 0,
1598 Some(started) => {
1599 opens.iter().position(|&(open, _)| open == started).unwrap_or(ranges.len())
1600 }
1601 },
1602 };
1603
1604 let window_first = ends_at.saturating_sub(turns);
1608 let (first, mut page_turns) = if budget == usize::MAX {
1609 let page_turns: Vec<TurnWire> = ranges[window_first..ends_at]
1610 .iter()
1611 .map(|&(from, to, key)| turn_wire(messages, from, to, key, &rendered.endings))
1612 .collect();
1613 (window_first, page_turns)
1614 } else {
1615 let mut kept: Vec<TurnWire> = Vec::new();
1619 let mut total = 0usize;
1620 let mut first = ends_at;
1621 while first > window_first {
1622 let (from, to, key) = ranges[first - 1];
1623 let mut turn = turn_wire(messages, from, to, key, &rendered.endings);
1624 let reports = cap.map_or_else(Vec::new, |(cap, _)| cap_turn(&mut turn, cap));
1628 let bytes = serde_json::to_string(&turn).map_or(0, |text| text.len() + 1);
1629 if !kept.is_empty() && total.saturating_add(bytes) > budget {
1630 break;
1631 }
1632 if let Some((cap, seat)) = cap {
1633 for (id, report) in reports {
1634 log_record_cap(seat, id.as_deref(), cap, &report);
1635 }
1636 }
1637 total = total.saturating_add(bytes);
1638 kept.push(turn);
1639 first -= 1;
1640 }
1641 if let Some((_, seat)) = cap.filter(|_| first > window_first) {
1646 tracing::debug!(
1647 event_name = "page_cut_by_budget",
1648 slot = %seat.display(),
1649 budget_key,
1650 budget,
1651 turns_kept = kept.len(),
1652 turns_available = ends_at - window_first,
1653 "the page stopped at its byte budget; its cursor names the turns above it",
1654 );
1655 }
1656 kept.reverse();
1657 (first, kept)
1658 };
1659 for turn in &mut page_turns {
1662 compact_counters(&mut turn.messages);
1663 }
1664 if dropped_thinking != 0
1668 && ends_at == ranges.len()
1669 && let Some(turn) = page_turns.last_mut()
1670 && let Some(counter) = turn.messages.iter_mut().find(|frame| is_counter(frame))
1671 {
1672 let total = grown(counter).saturating_add(dropped_thinking);
1673 if let Some(object) = counter.as_object_mut() {
1674 object.insert("estimated_tokens_delta".to_owned(), Value::from(total));
1675 }
1676 }
1677
1678 let cursor = if first == 0 {
1682 None
1683 } else {
1684 opens.get(first).map(|&(open, _)| (open + dropped).to_string())
1687 };
1688
1689 Page { turns: page_turns, cursor }
1690}
1691
1692const SEED_WAIT: std::time::Duration = std::time::Duration::from_millis(500);
1702
1703pub async fn conversation_for(
1730 state: &TransportState,
1731 slot: &SessionSlot,
1732 timings: &mut OpenTimings,
1733) -> Option<Arc<Held>> {
1734 let occupant = state.surface.header(slot).session_id;
1737 let current = |held: &Arc<Held>| held.holds_occupant(occupant.as_ref());
1738 if let Some(held) = state.conversations.get(slot) {
1739 if current(&held) {
1740 return Some(held);
1741 }
1742 return await_current(state, slot, ¤t, SEED_WAIT).await;
1745 }
1746 if let Some(held) = seed_from_transcript(state, slot, timings).await {
1750 return current(&held).then_some(held);
1754 }
1755 await_current(state, slot, ¤t, SEED_WAIT).await
1760}
1761
1762async fn await_current(
1770 state: &TransportState,
1771 slot: &SessionSlot,
1772 current: &impl Fn(&Arc<Held>) -> bool,
1773 within: std::time::Duration,
1774) -> Option<Arc<Held>> {
1775 let deadline = std::time::Instant::now() + within;
1776 loop {
1777 let seeded = state.conversations.seeded_notice();
1783 tokio::pin!(seeded);
1784 seeded.as_mut().enable();
1785 if let Some(held) = state.conversations.get(slot).filter(current) {
1786 return Some(held);
1787 }
1788 let left = deadline.saturating_duration_since(std::time::Instant::now());
1789 if left.is_zero() {
1790 break;
1791 }
1792 if tokio::time::timeout(left, seeded.as_mut()).await.is_err() {
1793 break;
1794 }
1795 }
1796 let held = state.conversations.get(slot).filter(current);
1797 if held.is_none() {
1798 tracing::debug!(
1799 event_name = "conversation_not_current",
1800 slot = %slot.display(),
1801 waited_ms = within.as_millis(),
1802 "no current conversation for this seat within the wait; the request is answered \
1803 without one, which a client draws the same way it draws a seat that is not \
1804 running",
1805 );
1806 }
1807 held
1808}
1809
1810async fn seed_from_transcript(
1819 state: &TransportState,
1820 slot: &SessionSlot,
1821 timings: &mut OpenTimings,
1822) -> Option<Arc<Held>> {
1823 #[cfg(any(test, feature = "testing"))]
1827 state.conversations.pass_the_seed_hold().await;
1828 let at = std::time::Instant::now();
1829 let surface = Arc::clone(&state.surface);
1830 let seat = slot.clone();
1831 let read =
1832 tokio::task::spawn_blocking(move || surface.conversation(&seat, Path::new(""))).await;
1833 let read = read.ok()?;
1834 if read.messages.is_empty() {
1835 return None;
1836 }
1837 let mut conversation =
1838 Conversation::new(read.messages, read.compaction_count, 0);
1840 timings.seed_ms = at.elapsed().as_secs_f64() * 1000.0;
1843 let occupant = read.session_id.map(forge_primitives::SessionId::new);
1848 conversation.set_occupant(occupant.clone());
1849 Some(state.conversations.insert(slot, conversation))
1854}
1855
1856pub const SUBSCRIBE_TURNS: u32 = 20;
1875
1876#[derive(Debug, Default)]
1893pub struct OpenTimings {
1894 pub seed_ms: f64,
1895 pub conversation_ms: f64,
1896 pub page_ms: f64,
1897 pub encode_ms: f64,
1898 pub prologue_ms: f64,
1899 pub header_ms: f64,
1900 pub catch_up_ms: f64,
1901 pub cards_ms: f64,
1902 pub work_ms: f64,
1903 pub base_ms: f64,
1906 pub rescan_ms: f64,
1908 pub content_ms: f64,
1909 pub reviews_ms: f64,
1910 pub tree_reviews_ms: f64,
1911 pub state_ms: f64,
1912 pub index_ms: f64,
1913 pub resume_ms: f64,
1914 pub tail_ms: f64,
1915 pub pr_age_s: Option<f64>,
1920}
1921
1922fn slice_ms(at: std::time::Instant) -> f64 {
1924 at.elapsed().as_secs_f64() * 1000.0
1925}
1926
1927pub async fn encode_subject(state: &TransportState, subject: &Subject) -> Result<Value> {
1941 encode_subject_timed(state, subject, false).await.map(|(value, _)| value)
1942}
1943
1944pub(crate) async fn encode_subject_timed(
1955 state: &TransportState,
1956 subject: &Subject,
1957 review: bool,
1958) -> Result<(Value, OpenTimings)> {
1959 let surface = &state.surface;
1960 let mut timings = OpenTimings::default();
1961 match subject {
1962 Subject::Home => Ok((serde_json::to_value(home(state, surface).await)?, timings)),
1963 Subject::Board => Ok((serde_json::to_value(board(surface))?, timings)),
1964 Subject::Session(slot) => {
1965 let at = std::time::Instant::now();
1966 let roster = surface.roster();
1967 let Some(cwd) = roster.cwd_for(slot) else {
1968 anyhow::bail!("forge holds no session for {}", slot.display());
1969 };
1970 request_context_usage_if_unreported(surface, slot);
1975 request_mcp_snapshot_if_unreported(surface, slot);
1976 timings.prologue_ms = slice_ms(at);
1977 let wire = session(state, surface, slot, &cwd, &mut timings, review).await?;
1978 let at = std::time::Instant::now();
1981 let value = serde_json::to_value(&wire)?;
1982 timings.encode_ms = at.elapsed().as_secs_f64() * 1000.0;
1983 Ok((value, timings))
1984 }
1985 Subject::Usage => Ok((serde_json::to_value(surface.usage().await?)?, timings)),
1989 Subject::DictateModels => Ok((serde_json::to_value(surface.dictate_models())?, timings)),
1993 Subject::Extensions => Ok((serde_json::to_value(extensions(state).await)?, timings)),
1996 }
1997}
1998
1999async fn extensions(state: &TransportState) -> ExtensionsWire {
2001 let view = state.surface.extensions().await;
2002 ExtensionsWire {
2003 snapshot: view.snapshot,
2004 update_run: view.update_run,
2005 lsp: view
2006 .lsp
2007 .into_iter()
2008 .map(|check| LspCheckWire {
2009 plugin: check.plugin,
2010 server: check.server,
2011 command: check.command,
2012 on_path: check.on_path,
2013 })
2014 .collect(),
2015 }
2016}
2017
2018fn request_context_usage_if_unreported(surface: &ViewSurface, slot: &SessionSlot) {
2032 if surface.header(slot).context.percent.is_some() {
2033 return;
2034 }
2035 if let Err(error) = surface.refresh_context_usage(slot) {
2036 tracing::debug!(
2037 event_name = "context_usage_request_failed",
2038 %error,
2039 slot = %slot.display(),
2040 "a seat a client reads reports no context usage and its probe was not requested",
2041 );
2042 }
2043}
2044
2045fn request_mcp_snapshot_if_unreported(surface: &ViewSurface, slot: &SessionSlot) {
2062 if surface.mcp_servers(slot).is_some() {
2063 return;
2064 }
2065 if let Err(error) = surface.refresh_mcp_snapshot(slot) {
2066 tracing::debug!(
2067 event_name = "mcp_snapshot_request_failed",
2068 %error,
2069 slot = %slot.display(),
2070 "a seat a client reads reports no MCP snapshot and its ask was not requested",
2071 );
2072 }
2073}
2074
2075async fn home(state: &TransportState, surface: &ViewSurface) -> HomeWire {
2080 let roster = surface.roster();
2081 let accounts = surface.accounts();
2082 let agents = surface.agents();
2083 let connectors = surface.connectors(None);
2084 let dictate = surface.dictate();
2085 let encode = |value: Option<Value>| value;
2086
2087 let mut projects = Vec::with_capacity(roster.projects.len());
2088 for project in &roster.projects {
2089 let seat = SessionSlot::lead(project.org.clone(), project.name.clone());
2090 projects.push(ProjectWire {
2091 work: state.work.snapshot(&seat, &project.path).await,
2092 would_bind: roster.would_bind(&project.key),
2093 chip: roster.chip_for(&project.key),
2094 project: project.clone(),
2095 });
2096 }
2097
2098 let live = crate::live::Live::lock(&state.live).snapshot();
2106 let mut agent_rows = Vec::with_capacity(agents.all().len());
2107 for agent in agents.all() {
2108 let work = match roster.cwd_for(&agent.slot) {
2109 Some(cwd) => Some(state.work.snapshot(&agent.slot, cwd.as_path()).await),
2110 None => None,
2111 };
2112 let mut row = AgentWire { work, ..AgentWire::from(agent) };
2113 row.failed_turn = row.failed_turn.and_then(|at| live.failed_mark(&agent.slot, at));
2114 agent_rows.push(row);
2115 }
2116
2117 HomeWire {
2118 fleet: surface.fleet(),
2119 accounts,
2120 connectors: ConnectorsWire {
2121 gotify: GotifyWire { connected: connectors.gotify.connected },
2122 slack: SlackWire {
2123 connected_workspaces: connectors.slack.connected_workspaces.into_iter().collect(),
2124 load_failed: connectors.slack.load_failed,
2125 },
2126 },
2127 dictate: DictateWire {
2128 enabled: dictate.enabled,
2129 snapshot: serde_json::to_value(dictate.snapshot).unwrap_or(Value::Null),
2130 models_dir: dictate.models_dir,
2131 },
2132 voice: {
2133 let voice = surface.voices();
2134 VoiceWire { enabled: voice.enabled, models_dir: voice.models_dir }
2135 },
2136 cli_version: surface.cli_version().and_then(|version| serde_json::to_value(version).ok()),
2137 forge_version: crate::FORGE_VERSION.to_owned(),
2138 forge_version_short: crate::FORGE_VERSION_SHORT.to_owned(),
2139 boot_id: boot_id().to_owned(),
2140 service_status: surface.service_status().and_then(|issue| serde_json::to_value(issue).ok()),
2141 fatal_error: encode(
2144 surface.fatal_error().map(|error| Value::String(error.user_message().to_owned())),
2145 ),
2146 biometric: surface.biometric_enrolments().into(),
2147 vault: surface
2148 .vault_state()
2149 .map(|state| VaultWire { locked: state.locked, idle_minutes: state.idle_minutes }),
2150 agents: agent_rows,
2151 unseen: agents
2152 .all()
2153 .iter()
2154 .map(|row| row.slot.clone())
2155 .filter(|slot| live.unseen.is_unseen(slot))
2156 .collect(),
2157 projects,
2158 }
2159}
2160
2161fn board(surface: &ViewSurface) -> BoardWire {
2167 let roster = surface.roster();
2168 let mut projects = Vec::with_capacity(roster.projects.len());
2169 for project in &roster.projects {
2170 let subscribed = surface.connectors(Some(&project.name));
2171 projects.push(ProjectBoardWire {
2172 project: ProjectBoardIdentity { org: project.org.clone(), name: project.name.clone() },
2173 rows: surface.board_rows(&project.name),
2174 crons: roster.crons_for_project(&project.name),
2175 connectors: ProjectConnectorsWire {
2176 gotify: subscribed.gotify.subscriptions,
2177 slack: subscribed.slack.subscriptions,
2178 },
2179 });
2180 }
2181 BoardWire { projects }
2182}
2183
2184const CATCH_UP_WAIT: std::time::Duration = std::time::Duration::from_millis(250);
2191
2192const SEAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
2202
2203#[derive(Debug)]
2206enum NotReady {
2207 Behind,
2209 AwaitsSeal,
2211}
2212
2213async fn catch_up(state: &TransportState, slot: &SessionSlot, want: u64) -> Result<(), NotReady> {
2226 if want == 0 || state.conversations.consumed_for(slot) >= want {
2227 return Ok(());
2228 }
2229 if state.conversations.await_consumed(slot, want, CATCH_UP_WAIT).await {
2230 return Ok(());
2231 }
2232 if awaits_seal_history(state, slot)
2240 && state.conversations.await_consumed(slot, want, SEAL_WAIT).await
2241 {
2242 return Ok(());
2243 }
2244 let fill = state.conversations.fill_lock(slot);
2249 let _filling = fill.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2250 if state.conversations.consumed_for(slot) >= want {
2251 return Ok(());
2252 }
2253 let from = state.conversations.consumed_for(slot).saturating_add(1);
2254 let frames = state.surface.seat_frames_between(slot, from, want);
2255 let needed = want
2261 .saturating_sub(from)
2262 .saturating_add(1)
2263 .saturating_sub(state.surface.seat_unringed_between(slot, from, want));
2264 if u64::try_from(frames.len()).unwrap_or(u64::MAX) < needed {
2265 tracing::error!(
2266 event_name = "seat_copy_behind",
2267 slot = %slot.display(),
2268 want,
2269 got = frames.len(),
2270 "the seat's copy is behind the number an answer would carry and its own window no \
2271 longer reaches back; the read is refused rather than answered with a hole",
2272 );
2273 return Err(NotReady::Behind);
2274 }
2275 let occupant = state.surface.header(slot).session_id;
2291 let matching =
2292 state.conversations.get(slot).is_some_and(|held| held.holds_occupant(occupant.as_ref()));
2293 if !matching {
2294 if state.surface.seat_epoch_opened_with_rows(slot) {
2295 tracing::error!(
2296 event_name = "seat_copy_awaits_seal",
2297 slot = %slot.display(),
2298 want,
2299 "the seat's copy is behind a seal the fold has not applied, and the ring cannot \
2300 carry the history that seal opened on; the read is refused rather than answered \
2301 with a hole",
2302 );
2303 return Err(NotReady::AwaitsSeal);
2304 }
2305 let mut conversation = crate::transport::conversation::Conversation::empty();
2306 conversation.set_occupant(occupant);
2307 state.conversations.replace(slot, conversation);
2308 }
2309 for frame in &frames {
2310 state.conversations.apply(&frame.update, frame.seq);
2311 }
2312 state.conversations.consumed(slot, want);
2313 Ok(())
2314}
2315
2316fn awaits_seal_history(state: &TransportState, slot: &SessionSlot) -> bool {
2324 let occupant = state.surface.header(slot).session_id;
2325 let matching =
2326 state.conversations.get(slot).is_some_and(|held| held.holds_occupant(occupant.as_ref()));
2327 !matching && state.surface.seat_epoch_opened_with_rows(slot)
2328}
2329
2330fn checked_refresh(
2335 refresh: Option<&forge_workspace::env::git_diff::BaseRefresh>,
2336) -> Option<&forge_workspace::env::git_diff::BaseRefresh> {
2337 refresh.filter(|refresh| !refresh.unanswered && !refresh.cut)
2338}
2339
2340fn diff_base_error<'a>(
2344 refresh: Option<&'a forge_workspace::env::git_diff::BaseRefresh>,
2345 stored_error: Option<&'a str>,
2346) -> Option<&'a str> {
2347 match (refresh, checked_refresh(refresh)) {
2348 (_, Some(refresh)) => refresh.error.as_deref(),
2349 (Some(_), None) => stored_error,
2350 (None, None) => None,
2351 }
2352}
2353
2354fn stated_base<'a>(
2360 refresh: Option<&'a forge_workspace::env::git_diff::BaseRefresh>,
2361 stored: Option<forge_primitives::git_diff::BaseState>,
2362 stored_error: Option<&'a str>,
2363) -> (Option<forge_primitives::git_diff::BaseState>, Option<&'a str>) {
2364 match checked_refresh(refresh) {
2365 Some(refresh) => (Some(refresh.state), refresh.error.as_deref()),
2366 None => (stored, stored_error),
2367 }
2368}
2369
2370async fn session(
2372 state: &TransportState,
2373 surface: &ViewSurface,
2374 slot: &SessionSlot,
2375 cwd: &Path,
2376 timings: &mut OpenTimings,
2377 review: bool,
2378) -> Result<SessionWire> {
2379 let at = std::time::Instant::now();
2380 let header = surface.header(slot);
2381 timings.header_ms = slice_ms(at);
2382 let seq = surface.seat_next_seq(slot);
2388 let at = std::time::Instant::now();
2394 if let Err(reason) = catch_up(state, slot, seq.saturating_sub(1)).await {
2395 let why = match reason {
2396 NotReady::Behind => "its copy is behind the number an answer would carry",
2397 NotReady::AwaitsSeal => {
2398 "the fold has not applied its epoch's seal, whose history an answer would be \
2399 missing"
2400 }
2401 };
2402 anyhow::bail!("the seat's stream has not settled for {}; {why}", slot.display());
2403 }
2404 timings.catch_up_ms = slice_ms(at);
2405 let at = std::time::Instant::now();
2409 let conversation = conversation_for(state, slot, timings).await;
2410 timings.conversation_ms = at.elapsed().as_secs_f64() * 1000.0 - timings.seed_ms;
2416 let cap = surface.tool_record_cap_bytes();
2419 let first_frame = surface.first_frame_cap_bytes();
2420 let read = match conversation {
2421 Some(held) => {
2422 let seat = slot.clone();
2423 let capped = seat.clone();
2424 let surface = Arc::clone(&state.surface);
2425 tokio::task::spawn_blocking(move || {
2426 let at = std::time::Instant::now();
2427 let (mut page, count, covered) = held.read(|held| {
2432 let mut page = page_within(
2433 held.messages(),
2434 held.rendered(),
2435 held.dropped(),
2436 held.dropped_thinking(),
2437 None,
2438 SUBSCRIBE_TURNS,
2439 first_frame,
2440 "first_frame_cap_kb",
2441 Some((cap, &capped)),
2442 );
2443 floor_cursor(&mut page, held.dropped());
2444 (page, held.compaction_count(), held.watermark())
2445 });
2446 if first_frame == usize::MAX {
2451 cap_tool_records(&mut page.turns, cap, &capped);
2452 }
2453 let names = cron_names_of(&surface, &capped, &page.turns);
2454 let page_ms = at.elapsed().as_secs_f64() * 1000.0;
2460 (page.turns, page.cursor, count, page_ms, covered, names)
2461 })
2462 .await
2463 .unwrap_or_else(|error| {
2464 tracing::warn!(
2465 event_name = "transcript_fold_failed",
2466 %error,
2467 slot = %seat.display(),
2468 "the fold did not finish; the record is answered without it",
2469 );
2470 (Vec::new(), None, 0, 0.0, 0, BTreeMap::new())
2471 })
2472 }
2473 None => (Vec::new(), None, 0, 0.0, 0, BTreeMap::new()),
2474 };
2475 let (turns, cursor, compaction_count, page_ms, covered, cron_names) = read;
2476 timings.page_ms = page_ms;
2477 let seq = seq.min(covered.saturating_add(1));
2481 let at = std::time::Instant::now();
2489 let instances = surface.subagent_cards(slot);
2490 timings.cards_ms = slice_ms(at);
2491 let at = std::time::Instant::now();
2497 let held = surface.work(slot, cwd, review).await;
2498 timings.work_ms = slice_ms(at);
2499 let at = std::time::Instant::now();
2505 let refresh = if review { surface.refresh_diff_base(slot, &held).await } else { None };
2506 timings.base_ms = slice_ms(at);
2507 let at = std::time::Instant::now();
2510 let pin = match &refresh {
2511 Some(refresh) => surface
2512 .diff_scan(slot, &held.cwd, refresh)
2513 .await
2514 .map(|scan| (scan, refresh.base_ref.as_deref())),
2515 None => None,
2516 };
2517 let (held, base_ref) = match pin {
2525 Some((scan, base_ref)) => (scan, base_ref),
2526 None => match &refresh {
2527 Some(refresh) => (
2528 surface
2529 .work_pinned_local(slot, &held.cwd, refresh.base_ref.as_deref(), Some(refresh))
2530 .await,
2531 refresh.base_ref.as_deref(),
2532 ),
2533 None => (held, None),
2534 },
2535 };
2536 timings.rescan_ms = slice_ms(at);
2537 let base = match &refresh {
2541 Some(refresh) => Some(refresh.state),
2546 None if held.diff.branch_ahead.is_populated() => {
2549 Some(forge_primitives::git_diff::BaseState::Unverified)
2550 }
2551 None => None,
2552 };
2553 let base_error = diff_base_error(refresh.as_ref(), held.base_error.as_deref());
2559 let at = std::time::Instant::now();
2560 let diff = scan_content(&held.cwd, &held.diff, base, base_ref, base_error).await;
2561 timings.content_ms = slice_ms(at);
2562 timings.pr_age_s = held
2563 .diff
2564 .pr_fetched_at
2565 .and_then(|at| std::time::SystemTime::now().duration_since(at).ok())
2566 .map(|age| age.as_secs_f64());
2567 let work = work_from_scan(&held.diff, &held.cwd);
2568 let (row_base, row_base_error) =
2572 stated_base(refresh.as_ref(), held.base, held.base_error.as_deref());
2573 let git = git_work_view(&held.diff, row_base, row_base_error);
2574 let branch = work.branch.clone().unwrap_or_default();
2575 let at = std::time::Instant::now();
2576 let reviews = surface.reviews(slot.project(), &branch);
2577 let whole = match &reviews.threads {
2581 Ok(threads) => {
2582 whole_files_for(
2583 &held.cwd,
2584 surface.document_max_bytes(),
2585 threads,
2586 &diff,
2587 held.diff.default_branch.as_deref(),
2588 base_ref,
2591 )
2592 .await
2593 }
2594 Err(_) => WholeFiles::new(),
2595 };
2596 timings.reviews_ms = slice_ms(at);
2597 let at = std::time::Instant::now();
2602 let tree_reviews = {
2603 let surface = std::sync::Arc::clone(&state.surface);
2604 let seat = slot.clone();
2605 tokio::task::spawn_blocking(move || surface.tree_reviews(&seat)).await.unwrap_or_else(
2606 |error| {
2607 tracing::warn!(
2608 event_name = "tree_reviews_read_failed",
2609 %error,
2610 slot = %slot.display(),
2611 "the seat-tree review read did not finish; the record answers the failure",
2612 );
2613 let why = "the seat tree's reviews could not be read".to_owned();
2614 crate::surface::reviews::TreeReviewsView {
2615 scope: forge_primitives::review::tree_scope_ref(slot),
2616 threads: Err(why.clone()),
2617 reviews: Err(why),
2618 }
2619 },
2620 )
2621 };
2622 timings.tree_reviews_ms = slice_ms(at);
2623 let at = std::time::Instant::now();
2624 let state_at = surface.session(slot, cwd);
2625 timings.state_ms = slice_ms(at);
2626 let at = std::time::Instant::now();
2632 let file_index = surface.file_index_for_record(slot).await;
2633 timings.index_ms = slice_ms(at);
2634 let at = std::time::Instant::now();
2640 let processes = surface.processes(slot);
2641 surface.note_record_read(slot);
2642
2643 let asks = surface.pending_asks(slot);
2646
2647 let resume_at = std::time::Instant::now();
2653 let resume_sessions = surface
2654 .resumable_sessions(slot)
2655 .await
2656 .into_iter()
2657 .map(|info| ResumableSessionWire { session_id: info.session_id, summary: info.summary })
2658 .collect();
2659 timings.resume_ms = slice_ms(resume_at);
2660
2661 let wire = SessionWire {
2662 slot: slot.clone(),
2663 file_index,
2664 resume_sessions,
2665 slash_commands: surface.slash_commands(slot),
2666 subagents: surface.subagents(slot),
2667 subagent_instances: instances,
2668 mcp: surface.mcp_servers(slot),
2669 processes,
2670 background_tasks: surface.background_tasks(slot),
2671 monitors: surface.monitors(slot),
2672 terminal: surface.terminal(slot),
2673 pending_ask: asks.first().map(PendingAskWire::from),
2674 pending_asks: asks.iter().map(PendingAskWire::from).collect(),
2675 conversation: ConversationWire { turns, cursor, compaction_count, cron_names },
2676 header: SessionHeaderWire {
2677 session_id: header.session_id.clone(),
2678 seq,
2679 model: header.model.as_ref().and_then(|model| serde_json::to_value(model).ok()),
2680 effort: serde_json::to_value(header.effort).unwrap_or(Value::Null),
2681 permission_mode: header
2682 .permission_mode
2683 .as_ref()
2684 .and_then(|mode| serde_json::to_value(mode).ok()),
2685 context: serde_json::to_value(header.context).unwrap_or(Value::Null),
2686 available_models: header
2687 .available_models
2688 .iter()
2689 .filter_map(|model| serde_json::to_value(model).ok())
2690 .collect(),
2691 turn_in_flight: header.turn_in_flight,
2692 },
2693 reviews: ReviewsWire {
2694 threads: ReadWire::from(reviews.threads.map(|threads| {
2695 reviewed_threads(threads, &diff, held.diff.default_branch.as_deref(), &whole)
2696 })),
2697 reviews: ReadWire::from(reviews.reviews),
2698 },
2699 tree_reviews: ReviewsWire {
2700 threads: ReadWire::from(tree_reviews.threads.map(|threads| {
2701 threads
2702 .into_iter()
2703 .map(|held| ReviewedThreadWire {
2704 thread: held.thread,
2705 resolution: held.placement.map(placement_wire),
2706 })
2707 .collect()
2708 })),
2709 reviews: ReadWire::from(tree_reviews.reviews),
2710 },
2711 state: SessionStateWire {
2712 slot: state_at.slot,
2713 scan_cwd: state_at.scan_cwd,
2714 queue: state_at.queue,
2715 voice_listeners: state_at.voice_listeners,
2716 },
2717 composer: {
2718 let live = crate::live::Live::lock(&state.live).snapshot();
2719 let composer = &live.composer;
2720 ComposerWire {
2721 machine: crate::surface::machine_turn(
2722 surface.handoff_in_flight(slot),
2723 composer.compacting(slot),
2724 ),
2725 bind: surface.dictate_bind().label().to_owned(),
2726 mode: surface.dictate_mode().label().to_owned(),
2727 notice: composer.landed(slot).map(|landed| {
2728 serde_json::json!({
2729 "kind": "landed",
2730 "text": landed.text,
2731 "truncated": landed.truncated,
2732 "generation": landed.generation,
2733 "carried": true,
2734 "surface": landed.surface,
2735 })
2736 }),
2737 }
2738 },
2739 work,
2740 git,
2741 pr: held.diff.pr,
2742 closes: held.diff.closes,
2743 diff,
2744 };
2745 timings.tail_ms = slice_ms(at) - timings.resume_ms;
2750 Ok(wire)
2751}
2752
2753#[cfg(test)]
2754mod tests {
2755 #[test]
2759 fn the_served_queue_carries_no_attachment_payload() {
2760 let state = super::SessionStateWire {
2761 slot: forge_primitives::SessionSlot::lead("TestOrg", "proj"),
2762 scan_cwd: std::path::PathBuf::from("/tmp/proj"),
2763 queue: vec![forge_workspace::protocol::QueuedPrompt {
2764 uuid: "p1".to_owned(),
2765 source: forge_workspace::protocol::PromptSource::You,
2766 text: "hello".to_owned(),
2767 attachments: vec![forge_primitives::ImageAttachment {
2768 data: "iVBORw0KGgo=".to_owned(),
2769 mime_type: "image/png".to_owned(),
2770 }],
2771 }],
2772 voice_listeners: 0,
2773 };
2774 let encoded = serde_json::to_string(&state).expect("the state encodes");
2775 assert!(
2776 !encoded.contains("iVBORw0KGgo=") && !encoded.contains("\"attachments\""),
2777 "the queue a view reads carries no attachment payload: {encoded}",
2778 );
2779 assert!(
2780 encoded.contains("\"uuid\":\"p1\"") && encoded.contains("\"text\":\"hello\""),
2781 "and carries the row a view draws: {encoded}",
2782 );
2783 }
2784
2785 #[test]
2791 fn a_conversation_page_carries_the_schedule_each_fired_prompt_came_from() {
2792 let dir = tempfile::tempdir().expect("tempdir");
2793 let support = dir.path().join("app-support");
2794 std::fs::create_dir_all(&support).expect("app-support");
2795 {
2796 let db_path = forge_workspace::store::path_for(&support, dir.path());
2798 std::fs::create_dir_all(db_path.parent().expect("stores/")).expect("stores/");
2799 let db = forge_workspace::store::Db::open(&db_path).expect("open db");
2800 forge_workspace::store::cron_names::put(
2801 &db,
2802 "p-reload",
2803 &forge_primitives::cron::CronName {
2804 cron_id: "c1".to_owned(),
2805 description: Some("Morning summary".to_owned()),
2806 },
2807 std::time::SystemTime::now(),
2808 )
2809 .expect("write the pairing");
2810 forge_workspace::store::cron_names::put(
2811 &db,
2812 "a-1",
2813 &forge_primitives::cron::CronName {
2814 cron_id: "not-a-fire".to_owned(),
2815 description: Some("an assistant row's id".to_owned()),
2816 },
2817 std::time::SystemTime::now(),
2818 )
2819 .expect("write a pairing under a non-prompt id too");
2820 }
2821 let fleet =
2822 crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])]).expect("a fleet");
2823 let page = vec![super::TurnWire {
2824 key: None,
2825 messages: vec![
2826 serde_json::json!({
2827 "type": "user",
2828 "uuid": "p-reload",
2829 "message": {
2830 "role": "user",
2831 "content": [{ "type": "text", "text": "[Cron]\n\nsummarise overnight CI" }],
2832 },
2833 }),
2834 serde_json::json!({
2835 "type": "assistant",
2836 "uuid": "a-1",
2837 "message": { "role": "assistant", "content": [] },
2838 }),
2839 serde_json::json!({
2840 "type": "user",
2841 "uuid": "p-other",
2842 "message": {
2843 "role": "user",
2844 "content": [{ "type": "text", "text": "my own words" }],
2845 },
2846 }),
2847 ],
2848 }];
2849
2850 let seat = forge_primitives::SessionSlot::lead("TestOrg", "proj");
2851 let names = super::cron_names_of(&fleet.surface(), &seat, &page);
2852 assert_eq!(
2853 names.get("p-reload").and_then(|name| name.description.as_deref()),
2854 Some("Morning summary"),
2855 "the fired row's schedule rides the read, as its delivery frame once did",
2856 );
2857 assert!(
2858 !names.contains_key("p-other"),
2859 "a page carries the pairings its own fired rows need and no others",
2860 );
2861 assert!(
2862 !names.contains_key("a-1"),
2863 "a pairing stored under an id an ASSISTANT row carries is not a fired prompt, so the \
2864 type filter must keep it out of the map",
2865 );
2866 }
2867
2868 fn a_turns_rows(turn: usize) -> String {
2873 format!(
2874 r#"{{"type":"user","uuid":"u{turn}","message":{{"role":"user","content":"turn {turn}"}}}}
2875{{"type":"assistant","uuid":"a{turn}","message":{{"id":"m{turn}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"reply {turn}"}}]}}}}
2876{{"type":"result","uuid":"r{turn}","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}}"#
2877 )
2878 }
2879
2880 #[test]
2885 fn a_record_states_one_base_across_its_diff_and_its_row() {
2886 use forge_primitives::git_diff::BaseState;
2887 use forge_workspace::env::git_diff::BaseRefresh;
2888
2889 let refresh = BaseRefresh {
2890 state: BaseState::Unverified,
2891 base_ref: None,
2892 error: None,
2893 cut: false,
2894 unanswered: false,
2895 at: std::time::Instant::now(),
2896 };
2897 assert_eq!(
2898 super::stated_base(Some(&refresh), Some(BaseState::Current), Some("stale words")),
2899 (Some(BaseState::Unverified), None),
2900 "the read's own check is the frame's base, not the store's newer measurement",
2901 );
2902 assert_eq!(
2903 super::stated_base(None, Some(BaseState::Current), Some("last check")),
2904 (Some(BaseState::Current), Some("last check")),
2905 "and a read that did not refresh states what the store holds",
2906 );
2907 assert!(super::checked_refresh(Some(&refresh)).is_some(), "an answered run is a check");
2908 let mut failed = refresh.clone();
2909 failed.error = Some("fatal: could not resolve host".to_owned());
2910 assert_eq!(
2911 super::diff_base_error(Some(&failed), Some("last check")),
2912 Some("fatal: could not resolve host"),
2913 "a run that ran and failed names its own words",
2914 );
2915
2916 for placeholder in [
2917 BaseRefresh {
2918 cut: true,
2919 error: Some("the fetch did not finish inside the 10s it was given".to_owned()),
2920 ..refresh.clone()
2921 },
2922 BaseRefresh {
2923 unanswered: true,
2924 error: Some("the base refresh ended without an answer".to_owned()),
2925 ..refresh.clone()
2926 },
2927 ] {
2928 assert!(
2929 super::checked_refresh(Some(&placeholder)).is_none(),
2930 "a placeholder run is not a check",
2931 );
2932 assert_eq!(
2933 super::stated_base(
2934 Some(&placeholder),
2935 Some(BaseState::Current),
2936 Some("last check")
2937 ),
2938 (Some(BaseState::Current), Some("last check")),
2939 "so the row falls back to the store, never to forge's own words",
2940 );
2941 assert_eq!(
2942 super::diff_base_error(Some(&placeholder), Some("git's own failure")),
2943 Some("git's own failure"),
2944 "and the diff's reason is git's words, never forge's own",
2945 );
2946 }
2947 assert_eq!(
2948 super::diff_base_error(None, Some("last check")),
2949 None,
2950 "a read with no refresh names no failure of its own",
2951 );
2952 }
2953
2954 struct WillNotSerialise;
2957
2958 impl Serialize for WillNotSerialise {
2959 fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
2960 Err(serde::ser::Error::custom("this value cannot be a frame"))
2961 }
2962 }
2963
2964 #[test]
2968 fn a_frame_that_will_not_serialise_draws_a_notice_rather_than_null() {
2969 let frame = frame_of(&WillNotSerialise, Some("a-session"));
2970 assert!(
2971 !frame.is_null(),
2972 "the null this replaces is the value the fold throws on: {frame}",
2973 );
2974 assert_eq!(frame.get("type").and_then(Value::as_str), Some("system"), "{frame}");
2975 assert_eq!(
2976 frame.get("subtype").and_then(Value::as_str),
2977 Some("forge_notice"),
2978 "the frame is the notice the client already draws: {frame}",
2979 );
2980 let Some(text) = frame.get("text").and_then(Value::as_str) else {
2981 panic!("the notice says what failed: {frame}");
2982 };
2983 assert!(text.contains("could not be serialised"), "the failure is named: {frame}");
2984
2985 let held = frame_of(&serde_json::json!({ "type": "widget_state", "n": 1 }), None);
2986 assert_eq!(
2987 held,
2988 serde_json::json!({ "type": "widget_state", "n": 1 }),
2989 "a frame that serialises is carried unchanged",
2990 );
2991 }
2992
2993 fn a_conversation(rows: &[&str]) -> (Vec<Message>, Rendered) {
2996 let fleet =
2997 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
2998 fleet.seed_transcript("TestOrg", "proj", "lead", rows).expect("the transcript seeds");
2999 let seat = fixture_seat();
3000 let surface = fleet.surface();
3001 let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
3002
3003 let messages = surface.conversation(&seat, &cwd).messages;
3004 let rendered = crate::transcript::render(&messages, false);
3005 (messages, rendered)
3006 }
3007
3008 fn turns_of(turns: usize) -> Vec<String> {
3010 (0..turns).map(a_turns_rows).collect()
3011 }
3012
3013 fn opened_on(turn: &TurnWire) -> String {
3019 turn.messages
3020 .iter()
3021 .find_map(|frame| frame["message"]["content"][0]["text"].as_str().map(str::to_owned))
3022 .unwrap_or_default()
3023 }
3024
3025 fn every_turn(messages: &[Message], rendered: &Rendered) -> Vec<String> {
3027 page(messages, rendered, 0, 0, None, u32::MAX).turns.iter().map(opened_on).collect()
3028 }
3029
3030 fn wire_bytes(turns: &[TurnWire]) -> usize {
3033 turns
3034 .iter()
3035 .map(|turn| serde_json::to_string(turn).expect("a turn serialises").len() + 1)
3036 .sum()
3037 }
3038
3039 #[test]
3044 fn a_page_over_the_budget_is_the_newest_whole_turns_that_fit() {
3045 let rows = turns_of(8);
3046 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3047 let (messages, rendered) = a_conversation(&borrowed);
3048 let every = page(&messages, &rendered, 0, 0, None, u32::MAX);
3049 let newest_two = &every.turns[every.turns.len() - 2..];
3050 let budget = wire_bytes(newest_two);
3051
3052 let bounded =
3053 page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, budget, "test", None);
3054
3055 assert_eq!(
3056 bounded.turns.iter().map(opened_on).collect::<Vec<_>>(),
3057 newest_two.iter().map(opened_on).collect::<Vec<_>>(),
3058 "the budget keeps the newest whole turns and nothing below them",
3059 );
3060 assert_eq!(
3061 bounded.cursor,
3062 page(&messages, &rendered, 0, 0, None, 2).cursor,
3063 "the handle above the page names the message the first kept turn opened at",
3064 );
3065 }
3066
3067 #[test]
3072 fn a_page_keeps_the_newest_turn_when_it_alone_is_over_the_budget() {
3073 let rows = turns_of(3);
3074 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3075 let (messages, rendered) = a_conversation(&borrowed);
3076 let every = every_turn(&messages, &rendered);
3077
3078 let bounded =
3079 page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, 1, "test", None);
3080
3081 assert_eq!(bounded.turns.len(), 1, "a budget under one turn still answers with one");
3082 assert_eq!(opened_on(&bounded.turns[0]), every[every.len() - 1], "and it is the newest");
3083 assert!(bounded.cursor.is_some(), "with history above it to ask for");
3084 }
3085
3086 #[test]
3089 fn a_page_under_the_budget_is_the_page_its_count_asks_for() {
3090 let rows = turns_of(30);
3091 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3092 let (messages, rendered) = a_conversation(&borrowed);
3093 let counted = page(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS);
3094 let budget = wire_bytes(&counted.turns);
3095
3096 let bounded =
3097 page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, budget, "test", None);
3098
3099 assert_eq!(
3100 bounded.turns.len(),
3101 counted.turns.len(),
3102 "a page that fits its budget is the page the count asked for",
3103 );
3104 assert_eq!(bounded.cursor, counted.cursor, "on the same handle");
3105 }
3106
3107 fn a_frame(text: &str) -> Message {
3109 serde_json::from_str(text).expect("a wire frame decodes")
3110 }
3111
3112 fn opens_turn() -> Message {
3114 opens_turn_at(1)
3115 }
3116
3117 fn opens_turn_at(seq: u32) -> Message {
3119 a_frame(&format!(
3120 r#"{{"type":"user","uuid":"u-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:{seq:02}.000000Z","message":{{"role":"user","content":[{{"type":"text","text":"turn {seq}"}}]}}}}"#
3121 ))
3122 }
3123
3124 fn ends_turn(seq: u32) -> Message {
3126 a_frame(&format!(
3127 r#"{{"type":"result","uuid":"r-{seq}","session_id":"s","subtype":"success","is_error":false,"duration_ms":1,"duration_api_ms":1,"num_turns":1}}"#
3128 ))
3129 }
3130
3131 fn counter(seq: u32, delta: i64) -> Message {
3133 counter_of(seq, delta, None)
3134 }
3135
3136 fn counter_of(seq: u32, delta: i64, dispatch: Option<&str>) -> Message {
3138 let parent =
3139 dispatch.map_or_else(String::new, |id| format!(r#","parent_tool_use_id":"{id}""#));
3140 a_frame(&format!(
3141 r#"{{"type":"system","subtype":"thinking_tokens","estimated_tokens":{delta},"estimated_tokens_delta":{delta},"uuid":"t-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:00.{seq:06}Z"{parent}}}"#
3142 ))
3143 }
3144
3145 fn says(seq: u32) -> Message {
3147 a_frame(&format!(
3148 r#"{{"type":"assistant","uuid":"a-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:00.{seq:06}Z","message":{{"id":"m-{seq}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"reply"}}]}}}}"#
3149 ))
3150 }
3151
3152 fn answers(seq: u32) -> Message {
3154 a_frame(&format!(
3155 r#"{{"type":"user","uuid":"r-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:00.{seq:06}Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"call-1","content":"ok"}}]}}}}"#
3156 ))
3157 }
3158
3159 fn counters_on(page: &Page) -> Vec<(i64, Option<u64>)> {
3161 page.turns
3162 .iter()
3163 .flat_map(|turn| turn.messages.iter())
3164 .filter(|frame| frame["subtype"] == "thinking_tokens")
3165 .map(|frame| {
3166 (
3167 frame["estimated_tokens_delta"].as_i64().expect("a delta"),
3168 frame["folded_frames"].as_u64(),
3169 )
3170 })
3171 .collect()
3172 }
3173
3174 #[test]
3181 fn a_turns_counter_runs_are_one_frame_each_on_a_page() {
3182 let messages = vec![
3183 opens_turn(),
3184 counter(1, 10),
3185 counter(2, 20),
3186 counter(3, 5),
3187 says(4),
3188 counter(5, 7),
3189 counter(6, 3),
3190 answers(7),
3191 counter(8, 9),
3192 ];
3193 let rendered = crate::transcript::render(&messages, true);
3194
3195 let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3196
3197 assert_eq!(page.turns.len(), 1, "the frames are one live turn");
3198 let served = &page.turns[0].messages;
3199 assert_eq!(served.len(), 6, "nine frames, three runs folded: {served:?}");
3200 assert_eq!(
3201 counters_on(&page),
3202 vec![(35, Some(3)), (10, Some(2)), (9, None)],
3203 "each run is its summed delta and its count, and a run of one is untouched",
3204 );
3205 let first = served.iter().find(|frame| frame["folded_frames"] == 3).expect("the first run");
3206 assert_eq!(first["uuid"], "t-3", "the surviving frame is the newest of its run");
3207 assert_eq!(
3208 first["folded_from"], "2026-10-24T09:00:00.000001Z",
3209 "and it names where its run starts, so a landing knows which counters it covers",
3210 );
3211 assert_eq!(
3212 first["timestamp"], "2026-10-24T09:00:00.000003Z",
3213 "so the turn's clock reads the newest stamp, as it did over the raw run",
3214 );
3215 assert_eq!(
3216 first["estimated_tokens"], 5,
3217 "the running value is the newest frame's own, the same exchange the live fold makes",
3218 );
3219 assert_eq!(
3220 served.last().map(|frame| &frame["timestamp"]),
3221 Some(&serde_json::json!("2026-10-24T09:00:00.000008Z")),
3222 "the turn still ends on the instant its newest frame was stamped",
3223 );
3224 }
3225
3226 #[test]
3231 fn a_dispatched_counters_do_not_fold_into_the_turns_own() {
3232 let messages = vec![
3233 opens_turn(),
3234 counter_of(1, 10, Some("tool-1")),
3235 counter_of(2, 20, Some("tool-1")),
3236 counter_of(3, 7, Some("tool-2")),
3237 counter(4, 5),
3238 ];
3239 let rendered = crate::transcript::render(&messages, true);
3240
3241 let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3242
3243 assert_eq!(
3244 counters_on(&page),
3245 vec![(30, Some(2)), (7, None), (5, None)],
3246 "each dispatch keeps its own run, and the turn's own counter stays its own",
3247 );
3248 let folded = page.turns[0]
3249 .messages
3250 .iter()
3251 .find(|frame| frame["folded_frames"] == 2)
3252 .expect("the first dispatch's run");
3253 assert_eq!(folded["parent_tool_use_id"], "tool-1", "under the dispatch it ran as");
3254 assert_eq!(
3255 folded["folded_from"], "2026-10-24T09:00:00.000001Z",
3256 "and its range is its own run's, not the turn's",
3257 );
3258 }
3259
3260 #[test]
3264 fn a_before_page_does_not_carry_the_cut_debt() {
3265 let messages =
3266 vec![opens_turn_at(1), counter(2, 10), ends_turn(3), opens_turn_at(4), counter(5, 20)];
3267 let rendered = crate::transcript::render(&messages, false);
3268 let newest = page_within(&messages, &rendered, 0, 1_000, None, 1, usize::MAX, "test", None);
3269 assert_eq!(counters_on(&newest), vec![(1_020, None)], "the newest page carries the debt");
3270 let cursor = newest.cursor.expect("a page above the newest turn");
3271 let above =
3272 page_within(&messages, &rendered, 0, 1_000, Some(&cursor), 2, usize::MAX, "test", None);
3273
3274 assert_eq!(
3275 counters_on(&above),
3276 vec![(10, None)],
3277 "the page above the newest turn serves its own counters, not the debt",
3278 );
3279 }
3280
3281 #[test]
3285 fn the_cut_debt_lands_on_the_newest_turn_only() {
3286 let messages =
3287 vec![opens_turn_at(1), counter(2, 10), ends_turn(3), opens_turn_at(4), counter(5, 20)];
3288 let rendered = crate::transcript::render(&messages, false);
3289 let served =
3290 page_within(&messages, &rendered, 0, 1_000, None, u32::MAX, usize::MAX, "test", None);
3291
3292 assert_eq!(
3293 counters_on(&served),
3294 vec![(10, None), (1_020, None)],
3295 "the older turn keeps its own counter and the newest carries the debt once",
3296 );
3297 }
3298
3299 #[test]
3304 fn a_leading_counter_run_of_a_cut_turn_is_not_folded() {
3305 let messages = vec![
3306 counter(1, 10),
3307 counter(2, 20),
3308 says(3),
3309 counter(4, 7),
3310 counter(5, 3),
3311 answers(6),
3312 counter(7, 5),
3313 ];
3314 let rendered = crate::transcript::render(&messages, true);
3315
3316 let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3317
3318 assert_eq!(
3319 counters_on(&page),
3320 vec![(10, None), (20, None), (10, Some(2)), (5, None)],
3321 "the leading pair stays raw and the run after the assistant folds",
3322 );
3323 }
3324
3325 #[test]
3329 fn a_counter_frame_with_no_stamp_breaks_the_run() {
3330 let unstamped = |seq: u32, delta: i64| {
3331 a_frame(&format!(
3332 r#"{{"type":"system","subtype":"thinking_tokens","estimated_tokens":{delta},"estimated_tokens_delta":{delta},"uuid":"t-{seq}","session_id":"s"}}"#
3333 ))
3334 };
3335 let messages = vec![opens_turn(), unstamped(1, 10), counter(2, 20), counter(3, 5)];
3336 let rendered = crate::transcript::render(&messages, true);
3337
3338 let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3339
3340 assert_eq!(
3341 counters_on(&page),
3342 vec![(10, None), (25, Some(2))],
3343 "the unstamped frame is served, and the stamped pair after it folds",
3344 );
3345 let folded = page.turns[0]
3346 .messages
3347 .iter()
3348 .find(|frame| frame["folded_frames"] == 2)
3349 .expect("the stamped pair");
3350 assert_eq!(
3351 folded["folded_from"], "2026-10-24T09:00:00.000002Z",
3352 "its range starts at the first frame it can place",
3353 );
3354 }
3355
3356 #[test]
3361 fn the_budget_cuts_on_the_bytes_a_page_carried_before_the_fold() {
3362 let mut messages = Vec::new();
3363 let mut seq = 0;
3364 for turn in 1..=4 {
3365 messages.push(opens_turn_at(turn));
3366 for _ in 0..40 {
3367 seq += 1;
3368 messages.push(counter(seq, 10));
3369 }
3370 messages.push(ends_turn(turn));
3371 }
3372 let rendered = crate::transcript::render(&messages, false);
3373 let ranges = turn_ranges(&messages, &rendered.turns);
3374 let raw_turn = |&(from, to, key): &(usize, usize, Option<&str>)| {
3375 serde_json::to_string(&turn_wire(&messages, from, to, key, &rendered.endings))
3376 .expect("a turn serialises")
3377 .len()
3378 + 1
3379 };
3380 let newest_two: usize = ranges[ranges.len() - 2..].iter().map(raw_turn).sum();
3381
3382 let bounded = page_within(
3383 &messages,
3384 &rendered,
3385 0,
3386 0,
3387 None,
3388 SUBSCRIBE_TURNS,
3389 newest_two,
3390 "test",
3391 None,
3392 );
3393
3394 assert_eq!(bounded.turns.len(), 2, "the same two turns the raw budget kept");
3395 assert!(
3396 wire_bytes(&bounded.turns) < newest_two,
3397 "and the page is served folded: {} bytes under a {newest_two}-byte cut",
3398 wire_bytes(&bounded.turns),
3399 );
3400 }
3401
3402 fn a_big_result_rows(turn: usize, pad: usize) -> String {
3404 format!(
3405 r#"{{"type":"user","uuid":"u{turn}","message":{{"role":"user","content":"turn {turn}"}}}}
3406{{"type":"assistant","uuid":"a{turn}","message":{{"id":"m{turn}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"reply {turn}"}}]}}}}
3407{{"type":"user","uuid":"t{turn}","session_id":"s","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"call-{turn}","content":"ok"}}]}},"toolUseResult":{{"payload":"{}"}}}}
3408{{"type":"result","uuid":"r{turn}","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}}"#,
3409 "x".repeat(pad),
3410 )
3411 }
3412
3413 #[test]
3418 fn the_budget_counts_the_capped_record_it_serves() {
3419 let rows: Vec<String> = (0..4).map(|turn| a_big_result_rows(turn, 20_000)).collect();
3420 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3421 let (messages, rendered) = a_conversation(&borrowed);
3422 let seat = fixture_seat();
3423
3424 let raw =
3425 page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, 2_048, "test", None);
3426 assert_eq!(raw.turns.len(), 1, "a raw record of 20 KB leaves one turn in 2 KB");
3427
3428 let capped = page_within(
3429 &messages,
3430 &rendered,
3431 0,
3432 0,
3433 None,
3434 SUBSCRIBE_TURNS,
3435 2_048,
3436 "test",
3437 Some((1_024, &seat)),
3438 );
3439 assert!(
3440 capped.turns.len() > 1,
3441 "the capped records are small, so the same budget fits more: {} turns",
3442 capped.turns.len(),
3443 );
3444 }
3445
3446 fn endings_in(turn: &TurnWire, call: &str) -> usize {
3448 turn.messages
3449 .iter()
3450 .filter(|frame| {
3451 frame["message"]["content"].as_array().is_some_and(|blocks| {
3452 blocks.iter().any(|block| {
3453 block["type"] == "queued_command"
3454 && block["commandMode"] == "task-notification"
3455 && block["prompt"].as_str().is_some_and(|text| text.contains(call))
3456 })
3457 })
3458 })
3459 .count()
3460 }
3461
3462 fn holds_call(turn: &TurnWire, id: &str) -> bool {
3464 turn.messages.iter().any(|frame| {
3465 frame["message"]["content"].as_array().is_some_and(|blocks| {
3466 blocks.iter().any(|block| {
3467 matches!(block["type"].as_str(), Some("tool_use" | "server_tool_use"))
3468 && block["id"] == id
3469 })
3470 })
3471 })
3472 }
3473
3474 #[test]
3481 fn the_ending_is_carried_into_the_call_turn() {
3482 for (rows, call, said) in [
3483 (
3484 crate::fixtures::SAME_TURN_USER_ROW,
3485 "call_da4c7ee117d14d48ba99e036",
3486 "Background command \"Watch the account-lifecycle CI run\" failed with exit code 1",
3487 ),
3488 (
3489 crate::fixtures::CROSS_TURN_ATTACHMENT,
3490 "call_17c05f64497746b0ac450728",
3491 "Background command \"Restart the harness with a long terminate window\" failed with exit code 100",
3492 ),
3493 ] {
3494 let (messages, rendered) = a_conversation(rows);
3495 let turns = all_turns(&messages, &rendered);
3496
3497 let holding: Vec<&TurnWire> =
3503 turns.iter().filter(|turn| holds_call(turn, call)).collect();
3504 assert_eq!(holding.len(), 1, "one turn holds {call}, so one can end it");
3505 let held = holding[0];
3506 assert!(
3507 endings_in(held, call) > 0,
3508 "and the ending rides that turn, where a per-turn fold reads it",
3509 );
3510 assert!(
3511 held.messages.iter().any(|frame| {
3512 frame["message"]["content"].as_array().is_some_and(|blocks| {
3513 blocks.iter().any(|block| {
3514 block["type"] == "queued_command"
3515 && block["prompt"].as_str().is_some_and(|text| text.contains(said))
3516 })
3517 })
3518 }),
3519 "reaching the wire as the block a view reads, not as the row's own text",
3520 );
3521 assert!(
3522 turns.iter().flat_map(|turn| turn.messages.iter()).all(|frame| {
3523 frame["message"]["content"].as_array().is_none_or(|blocks| {
3524 blocks.iter().all(|block| {
3525 !(block["type"] == "text"
3526 && block["text"]
3527 .as_str()
3528 .is_some_and(|text| text.contains("<task-notification>")))
3529 })
3530 })
3531 }),
3532 "and no turn carries the raw notice as the reader's own words",
3533 );
3534
3535 let mut before: Option<String> = None;
3541 let mut held_on_a_page = 0_usize;
3542 let mut ended_where_held = 0_usize;
3543 loop {
3544 let page = page(&messages, &rendered, 0, 0, before.as_deref(), 1);
3545 for turn in &page.turns {
3546 if holds_call(turn, call) {
3547 held_on_a_page += 1;
3548 if endings_in(turn, call) > 0 {
3549 ended_where_held += 1;
3550 }
3551 }
3552 }
3553 match page.cursor {
3554 Some(cursor) if Some(&cursor) != before.as_ref() => before = Some(cursor),
3555 _ => break,
3556 }
3557 }
3558 assert!(held_on_a_page > 0, "the walk reaches the turn that holds the call");
3559 assert_eq!(
3560 ended_where_held, held_on_a_page,
3561 "and every copy of that turn a page carries ends it",
3562 );
3563 }
3564 }
3565
3566 #[test]
3576 fn every_kind_a_turn_holds_reaches_a_page_stamped() {
3577 let (messages, rendered) = a_conversation(&[
3578 r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"uuid":"u1","sessionId":"s","timestamp":"2026-10-15T05:18:05.217Z"}"#,
3579 r#"{"type":"attachment","attachment":{"type":"queued_command","prompt":"queued words","commandMode":"prompt"},"uuid":"q1","sessionId":"s","timestamp":"2026-10-15T05:19:00.100Z"}"#,
3580 r#"{"type":"queue-operation","operation":"remove","timestamp":"2026-10-15T05:19:03.000Z","sessionId":"s","content":"queued words"}"#,
3581 r#"{"type":"assistant","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"working"}]},"uuid":"a1","sessionId":"s","timestamp":"2026-10-15T05:19:05.544Z"}"#,
3582 r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[],"hookErrors":[],"hasOutput":false,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"tu1","uuid":"h1","sessionId":"s","timestamp":"2026-10-15T05:19:40.632Z"}"#,
3583 r#"{"type":"system","subtype":"compact_boundary","uuid":"b1","sessionId":"s","compactMetadata":{"trigger":"auto","preTokens":1018466,"postTokens":19526},"timestamp":"2026-10-15T05:20:11.004Z"}"#,
3584 ]);
3585 let served = page(&messages, &rendered, 0, 0, None, u32::MAX);
3586
3587 let frames: Vec<&Value> =
3588 served.turns.iter().flat_map(|turn| turn.messages.iter()).collect();
3589 let kinds: Vec<String> = frames
3590 .iter()
3591 .map(|frame| {
3592 let subtype = frame["subtype"].as_str().unwrap_or("");
3593 if subtype.is_empty() {
3594 frame["type"].as_str().unwrap_or("?").to_owned()
3595 } else {
3596 format!("{}/{subtype}", frame["type"].as_str().unwrap_or("?"))
3597 }
3598 })
3599 .collect();
3600 assert_eq!(
3601 kinds,
3602 vec![
3603 "user",
3604 "user",
3605 "assistant",
3606 "system/stop_hook_summary",
3607 "system/compact_boundary",
3608 ],
3609 "the page carries every kind the read kept - the denominator this test asserts over",
3610 );
3611
3612 let stamps: Vec<Option<&str>> =
3613 frames.iter().map(|frame| frame["timestamp"].as_str()).collect();
3614 assert_eq!(
3615 stamps,
3616 vec![
3617 Some("2026-10-15T05:18:05.217Z"),
3618 Some("2026-10-15T05:19:03.000Z"),
3619 Some("2026-10-15T05:19:05.544Z"),
3620 Some("2026-10-15T05:19:40.632Z"),
3621 Some("2026-10-15T05:20:11.004Z"),
3622 ],
3623 "each row carries the row's own stamp, in the row's own order",
3624 );
3625 }
3626
3627 #[test]
3631 fn a_page_of_no_turns_still_walks_backwards() {
3632 let rows = turns_of(6);
3633 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3634 let (messages, rendered) = a_conversation(&borrowed);
3635
3636 let lower = page(&messages, &rendered, 0, 0, None, 2);
3638 let cursor = lower.cursor.expect("there is a page above this one");
3639
3640 let above = page(&messages, &rendered, 0, 0, Some(&cursor), 0);
3641
3642 assert!(!above.turns.is_empty(), "a page carries turns rather than none at all");
3643 assert_ne!(
3644 above.cursor.as_deref(),
3645 Some(cursor.as_str()),
3646 "and it moves rather than naming the turn it already opened at",
3647 );
3648 }
3649
3650 #[test]
3654 fn the_rows_before_the_first_turn_ride_the_first_page() {
3655 let (messages, rendered) = a_conversation(&[
3656 r#"{"type":"assistant","uuid":"a0","message":{"id":"m0","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"a delivery arrived"}]}}"#,
3657 r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"hello"},"session_id":"s"}"#,
3658 r#"{"type":"result","uuid":"r1","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3659 ]);
3660
3661 let first = page(&messages, &rendered, 0, 0, None, 10);
3662
3663 assert_eq!(first.turns.len(), 1, "precondition: this conversation is one turn");
3664 assert_eq!(
3665 first.turns[0].messages.first().map(|frame| frame["uuid"].clone()),
3666 Some(Value::String("a0".to_owned())),
3667 "the row before the first turn rides that turn rather than sitting above every page: \
3668 {:?}",
3669 first.turns[0].messages,
3670 );
3671 }
3672
3673 #[test]
3681 fn a_conversation_with_no_turns_still_pages() {
3682 let (messages, rendered) = a_conversation(&[
3687 r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"[Request interrupted by user]"},"session_id":"s"}"#,
3688 r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"morning"}]}}"#,
3689 r#"{"type":"result","uuid":"r1","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3690 ]);
3691
3692 assert!(
3693 rendered.turns.is_empty(),
3694 "precondition: nothing in this conversation opens a turn",
3695 );
3696 assert!(!messages.is_empty(), "precondition: and it has content");
3697
3698 let page = page(&messages, &rendered, 0, 0, None, 10);
3699
3700 assert_eq!(page.turns.len(), 1, "the conversation rides one turn rather than none");
3701 assert_eq!(page.turns[0].messages.len(), messages.len(), "and that turn carries all of it");
3702 assert_eq!(page.cursor, None, "with nothing above it, which is the one honest null");
3703 }
3704
3705 #[test]
3712 fn a_user_row_with_two_blocks_opens_one_turn() {
3713 let (messages, rendered) = a_conversation(&[
3714 r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"text","text":"one"},{"type":"text","text":"two"}]},"session_id":"s"}"#,
3715 r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"reply"}]}}"#,
3716 r#"{"type":"result","uuid":"r1","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3717 ]);
3718
3719 assert_eq!(rendered.turns.len(), 2, "precondition: the fold opens a turn per block");
3720 assert_eq!(
3721 rendered.turns[0].opens_at, rendered.turns[1].opens_at,
3722 "precondition: and both open at the one message that carried them",
3723 );
3724
3725 let page = page(&messages, &rendered, 0, 0, None, 10);
3726
3727 assert_eq!(page.turns.len(), 1, "one message is one turn on the page");
3728 assert_eq!(
3729 page.turns[0].messages.len(),
3730 messages.len(),
3731 "carrying the whole conversation, since both blocks open at its first row",
3732 );
3733 assert_eq!(page.cursor, None, "and the walk is not sent after a page that cannot move");
3734 }
3735
3736 #[test]
3738 fn a_page_opens_on_a_turn_and_the_pages_meet_exactly() {
3739 let rows = turns_of(50);
3740 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3741 let (messages, rendered) = a_conversation(&borrowed);
3742 let first = page(&messages, &rendered, 0, 0, None, 10);
3743
3744 assert_eq!(first.turns.len(), 10, "a page is the turns it was asked for");
3748 assert_eq!(
3752 opened_on(first.turns.first().expect("ten turns")),
3753 "turn 40",
3754 "the page reaches back exactly as far as it was asked for, no further",
3755 );
3756 assert_eq!(
3757 opened_on(first.turns.last().expect("ten turns")),
3758 "turn 49",
3759 "and it ends on the newest turn of the conversation",
3760 );
3761
3762 let all = every_turn(&messages, &rendered);
3767 let second = page(&messages, &rendered, 0, 0, first.cursor.as_deref(), 10);
3768 let second_turns: Vec<String> = second.turns.iter().map(opened_on).collect();
3769
3770 assert!(!second_turns.is_empty(), "asking for more turns returns some");
3771 let above = all
3772 .iter()
3773 .position(|held| *held == opened_on(&first.turns[0]))
3774 .and_then(|at| at.checked_sub(1))
3775 .map(|at| all[at].clone());
3776 assert_eq!(
3777 second_turns.last().cloned(),
3778 above,
3779 "the page above ends on the turn directly above this one, so no turn is skipped",
3780 );
3781 }
3782
3783 #[test]
3793 fn walking_back_through_history_sees_every_turn_and_ends() {
3794 let rows = turns_of(25);
3795 let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3796 let (messages, rendered) = a_conversation(&borrowed);
3797 let every = every_turn(&messages, &rendered);
3798
3799 let mut seen: Vec<String> = Vec::new();
3800 let mut cursor: Option<String> = None;
3801 let mut pages = 0;
3802 loop {
3803 let asked = page(&messages, &rendered, 0, 0, cursor.as_deref(), 5);
3804 pages += 1;
3805 assert!(pages < every.len() + 2, "the walk terminates rather than cycling");
3806 seen.extend(asked.turns.iter().map(opened_on));
3807 match asked.cursor {
3808 Some(next) => cursor = Some(next),
3809 None => break,
3810 }
3811 }
3812
3813 assert!(pages > 1, "the walk goes past the first page, or nothing above it is reachable");
3814 for turn in &every {
3815 assert!(seen.contains(turn), "every turn is reached, and {turn} was not");
3816 }
3817 assert!(
3818 seen.contains(&every[0]),
3819 "and the walk ends only once the OLDEST turn has been seen: {pages} pages",
3820 );
3821 }
3822
3823 use std::collections::BTreeSet;
3824 use std::path::{Path, PathBuf};
3825 use std::sync::{Arc, Mutex};
3826
3827 use super::*;
3828 use crate::surface::PendingKind;
3829 use crate::testing::ViewFacts;
3830 use crate::transport::Heartbeat;
3831 use crate::work::WorkCache;
3832 use forge_primitives::git_diff::GitDiffSnapshot;
3833
3834 fn fixture_dir() -> tempfile::TempDir {
3848 tempfile::Builder::new().tempdir_in("/tmp").expect("the fixture's directory")
3849 }
3850
3851 fn fixture_repo(path: &Path) {
3859 let git = |args: &[&str]| {
3860 let out = std::process::Command::new("git")
3861 .env_remove("GIT_DIR")
3862 .env_remove("GIT_WORK_TREE")
3863 .env_remove("GIT_COMMON_DIR")
3864 .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00+00:00")
3869 .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00+00:00")
3870 .arg("-C")
3871 .arg(path)
3872 .args(args)
3873 .output()
3874 .expect("run git");
3875 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
3876 };
3877 std::fs::create_dir_all(path).expect("the fixture's directory");
3878 git(&["init", "-q"]);
3879 git(&["symbolic-ref", "HEAD", "refs/heads/main"]);
3880 git(&["config", "user.email", "fixture@example.test"]);
3881 git(&["config", "user.name", "Fixture"]);
3882 git(&["config", "commit.gpgsign", "false"]);
3883 std::fs::write(path.join("kept.txt"), "one\ntwo\n").expect("write kept.txt");
3884 git(&["add", "-A"]);
3885 git(&["commit", "-qm", "base"]);
3886 git(&["checkout", "-q", "-b", "worktree-pr"]);
3887 std::fs::write(
3888 path.join("branch.txt"),
3889 "committed on the branch\nsecond line\nthird line\n",
3890 )
3891 .expect("write");
3892 git(&["add", "-A"]);
3893 git(&["commit", "-qm", "branch"]);
3894 std::fs::write(path.join("kept.txt"), "one\ntwo\nthree\n").expect("write kept.txt");
3897 std::fs::write(path.join("added.txt"), "added one\n").expect("write added.txt");
3898 git(&["add", "added.txt"]);
3899 }
3900
3901 async fn fixture_state(root: &Path) -> TransportState {
3911 fixture_state_seeded(root, |_| {}).await
3912 }
3913
3914 async fn fixture_state_seeded(
3918 root: &Path,
3919 seed: impl FnOnce(&crate::testing::Fleet),
3920 ) -> TransportState {
3921 let fleet =
3922 crate::testing::Fleet::in_dir(root, &[("TestOrg", &["proj"])]).expect("the fleet");
3923 fleet.start("TestOrg", "proj").expect("the project starts");
3924 fleet.set_cli_version(Some("1.0.0"), Some("1.1.0"));
3925 fleet.set_user_preferences(serde_json::json!({}));
3926 fleet.add_worker("TestOrg", "proj", "w1").expect("the worker is added");
3927 fleet.install_agent("TestOrg", "proj", "lead");
3928 fleet
3929 .seed_transcript(
3930 "TestOrg",
3931 "proj",
3932 "lead",
3933 &[
3934 r#"{"type":"user","message":{"role":"user","content":"hello"},"session_id":"s"}"#,
3935 r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"tu1","name":"Bash","input":{"command":"ls","description":"List files"}}]}}"#,
3939 r#"{"type":"user","uuid":"u2","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu1","content":"a\nb"}]}}"#,
3940 r#"{"type":"assistant","uuid":"a2","timestamp":"2025-01-02T03:04:05.000Z","message":{"id":"m-usage","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"counted"}],"usage":{"input_tokens":7,"output_tokens":11,"cache_read_input_tokens":13,"cache_creation_input_tokens":17}}}"#,
3946 r#"{"type":"assistant","uuid":"a3","message":{"id":"m-sub","role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"tu-sub","name":"Task","input":{"description":"map the calls","subagent_type":"general-purpose","run_in_background":false,"prompt":"do the thing"}}]}}"#,
3950 r#"{"type":"result","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3951 ],
3952 )
3953 .expect("the transcript seeds");
3954 fleet.seed_test_pending_interaction(&fixture_seat(), PendingKind::Permission);
3955 fleet.seed_view_facts(
3962 &fixture_seat(),
3963 ViewFacts {
3964 background_tasks: vec![forge_workspace::BackgroundTask {
3965 task_id: "t-fixture".to_owned(),
3966 is_backgrounded: true,
3967 task_type: "local_bash".to_owned(),
3968 description: "gh run watch".to_owned(),
3969 command: Some("gh run watch 123 --exit-status".to_owned()),
3970 tool_use_id: Some("tu-fixture".to_owned()),
3971 }],
3972 cards: vec![forge_primitives::runtime::SubagentCard {
3973 name: "map the calls".to_owned(),
3974 dispatch_id: "tu-sub".to_owned(),
3975 agent_type: Some("general-purpose".to_owned()),
3976 running: false,
3977 failed: false,
3978 backgrounded: false,
3979 ended_at_ms: Some(1_750_000_000_000),
3980 calls: 1,
3981 tail: vec![forge_primitives::runtime::SubagentCall {
3982 name: "Read".to_owned(),
3983 title: "Read src/lib.rs".to_owned(),
3984 status: forge_primitives::runtime::SubagentCallStatus::Completed,
3985 }],
3986 usage: Some(forge_primitives::messages::TaskUsage {
3987 total_tokens: 9_714,
3988 tool_uses: 1,
3989 duration_ms: 2_716,
3990 extras: serde_json::Map::new(),
3991 }),
3992 }],
3993 ..ViewFacts::default()
3994 },
3995 );
3996 seed(&fleet);
3997 let surface = fleet.surface();
4007 let work = Arc::new(WorkCache::new());
4008 let repo = root.join("diff-repo");
4009 fixture_repo(&repo);
4010 let mut diff =
4011 forge_workspace::env::git_diff::scan(&repo, &std::collections::HashMap::new(), None)
4012 .await;
4013 diff.pr = Some(forge_primitives::git::GitPrInfo {
4014 number: 1249,
4015 url: "https://example.test/pull/1249".to_owned(),
4016 draft: false,
4017 });
4018 diff.closes = vec![forge_primitives::git::GitIssueRef {
4019 number: 1215,
4020 url: "https://example.test/issues/1215".to_owned(),
4021 }];
4022 diff.pushed_sha = Some("abc123".to_owned());
4023 diff.pr_fetched_at = None;
4024 surface.store_work_snapshot(
4025 &fixture_seat(),
4026 forge_workspace::work::WorkSnapshot {
4027 diff,
4028 cwd: repo,
4029 read_at: std::time::Instant::now(),
4030 base: None,
4031 base_at: None,
4032 base_error: None,
4033 },
4034 );
4035
4036 let state = TransportState {
4037 surface,
4038 work,
4039 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4040 live: Mutex::new(crate::live::Live::new()),
4041 settings: crate::transport::settings_channel(),
4042 browser_host: None,
4043 heartbeat: Heartbeat::default(),
4044 };
4045 fleet
4048 .hold_conversation(&state, "TestOrg", "proj", "lead")
4049 .expect("the fixture's conversation is held");
4050 state
4051 }
4052
4053 fn volatile(root: &Path) -> Vec<(String, &'static str)> {
4063 let raw = root.to_string_lossy().into_owned();
4064 let canonical = root
4065 .canonicalize()
4066 .unwrap_or_else(|_| root.to_path_buf())
4067 .to_string_lossy()
4068 .into_owned();
4069 let mut forms = vec![
4070 (raw.clone(), "<fixture>"),
4071 (canonical.clone(), "<fixture>"),
4072 (slug(&raw), "<fixture>"),
4073 (slug(&canonical), "<fixture>"),
4074 (crate::FORGE_VERSION.to_owned(), "<forge-version>"),
4080 (crate::FORGE_VERSION_SHORT.to_owned(), "<forge-version-short>"),
4081 (boot_id().to_owned(), "<boot-id>"),
4084 ];
4085 forms.sort_by_key(|(form, _)| std::cmp::Reverse(form.len()));
4086 forms.dedup_by(|left, right| left.0 == right.0);
4087 forms
4088 }
4089
4090 fn slug(path: &str) -> String {
4092 path.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
4093 }
4094
4095 fn normalise(value: &mut Value, forms: &[(String, &'static str)]) {
4096 match value {
4097 Value::String(text) => {
4098 for (form, stands_in) in forms {
4099 if let Some(rest) = text.strip_prefix(form.as_str()) {
4100 *text = format!("{stands_in}{rest}");
4101 break;
4102 }
4103 }
4104 }
4105 Value::Array(items) => items.iter_mut().for_each(|item| normalise(item, forms)),
4106 Value::Object(fields) => fields.values_mut().for_each(|field| normalise(field, forms)),
4107 _ => {}
4108 }
4109 }
4110
4111 #[tokio::test]
4117 async fn a_decision_wait_crosses_with_its_ask() {
4118 let dir = fixture_dir();
4119 let state = fixture_state_seeded(dir.path(), |fleet| {
4120 fleet.seed_task(forge_primitives::tasks::Task {
4121 id: forge_primitives::tasks::TaskId::from("t-wait"),
4122 project_name: "proj".to_owned(),
4123 subject: "ship the parser".to_owned(),
4124 active_form: None,
4125 detail: None,
4126 status: forge_primitives::tasks::TaskStatus::Waiting,
4127 owner: Some(forge_primitives::SessionSlot::worker("TestOrg", "proj", "w1")),
4128 parent: None,
4129 waiting_on: Some(forge_primitives::tasks::Waiting {
4130 kind: Some(forge_primitives::tasks::WaitingKind::Decision),
4131 detail: Some("sign this off".to_owned()),
4132 on: None,
4133 verification: false,
4134 parked: false,
4135 since: Some(
4136 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(5),
4137 ),
4138 }),
4139 estimate: None,
4140 rank: None,
4141 verify: None,
4142 attested: None,
4143 reserved: None,
4144 links: Vec::new(),
4145 attempt: 0,
4146 archived_at: None,
4147 created_at: std::time::SystemTime::UNIX_EPOCH,
4148 updated_at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(9),
4150 });
4151 })
4152 .await;
4153
4154 let mut encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4155 normalise(&mut encoded, &volatile(dir.path()));
4156 let row = encoded["fleet"]
4157 .as_array()
4158 .expect("the fleet")
4159 .iter()
4160 .find(|row| row["project"] == "proj")
4161 .expect("the project's row");
4162 assert_eq!(row["waiting_on_user"], 1, "the wait counts");
4163 let item = &row["attention"][0];
4164 assert_eq!(item["task"], "t-wait", "the row's id");
4165 assert_eq!(item["owner"], "w1", "the seat that raised it");
4166 assert_eq!(item["subject"], "ship the parser", "the row's subject");
4167 assert_eq!(item["words"], "sign this off", "the ask's own words");
4168 assert_eq!(
4169 item["at"],
4170 serde_json::json!({ "secs_since_epoch": 5, "nanos_since_epoch": 0 }),
4171 "the moment the wait began",
4172 );
4173 }
4174
4175 fn fixtures() -> Vec<(Subject, PathBuf)> {
4178 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/wire_fixtures");
4179 vec![
4180 (Subject::Home, dir.join("home.json")),
4181 (Subject::Board, dir.join("board.json")),
4182 (Subject::Session(fixture_seat()), dir.join("session.json")),
4183 (Subject::Usage, dir.join("usage.json")),
4184 ]
4185 }
4186
4187 #[tokio::test]
4195 #[ignore = "writes the fixtures; run deliberately"]
4196 async fn write_the_fixtures() {
4197 let dir = fixture_dir();
4198 let state = fixture_state(dir.path()).await;
4199 let forms = volatile(dir.path());
4200 std::fs::create_dir_all(fixtures()[0].1.parent().expect("a directory")).expect("mkdir");
4201 for (subject, fixture) in fixtures() {
4202 let mut encoded = encode_subject(&state, &subject).await.expect("encode");
4203 normalise(&mut encoded, &forms);
4204 std::fs::write(&fixture, serde_json::to_string_pretty(&encoded).expect("render"))
4205 .expect("write");
4206 }
4207 }
4208
4209 #[tokio::test]
4216 async fn a_home_agents_row_carries_its_own_tree() {
4217 let fleet =
4218 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4219 fleet.start("TestOrg", "proj").expect("the project starts");
4220 let state = TransportState {
4221 surface: fleet.surface(),
4222 work: Arc::new(WorkCache::new()),
4223 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4224 live: Mutex::new(crate::live::Live::new()),
4225 settings: crate::transport::settings_channel(),
4226 browser_host: None,
4227 heartbeat: Heartbeat::default(),
4228 };
4229
4230 let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4231 let row = encoded["agents"]
4232 .as_array()
4233 .expect("agents is a list")
4234 .iter()
4235 .find(|row| row["slot"]["label"] == "lead")
4236 .expect("the started project's lead is a row");
4237
4238 assert!(
4239 row.get("work").is_some(),
4240 "the seat's own tree crosses on the row, not the project's read: {row}"
4241 );
4242 }
4243
4244 #[tokio::test]
4249 async fn a_home_agents_row_carries_the_heal_attempt() {
4250 let fleet =
4251 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4252 fleet.start("TestOrg", "proj").expect("the project starts");
4253 let lead = forge_primitives::SessionSlot::lead("TestOrg", "proj");
4254 let next_at =
4255 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_790_000_000);
4256 fleet.workspace().set_heal_progress(&lead, 1, next_at);
4257 let state = TransportState {
4258 surface: fleet.surface(),
4259 work: Arc::new(WorkCache::new()),
4260 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4261 live: Mutex::new(crate::live::Live::new()),
4262 settings: crate::transport::settings_channel(),
4263 browser_host: None,
4264 heartbeat: Heartbeat::default(),
4265 };
4266
4267 let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4268 let row = encoded["agents"]
4269 .as_array()
4270 .expect("agents is a list")
4271 .iter()
4272 .find(|row| row["slot"]["label"] == "lead")
4273 .expect("the started project's lead is a row");
4274
4275 assert_eq!(row["lifecycle"], serde_json::json!("Restarting"), "the state the row draws");
4276 assert_eq!(row["heal_attempt"], serde_json::json!(1), "the attempt it names");
4277 assert_eq!(row["heal_attempts"], serde_json::json!(3), "the cap the row's 'of 3' reads");
4278 }
4279
4280 #[tokio::test]
4284 async fn the_home_carries_no_board_and_the_board_carries_all_of_it() {
4285 let fleet =
4286 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4287 fleet.start("TestOrg", "proj").expect("the project starts");
4288 fleet.add_task("TestOrg", "proj", "a counted row", "w1", None).expect("the task is seeded");
4289 let state = TransportState {
4290 surface: fleet.surface(),
4291 work: Arc::new(WorkCache::new()),
4292 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4293 live: Mutex::new(crate::live::Live::new()),
4294 settings: crate::transport::settings_channel(),
4295 browser_host: None,
4296 heartbeat: Heartbeat::default(),
4297 };
4298
4299 let home = encode_subject(&state, &Subject::Home).await.expect("the home encodes");
4300 let home_project = &home["projects"][0];
4301 for field in ["rows", "crons", "connectors"] {
4302 assert!(
4303 home_project.get(field).is_none(),
4304 "the home still carries the board's `{field}`: {home_project}",
4305 );
4306 }
4307
4308 let board = encode_subject(&state, &Subject::Board).await.expect("the board encodes");
4309 let board_project = &board["projects"][0];
4310 for field in ["rows", "crons", "connectors"] {
4311 assert!(
4312 board_project.get(field).is_some(),
4313 "the board's snapshot does not carry `{field}`, which its updates move: \
4314 {board_project}",
4315 );
4316 }
4317
4318 let board_rows = board_project["rows"].as_array().map(Vec::len);
4321 assert_eq!(board_rows, Some(1), "the seeded row did not reach the board: {board}");
4322 assert_eq!(
4323 home["fleet"][0]["rows"].as_u64(),
4324 Some(1),
4325 "the fleet row's total does not match the board's rows: {home}",
4326 );
4327 }
4328
4329 #[tokio::test]
4333 async fn a_board_row_marks_an_owner_whose_seat_is_not_live() {
4334 let fleet =
4335 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4336 fleet.add_task("TestOrg", "proj", "an owned row", "w1", None).expect("the task is seeded");
4337 let state = TransportState {
4338 surface: fleet.surface(),
4339 work: Arc::new(WorkCache::new()),
4340 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4341 live: Mutex::new(crate::live::Live::new()),
4342 settings: crate::transport::settings_channel(),
4343 browser_host: None,
4344 heartbeat: Heartbeat::default(),
4345 };
4346
4347 let board = encode_subject(&state, &Subject::Board).await.expect("encode");
4348 let gone_chip = |board: &Value| board["projects"][0]["rows"][0]["marks"]["stale"].clone();
4349 assert_eq!(
4350 gone_chip(&board),
4351 Value::Bool(true),
4352 "an owner no seat holds must draw the gone chip: {board}",
4353 );
4354
4355 let _commands = fleet.install_agent("TestOrg", "proj", "w1");
4357 let board = encode_subject(&state, &Subject::Board).await.expect("encode");
4358 assert_eq!(
4359 gone_chip(&board),
4360 Value::Bool(false),
4361 "a live owner must not draw the gone chip: {board}",
4362 );
4363 }
4364
4365 #[tokio::test]
4368 async fn a_home_fatal_carries_the_terminals_words() {
4369 let fleet =
4370 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4371 fleet.start("TestOrg", "proj").expect("the project starts");
4372 fleet.set_fatal_error(forge_primitives::error::AppError::ConnectionFailed);
4373 let state = TransportState {
4374 surface: fleet.surface(),
4375 work: Arc::new(WorkCache::new()),
4376 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4377 live: Mutex::new(crate::live::Live::new()),
4378 settings: crate::transport::settings_channel(),
4379 browser_host: None,
4380 heartbeat: Heartbeat::default(),
4381 };
4382
4383 let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4384 assert_eq!(
4385 encoded["fatal_error"],
4386 serde_json::json!("Failed to establish or maintain the Agent SDK bridge connection."),
4387 "the home's fatal is the terminal's own words"
4388 );
4389 }
4390
4391 #[tokio::test]
4398 async fn a_session_snapshot_carries_the_background_registry() {
4399 let fleet =
4400 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4401 fleet.start("TestOrg", "proj").expect("the project starts");
4402 fleet.seed_view_facts(
4403 &fixture_seat(),
4404 ViewFacts {
4405 background_tasks: vec![forge_workspace::BackgroundTask {
4406 task_id: "t1".to_owned(),
4407 is_backgrounded: true,
4408 task_type: "local_bash".to_owned(),
4409 description: "gh run watch".to_owned(),
4410 command: Some("gh run watch 123 --exit-status".to_owned()),
4411 tool_use_id: Some("tu-1".to_owned()),
4412 }],
4413 ..ViewFacts::default()
4414 },
4415 );
4416 let state = TransportState {
4417 surface: fleet.surface(),
4418 work: Arc::new(WorkCache::new()),
4419 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4420 live: Mutex::new(crate::live::Live::new()),
4421 settings: crate::transport::settings_channel(),
4422 browser_host: None,
4423 heartbeat: Heartbeat::default(),
4424 };
4425
4426 let encoded =
4427 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4428
4429 assert_eq!(
4430 encoded["background_tasks"][0]["task_id"], "t1",
4431 "the seat's registry crosses: {encoded}"
4432 );
4433 assert_eq!(
4434 encoded["background_tasks"][0]["description"], "gh run watch",
4435 "with the line the row leads with: {encoded}"
4436 );
4437 assert_eq!(
4438 encoded["background_tasks"][0]["command"], "gh run watch 123 --exit-status",
4439 "and the command the scan adopts by: {encoded}"
4440 );
4441 }
4442
4443 #[tokio::test]
4448 async fn a_session_snapshot_carries_the_occupants_id() {
4449 let fleet =
4450 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4451 fleet.start("TestOrg", "proj").expect("the project starts");
4452 fleet.seed_view_facts(
4453 &fixture_seat(),
4454 ViewFacts {
4455 session_id: Some(forge_primitives::SessionId::new("d4f70669-1f2a")),
4456 ..ViewFacts::default()
4457 },
4458 );
4459 let state = TransportState {
4460 surface: fleet.surface(),
4461 work: Arc::new(WorkCache::new()),
4462 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4463 live: Mutex::new(crate::live::Live::new()),
4464 settings: crate::transport::settings_channel(),
4465 browser_host: None,
4466 heartbeat: Heartbeat::default(),
4467 };
4468
4469 let encoded =
4470 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4471
4472 assert_eq!(
4473 encoded["header"]["session_id"], "d4f70669-1f2a",
4474 "the occupant the core named crosses on the header: {encoded}"
4475 );
4476 }
4477
4478 #[tokio::test]
4482 async fn a_session_snapshot_carries_the_sessions_the_seat_can_resume() {
4483 let dir = tempfile::tempdir().expect("fleet dir");
4484 let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
4485 .expect("the fleet builds");
4486 fleet.start("TestOrg", "proj").expect("the project starts");
4487 let cwd =
4488 fleet.workspace().cwd_for_session(&fixture_seat()).expect("the project's directory");
4489 let key = forge_workspace::userdata::catalog::scan::project_key_for_directory(Some(&cwd));
4490 let project_dir = dir.path().join("projects").join(key);
4491 std::fs::create_dir_all(&project_dir).expect("the project's transcripts");
4492 for (id, summary, secs) in [
4493 ("aaaa-0000", "the older session", 1_700_000_100_u64),
4494 ("bbbb-1111", "the newer session", 1_700_000_200),
4495 ] {
4496 let path = project_dir.join(format!("{id}.jsonl"));
4497 std::fs::write(
4498 &path,
4499 format!(
4500 "{{\"type\":\"user\",\"cwd\":\"{cwd}\",\"message\":{{\"content\":\"{summary}\"}}}}\n"
4501 ),
4502 )
4503 .expect("write a transcript");
4504 let file = std::fs::File::options().write(true).open(&path).expect("open it");
4505 file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
4506 .expect("pin the transcript's mtime");
4507 }
4508 let state = TransportState {
4509 surface: fleet.surface(),
4510 work: Arc::new(WorkCache::new()),
4511 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4512 live: Mutex::new(crate::live::Live::new()),
4513 settings: crate::transport::settings_channel(),
4514 browser_host: None,
4515 heartbeat: Heartbeat::default(),
4516 };
4517
4518 let encoded =
4519 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4520
4521 assert_eq!(
4522 encoded["resume_sessions"][0]["session_id"], "bbbb-1111",
4523 "the newest session leads, by the id a pick writes: {encoded}"
4524 );
4525 assert_eq!(
4526 encoded["resume_sessions"][0]["summary"], "the newer session",
4527 "with the transcript's own title beside it: {encoded}"
4528 );
4529 assert_eq!(
4530 encoded["resume_sessions"][1]["session_id"], "aaaa-0000",
4531 "and the older one follows: {encoded}"
4532 );
4533 }
4534
4535 #[tokio::test]
4546 async fn the_record_reads_the_instance_list_from_the_workspace() {
4547 let fleet =
4548 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4549 fleet.start("TestOrg", "proj").expect("the project starts");
4550 fleet
4551 .seed_transcript(
4552 "TestOrg",
4553 "proj",
4554 "lead",
4555 &[
4556 r#"{"type":"user","message":{"role":"user","content":"map it"},"session_id":"s"}"#,
4557 r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"tu-sub","name":"Task","input":{"description":"from the conversation","subagent_type":"Explore"}}]}}"#,
4558 ],
4559 )
4560 .expect("the transcript seeds");
4561 let state = TransportState {
4562 surface: fleet.surface(),
4563 work: Arc::new(WorkCache::new()),
4564 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4565 live: Mutex::new(crate::live::Live::new()),
4566 settings: crate::transport::settings_channel(),
4567 browser_host: None,
4568 heartbeat: Heartbeat::default(),
4569 };
4570 fleet
4571 .hold_conversation(&state, "TestOrg", "proj", "lead")
4572 .expect("the fixture's conversation is held");
4573
4574 let before =
4575 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4576 assert_eq!(
4577 before["subagent_instances"].as_array().expect("a list").len(),
4578 0,
4579 "the conversation's own dispatch is not folded by the read: {before}",
4580 );
4581
4582 fleet.seed_view_facts(
4583 &fixture_seat(),
4584 ViewFacts {
4585 cards: vec![forge_primitives::runtime::SubagentCard {
4586 name: "from the workspace".to_owned(),
4587 dispatch_id: "tu-sub".to_owned(),
4588 agent_type: Some("Explore".to_owned()),
4589 running: true,
4590 failed: false,
4591 backgrounded: false,
4592 ended_at_ms: None,
4593 calls: 0,
4594 tail: Vec::new(),
4595 usage: None,
4596 }],
4597 ..ViewFacts::default()
4598 },
4599 );
4600
4601 let after =
4602 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4603 let cards = after["subagent_instances"].as_array().expect("a list");
4604 assert_eq!(
4605 cards.len(),
4606 1,
4607 "and the list the workspace holds is the one that crosses: {after}"
4608 );
4609 assert_eq!(
4610 cards[0]["name"], "from the workspace",
4611 "named by the fold that announces it rather than by a second one here: {after}",
4612 );
4613 }
4614
4615 #[tokio::test]
4620 async fn the_record_walks_the_index_when_the_store_holds_none() {
4621 let dir = tempfile::tempdir().expect("tempdir");
4622 let root = dir.path().join("proj");
4623 std::fs::create_dir_all(&root).expect("mkdir");
4624 std::fs::write(root.join("found.rs"), "").expect("write");
4625 let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
4626 .expect("the fleet builds");
4627 fleet.start("TestOrg", "proj").expect("the project starts");
4628 let state = TransportState {
4629 surface: fleet.surface(),
4630 work: Arc::new(WorkCache::new()),
4631 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4632 live: Mutex::new(crate::live::Live::new()),
4633 settings: crate::transport::settings_channel(),
4634 browser_host: None,
4635 heartbeat: Heartbeat::default(),
4636 };
4637
4638 let encoded =
4639 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4640
4641 let entries = encoded["file_index"]["entries"].as_object().expect("an index crosses");
4642 assert!(
4643 entries.contains_key("found.rs"),
4644 "a seat nothing has walked is walked at the read: {encoded}",
4645 );
4646 }
4647
4648 #[tokio::test]
4663 async fn a_session_snapshot_carries_the_pr_row() {
4664 let fleet =
4665 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4666 fleet.start("TestOrg", "proj").expect("the project starts");
4667 let state = TransportState {
4668 surface: fleet.surface(),
4669 work: Arc::new(WorkCache::new()),
4670 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4671 live: Mutex::new(crate::live::Live::new()),
4672 settings: crate::transport::settings_channel(),
4673 browser_host: None,
4674 heartbeat: Heartbeat::default(),
4675 };
4676
4677 let encoded =
4678 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4679
4680 assert!(encoded.get("pr").is_some(), "the session carries a PR field: {encoded}");
4681 assert_eq!(
4682 encoded["closes"],
4683 serde_json::json!([]),
4684 "and the issues it closes, empty when there is no PR: {encoded}"
4685 );
4686 }
4687
4688 #[tokio::test]
4692 async fn a_record_attached_mid_handoff_carries_the_machine_turn() {
4693 let fleet =
4694 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4695 fleet.start("TestOrg", "proj").expect("the project starts");
4696 let workspace = fleet.workspace();
4697 let seat = fixture_seat();
4698 let domain = match workspace.domain_session_for(&seat) {
4699 Some(domain) => domain,
4700 None => workspace.register_domain_session(seat.clone(), None),
4701 };
4702 let state = TransportState {
4703 surface: fleet.surface(),
4704 work: Arc::new(WorkCache::new()),
4705 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4706 live: Mutex::new(crate::live::Live::new()),
4707 settings: crate::transport::settings_channel(),
4708 browser_host: None,
4709 heartbeat: Heartbeat::default(),
4710 };
4711 let subject = Subject::Session(seat);
4712
4713 let idle = encode_subject(&state, &subject).await.expect("the record encodes");
4714 assert_eq!(idle["composer"]["machine"], Value::Null, "an idle seat takes input");
4715
4716 domain.lock().handoff.awaiting = true;
4717 let mid = encode_subject(&state, &subject).await.expect("the record encodes");
4718 assert_eq!(
4719 mid["composer"]["machine"],
4720 serde_json::json!("handoff"),
4721 "a page attaching mid-handoff reads the machine turn",
4722 );
4723 }
4724
4725 #[tokio::test]
4732 async fn the_record_carries_the_changed_files_with_their_hunks() {
4733 let fleet =
4734 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4735 fleet.start("TestOrg", "proj").expect("the project starts");
4736 let seat = fixture_seat();
4737 let surface = fleet.surface();
4738 let repo = tempfile::tempdir().expect("a repository directory");
4739 fixture_repo(repo.path());
4740 let diff = forge_workspace::env::git_diff::scan(
4741 repo.path(),
4742 &std::collections::HashMap::new(),
4743 None,
4744 )
4745 .await;
4746 surface.store_work_snapshot(
4747 &seat,
4748 forge_workspace::work::WorkSnapshot {
4749 diff,
4750 cwd: repo.path().to_owned(),
4751 read_at: std::time::Instant::now(),
4752 base: None,
4753 base_at: None,
4754 base_error: None,
4755 },
4756 );
4757 let state = TransportState {
4758 surface,
4759 work: Arc::new(WorkCache::new()),
4760 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4761 live: Mutex::new(crate::live::Live::new()),
4762 settings: crate::transport::settings_channel(),
4763 browser_host: None,
4764 heartbeat: Heartbeat::default(),
4765 };
4766
4767 let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
4768
4769 let files = encoded["diff"]["worktree"]["populated"]
4770 .as_array()
4771 .expect("the worktree layer is populated");
4772 let added = files.iter().find(|file| file["path"] == "added.txt").expect("added.txt");
4773 assert_eq!(added["status"], "added", "the status crosses: {encoded}");
4774 let lines = added["hunks"][0]["lines"].as_array().expect("hunks carry their lines");
4775 assert!(
4776 lines.iter().any(|line| line["kind"] == "added" && line["text"] == "added one"),
4777 "the hunk's own lines cross: {encoded}",
4778 );
4779 assert_eq!(encoded["diff"]["truncated"], false, "nothing was withheld");
4780 let ahead = encoded["diff"]["branch_ahead"]["populated"]
4781 .as_array()
4782 .expect("the branch layer is populated too");
4783 assert!(
4784 ahead.iter().any(|file| file["path"] == "branch.txt"),
4785 "and the branch's own commit is there: {encoded}",
4786 );
4787 }
4788
4789 #[tokio::test]
4806 async fn the_record_carries_each_threads_landing() {
4807 let fleet =
4808 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4809 fleet.start("TestOrg", "proj").expect("the project starts");
4810 let seat = fixture_seat();
4811 let surface = fleet.surface();
4812 let repo = tempfile::tempdir().expect("a repository directory");
4813 fixture_repo(repo.path());
4814 let diff = forge_workspace::env::git_diff::scan(
4815 repo.path(),
4816 &std::collections::HashMap::new(),
4817 None,
4818 )
4819 .await;
4820 surface.store_work_snapshot(
4821 &seat,
4822 forge_workspace::work::WorkSnapshot {
4823 diff,
4824 cwd: repo.path().to_owned(),
4825 read_at: std::time::Instant::now(),
4826 base: None,
4827 base_at: None,
4828 base_error: None,
4829 },
4830 );
4831 let thread = |id: &str,
4832 path: &str,
4833 text: &str,
4834 line: u32,
4835 context: &[&str],
4836 commit: Option<&str>,
4837 base: &str|
4838 -> forge_primitives::review::ReviewThread {
4839 forge_primitives::review::ReviewThread {
4840 id: id.to_owned(),
4841 anchor: forge_primitives::review::ReviewAnchor {
4842 path: path.to_owned(),
4843 side: forge_primitives::review::ReviewSide::New,
4844 line,
4845 content_hash: forge_workspace::env::git_diff::resolver::anchor_hash(text),
4846 context: context.iter().map(|held| (*held).to_owned()).collect(),
4847 base_ref: base.to_owned(),
4848 },
4849 comments: vec![forge_primitives::review::ReviewComment {
4850 author: forge_primitives::review::ReviewAuthor::User,
4851 text: "c".to_owned(),
4852 at: "2026-01-01T00:00:00Z".to_owned(),
4853 review_id: None,
4854 }],
4855 status: forge_primitives::review::ReviewStatus::Open,
4856 created_at: "2026-01-01T00:00:00Z".to_owned(),
4857 updated_at: "2026-01-01T00:00:00Z".to_owned(),
4858 commit: commit.map(str::to_owned),
4859 }
4860 };
4861 for (id, text, line, context, commit, base) in [
4862 ("placed", "committed on the branch", 1, &[][..], None, "main"),
4863 ("gone", "a line the branch does not carry", 1, &[][..], None, "main"),
4864 (
4865 "scoped",
4866 "second line",
4867 2,
4868 &["committed on the branch", "third line"][..],
4869 Some("abc123"),
4870 "main",
4871 ),
4872 ("scoped-moved", "third line", 1, &["second line"][..], Some("abc123"), "main"),
4873 ("scoped-gone", "not on the branch at all", 1, &[][..], Some("abc123"), "main"),
4874 ("other-base", "committed on the branch", 1, &[][..], None, "HEAD"),
4875 ] {
4876 assert!(
4877 fleet.workspace().upsert_review_thread(
4878 "proj",
4879 "worktree-pr",
4880 thread(id, "branch.txt", text, line, context, commit, base),
4881 ),
4882 "the {id} thread stores",
4883 );
4884 }
4885 let state = TransportState {
4886 surface,
4887 work: Arc::new(WorkCache::new()),
4888 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4889 live: Mutex::new(crate::live::Live::new()),
4890 settings: crate::transport::settings_channel(),
4891 browser_host: None,
4892 heartbeat: Heartbeat::default(),
4893 };
4894
4895 let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
4896
4897 let threads =
4898 encoded["reviews"]["threads"]["value"].as_array().expect("the threads read answered");
4899 let of = |id: &str| threads.iter().find(|held| held["id"] == id).expect(id);
4900 assert_eq!(
4901 of("placed")["resolution"],
4902 serde_json::json!({"state": "placed", "line": 1, "moved_from": null}),
4903 "an anchored line still carrying its text lands where it was written: {encoded}",
4904 );
4905 assert_eq!(
4906 of("gone")["resolution"],
4907 serde_json::json!({"state": "outdated", "reason": "gone"}),
4908 "code the branch no longer carries is outdated, in the resolver's own words",
4909 );
4910 assert_eq!(
4911 of("scoped")["resolution"],
4912 serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
4913 "a commit-scoped thread the branch still carries lands there, by content: {encoded}",
4914 );
4915 assert_eq!(
4916 of("scoped-moved")["resolution"],
4917 serde_json::json!({"state": "placed", "line": 3, "moved_from": null}),
4918 "and the recorded line is not named beside this scope's: it counts in the commit's",
4919 );
4920 assert_eq!(
4921 of("scoped-gone")["resolution"],
4922 serde_json::json!({"state": "outdated", "reason": "gone"}),
4923 "a commit-scoped thread the branch no longer carries is outdated there",
4924 );
4925 assert_eq!(
4926 of("other-base")["resolution"],
4927 serde_json::Value::Null,
4928 "nor has one numbered against another base, whose lines address other code here",
4929 );
4930 }
4931
4932 fn wide_repo(path: &Path) {
4935 let git = |args: &[&str]| {
4936 let out = std::process::Command::new("git")
4937 .env_remove("GIT_DIR")
4938 .env_remove("GIT_WORK_TREE")
4939 .env_remove("GIT_COMMON_DIR")
4940 .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00+00:00")
4941 .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00+00:00")
4942 .arg("-C")
4943 .arg(path)
4944 .args(args)
4945 .output()
4946 .expect("run git");
4947 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4948 };
4949 let wide = |ten: &str| -> String {
4950 (1..=20)
4951 .map(|n| if n == 10 { format!("{ten}\n") } else { format!("wide {n}\n") })
4952 .collect()
4953 };
4954 std::fs::create_dir_all(path).expect("the fixture's directory");
4955 git(&["init", "-q"]);
4956 git(&["symbolic-ref", "HEAD", "refs/heads/main"]);
4957 git(&["config", "user.email", "fixture@example.test"]);
4958 git(&["config", "user.name", "Fixture"]);
4959 git(&["config", "commit.gpgsign", "false"]);
4960 std::fs::write(path.join("wide.txt"), wide("wide ten")).expect("write wide.txt");
4961 git(&["add", "-A"]);
4962 git(&["commit", "-qm", "base"]);
4963 git(&["checkout", "-q", "-b", "worktree-pr"]);
4964 std::fs::write(path.join("wide.txt"), wide("wide ten changed")).expect("write wide.txt");
4965 git(&["add", "-A"]);
4966 git(&["commit", "-qm", "branch"]);
4967 }
4968
4969 #[tokio::test]
4975 async fn the_record_places_a_thread_outside_the_hunks_in_the_whole_file() {
4976 let fleet =
4977 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4978 fleet.start("TestOrg", "proj").expect("the project starts");
4979 let seat = fixture_seat();
4980 let surface = fleet.surface();
4981 let repo = tempfile::tempdir().expect("a repository directory");
4982 wide_repo(repo.path());
4983 let diff = forge_workspace::env::git_diff::scan(
4984 repo.path(),
4985 &std::collections::HashMap::new(),
4986 None,
4987 )
4988 .await;
4989 surface.store_work_snapshot(
4990 &seat,
4991 forge_workspace::work::WorkSnapshot {
4992 diff,
4993 cwd: repo.path().to_owned(),
4994 read_at: std::time::Instant::now(),
4995 base: None,
4996 base_at: None,
4997 base_error: None,
4998 },
4999 );
5000 let thread = |id: &str, side, text: &str, context: &[&str], commit: Option<&str>| {
5001 forge_primitives::review::ReviewThread {
5002 id: id.to_owned(),
5003 anchor: forge_primitives::review::ReviewAnchor {
5004 path: "wide.txt".to_owned(),
5005 side,
5006 line: 2,
5007 content_hash: forge_workspace::env::git_diff::resolver::anchor_hash(text),
5008 context: context.iter().map(|held| (*held).to_owned()).collect(),
5009 base_ref: "main".to_owned(),
5010 },
5011 comments: vec![forge_primitives::review::ReviewComment {
5012 author: forge_primitives::review::ReviewAuthor::User,
5013 text: "c".to_owned(),
5014 at: "2026-01-01T00:00:00Z".to_owned(),
5015 review_id: None,
5016 }],
5017 status: forge_primitives::review::ReviewStatus::Open,
5018 created_at: "2026-01-01T00:00:00Z".to_owned(),
5019 updated_at: "2026-01-01T00:00:00Z".to_owned(),
5020 commit: commit.map(str::to_owned),
5021 }
5022 };
5023 for held in [
5024 thread("revealed-new", ReviewSide::New, "wide 2", &["wide 1", "wide 3"], None),
5025 thread("revealed-old", ReviewSide::Old, "wide 2", &["wide 1", "wide 3"], None),
5026 thread("revealed-gone", ReviewSide::New, "not in the file", &[], None),
5027 thread(
5030 "scoped-borrow",
5031 ReviewSide::New,
5032 "wide 2",
5033 &["wide 1", "wide 3"],
5034 Some("abc123"),
5035 ),
5036 ] {
5037 assert!(
5038 fleet.workspace().upsert_review_thread("proj", "worktree-pr", held),
5039 "the thread stores",
5040 );
5041 }
5042 let state = TransportState {
5043 surface,
5044 work: Arc::new(WorkCache::new()),
5045 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5046 live: Mutex::new(crate::live::Live::new()),
5047 settings: crate::transport::settings_channel(),
5048 browser_host: None,
5049 heartbeat: Heartbeat::default(),
5050 };
5051 let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
5052 let threads =
5053 encoded["reviews"]["threads"]["value"].as_array().expect("the threads read answered");
5054 let of = |id: &str| threads.iter().find(|held| held["id"] == id).expect(id);
5055 assert_eq!(
5056 of("revealed-new")["resolution"],
5057 serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5058 "a new-side line outside the hunks lands in the branch tip's file: {encoded}",
5059 );
5060 assert_eq!(
5061 of("revealed-old")["resolution"],
5062 serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5063 "and an old-side one in the base's, whose numbering is its own: {encoded}",
5064 );
5065 assert_eq!(
5066 of("revealed-gone")["resolution"],
5067 serde_json::json!({"state": "outdated", "reason": "gone"}),
5068 "code the file does not carry is still outdated: {encoded}",
5069 );
5070 assert_eq!(
5071 of("scoped-borrow")["resolution"],
5072 serde_json::json!({"state": "outdated", "reason": "gone"}),
5073 "a commit-scoped thread never borrows the branch's read: {encoded}",
5074 );
5075 }
5076
5077 fn git_in(path: &Path, args: &[&str]) {
5079 let out = std::process::Command::new("git")
5080 .env_remove("GIT_DIR")
5081 .env_remove("GIT_WORK_TREE")
5082 .env_remove("GIT_COMMON_DIR")
5083 .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00+00:00")
5084 .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00+00:00")
5085 .arg("-C")
5086 .arg(path)
5087 .args(args)
5088 .output()
5089 .expect("run git");
5090 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
5091 }
5092
5093 fn init_repo(path: &Path) {
5095 std::fs::create_dir_all(path).expect("the fixture's directory");
5096 git_in(path, &["init", "-q"]);
5097 git_in(path, &["symbolic-ref", "HEAD", "refs/heads/main"]);
5098 git_in(path, &["config", "user.email", "fixture@example.test"]);
5099 git_in(path, &["config", "user.name", "Fixture"]);
5100 git_in(path, &["config", "commit.gpgsign", "false"]);
5101 }
5102
5103 fn wide_lines(ten: &str) -> String {
5105 (1..=20).map(|n| if n == 10 { format!("{ten}\n") } else { format!("wide {n}\n") }).collect()
5106 }
5107
5108 async fn encode_repo(
5110 fleet: &crate::testing::Fleet,
5111 seat: &SessionSlot,
5112 repo: &Path,
5113 ) -> serde_json::Value {
5114 let surface = fleet.surface();
5115 let diff =
5116 forge_workspace::env::git_diff::scan(repo, &std::collections::HashMap::new(), None)
5117 .await;
5118 surface.store_work_snapshot(
5119 seat,
5120 forge_workspace::work::WorkSnapshot {
5121 diff,
5122 cwd: repo.to_owned(),
5123 read_at: std::time::Instant::now(),
5124 base: None,
5125 base_at: None,
5126 base_error: None,
5127 },
5128 );
5129 let state = TransportState {
5130 surface,
5131 work: Arc::new(WorkCache::new()),
5132 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5133 live: Mutex::new(crate::live::Live::new()),
5134 settings: crate::transport::settings_channel(),
5135 browser_host: None,
5136 heartbeat: Heartbeat::default(),
5137 };
5138 encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode")
5139 }
5140
5141 fn wide_thread(
5143 id: &str,
5144 side: ReviewSide,
5145 line: u32,
5146 text: &str,
5147 context: &[&str],
5148 commit: Option<&str>,
5149 ) -> ReviewThread {
5150 ReviewThread {
5151 id: id.to_owned(),
5152 anchor: ReviewAnchor {
5153 path: "wide.txt".to_owned(),
5154 side,
5155 line,
5156 content_hash: forge_workspace::env::git_diff::resolver::anchor_hash(text),
5157 context: context.iter().map(|held| (*held).to_owned()).collect(),
5158 base_ref: "main".to_owned(),
5159 },
5160 comments: vec![forge_primitives::review::ReviewComment {
5161 author: forge_primitives::review::ReviewAuthor::User,
5162 text: "c".to_owned(),
5163 at: "2026-01-01T00:00:00Z".to_owned(),
5164 review_id: None,
5165 }],
5166 status: forge_primitives::review::ReviewStatus::Open,
5167 created_at: "2026-01-01T00:00:00Z".to_owned(),
5168 updated_at: "2026-01-01T00:00:00Z".to_owned(),
5169 commit: commit.map(str::to_owned),
5170 }
5171 }
5172
5173 fn landing(encoded: &serde_json::Value, id: &str) -> serde_json::Value {
5175 let threads =
5176 encoded["reviews"]["threads"]["value"].as_array().expect("the threads read answered");
5177 threads.iter().find(|held| held["id"] == id).expect(id)["resolution"].clone()
5178 }
5179
5180 #[tokio::test]
5186 async fn the_record_reads_the_old_side_at_the_merge_base() {
5187 let fleet =
5188 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5189 fleet.start("TestOrg", "proj").expect("the project starts");
5190 let seat = fixture_seat();
5191 let repo = tempfile::tempdir().expect("a repository directory");
5192 init_repo(repo.path());
5193 std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10")).expect("write");
5194 git_in(repo.path(), &["add", "-A"]);
5195 git_in(repo.path(), &["commit", "-qm", "base"]);
5196 git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5197 std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10 changed")).expect("write");
5198 git_in(repo.path(), &["add", "-A"]);
5199 git_in(repo.path(), &["commit", "-qm", "branch"]);
5200 git_in(repo.path(), &["checkout", "-q", "main"]);
5203 let advanced: String = std::iter::once("wide 0\n".to_owned())
5204 .chain((1..=20).map(|n| format!("wide {n}\n")))
5205 .collect();
5206 std::fs::write(repo.path().join("wide.txt"), advanced).expect("write");
5207 git_in(repo.path(), &["add", "-A"]);
5208 git_in(repo.path(), &["commit", "-qm", "advance"]);
5209 git_in(repo.path(), &["checkout", "-q", "worktree-pr"]);
5210 for held in [
5211 wide_thread("revealed-old", ReviewSide::Old, 2, "wide 2", &["wide 1", "wide 3"], None),
5213 wide_thread(
5216 "revealed-new",
5217 ReviewSide::New,
5218 16,
5219 "wide 16",
5220 &["wide 15", "wide 17"],
5221 None,
5222 ),
5223 ] {
5224 assert!(
5225 fleet.workspace().upsert_review_thread("proj", "worktree-pr", held),
5226 "the thread stores",
5227 );
5228 }
5229 let encoded = encode_repo(&fleet, &seat, repo.path()).await;
5230 assert_eq!(
5231 landing(&encoded, "revealed-old"),
5232 serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5233 "the merge-base's line 2 is the comment's own, though main's tip shifted it: {encoded}",
5234 );
5235 assert_eq!(
5236 landing(&encoded, "revealed-new"),
5237 serde_json::json!({"state": "placed", "line": 16, "moved_from": null}),
5238 "and the branch tip's line 16 is the new side's own: {encoded}",
5239 );
5240 }
5241
5242 #[tokio::test]
5247 async fn the_old_side_reads_the_pinned_base_not_the_moved_name() {
5248 let repo = tempfile::tempdir().expect("a repository directory");
5249 init_repo(repo.path());
5250 std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10")).expect("write");
5251 git_in(repo.path(), &["add", "-A"]);
5252 git_in(repo.path(), &["commit", "-qm", "base"]);
5253 git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5254 std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10 changed")).expect("write");
5255 git_in(repo.path(), &["add", "-A"]);
5256 git_in(repo.path(), &["commit", "-qm", "branch"]);
5257 let base = forge_workspace::env::git_diff::file::merge_base(repo.path(), "main")
5258 .await
5259 .expect("the fork point");
5260 git_in(repo.path(), &["checkout", "-q", "main"]);
5265 let advanced: String = std::iter::once("wide 0\n".to_owned())
5266 .chain((1..=20).map(|n| format!("wide {n}\n")))
5267 .collect();
5268 std::fs::write(repo.path().join("wide.txt"), advanced).expect("write");
5269 git_in(repo.path(), &["add", "-A"]);
5270 git_in(repo.path(), &["commit", "-qm", "advance"]);
5271 let moved = std::process::Command::new("git")
5272 .arg("-C")
5273 .arg(repo.path())
5274 .args(["rev-parse", "main"])
5275 .output()
5276 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
5277 .expect("git rev-parse");
5278 git_in(repo.path(), &["checkout", "-q", "worktree-pr"]);
5279 git_in(repo.path(), &["merge", "-q", "--no-edit", "-X", "theirs", "main"]);
5280
5281 let snapshot = forge_workspace::env::git_diff::scan(
5282 repo.path(),
5283 &std::collections::HashMap::new(),
5284 None,
5285 )
5286 .await;
5287 let content =
5288 forge_workspace::env::git_diff::content::scan(repo.path(), &snapshot, None, None, None)
5289 .await;
5290 let threads = vec![wide_thread(
5291 "revealed-old",
5292 ReviewSide::Old,
5293 2,
5294 "wide 2",
5295 &["wide 1", "wide 3"],
5296 None,
5297 )];
5298 let key = ("wide.txt".to_owned(), ReviewSide::Old);
5299
5300 let pinned =
5301 whole_files_for(repo.path(), 1 << 20, &threads, &content, Some("main"), Some(&base))
5302 .await;
5303 assert!(pinned.contains_key(&key), "the pinned base places the anchor");
5304 assert!(
5305 pinned[&key].lines.iter().any(|line| line.as_str() == "wide 2"),
5306 "and the file it read is the merge-base's",
5307 );
5308
5309 let by_name =
5310 whole_files_for(repo.path(), 1 << 20, &threads, &content, Some("main"), Some(&moved))
5311 .await;
5312 assert_eq!(
5313 by_name[&key].lines.get(1).map(String::as_str),
5314 Some("wide 1"),
5315 "the moved base reads a shifted line 2, which is the anchor's own proof it moved",
5316 );
5317 }
5318
5319 #[tokio::test]
5324 async fn the_whole_file_wins_for_a_line_no_hunk_carries() {
5325 let fleet =
5326 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5327 fleet.start("TestOrg", "proj").expect("the project starts");
5328 let seat = fixture_seat();
5329 let repo = tempfile::tempdir().expect("a repository directory");
5330 init_repo(repo.path());
5331 let body = |target: &str| -> String {
5332 let mut lines: Vec<String> = (1..=30).map(|n| format!("filler {n}\n")).collect();
5333 lines[1] = "alpha\n".to_owned();
5334 lines[2] = "beta\n".to_owned();
5335 lines[3] = "gamma\n".to_owned();
5336 lines[17] = "alpha\n".to_owned();
5337 lines[18] = "beta\n".to_owned();
5338 lines[19] = "gamma\n".to_owned();
5339 lines[20] = format!("{target}\n");
5340 lines.concat()
5341 };
5342 std::fs::write(repo.path().join("wide.txt"), body("target")).expect("write");
5343 git_in(repo.path(), &["add", "-A"]);
5344 git_in(repo.path(), &["commit", "-qm", "base"]);
5345 git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5346 std::fs::write(repo.path().join("wide.txt"), body("target changed")).expect("write");
5347 git_in(repo.path(), &["add", "-A"]);
5348 git_in(repo.path(), &["commit", "-qm", "branch"]);
5349 for held in [
5350 wide_thread("outside", ReviewSide::New, 3, "beta", &["alpha", "gamma"], None),
5353 wide_thread("inside", ReviewSide::New, 19, "beta", &["alpha", "gamma"], None),
5355 ] {
5356 assert!(
5357 fleet.workspace().upsert_review_thread("proj", "worktree-pr", held),
5358 "the thread stores",
5359 );
5360 }
5361 let encoded = encode_repo(&fleet, &seat, repo.path()).await;
5362 assert_eq!(
5363 landing(&encoded, "outside"),
5364 serde_json::json!({"state": "placed", "line": 3, "moved_from": null}),
5365 "the outside line is its own, not the hunk's lookalike: {encoded}",
5366 );
5367 assert_eq!(
5368 landing(&encoded, "inside"),
5369 serde_json::json!({"state": "placed", "line": 19, "moved_from": null}),
5370 "a line the hunks carry still places in the hunks: {encoded}",
5371 );
5372 }
5373
5374 #[tokio::test]
5379 async fn the_record_reads_a_renamed_files_old_side_at_its_old_path() {
5380 let fleet =
5381 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5382 fleet.start("TestOrg", "proj").expect("the project starts");
5383 let seat = fixture_seat();
5384 let repo = tempfile::tempdir().expect("a repository directory");
5385 init_repo(repo.path());
5386 std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10")).expect("write");
5387 git_in(repo.path(), &["add", "-A"]);
5388 git_in(repo.path(), &["commit", "-qm", "base"]);
5389 git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5390 git_in(repo.path(), &["mv", "wide.txt", "renamed.txt"]);
5391 std::fs::write(repo.path().join("renamed.txt"), wide_lines("wide 10 changed"))
5392 .expect("write");
5393 git_in(repo.path(), &["add", "-A"]);
5394 git_in(repo.path(), &["commit", "-qm", "rename"]);
5395 let mut held =
5396 wide_thread("revealed-old", ReviewSide::Old, 2, "wide 2", &["wide 1", "wide 3"], None);
5397 held.anchor.path = "renamed.txt".to_owned();
5398 assert!(fleet.workspace().upsert_review_thread("proj", "worktree-pr", held), "stores");
5399 let encoded = encode_repo(&fleet, &seat, repo.path()).await;
5400 assert_eq!(
5401 landing(&encoded, "revealed-old"),
5402 serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5403 "the old side lives at the old path, unchanged: {encoded}",
5404 );
5405 }
5406
5407 #[tokio::test]
5412 async fn the_whole_file_reads_are_bounded_per_record() {
5413 let repo = tempfile::tempdir().expect("a repository directory");
5414 init_repo(repo.path());
5415 let mut files = Vec::new();
5416 let mut threads = Vec::new();
5417 for n in 0..10 {
5418 let path = format!("f{n}.txt");
5419 std::fs::write(repo.path().join(&path), "one\ntwo\nthree\n").expect("write");
5420 files.push(FileContent {
5421 path: path.clone(),
5422 old_path: None,
5423 status: forge_workspace::env::git_diff::hunks::FileStatus::Modified,
5424 binary: false,
5425 submodule: false,
5426 truncated: false,
5427 hunks: vec![forge_workspace::env::git_diff::hunks::Hunk {
5428 old_start: 3,
5429 old_count: 1,
5430 new_start: 3,
5431 new_count: 1,
5432 lines: vec![forge_workspace::env::git_diff::hunks::DiffLine {
5433 kind: forge_workspace::env::git_diff::hunks::DiffLineKind::Context,
5434 text: "three".to_owned(),
5435 old_line: Some(3),
5436 new_line: Some(3),
5437 }],
5438 }],
5439 });
5440 threads.push(ReviewThread {
5441 id: format!("t{n}"),
5442 anchor: ReviewAnchor {
5443 path,
5444 side: ReviewSide::New,
5445 line: 2,
5446 content_hash: forge_workspace::env::git_diff::resolver::anchor_hash("two"),
5447 context: vec!["one".to_owned(), "three".to_owned()],
5448 base_ref: "main".to_owned(),
5449 },
5450 comments: Vec::new(),
5451 status: forge_primitives::review::ReviewStatus::Open,
5452 created_at: "2026-01-01T00:00:00Z".to_owned(),
5453 updated_at: "2026-01-01T00:00:00Z".to_owned(),
5454 commit: None,
5455 });
5456 }
5457 git_in(repo.path(), &["add", "-A"]);
5458 git_in(repo.path(), &["commit", "-qm", "base"]);
5459 let content = DiffContent {
5460 worktree: LayerState::Clean,
5461 branch_ahead: LayerState::Populated(files),
5462 base: Some(forge_primitives::git_diff::BaseState::Current),
5463 base_error: None,
5464 truncated: false,
5465 };
5466 let whole = whole_files_for(
5467 repo.path(),
5468 10 * 1024 * 1024,
5469 &threads,
5470 &content,
5471 Some("main"),
5472 Some("main"),
5473 )
5474 .await;
5475 assert_eq!(whole.len(), 8, "the encode stops at its eight-file bound");
5476 assert!(
5477 !whole.contains_key(&("f9.txt".to_owned(), ReviewSide::New)),
5478 "and the threads past it keep the bounded content's answer",
5479 );
5480 }
5481
5482 #[test]
5485 fn a_read_cut_before_the_anchor_answers_no_landing() {
5486 let anchor = ReviewAnchor {
5487 path: "wide.txt".to_owned(),
5488 side: ReviewSide::New,
5489 line: 5,
5490 content_hash: forge_workspace::env::git_diff::resolver::anchor_hash("wide 5"),
5491 context: Vec::new(),
5492 base_ref: "main".to_owned(),
5493 };
5494 let cut = WholeFiles::from([(
5495 ("wide.txt".to_owned(), ReviewSide::New),
5496 WholeFile { lines: vec!["wide 1".to_owned()], truncated: true },
5497 )]);
5498 assert!(
5499 resolution_wire(&anchor, &[], true, Some(&cut)).is_none(),
5500 "a line past the cut is unresolved, not gone",
5501 );
5502 let whole = WholeFiles::from([(
5503 ("wide.txt".to_owned(), ReviewSide::New),
5504 WholeFile { lines: vec!["wide 1".to_owned()], truncated: false },
5505 )]);
5506 assert!(
5507 matches!(
5508 resolution_wire(&anchor, &[], true, Some(&whole)),
5509 Some(ThreadResolutionWire::Outdated { reason: OutdatedReasonWire::Gone })
5510 ),
5511 "a complete read that does not carry the line is gone",
5512 );
5513 }
5514
5515 #[test]
5518 fn a_commit_scoped_thread_does_not_borrow_the_branch_read() {
5519 let anchor = wide_thread("commit", ReviewSide::New, 2, "wide 2", &["wide 1"], None).anchor;
5520 let whole = WholeFiles::from([(
5521 ("wide.txt".to_owned(), ReviewSide::New),
5522 WholeFile { lines: vec!["wide 1".to_owned(), "wide 2".to_owned()], truncated: false },
5523 )]);
5524 assert!(
5525 matches!(
5526 resolution_wire(&anchor, &[], false, None),
5527 Some(ThreadResolutionWire::Outdated { reason: OutdatedReasonWire::Gone })
5528 ),
5529 "with no read of its own, the commit's thread is not placed by the branch's",
5530 );
5531 assert!(
5532 matches!(
5533 resolution_wire(&anchor, &[], false, Some(&whole)),
5534 Some(ThreadResolutionWire::Placed { line: 2, .. })
5535 ),
5536 "and the same anchor would have been placed had it been eligible",
5537 );
5538 }
5539
5540 #[tokio::test]
5546 async fn the_pr_row_carries_the_scans_pr_and_its_closing_issues() {
5547 let fleet =
5548 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5549 fleet.start("TestOrg", "proj").expect("the project starts");
5550 let seat = fixture_seat();
5551 let surface = fleet.surface();
5552 let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
5553 surface.store_work_snapshot(
5554 &seat,
5555 forge_workspace::work::WorkSnapshot {
5556 diff: scanned(),
5557 cwd: std::path::PathBuf::from(&cwd),
5558 read_at: std::time::Instant::now(),
5559 base: None,
5560 base_at: None,
5561 base_error: None,
5562 },
5563 );
5564 let work = Arc::new(WorkCache::new());
5565 let state = TransportState {
5566 surface,
5567 work,
5568 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5569 live: Mutex::new(crate::live::Live::new()),
5570 settings: crate::transport::settings_channel(),
5571 browser_host: None,
5572 heartbeat: Heartbeat::default(),
5573 };
5574
5575 let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
5576
5577 assert_eq!(encoded["pr"]["number"], 1249, "the open PR crosses with its number: {encoded}");
5578 assert_eq!(encoded["closes"][0]["number"], 1215, "and the issues it closes: {encoded}");
5579 assert_eq!(
5580 encoded["work"]["branch"], "worktree-pr",
5581 "and the branch comes from the SAME scan as the PR, so the two cannot disagree: \
5582 {encoded}"
5583 );
5584 assert_eq!(encoded["work"]["changed"], 3, "with the count that scan took: {encoded}");
5585 }
5586
5587 #[tokio::test]
5593 async fn the_pushed_row_is_the_records_own_fields() {
5594 let fleet =
5597 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5598 fleet.start("TestOrg", "proj").expect("the project starts");
5599 let seat = fixture_seat();
5600 let surface = fleet.surface();
5601 let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
5602 surface.store_work_snapshot(
5603 &seat,
5604 forge_workspace::work::WorkSnapshot {
5605 diff: scanned(),
5606 cwd: std::path::PathBuf::from(&cwd),
5607 read_at: std::time::Instant::now(),
5608 base: None,
5609 base_at: None,
5610 base_error: None,
5611 },
5612 );
5613 let state = TransportState {
5614 surface,
5615 work: Arc::new(WorkCache::new()),
5616 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5617 live: Mutex::new(crate::live::Live::new()),
5618 settings: crate::transport::settings_channel(),
5619 browser_host: None,
5620 heartbeat: Heartbeat::default(),
5621 };
5622
5623 let encoded =
5624 encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode");
5625 let held = state.surface.work(&seat, std::path::Path::new(&cwd), false).await;
5626 let pushed = forge_workspace::work::work_from_scan(&held.diff, &held.cwd);
5627
5628 assert_eq!(
5629 encoded["work"],
5630 serde_json::to_value(&pushed).expect("encode"),
5631 "the row the seat pushes is the row its record answers",
5632 );
5633 assert_eq!(
5634 encoded["pr"],
5635 serde_json::to_value(&held.diff.pr).expect("encode"),
5636 "and so is the PR beside it",
5637 );
5638 assert_eq!(
5639 encoded["closes"],
5640 serde_json::to_value(&held.diff.closes).expect("encode"),
5641 "and the issues it closes",
5642 );
5643 assert_eq!(
5644 encoded["git"],
5645 serde_json::to_value(forge_workspace::work::git_work_view(&held.diff, None, None))
5646 .expect("encode"),
5647 "and the tree behind the row's depth",
5648 );
5649 }
5650
5651 fn scanned() -> GitDiffSnapshot {
5656 use forge_primitives::git::{GitBranch, GitCommit, GitIssueRef, GitPrInfo};
5657 use forge_primitives::git_diff::{GitBranchAhead, GitDiffStats, LayerState, RepoGate};
5658 GitDiffSnapshot {
5659 branch: GitBranch::Named("worktree-pr".to_owned()),
5660 pushed_sha: Some("abc123".to_owned()),
5661 pr_fetched_at: None,
5662 default_branch: Some("main".to_owned()),
5663 repo_gate: RepoGate::InRepo,
5664 worktree: LayerState::Populated(GitDiffStats {
5665 files: Vec::new(),
5666 total_files: 3,
5667 total_added: 9,
5668 total_removed: 2,
5669 }),
5670 branch_ahead: LayerState::Populated(GitBranchAhead {
5671 commit_count: 1,
5672 stats: GitDiffStats {
5673 files: Vec::new(),
5674 total_files: 0,
5675 total_added: 0,
5676 total_removed: 0,
5677 },
5678 commits: vec![GitCommit {
5679 sha: "a1b2c3d".to_owned(),
5680 subject: "the branch's own commit".to_owned(),
5681 stats: GitDiffStats::default(),
5682 time: 1_766_000_000,
5683 }],
5684 }),
5685 pr: Some(GitPrInfo {
5686 number: 1249,
5687 url: "https://example.test/pull/1249".to_owned(),
5688 draft: false,
5689 }),
5690 closes: vec![GitIssueRef {
5691 number: 1215,
5692 url: "https://example.test/issues/1215".to_owned(),
5693 }],
5694 }
5695 }
5696
5697 #[tokio::test]
5705 async fn a_usage_subscription_is_answered_with_the_pools_report() {
5706 let fleet =
5707 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5708 fleet.start("TestOrg", "proj").expect("the project starts");
5709 fleet
5713 .seed_transcript(
5714 "TestOrg",
5715 "proj",
5716 "lead",
5717 &[r#"{"type":"assistant","timestamp":"2025-01-02T03:04:05.000Z","message":{"id":"m-usage","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"counted"}],"usage":{"input_tokens":7,"output_tokens":11}}}"#],
5718 )
5719 .expect("the transcript seeds");
5720 let state = TransportState {
5721 surface: fleet.surface(),
5722 work: Arc::new(WorkCache::new()),
5723 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5724 live: Mutex::new(crate::live::Live::new()),
5725 settings: crate::transport::settings_channel(),
5726 browser_host: None,
5727 heartbeat: Heartbeat::default(),
5728 };
5729
5730 let encoded = encode_subject(&state, &Subject::Usage).await.expect("encode");
5731
5732 assert_eq!(
5733 encoded["lifetime"]["total"]["input"], 7,
5734 "the pool's own count crosses, so the read is the scanner's: {encoded}"
5735 );
5736 assert_eq!(
5737 encoded["today"]["total"]["input"], 0,
5738 "and a record from a past day is in no rolling window: {encoded}"
5739 );
5740 assert!(
5741 encoded.get("pricing_available").is_some(),
5742 "and the flag that blanks costs rather than reading a misleading zero: {encoded}"
5743 );
5744 }
5745
5746 #[tokio::test]
5751 async fn the_served_page_caps_an_oversized_tool_record() {
5752 let fleet =
5753 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5754 fleet.start("TestOrg", "proj").expect("the project starts");
5755 let state = TransportState {
5756 surface: fleet.surface(),
5757 work: Arc::new(WorkCache::new()),
5758 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5759 live: Mutex::new(crate::live::Live::new()),
5760 settings: crate::transport::settings_channel(),
5761 browser_host: None,
5762 heartbeat: Heartbeat::default(),
5763 };
5764 let huge = "x".repeat(100_000);
5765 let frame = |uuid: &str, record: Value| -> Message {
5766 serde_json::from_value(serde_json::json!({
5767 "type": "user",
5768 "uuid": uuid,
5769 "message": {"role": "user", "content": "the call"},
5770 "tool_use_result": record,
5771 "session_id": "s",
5772 }))
5773 .expect("a user frame")
5774 };
5775 let big = frame("b1", serde_json::json!({"payload": huge}));
5776 let small = frame("s1", serde_json::json!({"answers": {"q": "a"}}));
5777 state.conversations.insert(
5778 &fixture_seat(),
5779 crate::transport::conversation::Conversation::new(
5780 vec![big.clone(), small.clone()],
5781 0,
5782 0,
5783 ),
5784 );
5785
5786 let (value, _) = encode_subject_timed(&state, &Subject::Session(fixture_seat()), false)
5787 .await
5788 .expect("the seat encodes");
5789 let turns = value["conversation"]["turns"].as_array().expect("the page carries turns");
5790 let frame = |uuid: &str| -> &Value {
5791 turns
5792 .iter()
5793 .flat_map(|turn| turn["messages"].as_array().into_iter().flatten())
5794 .find(|frame| frame["uuid"] == uuid)
5795 .unwrap_or_else(|| panic!("the page carries the frame {uuid}"))
5796 };
5797 let capped = &frame("b1")["tool_use_result"];
5798 assert_eq!(capped["omitted"], true, "the big record is marked: {capped}");
5799 assert!(
5800 capped["bytes"].as_u64().is_some_and(|bytes| bytes > 100_000),
5801 "and says how much it weighed: {capped}",
5802 );
5803 assert_eq!(
5804 frame("s1")["tool_use_result"],
5805 serde_json::json!({"answers": {"q": "a"}}),
5806 "a record under the cap crosses as it is",
5807 );
5808
5809 let conversation = serde_json::to_vec(&value["conversation"]).expect("serialises");
5811 assert!(
5812 conversation.len() < 30_000,
5813 "the served conversation is bounded: {} bytes",
5814 conversation.len(),
5815 );
5816
5817 let held = state.conversations.get(&fixture_seat()).expect("the seat is held");
5820 let kept = held.lock().messages()[0].clone();
5821 let Message::User { tool_use_result, .. } = kept else {
5822 panic!("the held frame is the user frame it was seeded with");
5823 };
5824 assert_eq!(
5825 tool_use_result.expect("the held copy keeps its record")["payload"]
5826 .as_str()
5827 .map(str::len),
5828 Some(100_000),
5829 "the held copy is untouched by the page's cap",
5830 );
5831 }
5832
5833 #[test]
5838 fn an_oversized_record_keeps_the_keys_a_client_draws() {
5839 let patch = serde_json::json!([
5840 {"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 1, "lines": ["-one", "+two"]}
5841 ]);
5842 let mut record = serde_json::json!({
5843 "content": "x".repeat(100_000),
5844 "originalFile": "x".repeat(100_000),
5845 "structuredPatch": patch,
5846 "userModified": true,
5847 });
5848 let report = cap_tool_record(&mut record, 64 * 1024).expect("the record is over the cap");
5849 assert_eq!(
5850 report.kept, "structuredPatch,userModified",
5851 "the drawn keys fit, so the record was reduced to them",
5852 );
5853 assert_eq!(record["structuredPatch"], patch, "the drawn patch survives the cap: {record}");
5854 assert_eq!(record["userModified"], true, "and the outside-edit mark with it: {record}");
5855 assert!(record.get("content").is_none(), "the unread whole file is dropped: {record}");
5856 assert!(record.get("omitted").is_none(), "nothing was omitted: {record}");
5857 }
5858
5859 #[test]
5863 fn an_oversized_question_record_keeps_its_answers() {
5864 let mut record = serde_json::json!({
5865 "answers": {"Which one?": "the second"},
5866 "annotations": {"Which one?": {"notes": "because"}},
5867 "debug": "x".repeat(100_000),
5868 });
5869 cap_tool_record(&mut record, 64 * 1024);
5870 assert_eq!(
5871 record["answers"]["Which one?"], "the second",
5872 "the picked answer survives the cap: {record}",
5873 );
5874 assert_eq!(
5875 record["annotations"]["Which one?"]["notes"], "because",
5876 "and the typed note with it: {record}",
5877 );
5878 assert!(record.get("omitted").is_none(), "nothing was omitted: {record}");
5879 }
5880
5881 #[test]
5885 fn a_question_over_the_cap_keeps_its_answer_and_drops_the_notes() {
5886 let mut record = serde_json::json!({
5887 "answers": {"Which one?": "the second"},
5888 "annotations": {"Which one?": {"notes": "x".repeat(100_000)}},
5889 });
5890 let report = cap_tool_record(&mut record, 64 * 1024).expect("the record is over the cap");
5891 assert_eq!(
5892 record["answers"]["Which one?"], "the second",
5893 "the answer survives the cap: {record}",
5894 );
5895 assert!(record.get("annotations").is_none(), "the notes are dropped: {record}");
5896 assert_eq!(report.kept, "answers", "and the log says what was kept");
5897 }
5898
5899 #[test]
5904 fn a_capped_record_is_logged_with_its_seat_call_and_size() {
5905 use crate::test_support::logged;
5906
5907 let seat = fixture_seat();
5908 let record = serde_json::json!({
5909 "structuredPatch": [
5910 {"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 1,
5911 "lines": ["-one", format!("+{}", "x".repeat(100_000))]}
5912 ],
5913 "userModified": true,
5914 });
5915 let bytes = serde_json::to_string(&record).expect("serialises").len();
5916 let mut turns = vec![TurnWire {
5917 key: None,
5918 messages: vec![serde_json::json!({
5919 "type": "user",
5920 "message": {"role": "user", "content": [
5921 {"type": "tool_result", "tool_use_id": "toolu_edit", "content": "ok"}
5922 ]},
5923 "tool_use_result": record.clone(),
5924 })],
5925 }];
5926 let events = logged(|| cap_tool_records(&mut turns, 64 * 1024, &seat));
5927 assert!(
5928 events.iter().any(|(level, fields)| {
5929 *level == tracing::Level::DEBUG
5930 && fields.contains(r#"event_name="tool_record_capped""#)
5931 && fields.contains(&format!("slot={}", seat.display()))
5932 && fields.contains(r#"tool_use_id="toolu_edit""#)
5933 && fields.contains(&format!("bytes={bytes}"))
5934 && fields.contains("cap=65536")
5935 && fields.contains(r#"disposition="omitted""#)
5936 }),
5937 "the page's cap is recorded against the call it capped: {events:?}",
5938 );
5939
5940 let live = |record: &Value| -> Message {
5943 serde_json::from_value(serde_json::json!({
5944 "type": "user",
5945 "message": {"role": "user", "content": [
5946 {"type": "tool_result", "tool_use_id": "toolu_live", "content": "ok"}
5947 ]},
5948 "session_id": "s1",
5949 "tool_use_result": record.clone(),
5950 }))
5951 .expect("the frame decodes")
5952 };
5953 let mut msg = live(&record);
5954 let events = logged(|| cap_message_record(&mut msg, 64 * 1024, &seat));
5955 assert!(
5956 events.iter().any(|(level, fields)| {
5957 *level == tracing::Level::DEBUG
5958 && fields.contains(r#"event_name="tool_record_capped""#)
5959 && fields.contains(r#"tool_use_id="toolu_live""#)
5960 }),
5961 "the live path records its own cap: {events:?}",
5962 );
5963
5964 let mut under = live(&serde_json::json!({"payload": "x"}));
5965 let events = logged(|| cap_message_record(&mut under, 64 * 1024, &seat));
5966 assert!(
5967 events.is_empty(),
5968 "a record under the cap says nothing, and so logs nothing: {events:?}",
5969 );
5970 }
5971
5972 #[test]
5975 fn a_record_at_the_cap_is_not_capped() {
5976 let record = serde_json::json!({"payload": "x".repeat(100_000)});
5977 let exact = serde_json::to_string(&record).expect("serialises").len();
5978 let mut at = record.clone();
5979 assert!(
5980 cap_tool_record(&mut at, exact).is_none(),
5981 "a record at the cap is served as it is, and nothing is logged",
5982 );
5983 assert_eq!(at, record, "a record at the cap is served as it is");
5984 let mut over = record.clone();
5985 let report = cap_tool_record(&mut over, exact - 1).expect("one byte over is capped");
5986 assert_eq!(report.kept, "", "nothing drawable fits, so the marker replaced it");
5987 assert_eq!(report.bytes, exact, "the log line carries the record's own size");
5988 assert_eq!(over["omitted"], true, "one byte over and it is marked: {over}");
5989 }
5990
5991 #[test]
5995 fn a_record_over_the_cap_after_reduction_is_the_marker() {
5996 let mut record = serde_json::json!({
5997 "structuredPatch": [{"lines": ["x".repeat(100_000)]}],
5998 "userModified": true,
5999 });
6000 cap_tool_record(&mut record, 64 * 1024);
6001 assert_eq!(record["omitted"], true, "the record is the marker: {record}");
6002 assert_eq!(record["userModified"], true, "and keeps the outside-edit mark: {record}");
6003 assert!(
6004 record["bytes"].as_u64().is_some_and(|bytes| bytes > 100_000),
6005 "and says how much it weighed: {record}",
6006 );
6007 }
6008
6009 #[tokio::test]
6015 async fn the_open_path_carries_its_own_timings() {
6016 let root = tempfile::tempdir().expect("a temp dir");
6017 let state = fixture_state(root.path()).await;
6018
6019 let (_, timings) = encode_subject_timed(&state, &Subject::Session(fixture_seat()), false)
6020 .await
6021 .expect("the seat encodes");
6022
6023 assert!(timings.page_ms > 0.0, "the page's cut is work on the path: {timings:?}");
6024 assert!(timings.encode_ms > 0.0, "the record's encode is work on the path: {timings:?}");
6025 assert_eq!(timings.seed_ms, 0.0, "a HELD copy pays no transcript read: {timings:?}");
6026 assert!(
6030 timings.base_ms < 1.0,
6031 "no child runs for an ordinary read's base (a git call costs whole ms): {timings:?}",
6032 );
6033 assert!(timings.rescan_ms < 1.0, "and nothing re-scans the tree for one: {timings:?}");
6034 }
6035
6036 #[tokio::test]
6045 async fn a_cold_seats_seed_is_timed_outside_the_conversation_total() {
6046 let fleet =
6047 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6048 fleet.start("TestOrg", "proj").expect("the project starts");
6049 let rows: Vec<String> = (0..3_000).map(a_turns_rows).collect();
6050 let rows: Vec<&str> = rows.iter().map(String::as_str).collect();
6051 fleet.seed_transcript("TestOrg", "proj", "lead", &rows).expect("the transcript seeds");
6052 let state = TransportState {
6055 surface: fleet.surface(),
6056 work: Arc::new(WorkCache::new()),
6057 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6058 live: Mutex::new(crate::live::Live::new()),
6059 settings: crate::transport::settings_channel(),
6060 browser_host: None,
6061 heartbeat: Heartbeat::default(),
6062 };
6063
6064 let (_, timings) = encode_subject_timed(&state, &Subject::Session(fixture_seat()), false)
6065 .await
6066 .expect("the seat encodes");
6067
6068 assert!(
6069 timings.seed_ms > 0.0,
6070 "the transcript read a cold seat takes is timed: {timings:?}"
6071 );
6072 assert!(
6073 timings.conversation_ms < timings.seed_ms,
6074 "the conversation total EXCLUDES the seed, so a read that took real time must \
6075 dominate the lookup around it - the two overlapping is the bug this pins: {timings:?}"
6076 );
6077 }
6078
6079 #[tokio::test]
6084 async fn an_extensions_subscription_is_answered_with_the_inventory() {
6085 let fleet = crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("fleet");
6086 let mut components = forge_primitives::plugins::PluginComponents {
6087 plugin: "demo@probe-market".to_owned(),
6088 marketplace: "probe-market".to_owned(),
6089 installed: true,
6090 enabled: true,
6091 version: Some("1.0.0".to_owned()),
6092 available_version: Some("1.1.0".to_owned()),
6093 skills: vec!["one".to_owned()],
6094 ..forge_primitives::plugins::PluginComponents::default()
6095 };
6096 components.lsp_servers.insert("absent-server".to_owned(), "not-a-binary-xyz".to_owned());
6097 let snapshot = forge_primitives::plugins::PluginsInventorySnapshot {
6098 components: vec![components],
6099 ..forge_primitives::plugins::PluginsInventorySnapshot::default()
6100 };
6101 let pinned = snapshot.clone();
6102 fleet.workspace().set_plugin_scan_for_test(move || Some(pinned.clone()));
6103 let state = TransportState {
6104 surface: fleet.surface(),
6105 work: Arc::new(WorkCache::new()),
6106 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6107 live: Mutex::new(crate::live::Live::new()),
6108 settings: crate::transport::settings_channel(),
6109 browser_host: None,
6110 heartbeat: Heartbeat::default(),
6111 };
6112
6113 let encoded = encode_subject(&state, &Subject::Extensions).await.expect("encode");
6114
6115 assert_eq!(
6116 encoded["snapshot"]["components"][0]["plugin"], "demo@probe-market",
6117 "the inventory crosses: {encoded}"
6118 );
6119 assert_eq!(
6120 encoded["snapshot"]["components"][0]["available_version"], "1.1.0",
6121 "with the version the marketplace reports"
6122 );
6123 assert_eq!(
6124 encoded["snapshot"],
6125 serde_json::to_value(&snapshot).expect("value"),
6126 "the snapshot half is byte-for-byte the object the Plugins frames embed"
6127 );
6128 assert_eq!(
6129 encoded["lsp"][0]["server"], "absent-server",
6130 "the PATH check crosses beside it"
6131 );
6132 assert_eq!(
6133 encoded["lsp"][0]["on_path"], false,
6134 "and reflects this machine rather than guessing"
6135 );
6136 assert!(encoded["update_run"].is_null(), "no run has happened this process");
6137
6138 let wire: ExtensionsWire = serde_json::from_value(encoded).expect("round trip");
6140 assert_eq!(wire.snapshot.expect("snapshot").components.len(), 1);
6141 assert_eq!(wire.lsp.len(), 1);
6142 }
6143
6144 #[derive(Default)]
6157 struct Coverage {
6158 keys: usize,
6159 nulls: BTreeSet<String>,
6160 empty_arrays: BTreeSet<String>,
6161 }
6162
6163 fn coverage(value: &Value, path: &str, out: &mut Coverage) {
6164 match value {
6165 Value::Object(fields) => {
6166 for (key, held) in fields {
6167 out.keys += 1;
6168 let here = format!("{path}/{key}");
6169 if held.is_null() {
6170 out.nulls.insert(here.clone());
6171 }
6172 coverage(held, &here, out);
6173 }
6174 }
6175 Value::Array(items) => {
6176 if items.is_empty() {
6177 out.empty_arrays.insert(format!("{path}[]"));
6178 }
6179 for item in items {
6180 coverage(item, &format!("{path}[]"), out);
6181 }
6182 }
6183 _ => {}
6184 }
6185 }
6186
6187 #[tokio::test]
6188 async fn every_subject_round_trips_through_its_fixture() {
6189 let dir = fixture_dir();
6190 let state = fixture_state(dir.path()).await;
6191 let forms = volatile(dir.path());
6192 let mut keys = 0usize;
6193 let mut nulls = 0usize;
6194 let mut empty: BTreeSet<String> = BTreeSet::new();
6195
6196 for (subject, fixture) in fixtures() {
6197 let mut encoded = encode_subject(&state, &subject).await.expect("encode");
6198 normalise(&mut encoded, &forms);
6199 let expected: Value =
6200 serde_json::from_str(&std::fs::read_to_string(&fixture).expect("read"))
6201 .expect("parse");
6202 assert_eq!(encoded, expected, "{} changed shape", fixture.display());
6203
6204 let mut pin = Coverage::default();
6205 coverage(&encoded, "", &mut pin);
6206 keys += pin.keys;
6207 nulls += pin.nulls.len();
6208 empty.extend(pin.empty_arrays);
6209 }
6210
6211 assert!(keys > 0, "no fixture carries a key, so there is nothing to pin");
6214 eprintln!(
6215 "wire fixtures: {keys} key slots carried, {nulls} null (the key is named, nothing \
6216 behind it is pinned), {} empty collections (no element shape pinned anywhere): {}",
6217 empty.len(),
6218 empty.iter().cloned().collect::<Vec<_>>().join(", "),
6219 );
6220 }
6221
6222 #[tokio::test]
6228 async fn the_composer_carries_the_configured_bind_and_mode() {
6229 let dir = tempfile::tempdir().expect("tempdir");
6230 let forge = dir.path().join("forge");
6231 std::fs::create_dir_all(&forge).expect("forge/");
6232 let project = dir.path().join("proj");
6233 std::fs::create_dir_all(&project).expect("the project's directory");
6234 std::fs::write(
6235 forge.join("forge.toml"),
6236 format!(
6237 "\
6238[[orgs]]
6239name = \"TestOrg\"
6240accounts = [\"Acct\"]
6241
6242[[orgs.projects]]
6243name = \"proj\"
6244path = \"{}\"
6245
6246[[accounts]]
6247display_name = \"Acct\"
6248token = \"t\"
6249models = [\"claude-sonnet-5\"]
6250provider = \"anthropic\"
6251
6252[dictate]
6253enabled = true
6254bind = \"left_cmd\"
6255mode = \"toggle\"
6256",
6257 project.display()
6258 ),
6259 )
6260 .expect("write forge.toml");
6261 let workspace = Arc::new(
6262 forge_workspace::Workspace::new_for_test(dir.path().to_owned()).expect("workspace"),
6263 );
6264 let state = TransportState {
6265 surface: Arc::new(ViewSurface::new(workspace)),
6266 work: Arc::new(WorkCache::new()),
6267 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6268 live: Mutex::new(crate::live::Live::new()),
6269 settings: crate::transport::settings_channel(),
6270 browser_host: None,
6271 heartbeat: Heartbeat::default(),
6272 };
6273
6274 let session =
6275 encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
6276 assert_eq!(
6277 session["composer"]["bind"], "left_cmd",
6278 "the composer carries the configured push-to-talk key, not a default: {session}",
6279 );
6280 assert_eq!(
6281 session["composer"]["mode"], "toggle",
6282 "and how a press maps onto a take, not a default either: {session}",
6283 );
6284 }
6285
6286 #[tokio::test]
6294 async fn the_composer_re_hands_a_landing_nobody_has_sent() {
6295 let fleet =
6296 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6297 let state = TransportState {
6298 surface: fleet.surface(),
6299 work: Arc::new(WorkCache::new()),
6300 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6301 live: Mutex::new(crate::live::Live::new()),
6302 settings: crate::transport::settings_channel(),
6303 browser_host: None,
6304 heartbeat: Heartbeat::default(),
6305 };
6306 let seat = fixture_seat();
6307 let landing = crate::SessionUpdate::DictateEnded {
6308 key: seat.clone(),
6309 outcome: crate::surface::DictateOutcome::Landed {
6310 text: "the unsent words".to_owned(),
6311 truncated: true,
6312 },
6313 generation: 7,
6314 surface: Some("review".to_owned()),
6315 initiator: None,
6316 };
6317
6318 let session =
6319 encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode");
6320 assert!(session["composer"]["notice"].is_null(), "nothing landed yet: {session}");
6321
6322 crate::live::Live::lock(&state.live).apply(&landing);
6323 let session =
6324 encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode");
6325 assert_eq!(
6326 session["composer"]["notice"]["text"], "the unsent words",
6327 "the unsent landing is on the record: {session}",
6328 );
6329 assert_eq!(
6330 session["composer"]["notice"]["generation"], 7,
6331 "with the take's own number, the landing memory's discriminator",
6332 );
6333 assert_eq!(
6334 session["composer"]["notice"]["truncated"], true,
6335 "and whether the take was partial, which the client draws a warn line from",
6336 );
6337 assert_eq!(
6338 session["composer"]["notice"]["carried"], true,
6339 "and marked as the seat's re-hand rather than a landing of this page's own",
6340 );
6341 assert_eq!(
6342 session["composer"]["notice"]["surface"], "review",
6343 "and naming the surface that began it, so only that surface places it",
6344 );
6345
6346 let msg: forge_primitives::Message = serde_json::from_value(serde_json::json!({
6347 "type": "user",
6348 "session_id": "s1",
6349 "message": {"role": "user", "content": "about to be sent"},
6350 }))
6351 .expect("a user frame");
6352 crate::live::Live::lock(&state.live).apply(&crate::SessionUpdate::ChatAppended {
6353 key: seat.clone(),
6354 msg,
6355 source: Some(forge_workspace::PromptSource::You),
6356 });
6357 let session = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
6358 assert!(
6359 session["composer"]["notice"].is_null(),
6360 "and a prompt takes the words with it, so sent words are never handed back: {session}",
6361 );
6362 }
6363
6364 #[tokio::test]
6375 async fn a_projects_row_carries_the_chip_field() {
6376 let fleet =
6377 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6378 let state = TransportState {
6379 surface: fleet.surface(),
6380 work: Arc::new(WorkCache::new()),
6381 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6382 live: Mutex::new(crate::live::Live::new()),
6383 settings: crate::transport::settings_channel(),
6384 browser_host: None,
6385 heartbeat: Heartbeat::default(),
6386 };
6387
6388 let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
6389 let rows = encoded["projects"].as_array().expect("the home carries project rows");
6390
6391 assert!(rows[0].get("chip").is_some(), "the row carries a chip: {}", rows[0]);
6392 assert_eq!(
6393 rows[0]["project"]["has_model"], false,
6394 "and this fixture's project declares no model, which is why the chip is absent",
6395 );
6396 }
6397
6398 #[tokio::test]
6401 async fn a_projects_schedules_ride_its_board_row() {
6402 let fleet =
6403 crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6404 fleet.add_cron("proj", "a nightly sweep").expect("the cron is added");
6405 let state = TransportState {
6406 surface: fleet.surface(),
6407 work: Arc::new(WorkCache::new()),
6408 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6409 live: Mutex::new(crate::live::Live::new()),
6410 settings: crate::transport::settings_channel(),
6411 browser_host: None,
6412 heartbeat: Heartbeat::default(),
6413 };
6414
6415 let encoded = encode_subject(&state, &Subject::Board).await.expect("encode");
6416 let rows = encoded["projects"].as_array().expect("the board carries project rows");
6417 let crons = rows[0]["crons"].as_array().expect("and each row carries its schedules");
6418
6419 assert_eq!(crons.len(), 1, "the project's own cron reached the wire: {crons:?}");
6420 assert_eq!(crons[0]["prompt"], "a nightly sweep");
6421 }
6422
6423 #[tokio::test]
6432 async fn a_projects_connector_subscriptions_ride_its_board_row() {
6433 let (workspace, _dir) = crate::surface::testing::workspace_with_connector_subs();
6434 let state = TransportState {
6435 surface: Arc::new(crate::surface::ViewSurface::new(Arc::clone(&workspace))),
6436 work: Arc::new(WorkCache::new()),
6437 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6438 live: Mutex::new(crate::live::Live::new()),
6439 settings: crate::transport::settings_channel(),
6440 browser_host: None,
6441 heartbeat: Heartbeat::default(),
6442 };
6443
6444 let encoded = encode_subject(&state, &Subject::Board).await.expect("encode");
6445 let rows = encoded["projects"].as_array().expect("the board carries project rows");
6446 let row = rows
6447 .iter()
6448 .find(|row| row["project"]["name"] == "forge")
6449 .unwrap_or_else(|| panic!("the project has a row: {encoded}"));
6450
6451 assert_eq!(
6452 row["connectors"]["gotify"].as_array().map(Vec::len),
6453 Some(1),
6454 "the project's own gotify subscription is on its row: {encoded}",
6455 );
6456 assert_eq!(
6457 row["connectors"]["gotify"][0]["applications"][0], "forge",
6458 "as the subscription itself, not a placeholder",
6459 );
6460 assert_eq!(
6461 row["connectors"]["slack"].as_array().map(Vec::len),
6462 Some(1),
6463 "and so is its slack subscription: {encoded}",
6464 );
6465
6466 let home = encode_subject(&state, &Subject::Home).await.expect("encode");
6470 assert!(
6471 home["connectors"]["gotify"].get("subscriptions").is_none(),
6472 "the home's own connectors carries no subscription list: {home}",
6473 );
6474 assert!(
6475 home["connectors"]["slack"].get("subscriptions").is_none(),
6476 "for either connector: {home}",
6477 );
6478 assert!(
6479 home["connectors"]["slack"]["connected_workspaces"].is_array(),
6480 "the facts it does carry are still there: {home}",
6481 );
6482 }
6483
6484 #[tokio::test]
6493 async fn a_workers_row_carries_its_own_tree_and_not_the_projects() {
6494 let dir = tempfile::tempdir().expect("tempdir");
6495 let root = dir.path().join("proj");
6501 let worktree = root.join(".claude/worktrees/w1");
6502 repo_at(&root, "main", 1);
6503 repo_at(&worktree, "worktree-em-dash-sweep", 2);
6504 let worktree = std::fs::canonicalize(&worktree).expect("canonical worktree");
6508
6509 let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
6510 .expect("the fleet builds");
6511 fleet.start("TestOrg", "proj").expect("the project starts");
6512 fleet.add_git_worker("TestOrg", "proj", "w1").expect("the worker is added");
6513
6514 let state = TransportState {
6515 surface: fleet.surface(),
6516 work: Arc::new(WorkCache::new()),
6517 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6518 live: Mutex::new(crate::live::Live::new()),
6519 settings: crate::transport::settings_channel(),
6520 browser_host: None,
6521 heartbeat: Heartbeat::default(),
6522 };
6523
6524 let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
6525 let rows = encoded["agents"].as_array().expect("the home carries agent rows");
6526 let row_of = |label: &str| {
6527 rows.iter().find(|row| row["label"] == label).expect("the seat has a row")
6528 };
6529
6530 assert_eq!(
6533 state
6534 .surface
6535 .roster()
6536 .cwd_for(&SessionSlot::worker("TestOrg", "proj", "w1"))
6537 .as_deref(),
6538 Some(worktree.as_path()),
6539 "precondition: the worker's own directory is the worktree",
6540 );
6541 assert_eq!(
6542 row_of("lead")["work"]["branch"],
6543 "main",
6544 "the lead's row draws the project's own tree: {encoded}",
6545 );
6546 assert_eq!(row_of("lead")["work"]["changed"], 1, "with the count that tree has: {encoded}");
6547 assert_eq!(
6548 row_of("w1")["work"]["branch"],
6549 "worktree-em-dash-sweep",
6550 "and the worker's row draws its own, which the project's read cannot answer: \
6551 {encoded}",
6552 );
6553 assert_eq!(
6554 row_of("w1")["work"]["changed"],
6555 2,
6556 "with the worker's own count beside it: {encoded}",
6557 );
6558 }
6559
6560 #[tokio::test]
6569 async fn a_seat_with_no_directory_borrows_no_tree() {
6570 let dir = tempfile::tempdir().expect("tempdir");
6571 let root = dir.path().join("proj");
6572 repo_at(&root, "main", 1);
6573
6574 let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
6575 .expect("the fleet builds");
6576 fleet.start("TestOrg", "proj").expect("the project starts");
6577 fleet.add_despawned_worker("proj", "gone").expect("the label is left behind");
6578
6579 let state = TransportState {
6580 surface: fleet.surface(),
6581 work: Arc::new(WorkCache::new()),
6582 conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6583 live: Mutex::new(crate::live::Live::new()),
6584 settings: crate::transport::settings_channel(),
6585 browser_host: None,
6586 heartbeat: Heartbeat::default(),
6587 };
6588
6589 let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
6590 let rows = encoded["agents"].as_array().expect("the home carries agent rows");
6591 let row_of = |label: &str| {
6592 rows.iter().find(|row| row["label"] == label).expect("the seat has a row")
6593 };
6594
6595 assert_eq!(
6596 state.surface.roster().cwd_for(&SessionSlot::worker("TestOrg", "proj", "gone")),
6597 None,
6598 "precondition: forge holds no directory for the despawned label",
6599 );
6600 assert_eq!(
6601 row_of("gone")["work"],
6602 Value::Null,
6603 "so its row draws no tree rather than borrowing the project's: {encoded}",
6604 );
6605 assert_eq!(
6606 row_of("lead")["work"]["branch"],
6607 "main",
6608 "while the project's own row still draws the project's tree: {encoded}",
6609 );
6610 }
6611
6612 fn repo_at(dir: &Path, branch: &str, changed: usize) {
6618 std::fs::create_dir_all(dir).expect("mkdir");
6619 git(dir, &["init", "-q"]);
6620 git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
6621 git(dir, &["config", "user.email", "t@e.com"]);
6622 git(dir, &["config", "user.name", "T"]);
6623 git(dir, &["config", "commit.gpgsign", "false"]);
6624 std::fs::write(dir.join(".gitignore"), ".claude/\n").expect("write");
6625 std::fs::write(dir.join("tracked.txt"), "one\n").expect("write");
6626 git(dir, &["add", ".gitignore", "tracked.txt"]);
6627 git(dir, &["commit", "-qm", "init"]);
6628 for file in 0..changed {
6629 std::fs::write(dir.join(format!("changed-{file}.txt")), "x\n").expect("write");
6630 }
6631 }
6632
6633 fn git(dir: &Path, args: &[&str]) {
6637 let out = std::process::Command::new("git")
6638 .env_remove("GIT_DIR")
6639 .env_remove("GIT_WORK_TREE")
6640 .env_remove("GIT_COMMON_DIR")
6641 .arg("-C")
6642 .arg(dir)
6643 .args(args)
6644 .output()
6645 .expect("run git");
6646 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
6647 }
6648}