1use std::path::{Path, PathBuf};
4
5use forge_primitives::SessionSlot;
6use forge_workspace::Workspace;
7
8use super::ViewSurface;
9
10pub fn machine_turn(
16 handoff: bool,
17 compacting: bool,
18) -> Option<forge_primitives::runtime::MachineTurn> {
19 use forge_primitives::runtime::MachineTurn;
20 if handoff {
21 Some(MachineTurn::Handoff)
22 } else if compacting {
23 Some(MachineTurn::Compacting)
24 } else {
25 None
26 }
27}
28
29pub struct SessionState {
31 pub slot: SessionSlot,
33 pub scan_cwd: PathBuf,
36 pub queue: Vec<forge_workspace::protocol::QueuedPrompt>,
43 pub voice_listeners: u32,
46}
47
48impl ViewSurface {
49 pub fn handoff_in_flight(&self, slot: &SessionSlot) -> bool {
58 self.workspace.handoff_in_flight(slot)
59 }
60}
61
62impl SessionState {
63 pub(super) fn collect(workspace: &Workspace, slot: &SessionSlot, cwd_raw: &Path) -> Self {
64 let queue = workspace
65 .domain_session_for(slot)
66 .map(|domain| {
67 let guard = domain.lock();
68 guard
74 .prompt_queue
75 .iter()
76 .map(|prompt| forge_workspace::protocol::QueuedPrompt {
77 uuid: prompt.uuid.clone(),
78 source: prompt.source,
79 text: prompt.text.clone(),
80 attachments: Vec::new(),
81 })
82 .collect()
83 })
84 .unwrap_or_default();
85 Self {
86 slot: slot.clone(),
87 scan_cwd: workspace.git_scan_cwd_for_session(slot, cwd_raw),
88 queue,
89 voice_listeners: u32::try_from(workspace.voice_listeners(slot)).unwrap_or(u32::MAX),
90 }
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use std::path::{Path, PathBuf};
97 use std::sync::Arc;
98
99 use forge_primitives::{SessionSlot, WorkerLiveness};
100 use forge_workspace::{WorkerEntry, Workspace};
101
102 use forge_primitives::runtime::MachineTurn;
103
104 use super::machine_turn;
105 use crate::surface::ViewSurface;
106
107 fn stub_workspace() -> Arc<Workspace> {
108 let (workspace, _updates) = Workspace::testing_stub();
109 workspace
110 }
111
112 fn status_frame(slot: &SessionSlot, status: &serde_json::Value) -> crate::SessionUpdate {
113 use forge_primitives::Message;
114 let msg: Message = serde_json::from_value(serde_json::json!({
115 "type": "system",
116 "session_id": "s1",
117 "subtype": "status",
118 "status": status,
119 }))
120 .expect("a status frame");
121 crate::SessionUpdate::ChatAppended { key: slot.clone(), msg, source: None }
122 }
123
124 #[test]
128 fn the_read_queue_carries_no_attachment_payloads() {
129 let (workspace, _dir) = crate::surface::testing::workspace();
130 let seat = SessionSlot::lead("TestOrg", "forge");
131 workspace.register_domain_session(seat.clone(), None);
132 workspace.domain_session_for(&seat).expect("the domain").lock().prompt_queue.push(
133 forge_workspace::protocol::QueuedPrompt {
134 uuid: "p1".to_owned(),
135 source: forge_workspace::protocol::PromptSource::You,
136 text: "queued words".to_owned(),
137 attachments: vec![forge_primitives::ImageAttachment {
138 data: "iVBORw0KGgo=".to_owned(),
139 mime_type: "image/png".to_owned(),
140 }],
141 },
142 );
143
144 let state = super::SessionState::collect(&workspace, &seat, Path::new("/tmp"));
145
146 assert_eq!(state.queue.len(), 1, "the waiting prompt is still served");
147 assert!(state.queue[0].attachments.is_empty(), "and its payloads stay core-side");
148 }
149
150 #[test]
155 fn the_machine_turn_is_a_handoff_until_the_swap_and_a_compaction_until_it_settles() {
156 let (workspace, _dir) = crate::surface::testing::workspace();
157 let surface = ViewSurface::new(std::sync::Arc::clone(&workspace));
158 let seat = SessionSlot::lead("TestOrg", "forge");
159 workspace.register_domain_session(seat.clone(), None);
160 let mut composer = crate::composer::Composer::default();
161
162 let turn = |surface: &ViewSurface, composer: &crate::composer::Composer| {
163 machine_turn(surface.handoff_in_flight(&seat), composer.compacting(&seat))
164 };
165
166 assert_eq!(turn(&surface, &composer), None, "an idle seat takes input");
167
168 workspace.domain_session_for(&seat).expect("the domain").lock().handoff.parked = true;
173 assert_eq!(turn(&surface, &composer), None, "a parked handoff leaves the seat live");
174 workspace.domain_session_for(&seat).expect("the domain").lock().handoff.parked = false;
175
176 composer.apply(&status_frame(&seat, &serde_json::json!("compacting")));
177 assert_eq!(
178 turn(&surface, &composer),
179 Some(MachineTurn::Compacting),
180 "the CLI's status frame names the compaction",
181 );
182 composer.apply(&status_frame(&seat, &serde_json::Value::Null));
183 assert_eq!(turn(&surface, &composer), None, "and its null settles it");
184
185 workspace.domain_session_for(&seat).expect("the domain").lock().handoff.awaiting = true;
186 assert_eq!(
187 turn(&surface, &composer),
188 Some(MachineTurn::Handoff),
189 "the judgment turn owns the seat",
190 );
191 composer.apply(&status_frame(&seat, &serde_json::json!("compacting")));
192 assert_eq!(
193 turn(&surface, &composer),
194 Some(MachineTurn::Handoff),
195 "a handoff outranks a compaction begun beside it",
196 );
197 workspace.domain_session_for(&seat).expect("the domain").lock().handoff.awaiting = false;
198 composer.apply(&status_frame(&seat, &serde_json::Value::Null));
199 assert_eq!(turn(&surface, &composer), None, "the swap and the settle clear both");
200 }
201
202 fn git_worker(slot: SessionSlot) -> WorkerEntry {
203 WorkerEntry {
204 label: slot.label().to_owned(),
205 charter: "charter".to_owned(),
206 slot,
207 session_id: None,
208 status: WorkerLiveness::Running,
209 spawned_at: std::time::SystemTime::UNIX_EPOCH,
210 spawned_by: SessionSlot::lead("TestOrg", "forge"),
211 needs_tag: false,
212 is_git_repo_at_spawn: true,
213 cwd: None,
214 diagnostic: None,
215 kick: None,
216 }
217 }
218
219 #[test]
223 fn session_scan_cwd_agrees_with_the_workspace() {
224 let workspace = stub_workspace();
225 workspace.seed_test_project("forge", "/tmp/forge-session");
226 let surface = ViewSurface::new(Arc::clone(&workspace));
227 let project = surface.roster().project_named("forge").expect("seeded project").key.clone();
228 let worker = SessionSlot::worker("TestOrg", "forge", "probe-a");
229 workspace.insert_live_worker(&project, git_worker(worker.clone()));
230
231 for cwd_raw in ["/tmp/forge-session", "/tmp/forge-session/.claude/worktrees/probe-a"] {
235 let cwd = Path::new(cwd_raw);
236 assert_eq!(
237 surface.session(&worker, cwd).scan_cwd,
238 workspace.git_scan_cwd_for_session(&worker, cwd),
239 "a git worker's scan cwd must be the workspace's own answer for {cwd_raw}"
240 );
241 assert_eq!(
242 surface.session(&worker, cwd).scan_cwd,
243 PathBuf::from("/tmp/forge-session/.claude/worktrees/probe-a"),
244 "a git worker must scan its worktree, not the cwd it was handed"
245 );
246 }
247
248 let lead = SessionSlot::lead("TestOrg", "forge");
249 let cwd = Path::new("/tmp/elsewhere");
250 let state = surface.session(&lead, cwd);
251 assert_eq!(
252 state.scan_cwd,
253 PathBuf::from("/tmp/elsewhere"),
254 "a session that is not a git worker must scan the cwd it was handed"
255 );
256 assert_eq!(state.slot, lead, "the state must name the seat it was asked about");
257 }
258}