1use std::sync::Arc;
13
14use forge_agent::AgentHandle;
15use forge_agent::client::AgentEvent;
16use forge_primitives::SessionId;
17
18use parking_lot::Mutex;
19use tokio::sync::mpsc;
20use tracing::Instrument;
21
22use crate::SessionSlot;
23use crate::domain_session::{DomainSession, PendingAutoContinue};
24use crate::handoff;
25use crate::protocol::{
26 Command, NoticeSeverity, PendingInteractionSlot, PromptSource, SessionUpdate,
27};
28use crate::update_fanout::UpdateFanout;
29
30pub(crate) struct SessionTask {
31 pub(crate) key: SessionSlot,
32 pub(crate) handle: Arc<AgentHandle>,
33 pub(crate) command_rx: mpsc::UnboundedReceiver<Command>,
34 pub(crate) domain: Arc<Mutex<DomainSession>>,
35 pub(crate) update_tx: UpdateFanout,
36 pub(crate) connected_once: bool,
41 pub(crate) workspace: std::sync::Weak<crate::Workspace>,
49 pub(crate) compaction_count: u32,
52 pub(crate) successor: Option<handoff::SuccessorWatch>,
57 pub(crate) teardown_done: Option<tokio::sync::watch::Sender<bool>>,
64}
65
66impl SessionTask {
67 pub(crate) async fn run(mut self) {
68 tracing::debug!(
69 target: "forge_workspace::session_task",
70 slot = %self.key.display(),
71 "session task started"
72 );
73 let Some(mut event_rx) = self.handle.take_events() else {
77 tracing::error!(
78 target: "forge_workspace::session_task",
79 slot = %self.key.display(),
80 "AgentHandle::take_events returned None; session task aborting"
81 );
82 self.emit(SessionUpdate::ConnectionFailed {
83 key: self.key.clone(),
84 message: "agent event receiver unavailable".to_owned(),
85 fatal: false,
86 });
87 return;
88 };
89
90 if let Some(display_name) = self.handle.display_name() {
94 self.emit(SessionUpdate::ForgeAccountIdentity { key: self.key.clone(), display_name });
95 }
96
97 loop {
98 tokio::select! {
99 maybe_event = event_rx.recv() => {
100 let Some(event) = maybe_event else { break; };
101 let event = self.declared_models_for_session(event);
102 if !self.translate_event(event) {
103 break;
104 }
105 }
106 maybe_cmd = self.command_rx.recv() => {
107 let Some(cmd) = maybe_cmd else { break; };
108 self.execute_command(cmd);
109 }
110 }
111 }
112 tracing::info!(
113 target: "forge_workspace::session_task",
114 slot = %self.key.display(),
115 "session task exiting (agent event channel closed)"
116 );
117 if let Some(workspace) = self.workspace.upgrade() {
127 workspace.release_session_if_current(&self.key, &self.handle);
128 }
129 self.handle.disconnect().await;
135 }
136
137 fn declared_models_for_session(&self, event: AgentEvent) -> AgentEvent {
142 let AgentEvent::Connected {
143 session_id,
144 cwd,
145 current_model,
146 available_models: _,
147 mode,
148 history_updates,
149 compaction_count,
150 } = event
151 else {
152 return event;
153 };
154 let (available_models, canonical_model) = match self.workspace.upgrade() {
157 Some(workspace) => (
158 workspace.declared_models_for_session(&self.key),
159 workspace.canonical_model_for_session(&self.key),
160 ),
161 None => (Vec::new(), None),
162 };
163 let current_model = match canonical_model {
168 Some(model) => forge_primitives::CurrentModel {
169 requested_id: Some(model.clone()),
170 display_name_short: model.clone(),
171 display_name_long: model,
172 ..current_model
173 },
174 None => current_model,
175 };
176 AgentEvent::Connected {
177 session_id,
178 cwd,
179 current_model,
180 available_models,
181 mode,
182 history_updates,
183 compaction_count,
184 }
185 }
186
187 pub(crate) fn translate_event(&mut self, event: AgentEvent) -> bool {
194 let mut event = event;
202 if let AgentEvent::SdkMessage { msg, .. } = &mut event {
203 msg.stamp_if_absent(&crate::install::rfc3339_now());
204 }
205 let nudge_armed;
207 let turn_completed;
211 let turn_cancelled;
212 let turn_ended;
213 let repo_nudge;
214 let lost_prompts;
215 {
216 let mut guard = self.domain.lock();
217 let moved = apply_event_to_domain(&mut guard, &event);
218 nudge_armed = moved.nudge;
219 repo_nudge = moved.repo_nudge;
220 turn_completed = matches!(moved.turn_ended, Some(MovedTurnEnd::Complete { .. }));
221 turn_cancelled = matches!(moved.turn_ended, Some(MovedTurnEnd::Cancelled));
222 turn_ended = moved.turn_ended.is_some();
223 lost_prompts = moved.lost_prompts;
224 if moved.monitors {
228 self.emit(SessionUpdate::MonitorsChanged {
229 key: self.key.clone(),
230 monitors: guard.monitors.clone(),
231 });
232 }
233 if moved.background_tasks {
234 self.emit(SessionUpdate::BackgroundTasksChanged {
235 key: self.key.clone(),
236 tasks: guard.task_rows(),
237 });
238 }
239 if moved.commands {
240 self.emit(SessionUpdate::SlashCommandsChanged {
241 key: self.key.clone(),
242 commands: guard.available_commands.clone(),
243 });
244 }
245 if moved.agents {
246 self.emit(SessionUpdate::SubagentsChanged {
247 key: self.key.clone(),
248 subagents: guard.available_agents.clone(),
249 });
250 }
251 if moved.cards {
252 self.emit(SessionUpdate::SubagentCardsChanged {
253 key: self.key.clone(),
254 cards: guard.cards_snapshot.clone(),
255 });
256 }
257 if moved.processes {
258 self.emit(SessionUpdate::ProcessesChanged {
259 key: self.key.clone(),
260 snapshot: forge_agent::env::processes::ProcessSnapshot {
263 processes: Vec::new(),
264 scanned_at: std::time::SystemTime::now(),
265 },
266 });
267 }
268 if let Some(ended) = &moved.turn_ended {
269 match ended {
270 MovedTurnEnd::Complete { terminal_reason } => {
271 self.emit(SessionUpdate::TurnComplete {
272 key: self.key.clone(),
273 terminal_reason: *terminal_reason,
274 });
275 }
276 MovedTurnEnd::Cancelled => {
277 self.emit(SessionUpdate::TurnCancelled { key: self.key.clone() });
278 }
279 }
280 }
281 }
282 if turn_ended && let Some(workspace) = self.workspace.upgrade() {
289 workspace.record_seat_state(&self.key, crate::store::sessions::SeatState::Idle);
290 }
291 if let Some(pr_stale) = repo_nudge
294 && let Some(workspace) = self.workspace.upgrade()
295 {
296 if pr_stale {
297 workspace.note_code_change_published(&self.key);
298 } else {
299 workspace.note_repository_activity(&self.key);
300 }
301 }
302 if turn_completed {
307 self.after_turn_ended();
308 }
309 if turn_cancelled {
310 self.abort_cancelled_handoff();
311 }
312
313 if let AgentEvent::Connected { session_id, cwd, .. } = &event
317 && !cwd.is_empty()
318 && let Some(workspace) = self.workspace.upgrade()
319 {
320 if workspace.worker_lookup_for_session(&self.key).is_none() {
327 workspace.record_connected_session(cwd, session_id, None);
328 }
329 }
330
331 match event {
332 AgentEvent::Connected {
333 session_id,
334 cwd,
335 current_model,
336 available_models,
337 mode,
338 history_updates,
339 compaction_count,
340 } => {
341 let read = history_updates.unwrap_or_default();
342 let (mut window, shed) = crate::conversation_window::Window::tail_of(&read);
351 let left_behind = shed.frames;
352 window.carry_thinking(shed);
358 drop(read);
368 let history = window.messages().to_vec();
369 self.compaction_count = compaction_count;
370 let key = self.key.clone();
374 if let Some(workspace) = self.workspace.upgrade() {
386 workspace.clear_spawn_failure(&key);
387 workspace.note_seat_connected(&key);
388 }
389 let cwd_for_tag = cwd.clone();
430 let replacing = self.connected_once;
434 if replacing {
435 if self.successor.as_ref().is_none_or(|watch| watch.to != session_id) {
440 self.successor = None;
441 }
442 }
447 if self.connected_once {
448 let dropped: Vec<(String, Option<u64>)> = self
455 .domain
456 .lock()
457 .pending_interactions
458 .drain()
459 .map(|(tool_id, slot)| {
460 let question_index = match slot {
463 PendingInteractionSlot::Question { request, .. } => {
464 Some(request.question_index)
465 }
466 PendingInteractionSlot::Permission { .. } => None,
467 };
468 (tool_id, question_index)
469 })
470 .collect();
471 for (tool_id, question_index) in dropped {
472 self.emit(SessionUpdate::PendingInteractionResolved {
473 key: self.key.clone(),
474 tool_id,
475 question_index,
476 answered_with: None,
477 });
478 }
479 if let Some(workspace) = self.workspace.upgrade() {
484 workspace.expire_parked_for_slot(
485 &self.key,
486 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
487 );
488 }
489 let caller = self.domain.lock().key.clone();
493 self.drain_review_activity_for(&caller);
494 {
503 let mut domain = self.domain.lock();
504 domain.dictate_overrides = crate::dictate::DictateOverrides::default();
505 }
506 if let Some(workspace) = self.workspace.upgrade() {
510 workspace.note_running_session_id(&self.key, &session_id);
511 workspace.voice_occupant_changed(&self.key);
514 }
515 self.emit(SessionUpdate::SessionReplaced {
516 key: key.clone(),
517 session_id: SessionId::new(session_id.clone()),
518 cwd,
519 current_model,
520 available_models,
521 mode,
522 history,
523 dropped: left_behind,
524 compaction_count,
525 });
526 self.emit(SessionUpdate::DictateOverrides {
530 key: key.clone(),
531 overrides: crate::dictate::DictateOverrides::default(),
532 });
533 let awaited_successor = self.workspace.upgrade().is_some_and(|workspace| {
541 workspace
542 .pending_brief_for(&key)
543 .is_some_and(|brief| brief.to == session_id)
544 });
545 let held = self.workspace.upgrade().map(|workspace| workspace.end_swap(&key));
549 if awaited_successor {
550 self.deliver_pending_brief(&key, &session_id);
551 }
552 if awaited_successor || self.successor.is_none() {
559 self.successor = self.workspace.upgrade().and_then(|workspace| {
560 workspace.handoff_for_successor(&key, &session_id).map(
561 |(record, predecessor_reads)| handoff::SuccessorWatch {
562 to: record.to,
563 turns: 0,
564 predecessor_reads,
565 measure_at_boundary: false,
566 },
567 )
568 });
569 }
570 if let (Some(workspace), Some(held)) = (self.workspace.upgrade(), held) {
571 for prompt in held {
572 let sender = prompt.sender.clone();
573 let first = prompt.text.lines().next().unwrap_or_default().to_owned();
574 let uuid = prompt.uuid;
584 if let Err(error) = workspace.route(Command::PromptUnder {
585 key: key.clone(),
586 text: prompt.text,
587 attachments: prompt.attachments,
588 uuid,
589 source: prompt.source,
590 dispatch_id: None,
591 }) {
592 tracing::warn!(
593 target: "forge_workspace::session_task",
594 event_name = "handoff_held_prompt_delivery_failed",
595 slot = %key.display(),
596 %error,
597 "a prompt held during the handoff could not be delivered",
598 );
599 if let Some(sender) = sender {
600 workspace.notice_undelivered_message(
601 &sender,
602 &key,
603 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
604 );
605 } else {
606 self.emit(SessionUpdate::Notice {
607 key: key.clone(),
608 severity: NoticeSeverity::Error,
609 text: format!(
610 "a prompt held during the handoff could not be delivered: {first}"
611 ),
612 });
613 }
614 }
615 }
616 }
617 } else {
618 if self
625 .workspace
626 .upgrade()
627 .is_some_and(|workspace| workspace.fail_worker_without_worktree(&self.key))
628 {
629 return false;
630 }
631 self.connected_once = true;
632 if let Some(workspace) = self.workspace.upgrade() {
638 let force_new = self.domain.lock().spawned_force_new;
639 maybe_respawn_workers_on_connected(&workspace, &self.key, force_new);
640 maybe_kick_worker_on_connected(&workspace, &self.key);
641 maybe_kick_lead_on_connected(&workspace, &self.key, &self.domain);
642 self.successor = workspace
648 .handoff_for_successor(&self.key, &session_id)
649 .map(|(record, predecessor_reads)| handoff::SuccessorWatch {
650 to: record.to,
651 turns: 0,
652 predecessor_reads,
653 measure_at_boundary: false,
654 });
655 }
656 if let Some(workspace) = self.workspace.upgrade() {
660 workspace.note_running_session_id(&self.key, &session_id);
661 workspace.voice_occupant_changed(&self.key);
665 }
666 self.emit(SessionUpdate::Connected {
667 key: key.clone(),
668 session_id: SessionId::new(session_id.clone()),
669 cwd,
670 current_model,
671 available_models,
672 mode,
673 history,
674 dropped: left_behind,
675 compaction_count,
676 });
677 self.drain_parked();
684 }
685 if let Some(workspace) = self.workspace.upgrade()
693 && let Some(dead) = workspace.unannounced_death(&key)
694 {
695 let text = if dead.as_str() == session_id.as_str() {
696 format!("The agent's session ended; forge resumed it ({session_id}).")
697 } else {
698 format!(
699 "The agent's session ended; forge restarted it under a new id ({dead} -> {session_id})."
700 )
701 };
702 self.emit(SessionUpdate::Notice {
703 key: key.clone(),
704 severity: NoticeSeverity::Error,
705 text,
706 });
707 }
708 if let Some(workspace) = self.workspace.upgrade() {
713 let minted_row = !replacing && self.domain.lock().spawn_wrote_row;
720 workspace.apply_worker_tag_or_rollback(
721 &key,
722 &session_id,
723 &cwd_for_tag,
724 minted_row,
725 );
726 }
727 self.settle_lost_queued(lost_prompts);
731 }
732 AgentEvent::ConnectionFailed { message, kind } => {
733 let key = self.key.clone();
734 if let Some(workspace) = self.workspace.upgrade() {
738 workspace.record_spawn_failure(&key, &message);
739 if self.connected_once
748 && let Some(occupant) = self.domain.lock().session_id.clone()
749 {
750 tracing::warn!(
754 target: "forge_workspace::session_task",
755 event_name = "session_ended",
756 slot = %key.display(),
757 session_id = %occupant,
758 error = %message,
759 "the session's subprocess ended; the seat keeps the death until a reader is shown it",
760 );
761 workspace.note_seat_death(&key, &message);
762 workspace.record_death(&key, occupant, &message);
763 }
764 if !self.connected_once && workspace.heal_progress(&key).is_some() {
769 workspace.note_seat_death(&key, &message);
770 }
771 }
772 let caller = self.domain.lock().key.clone();
776 self.drain_review_activity_for(&caller);
777 if let Some(workspace) = self.workspace.upgrade() {
782 workspace.expire_parked_for_slot(
783 &self.key,
784 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
785 );
786 workspace.clear_pending_brief(&self.key);
792 for prompt in workspace.end_swap(&self.key) {
793 if let Some(sender) = &prompt.sender {
797 workspace.notice_undelivered_message(
798 sender,
799 &self.key,
800 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
801 );
802 continue;
803 }
804 let first = prompt.text.lines().next().unwrap_or_default();
805 self.emit(SessionUpdate::PromptLifecycle {
809 key: self.key.clone(),
810 uuid: prompt.uuid,
811 state: "cancelled".to_owned(),
812 });
813 self.emit(SessionUpdate::Notice {
814 key: self.key.clone(),
815 severity: NoticeSeverity::Error,
816 text: format!(
817 "the handoff failed before this prompt could run: {first}"
818 ),
819 });
820 }
821 workspace.handle_async_worker_spawn_failure(&key, &message, kind);
832 }
833 self.settle_lost_queued(lost_prompts);
837 self.emit(SessionUpdate::ConnectionFailed {
838 key: key.clone(),
839 message,
840 fatal: false,
841 });
842 self.emit(SessionUpdate::MachineTurn { key: self.key.clone(), machine: None });
848 if let Some(workspace) = self.workspace.upgrade() {
858 workspace.release_session_if_current(&self.key, &self.handle);
859 }
860 return false;
861 }
862 AgentEvent::PermissionRequest { session_id, request } => {
863 let tool_call_id = request.tool_call.tool_call_id.clone();
864 let wire_request = request;
865 let (response_tx, response_rx) =
866 tokio::sync::oneshot::channel::<forge_primitives::PermissionOutcome>();
867 {
868 let mut guard = self.domain.lock();
869 guard.pending_interactions.insert(
870 tool_call_id.clone(),
871 PendingInteractionSlot::Permission {
872 tx: response_tx,
873 request: Box::new(wire_request.clone()),
874 },
875 );
876 }
877 if let Some(workspace) = self.workspace.upgrade() {
878 workspace.note_ask(&self.key);
879 }
880 let answerable = self.update_tx.send_answering(SessionUpdate::PermissionRequest {
881 key: self.key.clone(),
882 tool_id: tool_call_id.clone(),
883 request: wire_request,
884 });
885 spawn_permission_response_forwarder(
889 Arc::clone(&self.handle),
890 response_rx,
891 session_id,
892 tool_call_id.clone(),
893 );
894 if !answerable {
895 if let Some(pending) =
901 self.domain.lock().pending_interactions.remove(&tool_call_id)
902 && let PendingInteractionSlot::Permission { tx, .. } = pending
903 {
904 let _ = tx.send(forge_primitives::PermissionOutcome::Cancelled);
905 self.emit(SessionUpdate::PendingInteractionResolved {
908 key: self.key.clone(),
909 tool_id: tool_call_id.clone(),
910 question_index: None,
911 answered_with: None,
912 });
913 }
914 tracing::warn!(
915 target: "forge_workspace::session_task",
916 slot = %self.key.display(),
917 tool_id = %tool_call_id,
918 "no subscriber can answer the PermissionRequest; orphaned oneshot cancelled"
919 );
920 }
921 }
922 AgentEvent::QuestionRequest { session_id, request } => {
923 let tool_call_id = request.tool_call.tool_call_id.clone();
924 let wire_request = request;
925 let (response_tx, response_rx) =
926 tokio::sync::oneshot::channel::<forge_primitives::QuestionOutcome>();
927 {
928 let mut guard = self.domain.lock();
929 guard.pending_interactions.insert(
930 tool_call_id.clone(),
931 PendingInteractionSlot::Question {
932 tx: response_tx,
933 request: Box::new(wire_request.clone()),
934 },
935 );
936 }
937 if let Some(workspace) = self.workspace.upgrade() {
938 workspace.note_ask(&self.key);
939 }
940 let answerable = self.update_tx.send_answering(SessionUpdate::QuestionRequest {
941 key: self.key.clone(),
942 tool_id: tool_call_id.clone(),
943 request: wire_request,
944 });
945 spawn_question_response_forwarder(
949 Arc::clone(&self.handle),
950 response_rx,
951 session_id,
952 tool_call_id.clone(),
953 );
954 if !answerable {
955 if let Some(pending) =
960 self.domain.lock().pending_interactions.remove(&tool_call_id)
961 && let PendingInteractionSlot::Question { tx, request, .. } = pending
962 {
963 let _ = tx.send(forge_primitives::QuestionOutcome::Cancelled);
964 self.emit(SessionUpdate::PendingInteractionResolved {
967 key: self.key.clone(),
968 tool_id: tool_call_id.clone(),
969 question_index: Some(request.question_index),
970 answered_with: None,
971 });
972 }
973 tracing::warn!(
974 target: "forge_workspace::session_task",
975 slot = %self.key.display(),
976 tool_id = %tool_call_id,
977 "no subscriber can answer the QuestionRequest; orphaned oneshot cancelled"
978 );
979 }
980 }
981 AgentEvent::McpOperationError { error, .. } => {
982 self.emit(SessionUpdate::McpOperationError { key: self.key.clone(), error });
983 }
984 AgentEvent::PromptCancelResolved { uuid, cancelled } => {
985 if cancelled {
990 self.domain.lock().drop_queued_prompt(&uuid);
991 }
992 self.emit(SessionUpdate::PromptCancelResolved {
993 key: self.key.clone(),
994 uuid: uuid.clone(),
995 cancelled,
996 });
997 let is_judgment = {
1002 let domain = self.domain.lock();
1003 domain.handoff.judgment_uuid.as_deref() == Some(uuid.as_str())
1004 };
1005 if cancelled && is_judgment {
1006 self.abort_handoff("the handoff's brief was cancelled; this session continues");
1007 }
1008 }
1009 AgentEvent::SetModeFailed { mode, message, .. } => {
1010 self.emit(SessionUpdate::SetModeFailed { key: self.key.clone(), mode, message });
1011 }
1012 AgentEvent::SetModelFailed { model, message, .. } => {
1013 self.emit(SessionUpdate::SetModelFailed { key: self.key.clone(), model, message });
1014 }
1015 AgentEvent::TurnError { message, class, .. } => {
1016 self.emit(SessionUpdate::TurnError {
1017 key: self.key.clone(),
1018 message,
1019 class: Some(class),
1020 terminal_reason: None,
1021 });
1022 }
1023 AgentEvent::ContextUsage {
1024 session_id,
1025 percentage,
1026 max_tokens,
1027 total_tokens,
1028 effective_max_tokens,
1029 } => {
1030 self.emit(SessionUpdate::ContextUsageSnapshot {
1031 key: self.key.clone(),
1032 percentage,
1033 max_tokens,
1034 });
1035 self.evaluate_handoff_trigger(
1036 &session_id,
1037 percentage,
1038 total_tokens,
1039 effective_max_tokens,
1040 );
1041 }
1042 AgentEvent::McpSnapshot { servers, error, .. } => {
1043 self.emit(SessionUpdate::McpSnapshot { key: self.key.clone(), servers, error });
1044 }
1045 AgentEvent::SdkMessage { msg, .. } => {
1046 self.capture_judgment(&msg);
1049 if let forge_primitives::Message::RateLimitEvent { rate_limit_info, .. } = &msg
1059 && rate_limit_info.status != forge_primitives::RateLimitStatus::Allowed
1060 && let Some(workspace) = self.workspace.upgrade()
1061 {
1062 let reset_at = rate_limit_info.resets_at.and_then(|t| u64::try_from(t).ok());
1063 if let Some(session_id) = self.session_id_string() {
1064 workspace.gateway.report_rate_limit(
1065 self.key.org(),
1066 self.key.project(),
1067 &session_id,
1068 reset_at,
1069 );
1070 }
1071 }
1072 if matches!(msg, forge_primitives::Message::Error { .. }) {
1083 let mut guard = self.domain.lock();
1084 guard.serve_turn();
1085 }
1086 if matches!(
1087 msg,
1088 forge_primitives::Message::Result { .. }
1089 | forge_primitives::Message::Error { .. }
1090 ) {
1091 let caller = self.domain.lock().key.clone();
1092 if matches!(msg, forge_primitives::Message::Result { .. })
1100 && let Some(workspace) = self.workspace.upgrade()
1101 {
1102 crate::spawn::drain_turn_held_for_slot(&workspace, &self.key);
1103 }
1104 self.drain_review_activity_for(&caller);
1108 }
1109 if let forge_primitives::Message::System { subtype, data, .. } = &msg
1116 && subtype == "init"
1117 {
1118 let advertised =
1119 data.get("capabilities").and_then(serde_json::Value::as_array).is_some_and(
1120 |caps| caps.iter().any(|cap| cap.as_str() == Some("msg_lifecycle_v1")),
1121 );
1122 let first_sight = {
1123 let mut guard = self.domain.lock();
1124 let first = guard.lifecycle_frames.is_none();
1125 if first {
1126 guard.lifecycle_frames = Some(advertised);
1127 }
1128 first
1129 };
1130 if first_sight && !advertised {
1131 let held = { self.domain.lock().take_queued_prompts() };
1139 for prompt in held {
1140 if prompt.source == PromptSource::You {
1141 self.emit_prompt_row(
1142 &prompt.text,
1143 &prompt.attachments,
1144 &prompt.uuid,
1145 prompt.source,
1146 );
1147 }
1148 self.emit(SessionUpdate::PromptLifecycle {
1151 key: self.key.clone(),
1152 uuid: prompt.uuid,
1153 state: "started".to_owned(),
1154 });
1155 }
1156 tracing::warn!(
1157 target: "forge_workspace::session_task",
1158 slot = %self.key.display(),
1159 event_name = "lifecycle_frames_absent",
1160 outcome = "pile_disabled",
1161 "this session's CLI does not advertise msg_lifecycle_v1, so queued \
1162 prompts cannot be followed here; the pile is not drawn for this seat, \
1163 its waiting rows drew on this frame and later prompts draw at dispatch",
1164 );
1165 }
1166 }
1167 if let forge_primitives::Message::CommandLifecycle { command_uuid, state, .. } =
1175 &msg
1176 {
1177 let judgment_settled_short = {
1183 let mut domain = self.domain.lock();
1184 let is_judgment =
1185 domain.handoff.judgment_uuid.as_deref() == Some(command_uuid.as_str());
1186 let started_before = domain.handoff.judgment_started;
1187 if is_judgment && matches!(state.as_str(), "started" | "completed") {
1188 domain.handoff.judgment_started = true;
1189 }
1190 is_judgment
1191 && (matches!(state.as_str(), "cancelled" | "discarded" | "refused")
1192 || (state == "completed" && !started_before))
1195 };
1196 let started_row = (state == "started")
1203 .then(|| self.domain.lock().queued_prompt(command_uuid).cloned())
1204 .flatten()
1205 .filter(|prompt| prompt.source == PromptSource::You);
1206 let known = { self.domain.lock().advance_queued_prompt(command_uuid, state) };
1207 if judgment_settled_short {
1211 self.abort_handoff(&format!(
1212 "the handoff's brief was {state}; this session continues"
1213 ));
1214 }
1215 if !known && matches!(state.as_str(), "queued" | "started") {
1220 tracing::debug!(
1221 target: "forge_workspace::session_task",
1222 slot = %self.key.display(),
1223 uuid = %command_uuid,
1224 state = %state,
1225 "lifecycle frame for a prompt this session never recorded",
1226 );
1227 }
1228 if let Some(prompt) = started_row {
1232 self.emit_prompt_row(
1233 &prompt.text,
1234 &prompt.attachments,
1235 &prompt.uuid,
1236 prompt.source,
1237 );
1238 }
1239 if state == "started"
1247 && let Some(workspace) = self.workspace.upgrade()
1248 {
1249 workspace.record_seat_state(
1250 &self.key,
1251 crate::store::sessions::SeatState::InTurn,
1252 );
1253 }
1254 self.emit(SessionUpdate::PromptLifecycle {
1255 key: self.key.clone(),
1256 uuid: command_uuid.clone(),
1257 state: state.clone(),
1258 });
1259 } else {
1260 if matches!(&msg, forge_primitives::Message::CompactBoundary { .. }) {
1264 self.compaction_count = self.compaction_count.saturating_add(1);
1265 }
1266 let msg = self.attribute_dispatch(msg);
1267 self.emit(SessionUpdate::ChatAppended {
1270 key: self.key.clone(),
1271 msg,
1272 source: None,
1273 });
1274 }
1275 }
1276 AgentEvent::HookObservation {
1277 tool_use_id,
1278 permission_mode,
1279 effort,
1280 agent_id,
1281 agent_type,
1282 ..
1283 } => {
1284 self.emit(SessionUpdate::HookObservation {
1285 key: self.key.clone(),
1286 tool_use_id,
1287 permission_mode,
1288 effort,
1289 agent_id,
1290 agent_type,
1291 });
1292 }
1293 }
1294 if nudge_armed && let Some(workspace) = self.workspace.upgrade() {
1299 workspace.fire_auto_continue(&self.key);
1300 }
1301 true
1302 }
1303
1304 fn record_queued(
1313 &self,
1314 uuid: &str,
1315 source: PromptSource,
1316 text: &str,
1317 attachments: &[forge_primitives::ImageAttachment],
1318 ) -> bool {
1319 {
1320 let mut guard = self.domain.lock();
1321 if guard.lifecycle_frames == Some(false) {
1322 return false;
1323 }
1324 guard.record_queued_prompt(uuid, source, text, attachments.to_vec());
1325 }
1326 self.emit(SessionUpdate::PromptQueued {
1327 key: self.key.clone(),
1328 uuid: uuid.to_owned(),
1329 source,
1330 text: text.to_owned(),
1331 });
1332 true
1333 }
1334
1335 fn emit_prompt_row(
1350 &self,
1351 text: &str,
1352 attachments: &[forge_primitives::ImageAttachment],
1353 uuid: &str,
1354 source: PromptSource,
1355 ) {
1356 let images = forge_primitives::display_blocks(attachments);
1357 let mut framed = forge_primitives::Message::display_only_user_with_blocks(
1358 text.to_owned(),
1359 images,
1360 uuid.to_owned(),
1361 );
1362 framed.stamp_if_absent(&crate::install::rfc3339_now());
1363 self.emit(SessionUpdate::ChatAppended {
1364 key: self.key.clone(),
1365 msg: framed,
1366 source: Some(source),
1367 });
1368 }
1369
1370 fn settle_lost_queued(&self, prompts: Vec<crate::protocol::QueuedPrompt>) {
1378 for prompt in prompts {
1379 self.emit(SessionUpdate::PromptQueued {
1385 key: self.key.clone(),
1386 uuid: prompt.uuid.clone(),
1387 source: prompt.source,
1388 text: prompt.text,
1389 });
1390 self.emit(SessionUpdate::PromptLifecycle {
1391 key: self.key.clone(),
1392 uuid: prompt.uuid,
1393 state: "discarded".to_owned(),
1394 });
1395 }
1396 }
1397
1398 fn execute_command(&mut self, cmd: Command) {
1399 let judgment_prompt = matches!(
1403 &cmd,
1404 Command::PromptUnder { uuid, .. }
1405 if self.domain.lock().handoff.judgment_uuid.as_deref() == Some(uuid.as_str())
1406 );
1407 if !judgment_prompt && held_while_swapping(&cmd, &self.key, &self.workspace) {
1412 return;
1413 }
1414 let cmd = match cmd {
1419 Command::Prompt { key, text, attachments } => {
1420 let uuid = forge_sdk::request_id::next_prompt_id();
1421 if !self.record_queued(&uuid, PromptSource::You, &text, &attachments) {
1422 self.emit_prompt_row(&text, &attachments, &uuid, PromptSource::You);
1423 }
1424 Command::PromptUnder {
1425 key,
1426 text,
1427 attachments,
1428 uuid,
1429 source: PromptSource::You,
1430 dispatch_id: None,
1431 }
1432 }
1433 Command::PromptUnder { key, text, attachments, uuid, source, dispatch_id } => {
1434 if self.continuation_lost_its_occupant(&uuid) {
1440 tracing::debug!(
1444 target: "forge_workspace::session_task",
1445 event_name = "auto_continue_dropped_occupant_replaced",
1446 slot = %self.key.display(),
1447 uuid = %uuid,
1448 "a continuation's occupant was replaced before it ran; the prompt is dropped",
1449 );
1450 return;
1451 }
1452 let recorded = self.record_queued(&uuid, source, &text, &attachments);
1453 if !recorded && source == PromptSource::You {
1457 self.emit_prompt_row(&text, &attachments, &uuid, source);
1458 }
1459 Command::PromptUnder { key, text, attachments, uuid, source, dispatch_id }
1460 }
1461 other => other,
1462 };
1463 match cmd {
1464 Command::RespondPermission { key: _, tool_id, outcome } => {
1465 let mut guard = self.domain.lock();
1469 let kind_matches = matches!(
1470 guard.pending_interactions.get(&tool_id),
1471 Some(PendingInteractionSlot::Permission { .. }),
1472 );
1473 if kind_matches
1474 && let Some(PendingInteractionSlot::Permission { tx, .. }) =
1475 guard.pending_interactions.remove(&tool_id)
1476 {
1477 drop(guard);
1478 let answered_with = permission_words(&outcome);
1479 if tx.send(outcome).is_err() {
1480 tracing::warn!(
1481 target: "forge_workspace::session_task",
1482 slot = %self.key.display(),
1483 tool_id = %tool_id,
1484 "permission oneshot receiver dropped before response could be sent"
1485 );
1486 }
1487 self.emit(SessionUpdate::PendingInteractionResolved {
1488 key: self.key.clone(),
1489 tool_id,
1490 question_index: None,
1491 answered_with,
1492 });
1493 } else if let Some(other) = guard.pending_interactions.get(&tool_id) {
1494 tracing::warn!(
1495 target: "forge_workspace::session_task",
1496 slot = %self.key.display(),
1497 tool_id = %tool_id,
1498 slot = ?other,
1499 "RespondPermission expected Permission slot; got different kind. Outcome dropped, slot preserved."
1500 );
1501 } else {
1502 tracing::warn!(
1503 target: "forge_workspace::session_task",
1504 slot = %self.key.display(),
1505 tool_id = %tool_id,
1506 "RespondPermission found no pending interaction (already responded or expired)"
1507 );
1508 }
1509 }
1510 Command::RespondQuestion { key: _, tool_id, outcome } => {
1511 let mut guard = self.domain.lock();
1513 let kind_matches = matches!(
1514 guard.pending_interactions.get(&tool_id),
1515 Some(PendingInteractionSlot::Question { .. }),
1516 );
1517 if kind_matches
1518 && let Some(PendingInteractionSlot::Question { tx, request, .. }) =
1519 guard.pending_interactions.remove(&tool_id)
1520 {
1521 drop(guard);
1522 let answered_with = question_words(&outcome);
1523 if tx.send(outcome).is_err() {
1524 tracing::warn!(
1525 target: "forge_workspace::session_task",
1526 slot = %self.key.display(),
1527 tool_id = %tool_id,
1528 "question oneshot receiver dropped"
1529 );
1530 }
1531 self.emit(SessionUpdate::PendingInteractionResolved {
1532 key: self.key.clone(),
1533 tool_id,
1534 question_index: Some(request.question_index),
1535 answered_with,
1536 });
1537 } else if let Some(other) = guard.pending_interactions.get(&tool_id) {
1538 tracing::warn!(
1539 target: "forge_workspace::session_task",
1540 slot = %self.key.display(),
1541 tool_id = %tool_id,
1542 slot = ?other,
1543 "RespondQuestion got non-Question slot. Outcome dropped, slot preserved."
1544 );
1545 } else {
1546 tracing::warn!(
1547 target: "forge_workspace::session_task",
1548 slot = %self.key.display(),
1549 tool_id = %tool_id,
1550 "RespondQuestion found no pending interaction"
1551 );
1552 }
1553 }
1554 Command::Handoff { key: _, brief: Some(note) } => self.complete_handoff(note),
1561 Command::Handoff { key: _, brief: None } => {
1562 self.request_handoff(forge_primitives::HandoffReason::Manual, None);
1563 }
1564 other => {
1565 let sid = self.session_id_string();
1566 let fresh = match &other {
1572 Command::NewSession { session_id, .. } => {
1573 session_id.clone().or_else(|| self.fresh_session_id())
1574 }
1575 _ => None,
1576 };
1577 let respawn_id = match &other {
1582 Command::NewSession { .. } => fresh.clone(),
1583 Command::ResumeSession { session_id, .. } => Some(session_id.clone()),
1584 _ => None,
1585 };
1586 if respawn_id.is_some()
1590 && let Some(workspace) = self.workspace.upgrade()
1591 {
1592 workspace.record_seat_state(&self.key, crate::store::sessions::SeatState::Idle);
1593 }
1594 let mut other = other;
1604 if let Command::NewSession { launch_settings, .. } = &mut other
1605 && let Some(charter) =
1606 self.workspace.upgrade().and_then(|ws| ws.stored_charter_for(&self.key))
1607 {
1608 launch_settings.charter = Some(charter);
1609 }
1610 if let Some(respawn_id) = respawn_id.as_deref()
1611 && let Some(workspace) = self.workspace.upgrade()
1612 && let Command::NewSession { launch_settings, .. }
1613 | Command::ResumeSession { launch_settings, .. } = &mut other
1614 {
1615 workspace.stamp_respawn_overrides(&self.key, respawn_id, launch_settings);
1616 }
1617 let counts_for_successor =
1620 matches!(&other, Command::PromptUnder { source: PromptSource::You, .. });
1621 let failed = match &other {
1625 Command::PromptUnder {
1626 text,
1627 attachments,
1628 uuid,
1629 source: PromptSource::You,
1630 ..
1631 } => Some((text.clone(), attachments.clone(), uuid.clone())),
1632 _ => None,
1633 };
1634 if let Err(err) = execute_command_via_handle(
1635 &self.handle,
1636 &self.key,
1637 sid.as_deref(),
1638 fresh,
1639 other,
1640 ) {
1641 if let Some((text, attachments, uuid)) = failed {
1642 let held = {
1643 let mut guard = self.domain.lock();
1644 let held = guard.queued_prompt(&uuid).is_some();
1645 guard.drop_queued_prompt(&uuid);
1646 held
1647 };
1648 if held {
1649 self.emit_prompt_row(&text, &attachments, &uuid, PromptSource::You);
1650 self.emit(SessionUpdate::PromptLifecycle {
1651 key: self.key.clone(),
1652 uuid,
1653 state: "started".to_owned(),
1654 });
1655 }
1656 }
1657 tracing::warn!(
1658 target: "forge_workspace::session_task",
1659 slot = %self.key.display(),
1660 error = %err,
1661 "agent command dispatch failed; bridge channel closed?"
1662 );
1663 self.emit(SessionUpdate::TurnError {
1667 key: self.key.clone(),
1668 message: err.to_string(),
1669 class: None,
1670 terminal_reason: None,
1671 });
1672 } else if counts_for_successor {
1673 self.count_successor_prompt();
1674 }
1675 }
1676 }
1677 }
1678
1679 fn continuation_lost_its_occupant(&self, uuid: &str) -> bool {
1683 let mut guard = self.domain.lock();
1684 let Some((sent, armed_for)) = guard.auto_continue_queued.as_ref() else {
1685 return false;
1686 };
1687 if sent != uuid {
1688 return false;
1689 }
1690 let armed_for = armed_for.clone();
1691 guard.auto_continue_queued = None;
1692 armed_for != guard.session_id
1693 }
1694
1695 fn session_id_string(&self) -> Option<String> {
1700 self.domain.lock().session_id.as_ref().map(std::string::ToString::to_string)
1701 }
1702
1703 fn emit(&self, update: SessionUpdate) {
1712 if !self.update_tx.send(update) {
1713 tracing::warn!(
1714 target: "forge_workspace::session_task",
1715 slot = %self.key.display(),
1716 "no SessionUpdate subscriber took the event"
1717 );
1718 }
1719 }
1720
1721 fn drain_review_activity_for(&self, caller: &SessionSlot) {
1731 let Some(workspace) = self.workspace.upgrade() else { return };
1732 for update in workspace.drain_review_activity(caller) {
1733 self.emit(update);
1734 }
1735 }
1736
1737 fn fresh_session_id(&self) -> Option<String> {
1742 Some(self.workspace.upgrade()?.fresh_session_id_for(&self.key))
1743 }
1744
1745 fn deliver_pending_brief(&mut self, key: &SessionSlot, occupant: &str) {
1749 let Some(workspace) = self.workspace.upgrade() else { return };
1750 let Some(pending) = workspace.take_pending_brief(key) else { return };
1751 if pending.to != occupant {
1752 tracing::debug!(
1753 target: "forge_workspace::session_task",
1754 event_name = "handoff_brief_occupant_mismatch",
1755 slot = %key.display(),
1756 written_for = %pending.to,
1757 occupant = %occupant,
1758 "the handoff brief was written for another occupant; it is not given",
1759 );
1760 return;
1761 }
1762 let uuid = forge_sdk::request_id::next_forged_prompt_id();
1763 let first = pending.text.lines().next().unwrap_or_default().to_owned();
1766 if let Err(error) = workspace.dispatch_handoff_brief(key, pending.text, uuid) {
1767 tracing::warn!(
1768 target: "forge_workspace::session_task",
1769 event_name = "handoff_brief_delivery_failed",
1770 slot = %key.display(),
1771 %error,
1772 "the handoff brief could not be delivered to its successor",
1773 );
1774 self.emit(SessionUpdate::Notice {
1779 key: key.clone(),
1780 severity: NoticeSeverity::Error,
1781 text: format!(
1782 "the handoff brief could not be delivered to this session ({first}); \
1783 resume the session that handed over with /resume"
1784 ),
1785 });
1786 }
1787 }
1788
1789 fn complete_handoff(&mut self, note: String) {
1795 let mut domain = self.domain.lock();
1796 if !domain.handoff.awaiting {
1800 drop(domain);
1801 self.emit(SessionUpdate::Notice {
1802 key: self.key.clone(),
1803 severity: NoticeSeverity::Error,
1804 text: "agents__handoff completes a running handoff; a handoff starts at /handoff \
1805 or at the automatic trigger (fill or compaction)"
1806 .to_owned(),
1807 });
1808 return;
1809 }
1810 domain.handoff.supplied = Some(note);
1811 }
1812
1813 fn request_handoff(
1818 &mut self,
1819 reason: forge_primitives::HandoffReason,
1820 fill: Option<handoff::FillReading>,
1821 ) {
1822 let Some(workspace) = self.workspace.upgrade() else { return };
1823 if workspace.swap_in_progress(&self.key) {
1824 tracing::debug!(
1825 target: "forge_workspace::session_task",
1826 event_name = "handoff_requested_during_swap",
1827 slot = %self.key.display(),
1828 "a swap is already building on this seat; this request is dropped",
1829 );
1830 return;
1831 }
1832 {
1833 let mut domain = self.domain.lock();
1834 if domain.handoff.in_progress() {
1835 tracing::debug!(
1836 target: "forge_workspace::session_task",
1837 event_name = "handoff_already_running",
1838 slot = %self.key.display(),
1839 "a handoff is already running; this request is dropped",
1840 );
1841 return;
1842 }
1843 if !domain.is_idle_for_handoff() {
1844 domain.handoff.reason = Some(reason);
1845 domain.handoff.fill = fill;
1846 domain.handoff.parked = true;
1847 return;
1848 }
1849 domain.handoff.reason = Some(reason);
1851 domain.handoff.fill = fill;
1852 }
1853 self.begin_handoff();
1854 }
1855
1856 fn begin_handoff(&mut self) {
1862 if self.session_id_string().is_none() {
1863 self.emit(SessionUpdate::Notice {
1864 key: self.key.clone(),
1865 severity: NoticeSeverity::Error,
1866 text: "a handoff needs a connected session; this seat has none yet".to_owned(),
1867 });
1868 return;
1869 }
1870 let Some(workspace) = self.workspace.upgrade() else { return };
1871 if workspace.swap_in_progress(&self.key) {
1875 tracing::debug!(
1876 target: "forge_workspace::session_task",
1877 event_name = "handoff_begin_during_swap",
1878 slot = %self.key.display(),
1879 "a swap is already building on this seat; this handoff is dropped",
1880 );
1881 return;
1882 }
1883 {
1888 let mut domain = self.domain.lock();
1889 if !domain.is_idle_for_handoff() {
1890 domain.handoff.parked = true;
1891 return;
1892 }
1893 }
1894 let token = workspace.begin_swap(&self.key);
1895 self.domain.lock().handoff.swap_token = Some(token);
1896 let uuid = forge_sdk::request_id::next_handoff_prompt_id();
1901 {
1902 let mut domain = self.domain.lock();
1903 domain.handoff.awaiting = true;
1904 domain.handoff.judgment_uuid = Some(uuid.clone());
1905 domain.handoff.judgment_started = false;
1906 }
1907 self.emit(SessionUpdate::MachineTurn {
1911 key: self.key.clone(),
1912 machine: Some(forge_primitives::runtime::MachineTurn::Handoff),
1913 });
1914 let text = handoff::judgment_prompt();
1915 let deadline_uuid = uuid.clone();
1916 if let Err(error) = workspace.dispatch_handoff_prompt(&self.key, text, uuid) {
1917 self.domain.lock().handoff = crate::domain_session::HandoffState::default();
1918 self.emit(SessionUpdate::MachineTurn { key: self.key.clone(), machine: None });
1919 let held = workspace.end_swap(&self.key);
1922 if !held.is_empty() {
1923 tracing::warn!(
1924 target: "forge_workspace::session_task",
1925 event_name = "handoff_prompts_dropped",
1926 slot = %self.key.display(),
1927 held = held.len(),
1928 "the handoff could not ask for its brief; prompts held for it were dropped",
1929 );
1930 }
1931 for prompt in held {
1932 self.emit(SessionUpdate::PromptLifecycle {
1934 key: self.key.clone(),
1935 uuid: prompt.uuid.clone(),
1936 state: "cancelled".to_owned(),
1937 });
1938 if let Some(sender) = &prompt.sender {
1939 workspace.notice_undelivered_message(
1940 sender,
1941 &self.key,
1942 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
1943 );
1944 } else {
1945 let first = prompt.text.lines().next().unwrap_or_default();
1946 self.emit(SessionUpdate::Notice {
1947 key: self.key.clone(),
1948 severity: NoticeSeverity::Error,
1949 text: format!("a prompt held for the failed handoff did not run: {first}"),
1950 });
1951 }
1952 }
1953 self.emit(SessionUpdate::TurnError {
1954 key: self.key.clone(),
1955 message: format!("the handoff could not ask for its brief: {error}"),
1956 class: None,
1957 terminal_reason: None,
1958 });
1959 return;
1960 }
1961 let workspace = Arc::clone(&workspace);
1967 let domain = Arc::clone(&self.domain);
1968 let update_tx = self.update_tx.clone();
1969 let key = self.key.clone();
1970 tokio::spawn(async move {
1971 tokio::time::sleep(handoff::JUDGMENT_DEADLINE).await;
1972 let wedged = {
1973 let domain = domain.lock();
1974 domain.handoff.awaiting
1975 && !domain.handoff.judgment_started
1976 && domain.handoff.judgment_uuid.as_deref() == Some(deadline_uuid.as_str())
1977 };
1978 if wedged {
1979 Self::abort_handoff_state(
1980 &workspace,
1981 &domain,
1982 &update_tx,
1983 &key,
1984 "the handoff's brief never started; this session continues",
1985 );
1986 }
1987 });
1988 }
1989
1990 fn attribute_dispatch(&self, msg: forge_primitives::Message) -> forge_primitives::Message {
1998 let (held, owner) = {
1999 let domain = self.domain.lock();
2000 let tracker = &domain.card_tracker;
2001 match &msg {
2002 forge_primitives::Message::Assistant { parent_tool_use_id, .. }
2003 | forge_primitives::Message::User { parent_tool_use_id, .. }
2004 | forge_primitives::Message::StopHookSummary { parent_tool_use_id, .. } => {
2005 let held = parent_tool_use_id.as_deref().filter(|id| !id.trim().is_empty());
2006 (
2007 held.map(str::to_owned),
2008 held.and_then(|id| tracker.owning_dispatch(id)).map(str::to_owned),
2009 )
2010 }
2011 forge_primitives::Message::PermissionDenied { extras, .. } => {
2015 let parent = extras
2016 .get("parent_tool_use_id")
2017 .and_then(serde_json::Value::as_str)
2018 .filter(|id| !id.trim().is_empty());
2019 let owner = match parent {
2020 Some(parent) => tracker.owning_dispatch(parent),
2021 None => extras
2022 .get("agent_id")
2023 .and_then(serde_json::Value::as_str)
2024 .and_then(|agent| tracker.dispatch_of(agent)),
2025 };
2026 (parent.map(str::to_owned), owner.map(str::to_owned))
2027 }
2028 _ => return msg,
2029 }
2030 };
2031 let Some(owner) = owner else {
2032 if let Some(held) = held.as_deref() {
2035 tracing::debug!(
2036 target: "forge_workspace::subagent_cards",
2037 event_name = "subagent_frame_without_a_card",
2038 parent = %held,
2039 "a parented frame resolves to no card; it keeps the parent the wire gave it",
2040 );
2041 }
2042 return msg;
2043 };
2044 if held.as_deref() == Some(owner.as_str()) {
2045 return msg;
2046 }
2047 let mut msg = msg;
2048 match &mut msg {
2049 forge_primitives::Message::Assistant { parent_tool_use_id, .. }
2050 | forge_primitives::Message::User { parent_tool_use_id, .. }
2051 | forge_primitives::Message::StopHookSummary { parent_tool_use_id, .. } => {
2052 *parent_tool_use_id = Some(owner);
2053 }
2054 forge_primitives::Message::PermissionDenied { extras, .. } => {
2055 extras.insert("parent_tool_use_id".to_owned(), serde_json::Value::String(owner));
2056 }
2057 _ => {}
2058 }
2059 msg
2060 }
2061
2062 fn capture_judgment(&self, message: &forge_primitives::Message) {
2064 let forge_primitives::Message::Assistant { message: envelope, parent_tool_use_id, .. } =
2065 message
2066 else {
2067 return;
2068 };
2069 if forge_primitives::names_a_dispatch(parent_tool_use_id.as_deref()) {
2074 return;
2075 }
2076 let mut domain = self.domain.lock();
2077 if !domain.handoff.awaiting {
2078 return;
2079 }
2080 if let Some(text) = handoff::assistant_text(&envelope.content) {
2081 domain.handoff.judgment = Some(text);
2082 }
2083 }
2084
2085 fn after_turn_ended(&mut self) {
2088 {
2095 let mut domain = self.domain.lock();
2096 domain.serve_turn();
2097 if !domain.turns_outstanding() {
2102 domain.turn_open = false;
2103 if matches!(
2104 domain.runtime_state,
2105 Some(
2106 forge_primitives::RuntimeSessionState::Running
2107 | forge_primitives::RuntimeSessionState::RequiresAction
2108 )
2109 ) {
2110 domain.runtime_state = None;
2111 }
2112 }
2113 }
2114 self.measure_successor_at_boundary();
2115 let (awaiting, started, lifecycle_correlates, parked, reason) = {
2116 let mut domain = self.domain.lock();
2117 let awaiting = domain.handoff.awaiting;
2118 let started = domain.handoff.judgment_started;
2119 let correlates = domain.lifecycle_frames == Some(true);
2123 let parked = if awaiting { false } else { std::mem::take(&mut domain.handoff.parked) };
2124 (awaiting, started, correlates, parked, domain.handoff.reason.unwrap_or_default())
2125 };
2126 if awaiting {
2127 if lifecycle_correlates && !started {
2128 tracing::debug!(
2132 target: "forge_workspace::session_task",
2133 event_name = "handoff_judgment_turn_not_ours",
2134 slot = %self.key.display(),
2135 "a turn ended without the judgment prompt starting; the handoff waits",
2136 );
2137 return;
2138 }
2139 let judgment = {
2140 let mut domain = self.domain.lock();
2141 let supplied = domain.handoff.supplied.take();
2145 let captured = domain.handoff.judgment.take();
2146 let from_tool_call = supplied.is_some();
2150 let from_turn_text = captured.is_some();
2151 domain.handoff.judgment_started = false;
2152 if let Some(text) = supplied.filter(|text| handoff::parses_as_note(text)) {
2157 handoff::parse_judgment(&text)
2158 } else if let Some(text) = captured.filter(|text| handoff::parses_as_note(text)) {
2159 let why = if from_tool_call {
2160 "the exit call's note carried no content; this is the session's closing \
2161 text"
2162 } else {
2163 "the exit call carried no note; this is the session's closing text"
2164 };
2165 tracing::info!(
2166 target: "forge_workspace::session_task",
2167 event_name = "handoff_note_from_turn_text",
2168 slot = %self.key.display(),
2169 from_tool_call,
2170 chars = text.chars().count(),
2171 "the turn's closing text is the judgment half; the exit call's own note \
2172 was absent or carried no content",
2173 );
2174 let mut sections = handoff::parse_judgment(&text);
2175 sections.insert(
2176 0,
2177 forge_primitives::HandoffSection {
2178 label: "source".to_owned(),
2179 lines: vec![why.to_owned()],
2180 },
2181 );
2182 sections
2183 } else {
2184 tracing::warn!(
2189 target: "forge_workspace::session_task",
2190 event_name = "handoff_note_rejected",
2191 slot = %self.key.display(),
2192 from_tool_call,
2193 from_turn_text,
2194 "the handoff turn produced no note; the successor is told the note did \
2195 not arrive",
2196 );
2197 handoff::missing_note()
2198 }
2199 };
2200 self.swap(reason, judgment);
2201 return;
2202 }
2203 if parked {
2204 self.begin_handoff();
2205 return;
2206 }
2207 self.maybe_trigger_handoff();
2208 }
2209
2210 fn abort_cancelled_handoff(&mut self) {
2214 let (awaiting, started, correlates) = {
2215 let domain = self.domain.lock();
2216 (
2217 domain.handoff.awaiting,
2218 domain.handoff.judgment_started,
2219 domain.lifecycle_frames == Some(true),
2220 )
2221 };
2222 if !awaiting || (correlates && !started) {
2223 return;
2224 }
2225 self.abort_handoff("the handoff's brief was cancelled; this session continues");
2226 }
2227
2228 fn abort_handoff(&mut self, why: &str) {
2237 let Some(workspace) = self.workspace.upgrade() else { return };
2238 Self::abort_handoff_state(&workspace, &self.domain, &self.update_tx, &self.key, why);
2239 }
2240
2241 fn abort_handoff_state(
2245 workspace: &Arc<crate::Workspace>,
2246 domain: &Arc<parking_lot::Mutex<crate::domain_session::DomainSession>>,
2247 update_tx: &crate::update_fanout::UpdateFanout,
2248 key: &SessionSlot,
2249 why: &str,
2250 ) {
2251 {
2252 let mut guard = domain.lock();
2253 guard.handoff = crate::domain_session::HandoffState::default();
2254 guard.serve_turn();
2260 }
2261 workspace.record_seat_state(key, crate::store::sessions::SeatState::Idle);
2267 let _ = update_tx.send(SessionUpdate::MachineTurn { key: key.clone(), machine: None });
2272 let held = workspace.end_swap(key);
2273 if !update_tx.send(SessionUpdate::Notice {
2274 key: key.clone(),
2275 severity: NoticeSeverity::Info,
2276 text: why.to_owned(),
2277 }) {
2278 tracing::warn!(
2279 target: "forge_workspace::session_task",
2280 slot = %key.display(),
2281 "no SessionUpdate subscriber took the handoff-abort notice"
2282 );
2283 }
2284 for prompt in held {
2285 let sender = prompt.sender.clone();
2286 let first = prompt.text.lines().next().unwrap_or_default().to_owned();
2287 let uuid = forge_sdk::request_id::next_forged_prompt_id();
2288 if let Err(error) = workspace.route(Command::PromptUnder {
2292 key: key.clone(),
2293 text: prompt.text,
2294 attachments: prompt.attachments,
2295 uuid,
2296 source: prompt.source,
2297 dispatch_id: None,
2298 }) {
2299 tracing::warn!(
2300 target: "forge_workspace::session_task",
2301 event_name = "handoff_held_prompt_redelivery_failed",
2302 slot = %key.display(),
2303 %error,
2304 "a prompt held during an aborted handoff could not be handed back",
2305 );
2306 let _ = update_tx.send(SessionUpdate::PromptLifecycle {
2309 key: key.clone(),
2310 uuid: prompt.uuid.clone(),
2311 state: "cancelled".to_owned(),
2312 });
2313 if let Some(sender) = sender {
2314 workspace.notice_undelivered_message(
2315 &sender,
2316 key,
2317 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2318 );
2319 } else {
2320 let _ = update_tx.send(SessionUpdate::Notice {
2321 key: key.clone(),
2322 severity: NoticeSeverity::Error,
2323 text: format!(
2324 "a prompt held during the aborted handoff could not be handed back: {first}"
2325 ),
2326 });
2327 }
2328 }
2329 }
2330 }
2331
2332 fn count_successor_prompt(&mut self) {
2340 let Some(watch) = self.successor.as_mut() else { return };
2341 watch.turns += 1;
2342 if watch.turns >= handoff::SUCCESSOR_TURNS {
2343 watch.measure_at_boundary = true;
2344 }
2345 }
2346
2347 fn measure_successor_at_boundary(&mut self) {
2351 let due = self.successor.as_ref().is_some_and(|watch| watch.measure_at_boundary);
2352 if !due {
2353 return;
2354 }
2355 let Some(watch) = self.successor.take() else { return };
2356 let Some(workspace) = self.workspace.upgrade() else { return };
2357 let cwd = workspace.cwd_for_session(&self.key).unwrap_or_default();
2358 workspace.measure_successor(self.key.clone(), watch.to, cwd, watch.predecessor_reads);
2359 }
2360
2361 fn maybe_trigger_handoff(&mut self) {
2370 let Some(workspace) = self.workspace.upgrade() else { return };
2371 if !workspace.handoff_policy_for(&self.key).enabled {
2372 return;
2373 }
2374 if workspace.swap_in_progress(&self.key) {
2378 return;
2379 }
2380 {
2381 let mut domain = self.domain.lock();
2382 if domain.handoff.in_progress() || !domain.is_idle_for_handoff() {
2383 return;
2384 }
2385 domain.handoff.probing = true;
2390 }
2391 let Some(session_id) = self.session_id_string() else {
2392 self.domain.lock().handoff.probing = false;
2393 return;
2394 };
2395 if let Err(error) = self.handle.get_context_usage(session_id) {
2396 self.domain.lock().handoff.probing = false;
2397 tracing::debug!(
2398 target: "forge_workspace::session_task",
2399 event_name = "handoff_reading_ask_failed",
2400 slot = %self.key.display(),
2401 %error,
2402 "the fill reading could not be asked for; no handoff trigger this turn",
2403 );
2404 }
2405 }
2406
2407 fn evaluate_handoff_trigger(
2410 &mut self,
2411 reading_for: &str,
2412 reported: Option<u8>,
2413 total_tokens: Option<u64>,
2414 effective_max_tokens: Option<u64>,
2415 ) {
2416 if self.session_id_string().as_deref() != Some(reading_for) {
2421 tracing::debug!(
2422 target: "forge_workspace::session_task",
2423 event_name = "handoff_reading_stale_occupant",
2424 slot = %self.key.display(),
2425 reading_for = %reading_for,
2426 "a fill reading arrived for a session this seat no longer runs; ignored",
2427 );
2428 return;
2429 }
2430 {
2431 let mut domain = self.domain.lock();
2432 if !std::mem::take(&mut domain.handoff.probing) {
2433 return;
2434 }
2435 }
2436 let Some(workspace) = self.workspace.upgrade() else { return };
2437 let policy = workspace.handoff_policy_for(&self.key);
2438 if !policy.enabled {
2439 return;
2440 }
2441 let fill = fill_of(reported, total_tokens, effective_max_tokens);
2442 let compaction_count = self.compaction_count;
2443 let reason = if compaction_count >= policy.after_compactions {
2444 Some(forge_primitives::HandoffReason::Compaction)
2447 } else if fill.is_some_and(|fill| fill.fires(policy.at_percent)) {
2448 Some(forge_primitives::HandoffReason::Fill)
2449 } else {
2450 None
2451 };
2452 let Some(reason) = reason else { return };
2453 self.request_handoff(reason, fill);
2459 }
2460
2461 fn swap(
2464 &mut self,
2465 reason: forge_primitives::HandoffReason,
2466 judgment: Vec<forge_primitives::HandoffSection>,
2467 ) {
2468 let Some(workspace) = self.workspace.upgrade() else { return };
2469 let (from, fill, token) = {
2470 let mut domain = self.domain.lock();
2471 let from = domain.session_id.as_ref().map(std::string::ToString::to_string);
2472 let fill = domain.handoff.fill;
2473 let token = domain.handoff.swap_token;
2474 domain.handoff = crate::domain_session::HandoffState::default();
2475 (from, fill, token)
2476 };
2477 let Some(from) = from else {
2478 self.emit(SessionUpdate::MachineTurn { key: self.key.clone(), machine: None });
2484 let held = workspace.end_swap(&self.key);
2485 for prompt in held {
2486 self.emit(SessionUpdate::PromptLifecycle {
2488 key: self.key.clone(),
2489 uuid: prompt.uuid.clone(),
2490 state: "cancelled".to_owned(),
2491 });
2492 if let Some(sender) = &prompt.sender {
2493 workspace.notice_undelivered_message(
2494 sender,
2495 &self.key,
2496 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2497 );
2498 } else {
2499 let first = prompt.text.lines().next().unwrap_or_default();
2500 self.emit(SessionUpdate::Notice {
2501 key: self.key.clone(),
2502 severity: NoticeSeverity::Error,
2503 text: format!(
2504 "the handoff did not run; this prompt waits no longer: {first}"
2505 ),
2506 });
2507 }
2508 }
2509 self.emit(SessionUpdate::Notice {
2510 key: self.key.clone(),
2511 severity: NoticeSeverity::Error,
2512 text: "a handoff needs a connected session; this seat has none yet".to_owned(),
2513 });
2514 return;
2515 };
2516 let policy = workspace.handoff_policy_for(&self.key);
2522 let cwd = workspace.cwd_for_session(&self.key).unwrap_or_default();
2523 let to = handoff::mint_session_id();
2528 let launch_settings = workspace.launch_settings_for(&self.key, &cwd);
2532 let charter = workspace.stored_charter_for(&self.key);
2533 let role = if self.key.is_lead() {
2534 handoff::Role::Lead
2535 } else {
2536 handoff::Role::Worker {
2537 charter_opening: charter
2538 .as_deref()
2539 .and_then(|charter| charter.lines().next())
2540 .map(|line| line.chars().take(200).collect()),
2541 }
2542 };
2543 let request = handoff::SwapRequest {
2544 slot: self.key.clone(),
2545 from,
2546 to,
2547 token,
2548 reason,
2549 fill,
2550 at_percent: policy.at_percent,
2551 role,
2552 judgment,
2553 cwd,
2554 launch_settings,
2555 };
2556 workspace.start_handoff(request);
2557 }
2558
2559 fn drain_parked(&self) {
2564 let Some(workspace) = self.workspace.upgrade() else { return };
2565 let parked = workspace.take_parked_for_slot(&self.key);
2566 self.deliver_parked_peers(&workspace, parked.peer);
2567 self.deliver_parked_crons(&workspace, parked.cron);
2568 self.deliver_parked_gotify(&workspace, parked.gotify);
2569 self.deliver_parked_slack(&workspace, parked.slack);
2570 }
2571
2572 fn deliver_parked_peers(
2580 &self,
2581 workspace: &Arc<crate::Workspace>,
2582 pending: Vec<crate::parked::ParkedPeer>,
2583 ) {
2584 if pending.is_empty() {
2585 return;
2586 }
2587 for entry in pending {
2592 let crate::parked::ParkedPeer { wrapped, .. } = entry;
2593 let uuid = forge_sdk::request_id::next_prompt_id();
2594 crate::spawn::push_peer_user_turn_into_chat(workspace, &self.key, &wrapped, &uuid);
2595 let text = wrapped.to_prose();
2596 if let Err(err) =
2597 workspace.dispatch_workspace_prompt_under(&self.key, text, PromptSource::Peer, uuid)
2598 {
2599 tracing::warn!(
2600 target: "forge_workspace::session_task",
2601 slot = %self.key.display(),
2602 error = ?err,
2603 "deliver_parked_peers: dispatch failed; prompt dropped"
2604 );
2605 crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2606 }
2607 }
2608 }
2609
2610 fn deliver_parked_crons(
2615 &self,
2616 workspace: &Arc<crate::Workspace>,
2617 pending: Vec<crate::crons::PendingCron>,
2618 ) {
2619 if pending.is_empty() {
2620 return;
2621 }
2622 for cron in pending {
2623 let text = crate::spawn::missed_cron_text(&cron.text, cron.missed);
2624 let uuid = forge_sdk::request_id::next_prompt_id();
2625 crate::spawn::push_cron_prompt_into_chat(
2626 workspace,
2627 &self.key,
2628 &text,
2629 &uuid,
2630 &cron.cron_id,
2631 cron.description.as_deref(),
2632 );
2633 if let Err(err) =
2634 workspace.dispatch_workspace_prompt_under(&self.key, text, PromptSource::Cron, uuid)
2635 {
2636 tracing::warn!(
2637 target: "forge_workspace::session_task",
2638 slot = %self.key.display(),
2639 error = ?err,
2640 "deliver_parked_crons: dispatch failed; prompt dropped",
2641 );
2642 crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2643 }
2644 }
2645 }
2646
2647 fn deliver_parked_gotify(
2653 &self,
2654 workspace: &Arc<crate::Workspace>,
2655 pending: Vec<crate::mcp::gotify::types::GotifyNotification>,
2656 ) {
2657 if pending.is_empty() {
2658 return;
2659 }
2660 for notification in pending {
2661 let uuid = forge_sdk::request_id::next_prompt_id();
2665 crate::spawn::push_gotify_notification_into_chat(
2666 workspace,
2667 &self.key,
2668 ¬ification,
2669 &uuid,
2670 );
2671 if let Err(err) = workspace.dispatch_workspace_prompt_under(
2672 &self.key,
2673 notification.to_prose(),
2674 PromptSource::Gotify,
2675 uuid,
2676 ) {
2677 tracing::warn!(
2678 target: "forge_workspace::session_task",
2679 slot = %self.key.display(),
2680 error = ?err,
2681 "deliver_parked_gotify: dispatch failed; prompt dropped"
2682 );
2683 crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2684 }
2685 }
2686 }
2687
2688 fn deliver_parked_slack(
2693 &self,
2694 workspace: &Arc<crate::Workspace>,
2695 pending: Vec<forge_primitives::slack::SlackMessage>,
2696 ) {
2697 if pending.is_empty() {
2698 return;
2699 }
2700 let mut by_conversation: std::collections::BTreeMap<String, Vec<_>> =
2703 std::collections::BTreeMap::new();
2704 for message in pending {
2705 by_conversation.entry(message.conversation.clone()).or_default().push(message);
2706 }
2707 for messages in by_conversation.into_values() {
2708 let Some(first) = messages.first() else { continue };
2709 let prose = crate::spawn::slack_bundle_to_prose(
2710 &messages,
2711 workspace.slack_stale_after(&first.workspace),
2712 std::time::SystemTime::now(),
2713 );
2714 let uuid = forge_sdk::request_id::next_prompt_id();
2715 crate::spawn::push_slack_message_into_chat(workspace, &self.key, &prose, &uuid);
2716 if let Err(err) = workspace.dispatch_workspace_prompt_under(
2717 &self.key,
2718 prose,
2719 PromptSource::Slack,
2720 uuid,
2721 ) {
2722 tracing::warn!(
2723 target: "forge_workspace::session_task",
2724 slot = %self.key.display(),
2725 error = ?err,
2726 "deliver_parked_slack: dispatch failed; prompt dropped"
2727 );
2728 crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2729 }
2730 }
2731 }
2732}
2733
2734impl Drop for SessionTask {
2742 fn drop(&mut self) {
2743 if let Some(done) = self.teardown_done.take() {
2754 let _ = done.send_replace(true);
2755 }
2756 let caller = self.domain.lock().key.clone();
2757 self.drain_review_activity_for(&caller);
2758 if let Some(workspace) = self.workspace.upgrade() {
2759 workspace.expire_parked_for_slot(
2760 &self.key,
2761 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2762 );
2763 let held = workspace.end_swap(&self.key);
2767 if !held.is_empty() {
2768 tracing::warn!(
2769 target: "forge_workspace::session_task",
2770 event_name = "handoff_prompts_dropped_on_teardown",
2771 slot = %self.key.display(),
2772 held = held.len(),
2773 "the session task ended during a handoff; prompts held for its successor were dropped",
2774 );
2775 }
2776 for prompt in &held {
2777 let _ = self.update_tx.send(SessionUpdate::PromptLifecycle {
2779 key: self.key.clone(),
2780 uuid: prompt.uuid.clone(),
2781 state: "cancelled".to_owned(),
2782 });
2783 if let Some(sender) = &prompt.sender {
2784 workspace.notice_undelivered_message(
2785 sender,
2786 &self.key,
2787 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2788 );
2789 } else {
2790 let first = prompt.text.lines().next().unwrap_or_default();
2791 let _ = self.update_tx.send(SessionUpdate::Notice {
2792 key: self.key.clone(),
2793 severity: NoticeSeverity::Error,
2794 text: format!(
2795 "a prompt held for the handoff did not run; the session ended: {first}"
2796 ),
2797 });
2798 }
2799 }
2800 workspace.release_session_if_current(&self.key, &self.handle);
2807 }
2808 }
2809}
2810
2811fn maybe_respawn_workers_on_connected(
2823 workspace: &Arc<crate::Workspace>,
2824 slot: &crate::SessionSlot,
2825 force_new: bool,
2826) {
2827 if !slot.is_lead() {
2828 return;
2829 }
2830 if matches!(workspace.stored_seat_state(slot), Some(crate::store::sessions::SeatState::Closed))
2834 {
2835 return;
2836 }
2837 if workspace.find_project_view_by_name(slot.project()).is_none() {
2838 return;
2839 }
2840 let Some(project_key) = workspace.project_key_for_name(slot.project()) else {
2841 return;
2842 };
2843 if !workspace.list_live_workers(&project_key).is_empty() {
2844 return;
2845 }
2846 workspace.respawn_workers_for_lead(slot, project_key, force_new);
2853}
2854
2855fn maybe_kick_worker_on_connected(workspace: &Arc<crate::Workspace>, slot: &crate::SessionSlot) {
2872 if slot.is_lead() {
2874 return;
2875 }
2876 let label = slot.label();
2877 let Some(project_key) = workspace.project_key_for_name(slot.project()) else {
2878 return;
2879 };
2880 let Some(entry) =
2885 workspace.list_live_workers(&project_key).into_iter().rev().find(|w| w.label == label)
2886 else {
2887 tracing::warn!(
2888 target: "forge_workspace::workers",
2889 label = %label,
2890 slot = %slot.display(),
2891 "no live worker entry for this label; the worker connects unkicked and idles",
2892 );
2893 return;
2894 };
2895 let Some(kick) = entry.kick else {
2896 return;
2897 };
2898 workspace.enqueue_kick(crate::workspace::KickRequest { slot: slot.clone(), prompt_body: kick });
2904}
2905
2906fn maybe_kick_lead_on_connected(
2912 workspace: &Arc<crate::Workspace>,
2913 slot: &crate::SessionSlot,
2914 domain: &Mutex<DomainSession>,
2915) {
2916 if !slot.is_lead() {
2917 return;
2918 }
2919 let Some(note) = domain.lock().pending_lead_resume_note.take() else { return };
2920 workspace.enqueue_kick(crate::workspace::KickRequest { slot: slot.clone(), prompt_body: note });
2921}
2922
2923#[cfg(test)]
2932fn on_connected_for_test(
2933 workspace: &Arc<crate::Workspace>,
2934 slot: &crate::SessionSlot,
2935 _real_session_id: &str,
2936) {
2937 maybe_respawn_workers_on_connected(workspace, slot, false);
2940 maybe_kick_worker_on_connected(workspace, slot);
2941}
2942
2943pub(crate) fn execute_command_via_handle(
2956 handle: &Arc<AgentHandle>,
2957 key: &SessionSlot,
2958 session_id: Option<&str>,
2959 new_session_id: Option<String>,
2960 cmd: Command,
2961) -> Result<(), forge_agent::AgentError> {
2962 match cmd {
2963 Command::Handoff { .. } | Command::RespondVaultAsk { .. } => Ok(()),
2966 Command::Prompt { key: _, text, attachments } => {
2967 let Some(sid) = session_id else {
2968 return Err(warn_no_session(key, "Prompt"));
2969 };
2970 let uuid = forge_sdk::request_id::next_prompt_id();
2975 handle.prompt_with_images(sid.to_owned(), text, attachments, uuid)
2976 }
2977 Command::PromptUnder { key: _, text, attachments, uuid, source: _, dispatch_id: _ } => {
2978 let Some(sid) = session_id else {
2979 return Err(warn_no_session(key, "PromptUnder"));
2980 };
2981 handle.prompt_with_images(sid.to_owned(), text, attachments, uuid)
2982 }
2983 Command::Cancel { key: _ } => {
2984 let Some(sid) = session_id else {
2985 return Err(warn_no_session(key, "Cancel"));
2986 };
2987 handle.cancel(sid.to_owned())
2988 }
2989 Command::CancelQueuedPrompt { key: _, uuid } => {
2990 let Some(sid) = session_id else {
2991 return Err(warn_no_session(key, "CancelQueuedPrompt"));
2992 };
2993 handle.cancel_queued(sid.to_owned(), uuid)
2994 }
2995 Command::SetMode { key: _, mode } => {
2996 let Some(sid) = session_id else {
2997 return Err(warn_no_session(key, "SetMode"));
2998 };
2999 handle.set_mode(sid.to_owned(), mode)
3000 }
3001 Command::SetModel { key: _, model } => {
3002 let Some(sid) = session_id else {
3003 return Err(warn_no_session(key, "SetModel"));
3004 };
3005 handle.set_model(sid.to_owned(), model)
3006 }
3007 Command::NewSession { key: _, cwd, launch_settings, .. } => {
3008 handle.new_session(new_session_id, cwd, launch_settings)
3009 }
3010 Command::ResumeSession { key: _, session_id, cwd, launch_settings } => {
3011 handle.resume_session(session_id, cwd, launch_settings)
3012 }
3013 Command::RespondPermission { .. } | Command::RespondQuestion { .. } => {
3014 tracing::error!(
3015 target: "forge_workspace::session_task",
3016 slot = %key.display(),
3017 command = ?cmd,
3018 "Respond* commands must be handled by SessionTask::execute_command before delegation"
3019 );
3020 Ok(())
3021 }
3022 misrouted @ (Command::ReadCallOutput { .. }
3027 | Command::SetDictateOverride { .. }
3028 | Command::ResetDictateOverrides { .. }
3029 | Command::DictateStream { .. }
3030 | Command::DictateStop { .. }
3031 | Command::SpawnProject { .. }
3032 | Command::DeliverPeerPrompt { .. }
3033 | Command::SpawnWorker { .. }
3034 | Command::CloseWorker { .. }
3035 | Command::DespawnWorker { .. }
3036 | Command::DeliverWorkerPrompt { .. }
3037 | Command::DeliverWorkerPromptToLead { .. }
3038 | Command::DeliverGotifyMessage { .. }
3039 | Command::RespondSlackPost { .. }
3040 | Command::RespondBrowserHandOff { .. }
3041 | Command::RespondTerminalHandOff { .. }
3042 | Command::AnswerDocumentHandOff { .. }
3043 | Command::DictateCatalogueCheck
3044 | Command::DictateInstall { .. }
3045 | Command::DictateActivate { .. }
3046 | Command::DictateDeactivate { .. }
3047 | Command::DictateBench { .. }
3048 | Command::DictateBenchStop
3049 | Command::VoiceBenchStart
3050 | Command::VoiceBenchStop
3051 | Command::DictateReadAloudStart { .. }
3052 | Command::DictateReadAloudStop { .. }
3053 | Command::DictateReadAloudDelete { .. }
3054 | Command::DictateBenchDelete { .. }
3055 | Command::DictateUninstall { .. }
3056 | Command::SetReviewThreadStatus { .. }
3057 | Command::CloseSession { .. }
3058 | Command::UpsertReviewThread { .. }
3059 | Command::DeleteReviewThread { .. }
3060 | Command::SubmitReview { .. }
3061 | Command::TaskVerdict { .. }
3062 | Command::TaskAnswer { .. }
3063 | Command::TaskRank { .. }
3064 | Command::TaskAssign { .. }
3065 | Command::TaskCreate { .. }
3066 | Command::TaskMove { .. }
3067 | Command::TaskReopen { .. }
3068 | Command::TaskReserve { .. }
3069 | Command::PluginInstall { .. }
3070 | Command::PluginUpdate { .. }
3071 | Command::PluginUpdateAll
3072 | Command::PluginCheckUpdates
3073 | Command::MarketplaceAdd { .. }
3074 | Command::MarketplaceRepair { .. }
3075 | Command::RespondSettingsWrite { .. }
3076 | Command::VoiceMode { .. }
3077 | Command::VoicePreview { .. }
3078 | Command::BiometricEnrol { .. }
3079 | Command::BiometricRewrite { .. }
3080 | Command::BiometricRelease { .. }
3081 | Command::BiometricRevoke { .. }
3082 | Command::VaultLock
3083 | Command::VaultEnrolmentMaterial { .. }) => {
3084 tracing::warn!(
3085 target: "forge_workspace::session_task",
3086 slot = %key.display(),
3087 command = ?misrouted,
3088 "command has no per-session route"
3089 );
3090 Ok(())
3091 }
3092 }
3093}
3094
3095fn held_while_swapping(
3104 cmd: &Command,
3105 key: &SessionSlot,
3106 workspace: &std::sync::Weak<crate::Workspace>,
3107) -> bool {
3108 let prompt = match cmd {
3109 Command::Prompt { key: _, text, attachments } => crate::handoff::HeldPrompt {
3110 text: text.clone(),
3111 attachments: attachments.clone(),
3112 source: PromptSource::You,
3113 uuid: forge_sdk::request_id::next_prompt_id(),
3117 sender: None,
3118 },
3119 Command::PromptUnder { key: _, text, attachments, source, uuid, .. } => {
3120 crate::handoff::HeldPrompt {
3121 text: text.clone(),
3122 attachments: attachments.clone(),
3123 source: *source,
3124 uuid: uuid.clone(),
3125 sender: None,
3126 }
3127 }
3128 _ => return false,
3129 };
3130 let Some(workspace) = workspace.upgrade() else { return false };
3131 workspace.hold_prompt(key, prompt)
3132}
3133
3134fn fill_of(
3142 reported: Option<u8>,
3143 total_tokens: Option<u64>,
3144 effective_max_tokens: Option<u64>,
3145) -> Option<crate::handoff::FillReading> {
3146 if let (Some(total), Some(max)) = (total_tokens, effective_max_tokens)
3147 && max > 0
3148 {
3149 let percent = total.saturating_mul(100).saturating_add(max / 2) / max;
3150 return Some(crate::handoff::FillReading {
3151 percent: u8::try_from(percent).unwrap_or(u8::MAX),
3152 total_tokens: total,
3153 max_tokens: max,
3154 });
3155 }
3156 reported.map(|percent| crate::handoff::FillReading { percent, total_tokens: 0, max_tokens: 0 })
3157}
3158
3159fn permission_words(outcome: &forge_primitives::PermissionOutcome) -> Option<String> {
3160 match outcome {
3161 forge_primitives::PermissionOutcome::Selected { notes_text, .. } => notes_text.clone(),
3162 forge_primitives::PermissionOutcome::Cancelled => None,
3163 }
3164}
3165
3166fn question_words(outcome: &forge_primitives::QuestionOutcome) -> Option<String> {
3169 match outcome {
3170 forge_primitives::QuestionOutcome::Answered { annotation, .. } => {
3171 annotation.as_ref().and_then(|held| held.notes.clone())
3172 }
3173 forge_primitives::QuestionOutcome::Cancelled => None,
3174 }
3175}
3176
3177fn warn_no_session(key: &SessionSlot, command: &'static str) -> forge_agent::AgentError {
3178 tracing::warn!(
3179 target: "forge_workspace::session_task",
3180 slot = %key.display(),
3181 command,
3182 "command dropped: no session_id stamped on DomainSession yet",
3183 );
3184 forge_agent::AgentError::NoSession { command }
3185}
3186
3187const API_RETRY_SUBTYPE: &str = "api_retry";
3191
3192pub(crate) fn failure_reason(errors: Option<&[String]>, subtype: &str) -> String {
3195 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3196 return errors.join("; ");
3197 }
3198 if !subtype.is_empty() && subtype != "success" {
3199 return subtype.to_owned();
3200 }
3201 "an error".to_owned()
3202}
3203
3204pub(crate) struct Moved {
3215 pub monitors: bool,
3216 pub background_tasks: bool,
3217 pub processes: bool,
3218 pub commands: bool,
3219 pub agents: bool,
3220 pub cards: bool,
3221 pub lost_prompts: Vec<crate::protocol::QueuedPrompt>,
3224 pub nudge: bool,
3227 pub repo_nudge: Option<bool>,
3231 pub turn_ended: Option<MovedTurnEnd>,
3234}
3235
3236pub(crate) enum MovedTurnEnd {
3238 Complete { terminal_reason: Option<forge_primitives::runtime::TerminalReason> },
3241 Cancelled,
3244}
3245
3246pub(crate) fn apply_event_to_domain(domain: &mut DomainSession, event: &AgentEvent) -> Moved {
3258 let held_monitors = domain.monitors.clone();
3259 let held_tasks = domain.background_tasks.clone();
3260 let held_live_tasks = domain.live_tasks.clone();
3261 let held_walk = domain.process_snapshot.is_some();
3262 let held_commands = domain.available_commands.clone();
3263 let held_agents = domain.available_agents.clone();
3264 let held_cards = domain.cards_snapshot.clone();
3265 let mut nudge = false;
3266 let mut turn_ended = None;
3267 let mut lost_prompts = Vec::new();
3268 let repo_nudge = match event {
3275 AgentEvent::SdkMessage {
3276 msg: forge_primitives::Message::VcsStateChanged { .. }, ..
3277 } => Some(false),
3278 AgentEvent::SdkMessage {
3279 msg: forge_primitives::Message::CodeChangePublished { .. },
3280 ..
3281 } => Some(true),
3282 _ => None,
3283 };
3284 hold_view_facts(domain, event);
3285 if let AgentEvent::ConnectionFailed { .. } = event {
3286 domain.runtime_state = None;
3290 domain.reset_turns();
3291 domain.turn_open = false;
3292 domain.pending_cancel = false;
3293 domain.failed_turn_at = None;
3294 domain.auto_continue = None;
3295 domain.auto_continue_spent = false;
3296 lost_prompts = domain.drop_background_tasks();
3300 domain.process_snapshot = None;
3304 }
3305 if let AgentEvent::SdkMessage {
3306 msg: forge_primitives::Message::Result { is_error, subtype, errors, terminal_reason, .. },
3307 ..
3308 } = event
3309 {
3310 let cancelled = std::mem::take(&mut domain.pending_cancel);
3313 turn_ended = Some(if cancelled {
3324 MovedTurnEnd::Cancelled
3325 } else {
3326 MovedTurnEnd::Complete { terminal_reason: *terminal_reason }
3327 });
3328 domain.settle_foreground_holds();
3333 if *is_error && !cancelled {
3337 let at = std::time::SystemTime::now();
3338 domain.failed_turn_at = Some(at);
3339 if !domain.auto_continue_spent {
3344 domain.auto_continue = Some(PendingAutoContinue {
3345 reason: failure_reason(errors.as_deref(), subtype),
3346 });
3347 nudge = true;
3348 }
3349 } else if !*is_error {
3350 domain.auto_continue = None;
3353 domain.auto_continue_spent = false;
3354 }
3355 }
3356 if let AgentEvent::SdkMessage { msg: forge_primitives::Message::Error { .. }, .. } = event {
3357 domain.pending_cancel = false;
3360 }
3361 if let AgentEvent::SdkMessage {
3364 msg: forge_primitives::Message::BackgroundTasksChanged { tasks, .. },
3365 ..
3366 } = event
3367 {
3368 domain.background_work = !tasks.is_empty();
3369 let parsed: Vec<crate::BackgroundTask> = tasks
3370 .iter()
3371 .filter_map(|task| {
3372 let entry = task.as_object()?;
3373 Some(crate::BackgroundTask {
3374 task_id: entry.get("task_id")?.as_str()?.to_owned(),
3375 is_backgrounded: true,
3378 task_type: entry.get("task_type")?.as_str()?.to_owned(),
3379 description: entry.get("description")?.as_str()?.to_owned(),
3380 command: None,
3381 tool_use_id: None,
3382 })
3383 })
3384 .collect();
3385 if parsed.len() != tasks.len() {
3386 tracing::debug!(
3387 target: "forge_workspace::session_task",
3388 event_name = "background_tasks_parse_dropped",
3389 dropped = tasks.len() - parsed.len(),
3390 entry_count = tasks.len(),
3391 "background_tasks_changed dropped unparseable entries; possible wire drift",
3392 );
3393 }
3394 domain.replace_background_tasks(parsed);
3395 }
3396 if let AgentEvent::SdkMessage {
3405 msg: forge_primitives::Message::Assistant { message, .. },
3406 ..
3407 } = event
3408 {
3409 for block in &message.content {
3410 let forge_primitives::ContentBlock::ToolUse { id, input, .. } = block else {
3411 continue;
3412 };
3413 if let Some(command) = input.get("command").and_then(serde_json::Value::as_str) {
3414 domain.hold_background_command(id.clone(), command.to_owned());
3415 }
3416 }
3417 }
3418 if let AgentEvent::SdkMessage {
3424 msg:
3425 forge_primitives::Message::TaskStarted {
3426 task_id,
3427 description,
3428 tool_use_id,
3429 task_type,
3430 extras,
3431 ..
3432 },
3433 ..
3434 } = event
3435 {
3436 if let Some(tool_use_id) = tool_use_id {
3437 domain.hold_task_tool_use(task_id.clone(), tool_use_id.clone());
3438 }
3439 let dispatched = extras.get("parent_tool_use_id").and_then(serde_json::Value::as_str);
3444 let backgrounded =
3445 extras.get("is_backgrounded").and_then(serde_json::Value::as_bool).unwrap_or(false);
3446 if task_type.as_deref() == Some("local_bash")
3447 && !forge_primitives::names_a_dispatch(dispatched)
3448 {
3449 domain.note_live_task(crate::BackgroundTask {
3450 task_id: task_id.clone(),
3451 is_backgrounded: backgrounded,
3452 task_type: "local_bash".to_owned(),
3453 description: description.clone(),
3454 command: None,
3455 tool_use_id: tool_use_id.clone(),
3456 });
3457 }
3458 }
3459 if let AgentEvent::SdkMessage {
3464 msg: forge_primitives::Message::TaskNotification { task_id, tool_use_id, .. },
3465 ..
3466 } = event
3467 {
3468 domain.settle_live_task(task_id, tool_use_id.as_deref());
3469 }
3470 if let AgentEvent::SdkMessage {
3471 msg: forge_primitives::Message::TaskUpdated { task_id, patch, .. },
3472 ..
3473 } = event
3474 && patch.status.as_deref().is_some_and(task_status_is_terminal)
3475 {
3476 domain.settle_live_task(task_id, None);
3478 }
3479 if let AgentEvent::Connected { session_id, .. } = event {
3480 domain.session_id = Some(SessionId::new(session_id.clone()));
3481 domain.runtime_state = None;
3482 domain.reset_turns();
3483 domain.turn_open = false;
3484 domain.pending_cancel = false;
3487 domain.failed_turn_at = None;
3488 domain.auto_continue = None;
3489 domain.auto_continue_spent = false;
3490 lost_prompts = domain.drop_background_tasks();
3495 domain.available_commands.clear();
3500 domain.available_agents.clear();
3501 domain.agents_emitted_this_turn = false;
3508 domain.awaiting_login = false;
3510 }
3511 if let AgentEvent::SdkMessage { msg, .. } = event {
3516 match msg {
3517 forge_primitives::Message::Assistant {
3518 error: Some(forge_primitives::AssistantMessageError::AuthenticationFailed),
3519 ..
3520 } => domain.awaiting_login = true,
3521 forge_primitives::Message::System { subtype, data, .. }
3522 if subtype == API_RETRY_SUBTYPE =>
3523 {
3524 if let Some(update) = data
3525 .as_object()
3526 .and_then(forge_agent::translate::state_parsing::build_api_retry_update)
3527 && update.error == forge_primitives::ApiRetryError::AuthenticationFailed
3528 {
3529 domain.awaiting_login = true;
3530 }
3531 }
3532 _ => {}
3533 }
3534 }
3535 if let AgentEvent::TurnError { class, .. } = event
3541 && *class == forge_primitives::TurnErrorClass::AuthRequired
3542 {
3543 domain.awaiting_login = true;
3544 }
3545 if let AgentEvent::SdkMessage {
3552 msg: forge_primitives::Message::Result { is_error, subtype, .. },
3553 ..
3554 } = event
3555 && !*is_error
3556 && subtype == "success"
3557 {
3558 domain.awaiting_login = false;
3559 }
3560 if let AgentEvent::SdkMessage {
3565 msg: forge_primitives::Message::System { subtype, data, .. },
3566 ..
3567 } = event
3568 && subtype == "session_state_changed"
3569 && let Some(state) =
3570 forge_agent::translate::state_parsing::parse_runtime_session_state(data.get("state"))
3571 {
3572 domain.runtime_state = Some(state);
3573 }
3574 if let AgentEvent::SdkMessage { msg, .. } = event
3580 && let Some(said) = crate::domain_session::liveness_of(msg)
3581 {
3582 domain.note_liveness(said);
3583 }
3584 if let AgentEvent::SdkMessage {
3589 msg: forge_primitives::Message::System { subtype, data, .. },
3590 ..
3591 } = event
3592 && subtype == "init"
3593 && let Some(record) = data.as_object()
3594 {
3595 if let Some(entries) = record.get("slash_commands").and_then(serde_json::Value::as_array) {
3596 let commands =
3597 forge_agent::translate::commands::map_available_commands_from_json(entries);
3598 if !commands.is_empty() {
3601 domain.available_commands = commands;
3602 }
3603 }
3604 if !domain.agents_emitted_this_turn
3605 && let Some(agents) = record.get("agents")
3606 {
3607 domain.available_agents =
3608 forge_agent::translate::agents::map_available_agents_from_names(Some(agents));
3609 domain.agents_emitted_this_turn = true;
3610 }
3611 }
3612 if let AgentEvent::SdkMessage {
3613 msg: forge_primitives::Message::CommandsChanged { commands, .. },
3614 ..
3615 } = event
3616 {
3617 let parsed = forge_agent::translate::commands::map_available_commands_from_json(commands);
3618 let drift = parsed.is_empty() && !commands.is_empty();
3622 if drift {
3623 tracing::warn!(
3626 target: "forge_workspace::session_task",
3627 slot = %domain.key.display(),
3628 event_name = "commands_changed_parse_empty",
3629 message = "commands_changed carried entries but none parsed; likely wire drift, keeping prior list",
3630 outcome = "skipped",
3631 entry_count = commands.len(),
3632 );
3633 } else {
3634 domain.available_commands = parsed;
3635 }
3636 }
3637 if let AgentEvent::SdkMessage {
3639 msg: forge_primitives::Message::Result { .. } | forge_primitives::Message::Error { .. },
3640 ..
3641 } = event
3642 {
3643 domain.agents_emitted_this_turn = false;
3644 }
3645 if let AgentEvent::SdkMessage { msg, .. } = event {
3651 domain.card_tracker.apply(msg);
3652 let cards = domain.card_tracker.cards();
3653 if cards != domain.cards_snapshot {
3654 domain.cards_snapshot = cards;
3655 }
3656 }
3657 Moved {
3658 monitors: domain.monitors != held_monitors,
3659 background_tasks: domain.background_tasks != held_tasks
3660 || domain.live_tasks != held_live_tasks,
3661 processes: held_walk && domain.process_snapshot.is_none(),
3662 commands: domain.available_commands != held_commands,
3663 agents: domain.available_agents != held_agents,
3664 cards: domain.cards_snapshot != held_cards
3667 && matches!(event, AgentEvent::SdkMessage { .. }),
3668 nudge,
3669 repo_nudge,
3670 turn_ended,
3671 lost_prompts,
3672 }
3673}
3674
3675fn clear_runtime_identity(domain: &mut DomainSession) {
3686 domain.observed_permission_mode = None;
3687 domain.observed_effort = None;
3688 domain.current_model = None;
3689 domain.mcp_servers = None;
3690 domain.context_usage = None;
3691 domain.handoff = crate::domain_session::HandoffState::default();
3695 domain.monitors.clear();
3696}
3697
3698fn hold_view_facts(domain: &mut DomainSession, event: &AgentEvent) {
3706 if let AgentEvent::Connected { current_model, available_models, history_updates, .. } = event {
3707 clear_runtime_identity(domain);
3711 domain.current_model = Some(current_model.clone());
3712 domain.available_models.clone_from(available_models);
3713 domain.card_tracker = crate::subagent_cards::CardTracker::default();
3716 if let Some(history) = history_updates.as_ref() {
3717 for message in history {
3718 domain.card_tracker.apply(message);
3719 }
3720 }
3721 domain.cards_snapshot = domain.card_tracker.cards();
3722 if let Some(history) = history_updates {
3727 for msg in history {
3728 if forge_primitives::messages::ran_under_a_dispatch(msg) {
3729 continue;
3730 }
3731 fold_monitor(domain, msg, MonitorOrigin::Transcript);
3732 }
3733 drain_settled_monitors(domain);
3737 }
3738 }
3739 if matches!(event, AgentEvent::ConnectionFailed { .. }) {
3743 clear_runtime_identity(domain);
3744 }
3745 if let AgentEvent::HookObservation { permission_mode, effort, .. } = event {
3746 if let Some(mode) =
3747 permission_mode.as_deref().and_then(forge_primitives::PermissionMode::from_wire)
3748 {
3749 domain.observed_permission_mode = Some(mode);
3750 }
3751 if let Some(level) = effort.as_deref().and_then(forge_primitives::EffortLevel::from_stored)
3754 {
3755 domain.observed_effort = Some(level);
3756 }
3757 }
3758 if let AgentEvent::McpSnapshot { servers, error, .. } = event {
3759 domain.mcp_servers = Some(crate::domain_session::McpServers {
3760 servers: servers.clone(),
3761 error: error.clone(),
3762 });
3763 }
3764 if let AgentEvent::ContextUsage { percentage, max_tokens, .. } = event {
3765 domain.context_usage = Some(crate::domain_session::ContextUsage {
3766 percent: *percentage,
3767 max_tokens: *max_tokens,
3768 });
3769 }
3770 if let AgentEvent::SdkMessage { msg, .. } = event {
3771 fold_monitor(domain, msg, MonitorOrigin::Wire);
3772 if let forge_primitives::Message::System { subtype, data, .. } = msg
3773 && subtype == "init"
3774 {
3775 reconcile_model_from_init(domain, data);
3776 }
3777 }
3778}
3779
3780fn reconcile_model_from_init(domain: &mut DomainSession, data: &serde_json::Value) {
3788 let Some(model_id) = data.get("model").and_then(serde_json::Value::as_str).map(str::trim)
3789 else {
3790 return;
3791 };
3792 if model_id.is_empty()
3793 || domain.current_model.as_ref().is_some_and(|held| held.resolved_id == model_id)
3794 {
3795 return;
3796 }
3797 domain.current_model = Some(forge_agent::session_lifecycle::resolve_current_model_from_inputs(
3801 model_id,
3802 None,
3803 None,
3804 &domain.available_models,
3805 ));
3806}
3807
3808fn task_status_is_terminal(status: &str) -> bool {
3810 monitor_status_from_wire(status).is_some()
3811}
3812
3813fn monitor_status_from_wire(status: &str) -> Option<forge_primitives::MonitorStatus> {
3818 match status {
3819 "completed" => Some(forge_primitives::MonitorStatus::Completed),
3820 "failed" | "killed" | "stopped" => Some(forge_primitives::MonitorStatus::Stopped),
3821 _ => None,
3822 }
3823}
3824
3825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3828enum MonitorOrigin {
3829 Wire,
3832 Transcript,
3838}
3839
3840fn fold_monitor(
3848 domain: &mut DomainSession,
3849 msg: &forge_primitives::Message,
3850 origin: MonitorOrigin,
3851) {
3852 match msg {
3853 forge_primitives::Message::Assistant { message, .. } => {
3854 for block in &message.content {
3855 let forge_primitives::ContentBlock::ToolUse { id, name, input, .. } = block else {
3856 continue;
3857 };
3858 if name != "Monitor" {
3859 continue;
3860 }
3861 let Some(parsed) = forge_agent::user_interaction::parse_monitor_input(input) else {
3862 continue;
3863 };
3864 if domain.monitors.iter().any(|held| &held.tool_use_id == id) {
3865 continue;
3866 }
3867 let status = match origin {
3868 MonitorOrigin::Wire => forge_primitives::MonitorStatus::Running,
3869 MonitorOrigin::Transcript => forge_primitives::MonitorStatus::Completed,
3870 };
3871 domain.monitors.push(forge_primitives::MonitorRecord {
3872 tool_use_id: id.clone(),
3873 task_id: None,
3874 description: parsed.description,
3875 command: parsed.command,
3876 persistent: parsed.persistent,
3877 timeout_ms: parsed.timeout_ms,
3878 status,
3879 output_file: None,
3880 ended_at: None,
3881 });
3882 }
3883 }
3884 forge_primitives::Message::TaskStarted { task_id, tool_use_id: Some(id), .. } => {
3885 let Some(record) = domain.monitors.iter_mut().find(|held| &held.tool_use_id == id)
3886 else {
3887 return;
3888 };
3889 record.task_id.get_or_insert_with(|| task_id.clone());
3890 }
3891 forge_primitives::Message::TaskUpdated { task_id, patch, .. } => {
3892 settle_monitor(
3896 domain,
3897 task_id,
3898 patch.status.as_deref().and_then(monitor_status_from_wire),
3899 None,
3900 patch.end_time,
3901 );
3902 }
3903 forge_primitives::Message::TaskNotification { task_id, status, output_file, .. } => {
3904 let settled = match status {
3905 forge_primitives::TaskNotificationStatus::Completed => {
3906 Some(forge_primitives::MonitorStatus::Completed)
3907 }
3908 forge_primitives::TaskNotificationStatus::Failed
3909 | forge_primitives::TaskNotificationStatus::Stopped => {
3910 Some(forge_primitives::MonitorStatus::Stopped)
3911 }
3912 forge_primitives::TaskNotificationStatus::Unknown => None,
3915 };
3916 if let Some(status) = settled {
3917 settle_monitor(domain, task_id, Some(status), Some(output_file.clone()), None);
3918 }
3919 drain_settled_monitors(domain);
3923 }
3924 _ => {}
3925 }
3926}
3927
3928fn drain_settled_monitors(domain: &mut DomainSession) {
3930 if !domain.monitors.is_empty()
3931 && domain.monitors.iter().all(|monitor| monitor.status.is_terminal())
3932 {
3933 domain.monitors.clear();
3934 }
3935}
3936
3937fn settle_monitor(
3938 domain: &mut DomainSession,
3939 task_id: &str,
3940 status: Option<forge_primitives::MonitorStatus>,
3941 output_file: Option<String>,
3942 end_time_ms: Option<u64>,
3943) {
3944 let Some(record) =
3945 domain.monitors.iter_mut().find(|held| held.task_id.as_deref() == Some(task_id))
3946 else {
3947 return;
3948 };
3949 if let Some(status) = status {
3950 record.status = status;
3951 }
3952 if let Some(path) = output_file {
3953 record.output_file = Some(path);
3954 }
3955 if let Some(end_ms) = end_time_ms {
3956 record.ended_at =
3957 Some(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(end_ms));
3958 }
3959}
3960
3961fn spawn_permission_response_forwarder(
3964 agent: Arc<AgentHandle>,
3965 response_rx: tokio::sync::oneshot::Receiver<forge_primitives::PermissionOutcome>,
3966 session_id: String,
3967 tool_call_id: String,
3968) {
3969 let span = tracing::info_span!(
3970 "permission_response_forwarder",
3971 session_id = %session_id,
3972 tool_call_id = %tool_call_id,
3973 );
3974 tokio::task::spawn(
3975 async move {
3976 let Ok(outcome) = response_rx.await else {
3977 tracing::warn!(
3978 target: "forge_workspace::session_task",
3979 session_id = %session_id,
3980 tool_call_id = %tool_call_id,
3981 "permission response channel closed before forwarding"
3982 );
3983 return;
3984 };
3985 let selected_option = match &outcome {
3986 forge_primitives::PermissionOutcome::Selected { option_id, .. } => {
3987 option_id.clone()
3988 }
3989 forge_primitives::PermissionOutcome::Cancelled => "cancelled".to_owned(),
3990 };
3991 let session_id_for_log = session_id.clone();
3992 let tool_call_id_for_log = tool_call_id.clone();
3993 match agent.permission_response(session_id, tool_call_id, outcome) {
3994 Ok(()) => {
3995 tracing::info!(
3996 target: "forge_workspace::session_task",
3997 session_id = %session_id_for_log,
3998 tool_call_id = %tool_call_id_for_log,
3999 selected_option = %selected_option,
4000 "permission response forwarded to bridge"
4001 );
4002 }
4003 Err(err) => {
4004 tracing::error!(
4005 target: "forge_workspace::session_task",
4006 session_id = %session_id_for_log,
4007 tool_call_id = %tool_call_id_for_log,
4008 selected_option = %selected_option,
4009 error = %err,
4010 "failed to forward permission response to bridge"
4011 );
4012 }
4013 }
4014 }
4015 .instrument(span),
4016 );
4017}
4018
4019fn spawn_question_response_forwarder(
4022 agent: Arc<AgentHandle>,
4023 response_rx: tokio::sync::oneshot::Receiver<forge_primitives::QuestionOutcome>,
4024 session_id: String,
4025 tool_call_id: String,
4026) {
4027 let span = tracing::info_span!(
4028 "question_response_forwarder",
4029 session_id = %session_id,
4030 tool_call_id = %tool_call_id,
4031 );
4032 tokio::task::spawn(
4033 async move {
4034 let Ok(outcome) = response_rx.await else {
4035 tracing::warn!(
4036 target: "forge_workspace::session_task",
4037 session_id = %session_id,
4038 tool_call_id = %tool_call_id,
4039 "question response channel closed before forwarding"
4040 );
4041 return;
4042 };
4043 let selected_option_count = match &outcome {
4044 forge_primitives::QuestionOutcome::Answered { selected_option_ids, .. } => {
4045 selected_option_ids.len()
4046 }
4047 forge_primitives::QuestionOutcome::Cancelled => 0,
4048 };
4049 let session_id_for_log = session_id.clone();
4050 let tool_call_id_for_log = tool_call_id.clone();
4051 match agent.question_response(session_id, tool_call_id, outcome) {
4052 Ok(()) => {
4053 tracing::info!(
4054 target: "forge_workspace::session_task",
4055 session_id = %session_id_for_log,
4056 tool_call_id = %tool_call_id_for_log,
4057 selected_option_count,
4058 "question response forwarded to bridge"
4059 );
4060 }
4061 Err(err) => {
4062 tracing::error!(
4063 target: "forge_workspace::session_task",
4064 session_id = %session_id_for_log,
4065 tool_call_id = %tool_call_id_for_log,
4066 selected_option_count,
4067 error = %err,
4068 "failed to forward question response to bridge"
4069 );
4070 }
4071 }
4072 }
4073 .instrument(span),
4074 );
4075}
4076
4077#[cfg(test)]
4078#[allow(clippy::expect_used, clippy::unwrap_used)]
4079mod tests {
4080 use super::*;
4081 use crate::update_fanout::{SeatUpdate, SubscriberRole};
4082 use forge_agent::Agent;
4083 use forge_agent::client::SpawnFailureKind;
4084 use forge_primitives::{ContentBlock, Message};
4085
4086 fn empty_domain() -> DomainSession {
4087 let (handle, _rx) = Agent::testing_stub();
4088 DomainSession::new(SessionSlot::from_str_for_test("test"), Some(Arc::new(handle)))
4089 }
4090
4091 fn test_slot() -> crate::SessionSlot {
4094 crate::SessionSlot::lead("TestOrg", "forge")
4095 }
4096
4097 async fn tag_rollback_leftover_row(connected_once: bool) -> Option<serde_json::Value> {
4106 let cfg_dir = tempfile::tempdir().expect("cfg tempdir");
4107 std::fs::create_dir_all(cfg_dir.path().join("forge")).expect("forge dir");
4108 std::fs::write(
4109 cfg_dir.path().join("forge").join("forge.toml"),
4110 "[[orgs]]\nname = \"Default\"\naccounts = [\"Acct\"]\n\n\
4111 [[orgs.projects]]\nname = \"forge\"\npath = \"/tmp/tag-rollback-row\"\n\n\
4112 [[accounts]]\ndisplay_name = \"Acct\"\ntoken = \"t\"\nmodels = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
4113 )
4114 .expect("write forge.toml");
4115 let config = crate::config::load_from_dir(cfg_dir.path()).expect("load config");
4116 let (workspace, mut update_rx) =
4117 crate::Workspace::testing_stub_with_config(cfg_dir.path().to_owned(), config)
4118 .expect("stub over the fixture config");
4119 let db_dir = tempfile::tempdir().expect("db tempdir");
4120 workspace.install_db_for_test(
4121 crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
4122 );
4123 let project_key = workspace.project_key_for_name("forge").expect("the fixture project");
4124 let worker_slot = crate::SessionSlot::worker("Default", "forge", "steward");
4125 workspace.insert_live_worker(
4126 &project_key,
4127 crate::mcp::workers::types::WorkerEntry {
4128 label: "steward".to_owned(),
4129 charter: "c".to_owned(),
4130 slot: worker_slot.clone(),
4131 session_id: Some(forge_primitives::SessionId::new("steward-id")),
4132 status: forge_primitives::WorkerLiveness::Spawning,
4133 spawned_at: std::time::SystemTime::UNIX_EPOCH,
4134 spawned_by: crate::SessionSlot::from_str_for_test("lead-uuid"),
4135 needs_tag: true,
4137 is_git_repo_at_spawn: false,
4138 cwd: None,
4139 diagnostic: None,
4140 kick: None,
4141 },
4142 );
4143 workspace
4144 .record_worker_row(
4145 &project_key,
4146 "steward",
4147 "steward-id",
4148 "charter",
4149 Some("kick"),
4150 None,
4151 false,
4152 crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
4153 None,
4154 )
4155 .expect("seed the row the rollback judges");
4156
4157 let (handle, _agent_cmds) = Agent::testing_stub();
4158 let arc = Arc::new(handle);
4159 let domain = workspace.register_domain_session(worker_slot.clone(), Some(Arc::clone(&arc)));
4160 domain.lock().spawn_wrote_row = true;
4162 let (_cmd_tx, command_rx) =
4163 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
4164 let mut task = SessionTask {
4165 key: worker_slot.clone(),
4166 handle: arc,
4167 command_rx,
4168 domain,
4169 update_tx: workspace.update_sender(),
4170 connected_once,
4171 workspace: Arc::downgrade(&workspace),
4172 compaction_count: 0,
4173 successor: None,
4174 teardown_done: None,
4175 };
4176
4177 task.translate_event(AgentEvent::Connected {
4178 session_id: "not-a-uuid".to_owned(),
4179 cwd: "/tmp/tag-rollback-row".to_owned(),
4180 current_model: forge_primitives::CurrentModel {
4181 resolved_id: "claude".to_owned(),
4182 display_name_short: "claude".to_owned(),
4183 display_name_long: "claude".to_owned(),
4184 requested_id: None,
4185 catalog_id: None,
4186 supports_effort: false,
4187 supported_effort_levels: Vec::new(),
4188 supports_auto_mode: None,
4189 supports_adaptive_thinking: None,
4190 is_authoritative: true,
4191 },
4192 available_models: Vec::new(),
4193 mode: None,
4194 history_updates: None,
4195 compaction_count: 0,
4196 });
4197
4198 tokio::time::timeout(std::time::Duration::from_secs(5), async {
4201 while let Some(frame) = update_rx.recv().await {
4202 if let crate::protocol::SessionUpdate::WorkerStatusChanged { action, .. } =
4203 std::sync::Arc::unwrap_or_clone(frame.update)
4204 && action == crate::protocol::WorkerStatusAction::Removed
4205 {
4206 return;
4207 }
4208 }
4209 })
4210 .await
4211 .expect("the rollback emits a Removed event");
4212
4213 let db = workspace.db.lock();
4214 crate::store::sessions::get(db.as_ref().expect("db"), "Default", "forge", "steward")
4215 .expect("read the row the rollback left")
4216 .map(|row| serde_json::json!({ "charter": row.charter, "kick": row.kick }))
4217 }
4218
4219 #[tokio::test]
4223 async fn a_first_connected_rolls_back_the_row_its_spawn_minted() {
4224 assert!(
4225 tag_rollback_leftover_row(false).await.is_none(),
4226 "the first Connected of a minting spawn takes the row with the rollback",
4227 );
4228 }
4229
4230 #[tokio::test]
4235 async fn a_new_session_retag_keeps_the_workers_row() {
4236 assert!(
4237 tag_rollback_leftover_row(true).await.is_some(),
4238 "a `/new` re-tag must not take the row of a worker that is already established",
4239 );
4240 }
4241
4242 fn workspace_with_account_config_dir(
4243 _config_dir: &str,
4244 ) -> (tempfile::TempDir, Arc<crate::Workspace>) {
4245 let dir = tempfile::tempdir().expect("tempdir");
4246 let forge = dir.path().join("forge");
4247 std::fs::create_dir_all(&forge).expect("forge dir");
4248 std::fs::write(
4249 forge.join("forge.toml"),
4250 "[[orgs]]\nname = \"Default\"\naccounts = [\"Acct\"]\n\n\
4251 [[orgs.projects]]\nname = \"forge\"\npath = \"~/Projects/forge\"\n\n\
4252 [[accounts]]\ndisplay_name = \"Acct\"\ntoken = \"t\"\nmodels = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
4253 )
4254 .expect("write forge.toml");
4255 let workspace =
4256 Arc::new(crate::Workspace::new_for_test(dir.path().to_owned()).expect("new"));
4257 (dir, workspace)
4258 }
4259
4260 fn workspace_with_pending_review_activity()
4265 -> (tempfile::TempDir, Arc<crate::Workspace>, SessionSlot, SessionSlot) {
4266 use forge_primitives::review::{
4267 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
4268 };
4269 let dir = tempfile::tempdir().expect("tempdir");
4270 let (workspace, _rx) =
4271 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4272 workspace.install_db_for_test(
4273 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4274 );
4275 workspace.upsert_review_thread(
4276 "forge",
4277 "feat",
4278 ReviewThread {
4279 id: "a".to_owned(),
4280 anchor: ReviewAnchor {
4281 path: "src/x.rs".to_owned(),
4282 side: ReviewSide::New,
4283 line: 1,
4284 content_hash: 1,
4285 context: vec!["ctx".to_owned()],
4286 base_ref: "main".to_owned(),
4287 },
4288 comments: vec![ReviewComment {
4289 author: ReviewAuthor::User,
4290 text: "look".to_owned(),
4291 at: "t".to_owned(),
4292 review_id: None,
4293 }],
4294 status: ReviewStatus::Open,
4295 created_at: "t".to_owned(),
4296 updated_at: "t".to_owned(),
4297 commit: None,
4298 },
4299 );
4300 let reviewer = SessionSlot::from_str_for_test("reviewer");
4301 let worker = SessionSlot::from_str_for_test("worker");
4302 workspace.submit_review("forge", "feat", None, &["a".to_owned()], reviewer.clone());
4303 workspace
4304 .review_reply(&worker, "forge", "feat", "a", "implementer", "fixed", "t")
4305 .expect("reply recorded as this turn's activity");
4306 (dir, workspace, reviewer, worker)
4307 }
4308
4309 fn review_task_for(
4311 workspace: &Arc<crate::Workspace>,
4312 key: &SessionSlot,
4313 ) -> (SessionTask, mpsc::UnboundedReceiver<SeatUpdate>) {
4314 let (handle, _cmds) = Agent::testing_stub();
4315 let handle = Arc::new(handle);
4316 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
4317 let update_tx = UpdateFanout::default();
4318 let update_rx = update_tx.subscribe(SubscriberRole::Answering);
4319 let domain = Arc::new(Mutex::new(DomainSession::new(key.clone(), Some(handle.clone()))));
4320 let task = SessionTask {
4321 key: key.clone(),
4322 handle,
4323 command_rx,
4324 domain,
4325 update_tx,
4326 connected_once: false,
4327 workspace: Arc::downgrade(workspace),
4328 compaction_count: 0,
4329 successor: None,
4330 teardown_done: None,
4331 };
4332 (task, update_rx)
4333 }
4334
4335 fn command_task_for(
4339 workspace: &Arc<crate::Workspace>,
4340 key: &SessionSlot,
4341 ) -> (SessionTask, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>) {
4342 let (handle, cmds) = Agent::testing_stub();
4343 let handle = Arc::new(handle);
4344 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
4345 let update_tx = UpdateFanout::default();
4346 let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
4347 let domain = Arc::new(Mutex::new(DomainSession::new(key.clone(), Some(handle.clone()))));
4348 let task = SessionTask {
4349 key: key.clone(),
4350 handle,
4351 command_rx,
4352 domain,
4353 update_tx,
4354 connected_once: false,
4355 workspace: Arc::downgrade(workspace),
4356 compaction_count: 0,
4357 successor: None,
4358 teardown_done: None,
4359 };
4360 (task, cmds)
4361 }
4362
4363 #[tokio::test]
4368 async fn dropping_a_session_task_releases_its_pooled_handle() {
4369 let (workspace, _updates) = crate::Workspace::testing_stub();
4370 let key = SessionSlot::from_str_for_test("drop-task-key");
4371 let (task, _update_rx) = review_task_for(&workspace, &key);
4372 let handle = Arc::clone(&task.handle);
4373 workspace.pool.lock().insert(
4374 key.clone(),
4375 crate::workspace::PooledAgent {
4376 handle: Arc::clone(&handle),
4377 account: forge_gateway::AccountKey("acct-a".to_owned()),
4378 permission_mode: None,
4379 registration: None,
4380 session_id: "drop-task".to_owned(),
4381 },
4382 );
4383 let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel();
4384 workspace.command_senders.lock().insert(key.clone(), cmd_tx);
4385 workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
4386
4387 drop(task);
4388
4389 assert!(!workspace.pool.lock().contains_key(&key), "the pool entry goes with its task");
4390 assert!(
4391 !workspace.command_senders.lock().contains_key(&key),
4392 "and the command sender the seat was addressed by",
4393 );
4394 assert!(
4395 !workspace.domain_handles.lock().contains_key(&key),
4396 "and the domain handle that pointed at it",
4397 );
4398 }
4399
4400 #[tokio::test]
4404 async fn a_handoff_swaps_under_the_id_its_row_names() {
4405 let dir = tempfile::tempdir().expect("tempdir");
4406 let (workspace, mut update_rx) =
4407 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4408 workspace.enable_test_dispatch_intercept();
4409 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4410 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
4411 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4412 workspace.install_db_for_test(
4413 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4414 );
4415 workspace.domain_handles.lock().insert(slot.clone(), Arc::clone(&task.domain));
4419 task.domain.lock().background_tasks.push(forge_primitives::background::BackgroundTask {
4420 task_id: "b1".to_owned(),
4421 is_backgrounded: true,
4422 task_type: "local_bash".to_owned(),
4423 description: "cargo watch".to_owned(),
4424 command: Some("cargo watch".to_owned()),
4425 tool_use_id: None,
4426 });
4427
4428 task.execute_command(Command::Handoff { key: slot.clone(), brief: None });
4429 task.domain.lock().handoff.judgment = Some("Now: the cut is half in.".to_owned());
4430 task.after_turn_ended();
4431
4432 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
4433 while let Some(frame) = update_rx.recv().await {
4434 if let SessionUpdate::Handoff { handoff, .. } =
4435 std::sync::Arc::unwrap_or_clone(frame.update)
4436 {
4437 return handoff;
4438 }
4439 }
4440 panic!("the handoff row was never emitted");
4441 })
4442 .await
4443 .expect("the handoff lands");
4444
4445 assert_eq!(record.from, "old-session-id", "the row names the session that handed over");
4446 assert_eq!(
4447 record.reason,
4448 forge_primitives::HandoffReason::Manual,
4449 "a manual handoff is a handoff asked for",
4450 );
4451 assert!(
4452 record.brief.ground_truth.iter().any(|section| section.label == "record"),
4453 "the ground truth is built at the swap: {:?}",
4454 record.brief.ground_truth,
4455 );
4456 let live = record
4457 .brief
4458 .ground_truth
4459 .iter()
4460 .find(|section| section.label == "background")
4461 .expect("the live background task reaches PART 1");
4462 assert!(
4463 live.lines.iter().any(|line| line.contains("local_bash: cargo watch [b1]")),
4464 "with what it is and its id: {:?}",
4465 live.lines,
4466 );
4467 assert!(
4468 record.brief.judgment.iter().any(|section| section.label == "Now"),
4469 "the caller's brief is the judgment half: {:?}",
4470 record.brief.judgment,
4471 );
4472
4473 let dispatched = workspace.drain_test_dispatch_buffer();
4474 let spawn = dispatched.iter().find_map(|command| match command {
4475 Command::NewSession { session_id, .. } => Some(session_id.clone()),
4476 _ => None,
4477 });
4478 assert_eq!(
4479 spawn,
4480 Some(Some(record.to.clone())),
4481 "the spawn runs under the id the row names",
4482 );
4483
4484 let pending =
4485 workspace.take_pending_brief(&slot).expect("the brief waits for its successor");
4486 assert_eq!(pending.to, record.to, "and it names the same occupant");
4487 assert!(pending.text.starts_with("HANDOFF - "), "the successor is given the brief");
4488 assert!(
4489 workspace.handoff_for_successor(&slot, &record.to).is_some(),
4490 "and the record is written, so the successor is measurable",
4491 );
4492 }
4493
4494 #[tokio::test]
4498 async fn a_prompt_sent_mid_swap_waits_for_the_successor() {
4499 let dir = tempfile::tempdir().expect("tempdir");
4500 let (workspace, _rx) =
4501 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4502 workspace.enable_test_dispatch_intercept();
4503 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4504 let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
4505 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4506 task.connected_once = true;
4507 workspace.begin_swap(&slot);
4508
4509 task.execute_command(Command::Prompt {
4510 key: slot.clone(),
4511 text: "sent mid swap".to_owned(),
4512 attachments: Vec::new(),
4513 });
4514 assert!(
4515 agent_rx.try_recv().is_err(),
4516 "the session on its way out is not handed the prompt",
4517 );
4518
4519 task.translate_event(connected_event("new-session-id", "/tmp/forge"));
4520
4521 let dispatched = workspace.drain_test_dispatch_buffer();
4522 assert!(
4523 dispatched.iter().any(|command| matches!(
4524 command,
4525 Command::PromptUnder { text, .. } if text == "sent mid swap"
4526 )),
4527 "the successor is handed it once it is here: {dispatched:?}",
4528 );
4529 assert!(!workspace.swap_in_progress(&slot), "and the gate is closed");
4530 }
4531
4532 #[tokio::test]
4536 async fn a_non_matching_replacement_leaves_the_brief_for_its_successor() {
4537 let dir = tempfile::tempdir().expect("tempdir");
4538 let (workspace, _rx) =
4539 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4540 workspace.enable_test_dispatch_intercept();
4541 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4542 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
4543 task.connected_once = true;
4544 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4545 workspace.set_pending_brief(
4546 &slot,
4547 crate::handoff::PendingBrief {
4548 to: "new-session-id".to_owned(),
4549 text: "HANDOFF - old -> new".to_owned(),
4550 },
4551 );
4552
4553 task.translate_event(connected_event("a-login-session", "/tmp/forge"));
4554
4555 assert!(
4556 workspace.pending_brief_for(&slot).is_some(),
4557 "another replacement leaves the brief for the successor it names",
4558 );
4559 assert!(
4560 workspace.drain_test_dispatch_buffer().is_empty(),
4561 "and it is given to nobody else",
4562 );
4563
4564 task.translate_event(connected_event("new-session-id", "/tmp/forge"));
4565 let dispatched = workspace.drain_test_dispatch_buffer();
4566 assert!(
4567 dispatched.iter().any(|command| matches!(
4568 command,
4569 Command::PromptUnder { text, .. } if text.starts_with("HANDOFF - old -> new")
4570 )),
4571 "the named successor is the one given it: {dispatched:?}",
4572 );
4573 }
4574
4575 #[tokio::test]
4580 async fn a_handoff_does_not_persist_its_successor_before_it_connects() {
4581 let dir = tempfile::tempdir().expect("tempdir");
4582 let (workspace, mut update_rx) =
4583 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4584 workspace.install_db_for_test(
4585 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4586 );
4587 workspace.enable_test_dispatch_intercept();
4588 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4589 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
4590 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4591 workspace.note_running_session_id(&slot, "old-session-id");
4592
4593 task.execute_command(Command::Handoff { key: slot.clone(), brief: None });
4594 task.domain.lock().handoff.judgment = Some("Now: the cut is half in.".to_owned());
4595 task.after_turn_ended();
4596 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
4597 while let Some(frame) = update_rx.recv().await {
4598 if let SessionUpdate::Handoff { handoff, .. } =
4599 std::sync::Arc::unwrap_or_clone(frame.update)
4600 {
4601 return handoff;
4602 }
4603 }
4604 panic!("the handoff row was never emitted");
4605 })
4606 .await
4607 .expect("the handoff lands");
4608
4609 assert_eq!(
4610 task.domain.lock().session_id.as_ref().map(forge_primitives::SessionId::as_str),
4611 Some("old-session-id")
4612 );
4613 let stored = {
4614 let guard = workspace.db.lock();
4615 let db = guard.as_ref().expect("a db");
4616 crate::store::sessions::get(db, slot.org(), slot.project(), slot.label())
4617 .expect("read")
4618 .and_then(|row| row.session_id)
4619 };
4620 assert_eq!(
4621 stored.as_deref(),
4622 Some("old-session-id"),
4623 "the successor id is not on the slot's row before it has run: {record:?}",
4624 );
4625 }
4626
4627 #[tokio::test]
4631 async fn a_handoff_that_loses_its_session_closes_the_gate() {
4632 let dir = tempfile::tempdir().expect("tempdir");
4633 let (workspace, _rx) =
4634 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4635 workspace.enable_test_dispatch_intercept();
4636 let key = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4637 let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &key);
4638 workspace.begin_swap(&key);
4639 workspace.hold_prompt(
4640 &key,
4641 crate::handoff::HeldPrompt {
4642 text: "held".to_owned(),
4643 attachments: Vec::new(),
4644 source: crate::protocol::PromptSource::You,
4645 uuid: "held-1".to_owned(),
4646 sender: None,
4647 },
4648 );
4649
4650 task.swap(forge_primitives::HandoffReason::Manual, Vec::new());
4651
4652 assert!(!workspace.swap_in_progress(&key), "the gate is closed with the swap");
4653 let mut said = Vec::new();
4654 let mut turns = Vec::new();
4655 let mut settled = Vec::new();
4656 while let Ok(frame) = update_rx.try_recv() {
4657 match std::sync::Arc::unwrap_or_clone(frame.update) {
4658 SessionUpdate::Notice { text, .. } => said.push(text),
4659 SessionUpdate::MachineTurn { machine, .. } => turns.push(machine),
4660 SessionUpdate::PromptLifecycle { uuid, state, .. } => settled.push((uuid, state)),
4661 _ => {}
4662 }
4663 }
4664 assert!(
4665 said.iter().any(|text| text.contains("needs a connected session")),
4666 "the seat is told why: {said:?}",
4667 );
4668 assert!(
4669 said.iter().any(|text| text.contains("this prompt waits no longer")),
4670 "and the held prompt is accounted for: {said:?}",
4671 );
4672 assert!(
4673 settled.iter().any(|(uuid, state)| uuid == "held-1" && state == "cancelled"),
4674 "and the press's composer mark settles: {settled:?}",
4675 );
4676 assert_eq!(turns, vec![None], "the machine turn ends with the handoff");
4679 }
4680
4681 #[tokio::test]
4684 async fn a_second_handoff_is_dropped_while_one_builds() {
4685 let dir = tempfile::tempdir().expect("tempdir");
4686 let (workspace, _rx) =
4687 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4688 workspace.enable_test_dispatch_intercept();
4689 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
4690 workspace.begin_swap(&task.key);
4691
4692 task.begin_handoff();
4693
4694 assert!(
4695 workspace.drain_test_dispatch_buffer().is_empty(),
4696 "no judgment prompt is sent by a handoff that cannot own the seat",
4697 );
4698 assert!(!task.domain.lock().handoff.awaiting, "and nothing is left waiting");
4699 }
4700
4701 #[tokio::test]
4704 async fn a_failed_handoff_answers_a_held_peers_sender() {
4705 let dir = tempfile::tempdir().expect("tempdir");
4706 let (workspace, mut update_rx) =
4707 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4708 workspace.enable_test_dispatch_intercept();
4709 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
4710 let sender = SessionSlot::from_str_for_test("sender-proj");
4711 workspace.begin_swap(&task.key);
4712 workspace.hold_prompt(
4713 &task.key,
4714 crate::handoff::HeldPrompt {
4715 text: "[Message id=m-1] are you up?".to_owned(),
4716 attachments: Vec::new(),
4717 source: crate::protocol::PromptSource::Peer,
4718 uuid: "peer-1".to_owned(),
4719 sender: Some(sender.clone()),
4720 },
4721 );
4722
4723 task.translate_event(AgentEvent::ConnectionFailed {
4724 message: "the child exited".to_owned(),
4725 kind: forge_agent::client::SpawnFailureKind::Unclassified,
4726 });
4727
4728 let mut told = None;
4729 while let Ok(frame) = update_rx.try_recv() {
4730 if let SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. } =
4731 std::sync::Arc::unwrap_or_clone(frame.update)
4732 && wrapped.kind == crate::mcp::peers::types::WrappedKind::DeliveryFailureNotice
4733 {
4734 told = Some((key, wrapped));
4735 }
4736 }
4737 let (key, notice) = told.expect("the sender is told its message never landed");
4738 assert_eq!(key, sender, "on the sender's own seat");
4739 assert_eq!(
4740 notice.sender_name, "forge/handoff-cut",
4741 "and the notice names the seat that never took it",
4742 );
4743 }
4744
4745 #[tokio::test]
4750 async fn every_result_settles_the_seat_to_idle() {
4751 use crate::store::sessions::SeatState;
4752
4753 let dir = tempfile::tempdir().expect("tempdir");
4754 let (workspace, _rx) =
4755 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4756 workspace.install_db_for_test(
4757 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4758 );
4759 let key = SessionSlot::worker("TestOrg", "forge", "settling");
4760 workspace.record_session_id("TestOrg", "forge", "settling", "id-1");
4761 let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4762
4763 for (label, subtype, cancelled, is_error) in [
4764 ("a success", "success", false, false),
4765 ("a failure", "error_during_execution", false, true),
4766 ("a cancel", "error_during_execution", true, true),
4767 ] {
4768 workspace.record_seat_state(&key, SeatState::InTurn);
4769 {
4770 let mut domain = task.domain.lock();
4771 domain.reset_turns();
4772 domain.commit_turn();
4773 domain.pending_cancel = cancelled;
4776 }
4777
4778 task.translate_event(AgentEvent::SdkMessage {
4779 session_id: "id-1".to_owned(),
4780 msg: result_message(subtype, is_error),
4781 });
4782
4783 assert_eq!(
4784 workspace.stored_seat_state(&key),
4785 Some(SeatState::Idle),
4786 "{label} settles the turn, so the seat is open with nothing in flight",
4787 );
4788 }
4789 }
4790
4791 #[tokio::test]
4798 async fn a_settle_does_not_erase_the_handoff_turn_it_starts() {
4799 use crate::store::sessions::SeatState;
4800
4801 let dir = tempfile::tempdir().expect("tempdir");
4802 let (workspace, _rx) =
4803 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4804 workspace.install_db_for_test(
4805 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4806 );
4807 let key = SessionSlot::lead("TestOrg", "forge");
4808 workspace.record_session_id("TestOrg", "forge", "lead", "id-1");
4809 workspace.record_seat_state(&key, SeatState::InTurn);
4810 let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4811 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("id-1".to_owned()));
4812 task.domain.lock().handoff.parked = true;
4815 task.domain.lock().commit_turn();
4816 workspace.domain_handles.lock().insert(key.clone(), Arc::clone(&task.domain));
4820 let (command_tx, _command_rx) = tokio::sync::mpsc::unbounded_channel();
4821 workspace.command_senders.lock().insert(key.clone(), command_tx);
4822 let _updates = workspace.subscribe_mirror();
4823
4824 task.translate_event(AgentEvent::SdkMessage {
4825 session_id: "id-1".to_owned(),
4826 msg: result_message("success", false),
4827 });
4828
4829 assert!(task.domain.lock().handoff.awaiting, "the boundary starts the handoff");
4830 assert_eq!(
4831 workspace.stored_seat_state(&key),
4832 Some(SeatState::InTurn),
4833 "the judgment turn the boundary started is the state the row holds",
4834 );
4835 }
4836
4837 #[tokio::test]
4842 async fn a_transport_death_leaves_an_interrupted_turn_in_turn() {
4843 use crate::store::sessions::SeatState;
4844
4845 let dir = tempfile::tempdir().expect("tempdir");
4846 let (workspace, _rx) =
4847 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4848 workspace.install_db_for_test(
4849 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4850 );
4851 let key = SessionSlot::worker("TestOrg", "forge", "dying");
4852 workspace.record_session_id("TestOrg", "forge", "dying", "id-1");
4853 workspace.record_seat_state(&key, SeatState::InTurn);
4854 let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4855 task.domain.lock().commit_turn();
4856
4857 task.translate_event(AgentEvent::ConnectionFailed {
4858 message: "the child exited".to_owned(),
4859 kind: forge_agent::client::SpawnFailureKind::Unclassified,
4860 });
4861
4862 assert_eq!(
4863 workspace.stored_seat_state(&key),
4864 Some(SeatState::InTurn),
4865 "the interrupted turn stays on the row for the next boot",
4866 );
4867 }
4868
4869 #[tokio::test]
4873 async fn a_resume_swap_leaves_the_seat_idle() {
4874 use crate::store::sessions::SeatState;
4875
4876 let dir = tempfile::tempdir().expect("tempdir");
4877 let (workspace, _rx) =
4878 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4879 workspace.install_db_for_test(
4880 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4881 );
4882 let key = SessionSlot::lead("TestOrg", "forge");
4883 workspace.record_session_id("TestOrg", "forge", "lead", "old-id");
4884 workspace.record_seat_state(&key, SeatState::InTurn);
4885 let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4886 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-id".to_owned()));
4887
4888 task.execute_command(Command::ResumeSession {
4889 key: key.clone(),
4890 session_id: "successor-id".to_owned(),
4891 cwd: "/tmp".to_owned(),
4892 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
4893 });
4894
4895 assert_eq!(
4896 workspace.stored_seat_state(&key),
4897 Some(SeatState::Idle),
4898 "the successor starts parked",
4899 );
4900 }
4901
4902 #[tokio::test]
4908 async fn a_queued_prompt_start_marks_the_seat_in_turn() {
4909 use crate::store::sessions::SeatState;
4910
4911 let dir = tempfile::tempdir().expect("tempdir");
4912 let (workspace, _rx) =
4913 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4914 workspace.install_db_for_test(
4915 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4916 );
4917 let key = SessionSlot::worker("TestOrg", "forge", "queued-start");
4918 workspace.record_session_id(key.org(), key.project(), key.label(), "id-1");
4919 workspace.record_seat_state(&key, SeatState::Idle);
4920 let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4921
4922 task.translate_event(AgentEvent::SdkMessage {
4923 session_id: "id-1".to_owned(),
4924 msg: forge_primitives::Message::CommandLifecycle {
4925 command_uuid: "queued-id".to_owned(),
4926 state: "started".to_owned(),
4927 uuid: "lifecycle-1".to_owned(),
4928 session_id: "id-1".to_owned(),
4929 extras: forge_primitives::messages::Extras::new(),
4930 },
4931 });
4932
4933 assert_eq!(
4934 workspace.stored_seat_state(&key),
4935 Some(SeatState::InTurn),
4936 "the CLI starting a queued prompt is a turn start",
4937 );
4938 }
4939
4940 #[tokio::test]
4943 async fn a_cancelled_judgment_turn_ends_the_handoff_and_returns_its_prompts() {
4944 let dir = tempfile::tempdir().expect("tempdir");
4945 let (workspace, _rx) =
4946 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4947 workspace.enable_test_dispatch_intercept();
4948 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
4949 {
4950 let mut domain = task.domain.lock();
4951 domain.lifecycle_frames = Some(true);
4952 domain.handoff.awaiting = true;
4953 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
4954 domain.handoff.judgment_started = true;
4955 domain.pending_cancel = true;
4956 }
4957 workspace.begin_swap(&task.key);
4958 workspace.hold_prompt(
4959 &task.key,
4960 crate::handoff::HeldPrompt {
4961 text: "sent during the swap".to_owned(),
4962 attachments: Vec::new(),
4963 source: crate::protocol::PromptSource::You,
4964 uuid: "held-swap".to_owned(),
4965 sender: None,
4966 },
4967 );
4968
4969 task.translate_event(AgentEvent::SdkMessage {
4970 session_id: "old-session-id".to_owned(),
4971 msg: result_message("error_during_execution", true),
4972 });
4973
4974 assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
4975 assert!(
4976 !task.domain.lock().handoff.awaiting,
4977 "and nothing is left waiting on a judgment turn",
4978 );
4979 let dispatched = workspace.drain_test_dispatch_buffer();
4980 assert!(
4981 dispatched.iter().any(|command| matches!(
4982 command,
4983 Command::PromptUnder { text, .. } if text == "sent during the swap"
4984 )),
4985 "the held prompt goes back to the session that continues, under the id it was held \
4986 with: {dispatched:?}",
4987 );
4988 }
4989
4990 fn drain_machine_turns(
4992 rx: &mut mpsc::UnboundedReceiver<SeatUpdate>,
4993 ) -> Vec<Option<forge_primitives::runtime::MachineTurn>> {
4994 let mut out = Vec::new();
4995 while let Ok(frame) = rx.try_recv() {
4996 if let SessionUpdate::MachineTurn { machine, .. } =
4997 std::sync::Arc::unwrap_or_clone(frame.update)
4998 {
4999 out.push(machine);
5000 }
5001 }
5002 out
5003 }
5004
5005 #[tokio::test]
5011 async fn a_handoff_announces_its_machine_turn_both_ways() {
5012 use forge_primitives::runtime::MachineTurn;
5013
5014 let dir = tempfile::tempdir().expect("tempdir");
5015 let (workspace, _workspace_rx) =
5016 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5017 workspace.enable_test_dispatch_intercept();
5018 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5019 let mut rx = task.update_tx.subscribe(SubscriberRole::Answering);
5022
5023 task.begin_handoff();
5024 assert_eq!(
5025 drain_machine_turns(&mut rx),
5026 vec![Some(MachineTurn::Handoff)],
5027 "the seat stands down as the judgment prompt goes out",
5028 );
5029
5030 {
5031 let mut domain = task.domain.lock();
5032 domain.lifecycle_frames = Some(true);
5033 domain.handoff.judgment_started = true;
5034 domain.pending_cancel = true;
5035 }
5036 task.translate_event(AgentEvent::SdkMessage {
5037 session_id: "old-session-id".to_owned(),
5038 msg: result_message("error_during_execution", true),
5039 });
5040 assert_eq!(
5041 drain_machine_turns(&mut rx),
5042 vec![None],
5043 "the cancelled handoff hands the seat back",
5044 );
5045 }
5046
5047 #[tokio::test]
5051 async fn a_refused_judgment_prompt_clears_the_machine_turn() {
5052 use forge_primitives::runtime::MachineTurn;
5053
5054 let dir = tempfile::tempdir().expect("tempdir");
5055 let (workspace, _workspace_rx) =
5056 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5057 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5059 let mut rx = task.update_tx.subscribe(SubscriberRole::Answering);
5060
5061 task.begin_handoff();
5062
5063 assert_eq!(
5064 drain_machine_turns(&mut rx),
5065 vec![Some(MachineTurn::Handoff), None],
5066 "the begin announces the turn and the refused prompt undoes it",
5067 );
5068 }
5069
5070 #[tokio::test]
5074 async fn a_judgment_prompt_cancelled_before_it_starts_ends_the_handoff() {
5075 let dir = tempfile::tempdir().expect("tempdir");
5076 let (workspace, _rx) =
5077 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5078 workspace.enable_test_dispatch_intercept();
5079 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5080 {
5081 let mut domain = task.domain.lock();
5082 domain.lifecycle_frames = Some(true);
5083 domain.handoff.awaiting = true;
5084 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5085 domain.handoff.judgment_started = false;
5086 }
5087 workspace.begin_swap(&task.key);
5088 workspace.hold_prompt(
5089 &task.key,
5090 crate::handoff::HeldPrompt {
5091 text: "sent during the swap".to_owned(),
5092 attachments: Vec::new(),
5093 source: crate::protocol::PromptSource::You,
5094 uuid: "held-swap".to_owned(),
5095 sender: None,
5096 },
5097 );
5098
5099 task.translate_event(AgentEvent::PromptCancelResolved {
5100 uuid: "judgment-id".to_owned(),
5101 cancelled: true,
5102 });
5103
5104 assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
5105 assert!(
5106 !task.domain.lock().handoff.awaiting,
5107 "and nothing waits on a brief that will never be written",
5108 );
5109 let dispatched = workspace.drain_test_dispatch_buffer();
5110 assert!(
5111 dispatched.iter().any(|command| matches!(
5112 command,
5113 Command::PromptUnder { text, .. } if text == "sent during the swap"
5114 )),
5115 "the held prompt goes back to the session that continues, under the id it was held \
5116 with: {dispatched:?}",
5117 );
5118 }
5119
5120 #[tokio::test]
5125 async fn a_cancelled_judgment_short_settles_the_committed_turn() {
5126 use crate::store::sessions::SeatState;
5127
5128 let dir = tempfile::tempdir().expect("tempdir");
5129 let (workspace, _rx) =
5130 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5131 workspace.install_db_for_test(
5132 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
5133 );
5134 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5135 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5136 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5137 workspace.domain_handles.lock().insert(slot.clone(), Arc::clone(&task.domain));
5138 workspace.record_session_id(slot.org(), slot.project(), slot.label(), "old-session-id");
5139 let (commands, mut routed) = mpsc::unbounded_channel();
5140 workspace.command_senders.lock().insert(slot.clone(), commands);
5141
5142 task.begin_handoff();
5143 assert_eq!(
5144 workspace.stored_seat_state(&slot),
5145 Some(SeatState::InTurn),
5146 "routing the judgment prompt commits its turn on the row",
5147 );
5148
5149 assert!(
5150 matches!(
5151 routed.try_recv(),
5152 Ok(Command::PromptUnder { text, .. }) if text == crate::handoff::judgment_prompt()
5153 ),
5154 "the judgment prompt reached the seat's sender",
5155 );
5156 assert!(task.domain.lock().turn_pending, "and its turn is committed");
5157 let judgment_uuid = task
5158 .domain
5159 .lock()
5160 .handoff
5161 .judgment_uuid
5162 .clone()
5163 .expect("the handoff recorded its prompt id");
5164
5165 task.translate_event(AgentEvent::PromptCancelResolved {
5166 uuid: judgment_uuid,
5167 cancelled: true,
5168 });
5169
5170 let domain = task.domain.lock();
5171 assert!(!domain.turn_pending, "the abort settles the judgment prompt's turn");
5172 assert!(domain.is_idle_for_handoff(), "so the seat is idle again");
5173 assert!(!domain.handoff.awaiting, "and the handoff is over");
5174 drop(domain);
5175 assert_eq!(
5176 workspace.stored_seat_state(&slot),
5177 Some(SeatState::Idle),
5178 "and the row settles with it, so no boot kicks a turn nothing interrupted",
5179 );
5180 }
5181
5182 #[tokio::test]
5185 async fn a_refused_judgment_prompt_ends_the_handoff() {
5186 let dir = tempfile::tempdir().expect("tempdir");
5187 let (workspace, _rx) =
5188 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5189 workspace.enable_test_dispatch_intercept();
5190 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5191 {
5192 let mut domain = task.domain.lock();
5193 domain.lifecycle_frames = Some(true);
5194 domain.handoff.awaiting = true;
5195 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5196 }
5197 workspace.begin_swap(&task.key);
5198
5199 task.translate_event(AgentEvent::SdkMessage {
5200 session_id: "old-session-id".to_owned(),
5201 msg: forge_primitives::Message::CommandLifecycle {
5202 command_uuid: "judgment-id".to_owned(),
5203 state: "refused".to_owned(),
5204 uuid: "lifecycle-1".to_owned(),
5205 session_id: "old-session-id".to_owned(),
5206 extras: forge_primitives::messages::Extras::new(),
5207 },
5208 });
5209
5210 assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
5211 assert!(
5212 !task.domain.lock().handoff.awaiting,
5213 "and the seat is free to continue on its own",
5214 );
5215 assert!(
5216 workspace.drain_test_dispatch_buffer().is_empty(),
5217 "nothing is swapped for a brief that was refused",
5218 );
5219 }
5220
5221 #[tokio::test]
5224 async fn a_completed_judgment_with_no_start_ends_the_handoff() {
5225 let dir = tempfile::tempdir().expect("tempdir");
5226 let (workspace, _rx) =
5227 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5228 workspace.enable_test_dispatch_intercept();
5229 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5230 {
5231 let mut domain = task.domain.lock();
5232 domain.lifecycle_frames = Some(true);
5233 domain.handoff.awaiting = true;
5234 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5235 domain.handoff.judgment_started = false;
5236 }
5237 workspace.begin_swap(&task.key);
5238
5239 task.translate_event(AgentEvent::SdkMessage {
5240 session_id: "old-session-id".to_owned(),
5241 msg: forge_primitives::Message::CommandLifecycle {
5242 command_uuid: "judgment-id".to_owned(),
5243 state: "completed".to_owned(),
5244 uuid: "lifecycle-1".to_owned(),
5245 session_id: "old-session-id".to_owned(),
5246 extras: forge_primitives::messages::Extras::new(),
5247 },
5248 });
5249
5250 assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
5251 assert!(!task.domain.lock().handoff.awaiting, "nothing waits on a turn that never started");
5252 }
5253
5254 #[tokio::test]
5259 async fn a_replacement_during_the_judgment_window_ends_the_handoff() {
5260 let dir = tempfile::tempdir().expect("tempdir");
5261 let (workspace, _rx) =
5262 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5263 workspace.enable_test_dispatch_intercept();
5264 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5265 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5266 task.connected_once = true;
5267 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5268 {
5269 let mut domain = task.domain.lock();
5270 domain.handoff.awaiting = true;
5271 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5272 domain.handoff.judgment_started = true;
5273 }
5274 workspace.begin_swap(&task.key);
5275 workspace.hold_prompt(
5276 &task.key,
5277 crate::handoff::HeldPrompt {
5278 text: "sent during the swap".to_owned(),
5279 attachments: Vec::new(),
5280 source: crate::protocol::PromptSource::You,
5281 uuid: "held-swap".to_owned(),
5282 sender: None,
5283 },
5284 );
5285
5286 task.translate_event(connected_event("replacement-id", "/tmp/forge"));
5287
5288 assert!(!workspace.swap_in_progress(&task.key), "the gate is closed");
5289 assert!(
5290 !task.domain.lock().handoff.awaiting,
5291 "nothing waits on the previous occupant's judgment",
5292 );
5293 let dispatched = workspace.drain_test_dispatch_buffer();
5294 let held = dispatched
5295 .iter()
5296 .find_map(|command| match command {
5297 Command::PromptUnder { text, uuid, .. } if text == "sent during the swap" => {
5298 Some(uuid.clone())
5299 }
5300 _ => None,
5301 })
5302 .expect("what the swap held goes to the occupant that is here");
5303 assert_eq!(
5304 held, "held-swap",
5305 "the drain keeps the id the hold carried, and draws no second turn: a fresh id \
5306 would orphan a cron row's schedule pairing (#1815)",
5307 );
5308 }
5309
5310 #[tokio::test(start_paused = true)]
5313 async fn a_judgment_prompt_that_never_starts_is_abandoned_at_the_deadline() {
5314 let dir = tempfile::tempdir().expect("tempdir");
5315 let (workspace, _rx) =
5316 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5317 workspace.enable_test_dispatch_intercept();
5318 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5319
5320 task.begin_handoff();
5321 assert!(workspace.swap_in_progress(&task.key), "precondition: the gate is open");
5322 tokio::task::yield_now().await;
5325
5326 tokio::time::advance(crate::handoff::JUDGMENT_DEADLINE + std::time::Duration::from_secs(1))
5327 .await;
5328 for _ in 0..4 {
5330 tokio::task::yield_now().await;
5331 }
5332
5333 assert!(!workspace.swap_in_progress(&task.key), "the deadline ends the handoff");
5334 assert!(
5335 !task.domain.lock().handoff.awaiting,
5336 "and the seat is free to continue on its own",
5337 );
5338 }
5339
5340 #[tokio::test]
5344 async fn a_replacement_connect_arms_the_successor_measurement() {
5345 let dir = tempfile::tempdir().expect("tempdir");
5346 let (workspace, _rx) =
5347 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5348 workspace.install_db_for_test(
5349 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
5350 );
5351 workspace.enable_test_dispatch_intercept();
5352 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5353 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5354 task.connected_once = true;
5355 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5356 workspace.record_handoff(
5357 &forge_primitives::HandoffRecord {
5358 slot: slot.clone(),
5359 from: "old-session-id".to_owned(),
5360 to: "new-session-id".to_owned(),
5361 reason: forge_primitives::HandoffReason::Fill,
5362 fill_percent: Some(85),
5363 total_tokens: Some(850_000),
5364 max_tokens: Some(1_000_000),
5365 at_percent: 85,
5366 brief: forge_primitives::HandoffBrief::default(),
5367 transcript_path: "/tmp/old.jsonl".to_owned(),
5368 at: "2026-10-24T08:00:00Z".to_owned(),
5369 cycle: forge_primitives::HandoffCycle::default(),
5370 successor: None,
5371 },
5372 &[],
5373 );
5374
5375 task.translate_event(connected_event("new-session-id", "/tmp/forge"));
5376
5377 let watch = task.successor.as_ref().expect("the replacement connect arms it");
5378 assert_eq!(watch.to, "new-session-id", "for the occupant the handoff named");
5379 assert_eq!(watch.turns, 0, "and no prompt is counted before one runs");
5380 assert!(!watch.measure_at_boundary, "nor is anything measured yet");
5381
5382 task.after_turn_ended();
5385 assert_eq!(
5386 task.successor.as_ref().map(|watch| watch.turns),
5387 Some(0),
5388 "a turn boundary alone counts nothing",
5389 );
5390 assert!(
5391 task.successor.as_ref().is_some_and(|watch| !watch.measure_at_boundary),
5392 "and no measurement is armed by it",
5393 );
5394 }
5395
5396 #[tokio::test]
5400 async fn a_reconnect_of_the_same_successor_keeps_its_count() {
5401 let dir = tempfile::tempdir().expect("tempdir");
5402 let (workspace, _rx) =
5403 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5404 workspace.install_db_for_test(
5405 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
5406 );
5407 workspace.enable_test_dispatch_intercept();
5408 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5409 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5410 task.connected_once = true;
5411 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5412 workspace.record_handoff(
5413 &forge_primitives::HandoffRecord {
5414 slot: slot.clone(),
5415 from: "old-session-id".to_owned(),
5416 to: "new-session-id".to_owned(),
5417 reason: forge_primitives::HandoffReason::Fill,
5418 fill_percent: Some(85),
5419 total_tokens: Some(850_000),
5420 max_tokens: Some(1_000_000),
5421 at_percent: 85,
5422 brief: forge_primitives::HandoffBrief::default(),
5423 transcript_path: "/tmp/old.jsonl".to_owned(),
5424 at: "2026-10-24T08:00:00Z".to_owned(),
5425 cycle: forge_primitives::HandoffCycle::default(),
5426 successor: None,
5427 },
5428 &[],
5429 );
5430 task.translate_event(connected_event("new-session-id", "/tmp/forge"));
5431 task.count_successor_prompt();
5432 task.count_successor_prompt();
5433
5434 task.translate_event(connected_event("new-session-id", "/tmp/forge"));
5435
5436 assert_eq!(
5437 task.successor.as_ref().map(|watch| watch.turns),
5438 Some(2),
5439 "a reconnect of the same occupant does not restart the ten",
5440 );
5441 }
5442
5443 #[tokio::test]
5447 async fn the_successor_is_measured_at_its_tenth_turn() {
5448 let dir = tempfile::tempdir().expect("tempdir");
5449 let (workspace, _rx) =
5450 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5451 workspace.enable_test_dispatch_intercept();
5452 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5453 task.successor = Some(crate::handoff::SuccessorWatch {
5454 to: "old-session-id".to_owned(),
5455 turns: 0,
5456 predecessor_reads: Vec::new(),
5457 measure_at_boundary: false,
5458 });
5459
5460 for _ in 0..(crate::handoff::SUCCESSOR_TURNS - 1) {
5461 task.count_successor_prompt();
5462 }
5463 assert_eq!(
5464 task.successor.as_ref().map(|watch| watch.turns),
5465 Some(crate::handoff::SUCCESSOR_TURNS - 1),
5466 "nine prompts are counted and the watch is still open",
5467 );
5468 assert!(
5469 task.successor.as_ref().is_some_and(|watch| !watch.measure_at_boundary),
5470 "nine prompts do not measure anything",
5471 );
5472
5473 task.count_successor_prompt();
5474 assert!(
5475 task.successor.as_ref().is_some_and(|watch| watch.measure_at_boundary),
5476 "the tenth prompt arms the measurement",
5477 );
5478 assert!(task.successor.is_some(), "but it waits for that prompt's turn to end");
5479 task.after_turn_ended();
5480 assert!(task.successor.is_none(), "the tenth turn closes the measurement");
5481 }
5482
5483 #[tokio::test]
5487 async fn only_a_persons_prompt_counts_toward_the_successor() {
5488 let dir = tempfile::tempdir().expect("tempdir");
5489 let (workspace, _rx) =
5490 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5491 workspace.enable_test_dispatch_intercept();
5492 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5493 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5494 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("new-session-id"));
5495 task.successor = Some(crate::handoff::SuccessorWatch {
5496 to: "new-session-id".to_owned(),
5497 turns: 0,
5498 predecessor_reads: Vec::new(),
5499 measure_at_boundary: false,
5500 });
5501
5502 task.execute_command(Command::Prompt {
5503 key: slot.clone(),
5504 text: "do the thing".to_owned(),
5505 attachments: Vec::new(),
5506 });
5507 assert_eq!(
5508 task.successor.as_ref().map(|watch| watch.turns),
5509 Some(1),
5510 "a person's prompt is one of the ten",
5511 );
5512
5513 task.execute_command(Command::PromptUnder {
5514 key: slot.clone(),
5515 text: "HANDOFF - old -> new".to_owned(),
5516 attachments: Vec::new(),
5517 uuid: forge_sdk::request_id::next_forged_prompt_id(),
5518 source: crate::protocol::PromptSource::Forge,
5519 dispatch_id: None,
5520 });
5521 assert_eq!(
5522 task.successor.as_ref().map(|watch| watch.turns),
5523 Some(1),
5524 "forge's own delivery is not one of them",
5525 );
5526 }
5527
5528 #[tokio::test]
5532 async fn a_prompt_held_by_a_swap_draws_no_row_while_it_waits() {
5533 let dir = tempfile::tempdir().expect("tempdir");
5534 let (workspace, _rx) =
5535 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5536 let slot = SessionSlot::worker("TestOrg", "forge", "held-frame");
5537 let (mut task, mut update_rx, mut agent_rx) = nudge_task_for(&workspace, &slot);
5538 workspace.begin_swap(&slot);
5539 assert!(workspace.swap_in_progress(&slot), "premise: the swap gate holds prompts");
5540
5541 task.execute_command(Command::PromptUnder {
5542 key: slot.clone(),
5543 text: "held words".to_owned(),
5544 attachments: Vec::new(),
5545 uuid: "held-frame-uuid".to_owned(),
5546 source: crate::protocol::PromptSource::You,
5547 dispatch_id: None,
5548 });
5549
5550 let mut drawn = Vec::new();
5551 while let Ok(frame) = update_rx.try_recv() {
5552 if let SessionUpdate::ChatAppended { msg, .. } = &*frame.update {
5553 drawn.push(msg.clone());
5554 }
5555 }
5556 assert!(drawn.is_empty(), "the held prompt draws no row: {drawn:?}");
5557 assert!(
5558 agent_rx.try_recv().is_err(),
5559 "and nothing was handed to the agent: the swap is holding it",
5560 );
5561 let held = workspace.end_swap(&slot);
5562 assert_eq!(held.len(), 1, "the prompt waits for the successor");
5563 assert_eq!(held[0].uuid, "held-frame-uuid", "under the id its row will carry");
5564 }
5565
5566 #[tokio::test]
5573 async fn a_dispatched_prompt_emits_no_row_before_the_cli_starts_it() {
5574 let dir = tempfile::tempdir().expect("tempdir");
5575 let (workspace, _rx) =
5576 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5577 let slot = SessionSlot::worker("TestOrg", "forge", "send-row");
5578 let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
5579 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("send-row-session"));
5580
5581 task.execute_command(Command::PromptUnder {
5582 key: slot.clone(),
5583 text: "sent mid turn".to_owned(),
5584 attachments: vec![
5587 forge_primitives::ImageAttachment {
5588 data: "iVBORw0KGgo=".to_owned(),
5589 mime_type: "image/png".to_owned(),
5590 },
5591 forge_primitives::ImageAttachment {
5592 data: "iVBORw0KGgo=".to_owned(),
5593 mime_type: "text/plain".to_owned(),
5594 },
5595 forge_primitives::ImageAttachment {
5596 data: "not base64!".to_owned(),
5597 mime_type: "image/png".to_owned(),
5598 },
5599 ],
5600 uuid: "send-row-uuid".to_owned(),
5601 source: crate::protocol::PromptSource::You,
5602 dispatch_id: None,
5603 });
5604
5605 let mut queued = false;
5606 while let Ok(frame) = update_rx.try_recv() {
5607 match &*frame.update {
5608 SessionUpdate::ChatAppended { msg, .. } => {
5609 panic!("a prompt that has not started draws no row: {msg:?}");
5610 }
5611 SessionUpdate::PromptQueued { uuid, text, .. } if uuid == "send-row-uuid" => {
5612 assert_eq!(text, "sent mid turn", "the queue carries the words meanwhile");
5613 queued = true;
5614 }
5615 _ => {}
5616 }
5617 }
5618 assert!(queued, "the queue announces the prompt until the CLI takes it");
5619
5620 let before = time::OffsetDateTime::now_utc();
5623 task.translate_event(AgentEvent::SdkMessage {
5624 session_id: "send-row-session".to_owned(),
5625 msg: forge_primitives::Message::CommandLifecycle {
5626 command_uuid: "send-row-uuid".to_owned(),
5627 state: "started".to_owned(),
5628 uuid: "lifecycle-1".to_owned(),
5629 session_id: "send-row-session".to_owned(),
5630 extras: forge_primitives::messages::Extras::new(),
5631 },
5632 });
5633 let after = time::OffsetDateTime::now_utc();
5634
5635 let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
5636 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
5637 .collect();
5638 let drawn: Vec<(&forge_primitives::Message, Option<crate::protocol::PromptSource>)> =
5639 announced
5640 .iter()
5641 .filter_map(|update| match update {
5642 SessionUpdate::ChatAppended { msg, source, .. } => Some((msg, *source)),
5643 _ => None,
5644 })
5645 .collect();
5646 let [(msg, source)] = drawn.as_slice() else {
5647 panic!("the start draws one row: {drawn:?}");
5648 };
5649 let row_at = announced
5653 .iter()
5654 .position(|update| matches!(update, SessionUpdate::ChatAppended { .. }))
5655 .expect("the row is announced");
5656 let lifecycle_at = announced
5657 .iter()
5658 .position(|update| {
5659 matches!(
5660 update,
5661 SessionUpdate::PromptLifecycle { state, .. } if state == "started"
5662 )
5663 })
5664 .expect("the state is announced");
5665 assert!(
5666 row_at < lifecycle_at,
5667 "the row is emitted before the lifecycle frame that drains it: {announced:?}",
5668 );
5669 assert_eq!(*source, Some(crate::protocol::PromptSource::You), "the reader's own send");
5670 let forge_primitives::Message::User { message, uuid, .. } = msg else {
5671 panic!("the row is the user turn it draws: {msg:?}");
5672 };
5673 assert_eq!(uuid.as_deref(), Some("send-row-uuid"), "under the prompt's own id");
5674 assert!(
5675 message.content.iter().any(|block| matches!(
5676 block,
5677 forge_primitives::ContentBlock::Text { text, .. } if text == "sent mid turn"
5678 )),
5679 "carrying the words the queue announced: {message:?}",
5680 );
5681 let Some(forge_primitives::ContentBlock::Image { source, extras }) = message
5682 .content
5683 .iter()
5684 .find(|block| matches!(block, forge_primitives::ContentBlock::Image { .. }))
5685 else {
5686 panic!("and the attachment the dispatch held: {message:?}");
5687 };
5688 assert_eq!(
5689 source.get("media_type").and_then(serde_json::Value::as_str),
5690 Some("image/png"),
5691 "the row names the mime",
5692 );
5693 assert_eq!(
5694 extras.get("forge_bytes").and_then(serde_json::Value::as_u64),
5695 Some(8),
5696 "and the eight bytes the payload weighed, never the payload",
5697 );
5698 assert_eq!(
5699 message.content.len(),
5700 2,
5701 "only the attachment the sender kept draws, beside the words: {message:?}",
5702 );
5703 let stamp =
5706 serde_json::to_value(msg).expect("encode")["timestamp"].as_str().map(str::to_owned);
5707 let stamp = stamp.expect("the row carries a stamp");
5708 let at =
5709 time::OffsetDateTime::parse(&stamp, &time::format_description::well_known::Rfc3339)
5710 .unwrap_or_else(|error| panic!("a client cannot read the stamp {stamp}: {error}"));
5711 assert!(
5712 at >= before - time::Duration::seconds(5) && at <= after + time::Duration::seconds(5),
5713 "the row carries the task's own clock, not a constant: {stamp} sits outside \
5714 {before}..{after}",
5715 );
5716 }
5717
5718 #[tokio::test]
5721 async fn the_judgment_turn_swaps_when_it_ends() {
5722 let dir = tempfile::tempdir().expect("tempdir");
5723 let (workspace, mut update_rx) =
5724 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5725 workspace.enable_test_dispatch_intercept();
5726 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5727 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5728 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5729 let token = workspace.begin_swap(&task.key);
5730 {
5731 let mut domain = task.domain.lock();
5732 domain.handoff.awaiting = true;
5733 domain.handoff.judgment = Some("Next: the client row.".to_owned());
5734 domain.handoff.swap_token = Some(token);
5735 }
5736
5737 task.after_turn_ended();
5738
5739 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5740 while let Some(frame) = update_rx.recv().await {
5741 if let SessionUpdate::Handoff { handoff, .. } =
5742 std::sync::Arc::unwrap_or_clone(frame.update)
5743 {
5744 return handoff;
5745 }
5746 }
5747 panic!("the handoff row was never emitted");
5748 })
5749 .await
5750 .expect("the handoff lands");
5751 assert!(
5752 record.brief.judgment.iter().any(|section| section.label == "Next"),
5753 "the judgment is the reply, parsed by its heading: {:?}",
5754 record.brief.judgment,
5755 );
5756 }
5757
5758 #[tokio::test]
5763 async fn an_agentic_judgment_turn_captures_its_last_text() {
5764 let dir = tempfile::tempdir().expect("tempdir");
5765 let (workspace, _rx) =
5766 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5767 workspace.enable_test_dispatch_intercept();
5768 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5769 task.domain.lock().handoff.awaiting = true;
5770
5771 for frame in [
5772 serde_json::json!({
5773 "type": "assistant",
5774 "session_id": "old-session-id",
5775 "message": {
5776 "id": "m1",
5777 "role": "assistant",
5778 "model": "a-model",
5779 "content": [{"type": "text", "text": "recording what this session learned"}],
5780 },
5781 }),
5782 serde_json::json!({
5783 "type": "assistant",
5784 "session_id": "old-session-id",
5785 "message": {
5786 "id": "m2",
5787 "role": "assistant",
5788 "model": "a-model",
5789 "content": [{
5790 "type": "tool_use",
5791 "id": "t1",
5792 "name": "Write",
5793 "input": {"file_path": "memory.md", "content": "the lesson"},
5794 }],
5795 },
5796 }),
5797 serde_json::json!({
5798 "type": "assistant",
5799 "session_id": "old-session-id",
5800 "message": {
5801 "id": "m3",
5802 "role": "assistant",
5803 "model": "a-model",
5804 "content": [{"type": "text", "text": "Now: the cut is in."}],
5805 },
5806 }),
5807 serde_json::json!({
5810 "type": "assistant",
5811 "session_id": "old-session-id",
5812 "parent_tool_use_id": "task-1",
5813 "message": {
5814 "id": "m4",
5815 "role": "assistant",
5816 "model": "a-model",
5817 "content": [{"type": "text", "text": "a sub-agent line that must not win"}],
5818 },
5819 }),
5820 ] {
5821 task.translate_event(AgentEvent::SdkMessage {
5822 session_id: "old-session-id".to_owned(),
5823 msg: serde_json::from_value(frame).expect("an assistant frame"),
5824 });
5825 }
5826
5827 assert_eq!(
5828 task.domain.lock().handoff.judgment.as_deref(),
5829 Some("Now: the cut is in."),
5830 "the note the turn ended on, not the bookkeeping before it",
5831 );
5832 }
5833
5834 #[tokio::test]
5838 async fn the_exit_calls_note_is_the_judgment_half() {
5839 let dir = tempfile::tempdir().expect("tempdir");
5840 let (workspace, mut update_rx) =
5841 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5842 workspace.enable_test_dispatch_intercept();
5843 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5844 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5845 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5846 let token = workspace.begin_swap(&task.key);
5847 {
5848 let mut domain = task.domain.lock();
5849 domain.handoff.awaiting = true;
5850 domain.handoff.swap_token = Some(token);
5851 domain.handoff.judgment = Some("I am still working".to_owned());
5853 }
5854
5855 task.execute_command(Command::Handoff {
5856 key: slot.clone(),
5857 brief: Some("Now: the cut is in.".to_owned()),
5858 });
5859 task.after_turn_ended();
5860
5861 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5862 while let Some(frame) = update_rx.recv().await {
5863 if let SessionUpdate::Handoff { handoff, .. } =
5864 std::sync::Arc::unwrap_or_clone(frame.update)
5865 {
5866 return handoff;
5867 }
5868 }
5869 panic!("the handoff row was never emitted");
5870 })
5871 .await
5872 .expect("the handoff lands");
5873 assert_eq!(
5874 record.brief.judgment.first().map(|section| section.label.as_str()),
5875 Some("Now"),
5876 "the exit call's note is PART 2: {:?}",
5877 record.brief.judgment,
5878 );
5879 assert!(
5880 record.brief.judgment[0].lines.iter().any(|line| line.contains("the cut is in.")),
5881 "and its text, not the captured prose",
5882 );
5883 assert!(
5884 record.brief.judgment.iter().all(|section| section.label != "source"),
5885 "an exit-call note carries no source row: {:?}",
5886 record.brief.judgment,
5887 );
5888
5889 let delivered =
5893 workspace.pending_brief_for(&slot).expect("the brief waits for the successor");
5894 assert!(
5895 delivered.text.contains("PART 2: JUDGMENT"),
5896 "the delivered brief carries PART 2: {}",
5897 delivered.text,
5898 );
5899 assert!(
5900 delivered.text.contains("Now: the cut is in."),
5901 "and the note's first heading: {}",
5902 delivered.text,
5903 );
5904 }
5905
5906 #[tokio::test]
5910 async fn a_handoff_without_a_note_states_it() {
5911 let dir = tempfile::tempdir().expect("tempdir");
5912 let (workspace, mut update_rx) =
5913 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5914 workspace.enable_test_dispatch_intercept();
5915 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5916 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5917 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5918 let token = workspace.begin_swap(&task.key);
5919 {
5920 let mut domain = task.domain.lock();
5921 domain.handoff.awaiting = true;
5922 domain.handoff.swap_token = Some(token);
5923 domain.handoff.judgment = Some(" \n".to_owned());
5924 }
5925
5926 task.after_turn_ended();
5927
5928 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5929 while let Some(frame) = update_rx.recv().await {
5930 if let SessionUpdate::Handoff { handoff, .. } =
5931 std::sync::Arc::unwrap_or_clone(frame.update)
5932 {
5933 return handoff;
5934 }
5935 }
5936 panic!("the handoff row was never emitted");
5937 })
5938 .await
5939 .expect("the handoff lands");
5940 let section = record.brief.judgment.first().expect("a stated section");
5941 assert_eq!(section.label, "note", "the missing note is its own section");
5942 assert!(
5943 section.lines[0].contains("the handoff note did not arrive"),
5944 "and says so: {:?}",
5945 section.lines,
5946 );
5947 assert_eq!(
5948 record.brief.judgment.len(),
5949 1,
5950 "an empty turn states the note once: {:?}",
5951 record.brief.judgment,
5952 );
5953 }
5954
5955 #[tokio::test]
5960 async fn a_note_taken_from_the_turn_text_is_marked() {
5961 let dir = tempfile::tempdir().expect("tempdir");
5962 let (workspace, mut update_rx) =
5963 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5964 workspace.enable_test_dispatch_intercept();
5965 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5966 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5967 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5968 let token = workspace.begin_swap(&task.key);
5969 {
5970 let mut domain = task.domain.lock();
5971 domain.handoff.awaiting = true;
5972 domain.handoff.swap_token = Some(token);
5973 domain.handoff.judgment =
5974 Some("Memory is updated and the monitor is stopped.".to_owned());
5975 }
5976
5977 task.after_turn_ended();
5978
5979 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5980 while let Some(frame) = update_rx.recv().await {
5981 if let SessionUpdate::Handoff { handoff, .. } =
5982 std::sync::Arc::unwrap_or_clone(frame.update)
5983 {
5984 return handoff;
5985 }
5986 }
5987 panic!("the handoff row was never emitted");
5988 })
5989 .await
5990 .expect("the handoff lands");
5991 assert_eq!(
5992 record.brief.judgment.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
5993 ["source", "note"],
5994 "the capture is marked, then drawn as the note it is: {:?}",
5995 record.brief.judgment,
5996 );
5997 assert!(
5998 record.brief.judgment[0]
5999 .lines
6000 .iter()
6001 .any(|line| line.contains("the exit call carried no note")),
6002 "the source row says where it came from: {:?}",
6003 record.brief.judgment[0].lines,
6004 );
6005 assert!(
6006 record.brief.judgment[1].lines.iter().any(|line| line.contains("Memory is updated")),
6007 "and the turn's text is the note: {:?}",
6008 record.brief.judgment[1].lines,
6009 );
6010 }
6011
6012 #[tokio::test]
6016 async fn a_content_free_exit_note_is_named_as_such() {
6017 let dir = tempfile::tempdir().expect("tempdir");
6018 let (workspace, mut update_rx) =
6019 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6020 workspace.enable_test_dispatch_intercept();
6021 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6022 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6023 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6024 let token = workspace.begin_swap(&task.key);
6025 {
6026 let mut domain = task.domain.lock();
6027 domain.handoff.awaiting = true;
6028 domain.handoff.swap_token = Some(token);
6029 domain.handoff.judgment =
6030 Some("Memory is updated and the monitor is stopped.".to_owned());
6031 }
6032
6033 task.execute_command(Command::Handoff {
6034 key: slot.clone(),
6035 brief: Some("## Next".to_owned()),
6036 });
6037 task.after_turn_ended();
6038
6039 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
6040 while let Some(frame) = update_rx.recv().await {
6041 if let SessionUpdate::Handoff { handoff, .. } =
6042 std::sync::Arc::unwrap_or_clone(frame.update)
6043 {
6044 return handoff;
6045 }
6046 }
6047 panic!("the handoff row was never emitted");
6048 })
6049 .await
6050 .expect("the handoff lands");
6051 assert_eq!(
6052 record.brief.judgment.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
6053 ["source", "note"],
6054 "the capture is marked: {:?}",
6055 record.brief.judgment,
6056 );
6057 assert!(
6058 record.brief.judgment[0]
6059 .lines
6060 .iter()
6061 .any(|line| line.contains("the exit call's note carried no content")),
6062 "the row names the call that was made: {:?}",
6063 record.brief.judgment[0].lines,
6064 );
6065 }
6066
6067 #[tokio::test]
6070 async fn an_exit_with_no_handoff_turn_is_stated() {
6071 let (workspace, _rx) = crate::Workspace::testing_stub();
6072 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6073 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6074 let mut updates = task.update_tx.subscribe(crate::update_fanout::SubscriberRole::Answering);
6075
6076 task.execute_command(Command::Handoff {
6077 key: slot.clone(),
6078 brief: Some("Now: x".to_owned()),
6079 });
6080
6081 assert!(task.domain.lock().handoff.supplied.is_none(), "no note is stored");
6082 let mut stated = false;
6083 while let Ok(frame) = updates.try_recv() {
6084 if let SessionUpdate::Notice { text, .. } =
6085 std::sync::Arc::unwrap_or_clone(frame.update)
6086 && text.contains("/handoff")
6087 && text.contains("automatic trigger")
6088 {
6089 stated = true;
6090 }
6091 }
6092 assert!(stated, "the seat is told where a handoff starts");
6093 }
6094
6095 #[tokio::test]
6098 async fn an_empty_exit_falls_back_to_the_captured_note() {
6099 let dir = tempfile::tempdir().expect("tempdir");
6100 let (workspace, mut update_rx) =
6101 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6102 workspace.enable_test_dispatch_intercept();
6103 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6104 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6105 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6106 let token = workspace.begin_swap(&task.key);
6107 {
6108 let mut domain = task.domain.lock();
6109 domain.handoff.awaiting = true;
6110 domain.handoff.swap_token = Some(token);
6111 domain.handoff.judgment = Some("Now: the captured note.".to_owned());
6112 }
6113
6114 task.execute_command(Command::Handoff { key: slot.clone(), brief: Some(" ".to_owned()) });
6115 task.after_turn_ended();
6116
6117 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
6118 while let Some(frame) = update_rx.recv().await {
6119 if let SessionUpdate::Handoff { handoff, .. } =
6120 std::sync::Arc::unwrap_or_clone(frame.update)
6121 {
6122 return handoff;
6123 }
6124 }
6125 panic!("the handoff row was never emitted");
6126 })
6127 .await
6128 .expect("the handoff lands");
6129 assert_eq!(
6130 record.brief.judgment.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
6131 ["source", "Now"],
6132 "the captured note is PART 2, marked as a capture: {:?}",
6133 record.brief.judgment,
6134 );
6135 assert!(
6136 record.brief.judgment[1].lines.iter().any(|line| line.contains("the captured note")),
6137 "and its text, not the empty exit",
6138 );
6139 }
6140
6141 #[tokio::test]
6144 async fn a_handoff_asked_mid_turn_waits_for_the_turn_to_end() {
6145 let (workspace, _rx) = crate::Workspace::testing_stub();
6146 workspace.enable_test_dispatch_intercept();
6147 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6148 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6149 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6150 task.domain.lock().turn_pending = true;
6151
6152 task.execute_command(Command::Handoff { key: slot.clone(), brief: None });
6153
6154 assert!(
6155 workspace.drain_test_dispatch_buffer().is_empty(),
6156 "a running turn is never handed a prompt in the middle",
6157 );
6158 assert!(task.domain.lock().handoff.parked, "the request waits for the boundary instead");
6159
6160 task.domain.lock().turn_pending = false;
6161 task.after_turn_ended();
6162
6163 let dispatched = workspace.drain_test_dispatch_buffer();
6164 let Some(Command::PromptUnder { text, source, .. }) = dispatched.first() else {
6165 panic!("the judgment prompt was never sent: {dispatched:?}");
6166 };
6167 assert_eq!(
6168 text,
6169 &crate::handoff::judgment_prompt(),
6170 "forge's own text, which names no skill"
6171 );
6172 assert_eq!(*source, PromptSource::Forge, "and it is forge's turn, not the user's");
6173 assert!(task.domain.lock().handoff.awaiting, "the reply is waited for");
6174 }
6175
6176 fn trigger_task(
6179 workspace: &Arc<crate::Workspace>,
6180 compaction_count: u32,
6181 ) -> (SessionTask, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>) {
6182 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6183 let (mut task, agent_rx) = command_task_for(workspace, &slot);
6184 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6185 task.compaction_count = compaction_count;
6186 (task, agent_rx)
6187 }
6188
6189 fn reading(reported: Option<u8>, total: Option<u64>, effective: Option<u64>) -> AgentEvent {
6190 reading_for("old-session-id", reported, total, effective)
6191 }
6192
6193 fn reading_for(
6194 session_id: &str,
6195 reported: Option<u8>,
6196 total: Option<u64>,
6197 effective: Option<u64>,
6198 ) -> AgentEvent {
6199 AgentEvent::ContextUsage {
6200 session_id: session_id.to_owned(),
6201 percentage: reported,
6202 max_tokens: Some(1_000_000),
6203 total_tokens: total,
6204 effective_max_tokens: effective,
6205 }
6206 }
6207
6208 #[tokio::test]
6214 async fn the_trigger_measures_at_a_real_turn_boundary() {
6215 let dir = tempfile::tempdir().expect("tempdir");
6216 let (workspace, _rx) =
6217 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6218 workspace.enable_test_dispatch_intercept();
6219 let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6220 task.domain.lock().turn_pending = true;
6222
6223 task.translate_event(AgentEvent::SdkMessage {
6224 session_id: "old-session-id".to_owned(),
6225 msg: result_message("success", false),
6226 });
6227
6228 assert!(
6229 matches!(
6230 agent_rx.try_recv(),
6231 Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6232 ),
6233 "the boundary asks for the reading",
6234 );
6235 assert!(
6236 !task.domain.lock().handoff.parked,
6237 "and does not park on the turn that just ended",
6238 );
6239
6240 task.translate_event(reading(Some(90), Some(180_000), Some(200_000)));
6241
6242 assert!(
6243 workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6244 command,
6245 Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6246 )),
6247 "the handoff begins at the boundary",
6248 );
6249 assert!(workspace.swap_in_progress(&task.key), "with its gate open");
6250 }
6251
6252 #[tokio::test]
6257 async fn a_handoff_parked_by_a_queued_prompt_begins_at_the_shared_result() {
6258 let dir = tempfile::tempdir().expect("tempdir");
6259 let (workspace, _rx) =
6260 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6261 workspace.enable_test_dispatch_intercept();
6262 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6263 {
6264 let mut domain = task.domain.lock();
6265 domain.commit_turn();
6266 domain.commit_turn();
6267 domain.handoff.reason = Some(forge_primitives::HandoffReason::Manual);
6268 domain.handoff.parked = true;
6269 }
6270
6271 task.after_turn_ended();
6272
6273 let dispatched = workspace.drain_test_dispatch_buffer();
6274 assert!(
6275 dispatched.iter().any(|command| matches!(
6276 command,
6277 Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6278 )),
6279 "the judgment prompt goes out once the shared turn ends: {dispatched:?}",
6280 );
6281 assert!(workspace.swap_in_progress(&task.key), "and the swap gate opens with it");
6282 }
6283
6284 #[tokio::test]
6288 async fn a_prompt_queued_behind_the_boundary_keeps_the_seat_busy() {
6289 let dir = tempfile::tempdir().expect("tempdir");
6290 let (workspace, _rx) =
6291 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6292 workspace.enable_test_dispatch_intercept();
6293 let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6294 {
6295 let mut domain = task.domain.lock();
6296 domain.commit_turn();
6297 domain.commit_turn();
6299 domain.record_queued_prompt(
6300 "queued-2",
6301 crate::protocol::PromptSource::You,
6302 "the next ask",
6303 Vec::new(),
6304 );
6305 }
6306
6307 task.translate_event(AgentEvent::SdkMessage {
6308 session_id: "old-session-id".to_owned(),
6309 msg: result_message("success", false),
6310 });
6311
6312 assert!(!task.domain.lock().turn_in_flight(), "the shared Result ended the turn");
6313 assert!(
6314 !task.domain.lock().is_idle_for_handoff(),
6315 "but the queued prompt still holds the seat",
6316 );
6317 assert!(agent_rx.try_recv().is_err(), "so no reading is asked for at this boundary");
6318
6319 task.domain.lock().advance_queued_prompt("queued-2", "completed");
6322 task.translate_event(AgentEvent::SdkMessage {
6323 session_id: "old-session-id".to_owned(),
6324 msg: result_message("success", false),
6325 });
6326 assert!(
6327 matches!(
6328 agent_rx.try_recv(),
6329 Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6330 ),
6331 "the clear boundary is the one that asks",
6332 );
6333 }
6334
6335 #[tokio::test]
6339 async fn the_trigger_parks_when_a_turn_started_while_the_probe_was_out() {
6340 let dir = tempfile::tempdir().expect("tempdir");
6341 let (workspace, _rx) =
6342 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6343 workspace.enable_test_dispatch_intercept();
6344 let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6345
6346 task.maybe_trigger_handoff();
6347 assert!(
6348 matches!(
6349 agent_rx.try_recv(),
6350 Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6351 ),
6352 "precondition: the reading was asked for",
6353 );
6354 task.domain.lock().turn_pending = true;
6356
6357 task.translate_event(reading(Some(90), Some(900_000), Some(1_000_000)));
6358
6359 assert!(
6360 workspace.drain_test_dispatch_buffer().is_empty(),
6361 "no judgment prompt is written into a running turn",
6362 );
6363 let parked = task.domain.lock().handoff.parked;
6364 assert!(parked, "the handoff waits for the boundary instead");
6365 assert_eq!(
6366 task.domain.lock().handoff.reason,
6367 Some(forge_primitives::HandoffReason::Fill),
6368 "and the reason it will fire for is kept",
6369 );
6370
6371 task.domain.lock().turn_pending = false;
6373 task.after_turn_ended();
6374 assert!(
6375 workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6376 command,
6377 Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6378 )),
6379 "the judgment prompt goes out as its own turn once the seat is idle",
6380 );
6381 assert!(workspace.swap_in_progress(&task.key), "and the swap gate is open");
6382 }
6383
6384 #[tokio::test]
6388 async fn a_parked_handoff_rechecks_the_seat_before_it_begins() {
6389 let dir = tempfile::tempdir().expect("tempdir");
6390 let (workspace, _rx) =
6391 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6392 workspace.enable_test_dispatch_intercept();
6393 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6394 {
6395 let mut domain = task.domain.lock();
6396 domain.handoff.reason = Some(forge_primitives::HandoffReason::Fill);
6397 domain.handoff.parked = true;
6398 domain.commit_turn();
6399 }
6400
6401 task.begin_handoff();
6402
6403 assert!(
6404 workspace.drain_test_dispatch_buffer().is_empty(),
6405 "the judgment prompt is not written into a running turn",
6406 );
6407 assert!(task.domain.lock().handoff.parked, "the handoff stays parked for its own boundary");
6408 assert!(!workspace.swap_in_progress(&task.key), "and its gate never opened");
6409 }
6410
6411 #[tokio::test]
6414 async fn a_turn_that_is_not_the_judgment_turn_does_not_swap() {
6415 let dir = tempfile::tempdir().expect("tempdir");
6416 let (workspace, mut update_rx) =
6417 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6418 workspace.enable_test_dispatch_intercept();
6419 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6420 {
6421 let mut domain = task.domain.lock();
6422 domain.lifecycle_frames = Some(true);
6423 domain.handoff.awaiting = true;
6424 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
6425 domain.handoff.judgment_started = false;
6426 domain.handoff.judgment = Some("Next: not the judgment turn".to_owned());
6427 }
6428
6429 task.after_turn_ended();
6430
6431 assert!(
6432 task.domain.lock().handoff.awaiting,
6433 "the handoff is still waiting for its own prompt's turn",
6434 );
6435 assert!(
6436 update_rx.try_recv().is_err(),
6437 "and no handoff row was emitted for another turn's prose",
6438 );
6439 }
6440
6441 #[tokio::test]
6444 async fn the_lifecycle_frame_names_the_judgment_turn() {
6445 let dir = tempfile::tempdir().expect("tempdir");
6446 let (workspace, mut update_rx) =
6447 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6448 workspace.enable_test_dispatch_intercept();
6449 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6450 let token = workspace.begin_swap(&task.key);
6451 {
6452 let mut domain = task.domain.lock();
6453 domain.lifecycle_frames = Some(true);
6454 domain.handoff.awaiting = true;
6455 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
6456 domain.handoff.swap_token = Some(token);
6457 }
6458 task.translate_event(AgentEvent::SdkMessage {
6459 session_id: "old-session-id".to_owned(),
6460 msg: forge_primitives::Message::CommandLifecycle {
6461 command_uuid: "judgment-id".to_owned(),
6462 state: "started".to_owned(),
6463 uuid: "lifecycle-1".to_owned(),
6464 session_id: "old-session-id".to_owned(),
6465 extras: forge_primitives::messages::Extras::new(),
6466 },
6467 });
6468 assert!(task.domain.lock().handoff.judgment_started, "the frame names the turn");
6469 task.domain.lock().handoff.judgment = Some("Next: the client row.".to_owned());
6470
6471 task.after_turn_ended();
6472
6473 let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
6474 while let Some(frame) = update_rx.recv().await {
6475 if let SessionUpdate::Handoff { handoff, .. } =
6476 std::sync::Arc::unwrap_or_clone(frame.update)
6477 {
6478 return handoff;
6479 }
6480 }
6481 panic!("the handoff row was never emitted");
6482 })
6483 .await
6484 .expect("the handoff lands");
6485 assert!(
6486 record.brief.judgment.iter().any(|section| section.label == "Next"),
6487 "the judgment is that turn's reply: {:?}",
6488 record.brief.judgment,
6489 );
6490 }
6491
6492 #[tokio::test]
6495 async fn a_reading_for_another_occupant_does_not_judge_this_one() {
6496 let dir = tempfile::tempdir().expect("tempdir");
6497 let (workspace, _rx) =
6498 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6499 workspace.enable_test_dispatch_intercept();
6500 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6501 task.maybe_trigger_handoff();
6502
6503 task.translate_event(reading_for(
6504 "an-older-session",
6505 Some(99),
6506 Some(990_000),
6507 Some(1_000_000),
6508 ));
6509
6510 assert!(
6511 task.domain.lock().handoff.probing,
6512 "the boundary's probe is still owed to this occupant's reading",
6513 );
6514 assert!(
6515 workspace.drain_test_dispatch_buffer().is_empty(),
6516 "and the previous session's fill asks for nothing here",
6517 );
6518 }
6519
6520 #[tokio::test]
6523 async fn no_second_handoff_fires_while_a_swap_builds() {
6524 let dir = tempfile::tempdir().expect("tempdir");
6525 let (workspace, _rx) =
6526 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6527 workspace.enable_test_dispatch_intercept();
6528 let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6529 workspace.begin_swap(&task.key);
6530
6531 task.maybe_trigger_handoff();
6532
6533 assert!(agent_rx.try_recv().is_err(), "no reading is even asked for while the swap builds");
6534 assert!(!task.domain.lock().handoff.probing, "and nothing is armed");
6535 }
6536
6537 #[tokio::test]
6540 async fn the_trigger_fires_at_the_configured_share() {
6541 let dir = tempfile::tempdir().expect("tempdir");
6542 let (workspace, _rx) =
6543 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6544 workspace.enable_test_dispatch_intercept();
6545 let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6546
6547 task.maybe_trigger_handoff();
6548 assert!(
6549 matches!(
6550 agent_rx.try_recv(),
6551 Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6552 ),
6553 "the reading is asked of the CLI at the boundary",
6554 );
6555
6556 task.translate_event(reading(Some(85), Some(850_000), Some(1_000_000)));
6557
6558 let dispatched = workspace.drain_test_dispatch_buffer();
6559 assert!(
6560 dispatched.iter().any(|command| matches!(
6561 command,
6562 Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6563 )),
6564 "at 85% the judgment half is asked for: {dispatched:?}",
6565 );
6566 assert_eq!(
6567 task.domain.lock().handoff.reason,
6568 Some(forge_primitives::HandoffReason::Fill),
6569 "and the reason rides to the row",
6570 );
6571 let fill = task.domain.lock().handoff.fill.expect("the reading that fired");
6572 assert_eq!(fill.percent, 85);
6573 assert_eq!((fill.total_tokens, fill.max_tokens), (850_000, 1_000_000));
6574 }
6575
6576 #[tokio::test]
6580 async fn a_late_reading_does_not_overwrite_a_running_handoffs_fill() {
6581 let dir = tempfile::tempdir().expect("tempdir");
6582 let (workspace, _rx) =
6583 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6584 workspace.enable_test_dispatch_intercept();
6585 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6586 {
6587 let mut domain = task.domain.lock();
6588 domain.handoff.awaiting = true;
6589 domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
6590 domain.handoff.fill = Some(crate::handoff::FillReading {
6591 percent: 86,
6592 total_tokens: 860_000,
6593 max_tokens: 1_000_000,
6594 });
6595 domain.handoff.probing = true;
6597 }
6598
6599 task.translate_event(reading(Some(99), Some(990_000), Some(1_000_000)));
6600
6601 let fill = task.domain.lock().handoff.fill.expect("the running handoff keeps its reading");
6602 assert_eq!(
6603 fill.percent, 86,
6604 "the late reading is dropped, not written over the running handoff's",
6605 );
6606 }
6607
6608 #[tokio::test]
6611 async fn a_reading_under_the_share_does_not_fire() {
6612 let dir = tempfile::tempdir().expect("tempdir");
6613 let (workspace, _rx) =
6614 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6615 workspace.enable_test_dispatch_intercept();
6616 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6617
6618 task.maybe_trigger_handoff();
6619 task.translate_event(reading(Some(84), Some(840_000), Some(1_000_000)));
6620
6621 assert!(
6622 workspace.drain_test_dispatch_buffer().is_empty(),
6623 "84% is under the default share and asks for nothing",
6624 );
6625 assert!(!task.domain.lock().handoff.probing, "and the boundary is spent");
6626 }
6627
6628 #[tokio::test]
6632 async fn the_effective_window_is_the_denominator() {
6633 let dir = tempfile::tempdir().expect("tempdir");
6634 let (workspace, _rx) =
6635 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6636 workspace.enable_test_dispatch_intercept();
6637 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6638
6639 task.maybe_trigger_handoff();
6640 task.translate_event(reading(Some(45), Some(900_000), Some(1_000_000)));
6643
6644 assert!(
6645 workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6646 command,
6647 Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6648 )),
6649 "the effective window's 90% fires even though the reported share does not",
6650 );
6651 }
6652
6653 #[tokio::test]
6656 async fn the_count_backstop_fires_when_a_compaction_came_first() {
6657 let dir = tempfile::tempdir().expect("tempdir");
6658 let (workspace, _rx) =
6659 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6660 workspace.enable_test_dispatch_intercept();
6661 let (mut task, mut agent_rx) = trigger_task(&workspace, 1);
6662
6663 task.maybe_trigger_handoff();
6664 assert!(
6665 matches!(
6666 agent_rx.try_recv(),
6667 Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6668 ),
6669 "the boundary asks for the reading",
6670 );
6671 task.translate_event(reading(Some(10), Some(100_000), Some(1_000_000)));
6672
6673 assert!(
6674 workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6675 command,
6676 Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6677 )),
6678 "a session that compacted hands over even at a low fill",
6679 );
6680 assert_eq!(
6681 task.domain.lock().handoff.reason,
6682 Some(forge_primitives::HandoffReason::Compaction),
6683 "and the row says a compaction came first",
6684 );
6685 assert!(workspace.swap_in_progress(&task.key), "with its gate open");
6686 }
6687
6688 #[tokio::test]
6691 async fn the_trigger_is_off_when_the_table_says_so() {
6692 let dir = tempfile::tempdir().expect("tempdir");
6693 let mut config = crate::config::LoadedConfig::empty_for_test();
6694 config.handoff = crate::config::HandoffSettings {
6695 enabled: Some(false),
6696 ..crate::config::HandoffSettings::default()
6697 };
6698 let (workspace, _rx) =
6699 crate::Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
6700 .expect("the stub builds");
6701 workspace.enable_test_dispatch_intercept();
6702 let (mut task, mut agent_rx) = trigger_task(&workspace, 3);
6703
6704 task.maybe_trigger_handoff();
6705 assert!(
6706 agent_rx.try_recv().is_err(),
6707 "a disabled trigger does not even ask for the reading",
6708 );
6709 assert!(!task.domain.lock().handoff.probing, "and nothing is left half-armed");
6710 }
6711
6712 #[tokio::test]
6715 async fn a_reading_with_no_numbers_does_not_fire() {
6716 let dir = tempfile::tempdir().expect("tempdir");
6717 let (workspace, _rx) =
6718 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6719 workspace.enable_test_dispatch_intercept();
6720 let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6721
6722 task.maybe_trigger_handoff();
6723 task.translate_event(reading(None, None, None));
6724
6725 assert!(
6726 workspace.drain_test_dispatch_buffer().is_empty(),
6727 "an empty reading is not a full one",
6728 );
6729 }
6730
6731 #[test]
6735 fn the_brief_goes_to_the_occupant_it_was_written_for() {
6736 let (workspace, _rx) = crate::Workspace::testing_stub();
6737 workspace.enable_test_dispatch_intercept();
6738 let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6739 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6740
6741 let brief = || crate::handoff::PendingBrief {
6742 to: "new-id".to_owned(),
6743 text: "HANDOFF - 9cfe315c -> 4b21e0a7".to_owned(),
6744 };
6745 workspace.set_pending_brief(&slot, brief());
6746 task.deliver_pending_brief(&slot, "someone-else");
6747 assert!(
6748 workspace.drain_test_dispatch_buffer().is_empty(),
6749 "another occupant is not given a brief written for this one",
6750 );
6751
6752 workspace.set_pending_brief(&slot, brief());
6753 task.deliver_pending_brief(&slot, "new-id");
6754 let dispatched = workspace.drain_test_dispatch_buffer();
6755 let Some(Command::PromptUnder { text, source, .. }) = dispatched.first() else {
6756 panic!("the brief was never delivered: {dispatched:?}");
6757 };
6758 assert!(text.starts_with("HANDOFF - "), "the successor is given the brief itself");
6759 assert_eq!(*source, PromptSource::Forge, "as forge's own first turn");
6760 }
6761
6762 #[test]
6769 fn a_worker_that_runs_new_is_re_delivered_its_charter() {
6770 let dir = tempfile::tempdir().expect("tempdir");
6771 let (workspace, _rx) =
6772 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6773 workspace.install_db_for_test(
6774 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
6775 );
6776 let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
6777 workspace.seed_test_session_charter(&slot, "mind the queues", false);
6778 let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6779
6780 task.execute_command(Command::NewSession {
6781 key: slot.clone(),
6782 cwd: "/tmp/forge".to_owned(),
6783 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6784 session_id: None,
6785 });
6786
6787 let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
6788 agent_rx.try_recv().ok()
6789 else {
6790 panic!("the task forwards the new-session command to the handle");
6791 };
6792 assert_eq!(
6793 launch_settings.get("charter").and_then(serde_json::Value::as_str),
6794 Some("mind the queues"),
6795 "the worker comes back with the charter its row holds",
6796 );
6797 }
6798
6799 #[test]
6806 fn a_respawn_moves_the_gateway_binding_to_the_id_the_child_runs_under() {
6807 let (workspace, _rx) = crate::Workspace::testing_stub();
6808 let slot = SessionSlot::worker("Busytools", "forge", "reviewer");
6809 let account = forge_gateway::AccountKey("OpenRouter".to_owned());
6810 let (handle, _cmds) = Agent::testing_stub();
6811 workspace.pool.lock().insert(
6812 slot.clone(),
6813 crate::workspace::PooledAgent {
6814 handle: Arc::new(handle),
6815 account: account.clone(),
6816 permission_mode: None,
6817 registration: Some(forge_gateway::binding::Registration {
6818 org: "Busytools".to_owned(),
6819 project: "forge".to_owned(),
6820 session: "spawn-id".to_owned(),
6821 account: account.clone(),
6822 provider: forge_primitives::account::Provider::Anthropic,
6823 }),
6824 session_id: "spawn-id".to_owned(),
6825 },
6826 );
6827 workspace.gateway.bindings.bind("Busytools", "forge", "spawn-id", account.clone());
6828 let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6829
6830 let stamped_base_url = |launch_settings: &serde_json::Value| -> Option<String> {
6831 launch_settings
6832 .get("env_overrides")
6833 .and_then(|overrides| overrides.get("ANTHROPIC_BASE_URL"))
6834 .and_then(serde_json::Value::as_str)
6835 .map(str::to_owned)
6836 };
6837 let listener_base = format!("http://127.0.0.1:{}", workspace.gateway_port());
6838 let denied = |launch_settings: &serde_json::Value| -> String {
6842 launch_settings
6843 .get("extra_args")
6844 .and_then(serde_json::Value::as_array)
6845 .and_then(|pairs| {
6846 pairs.iter().find(|pair| {
6847 pair.get(0).and_then(serde_json::Value::as_str) == Some("disallowedTools")
6848 })
6849 })
6850 .and_then(|pair| pair.get(1).and_then(serde_json::Value::as_str))
6851 .unwrap_or_default()
6852 .to_owned()
6853 };
6854
6855 task.execute_command(Command::NewSession {
6856 key: slot.clone(),
6857 cwd: "/tmp/forge".to_owned(),
6858 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6859 session_id: None,
6860 });
6861
6862 let Some(forge_primitives::AgentCommand::NewSession {
6863 session_id, launch_settings, ..
6864 }) = agent_rx.try_recv().ok()
6865 else {
6866 panic!("the task forwards the new-session command to the handle");
6867 };
6868 let minted = session_id.expect("`/new` starts under a minted id");
6869 assert_eq!(
6870 stamped_base_url(&launch_settings).as_deref(),
6871 Some(format!("{listener_base}/Busytools/forge/{minted}").as_str()),
6872 "`/new` stamps the base URL with the id it mints for the child",
6873 );
6874 assert_eq!(
6875 workspace.bound_account_for(&slot),
6876 Some(account.clone()),
6877 "the account read for the slot follows the child that is running",
6878 );
6879 for tool in [
6880 "CronCreate",
6881 "CronDelete",
6882 "CronList",
6883 "TaskCreate",
6884 "TaskGet",
6885 "TaskList",
6886 "TaskUpdate",
6887 "SendMessage",
6888 "ListAgents",
6889 "Workflow",
6890 "RemoteTrigger",
6891 ] {
6892 assert!(
6893 denied(&launch_settings).split(',').any(|name| name == tool),
6894 "{tool} must reach a respawned session's launch; got {:?}",
6895 denied(&launch_settings),
6896 );
6897 }
6898 assert_eq!(
6899 workspace.gateway.bindings.binding_for("Busytools", "forge", "spawn-id"),
6900 None,
6901 "the id the session left behind keeps no binding",
6902 );
6903
6904 task.execute_command(Command::ResumeSession {
6907 key: slot.clone(),
6908 session_id: "resumed-id".to_owned(),
6909 cwd: "/tmp/forge".to_owned(),
6910 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6911 });
6912
6913 let Some(forge_primitives::AgentCommand::ResumeSession { launch_settings, .. }) =
6914 agent_rx.try_recv().ok()
6915 else {
6916 panic!("the task forwards the resume command to the handle");
6917 };
6918 assert_eq!(
6919 stamped_base_url(&launch_settings).as_deref(),
6920 Some(format!("{listener_base}/Busytools/forge/resumed-id").as_str()),
6921 "`/resume` stamps the base URL with the transcript it re-enters",
6922 );
6923 assert_eq!(
6924 workspace.bound_account_for(&slot),
6925 Some(account),
6926 "the binding follows the resumed occupant",
6927 );
6928 assert_eq!(
6929 workspace.gateway.bindings.binding_for("Busytools", "forge", &minted),
6930 None,
6931 "and the segment the new session left behind is dropped",
6932 );
6933 }
6934
6935 #[test]
6941 fn a_respawned_worker_keeps_its_own_denials() {
6942 let dir = tempfile::tempdir().expect("tempdir");
6943 let (workspace, _rx) =
6944 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6945 workspace.install_db_for_test(
6946 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
6947 );
6948 let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
6949 workspace.seed_test_session_charter(&slot, "review the diff", false);
6950 let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6951
6952 task.execute_command(Command::NewSession {
6953 key: slot.clone(),
6954 cwd: "/tmp/forge".to_owned(),
6955 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6956 session_id: None,
6957 });
6958
6959 let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
6960 agent_rx.try_recv().ok()
6961 else {
6962 panic!("the task forwards the new-session command to the handle");
6963 };
6964 let denied = respawn_denials(&launch_settings);
6965 let names: Vec<&str> = denied.split(',').collect();
6966 for tool in ["AskUserQuestion", "EnterWorktree", "ExitWorktree"] {
6967 assert!(
6968 names.contains(&tool),
6969 "{tool} must come back on a non-interactive worker's respawn; got {denied:?}",
6970 );
6971 }
6972 }
6973
6974 #[test]
6978 fn a_respawned_interactive_worker_keeps_the_question() {
6979 let dir = tempfile::tempdir().expect("tempdir");
6980 let (workspace, _rx) =
6981 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6982 workspace.install_db_for_test(
6983 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
6984 );
6985 let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
6986 workspace.seed_test_session_charter(&slot, "review the diff", true);
6987 let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6988
6989 task.execute_command(Command::NewSession {
6990 key: slot.clone(),
6991 cwd: "/tmp/forge".to_owned(),
6992 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6993 session_id: None,
6994 });
6995
6996 let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
6997 agent_rx.try_recv().ok()
6998 else {
6999 panic!("the task forwards the new-session command to the handle");
7000 };
7001 let denied = respawn_denials(&launch_settings);
7002 let names: Vec<&str> = denied.split(',').collect();
7003 assert!(
7004 !names.contains(&"AskUserQuestion"),
7005 "an interactive worker asks its own row's user; got {denied:?}",
7006 );
7007 assert!(
7008 names.contains(&"EnterWorktree"),
7009 "and it is still a worker, pinned to its spawn location; got {denied:?}",
7010 );
7011 }
7012
7013 #[test]
7017 fn a_respawned_lead_keeps_its_own_surface() {
7018 let (workspace, _rx) = crate::Workspace::testing_stub();
7019 let slot = SessionSlot::lead("TestOrg", "forge");
7020 let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
7021
7022 task.execute_command(Command::NewSession {
7023 key: slot.clone(),
7024 cwd: "/tmp/forge".to_owned(),
7025 launch_settings: forge_agent::client::SessionLaunchSettings::default(),
7026 session_id: None,
7027 });
7028
7029 let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
7030 agent_rx.try_recv().ok()
7031 else {
7032 panic!("the task forwards the new-session command to the handle");
7033 };
7034 let denied = respawn_denials(&launch_settings);
7035 let names: Vec<&str> = denied.split(',').collect();
7036 for tool in ["EnterWorktree", "ExitWorktree", "AskUserQuestion"] {
7037 assert!(!names.contains(&tool), "a lead keeps {tool} across a respawn; got {denied:?}");
7038 }
7039 assert!(
7040 names.contains(&"Workflow"),
7041 "the replaced set still reaches a lead's respawn; got {denied:?}",
7042 );
7043 }
7044
7045 fn respawn_denials(launch_settings: &serde_json::Value) -> String {
7047 launch_settings
7048 .get("extra_args")
7049 .and_then(serde_json::Value::as_array)
7050 .and_then(|pairs| {
7051 pairs.iter().find(|pair| {
7052 pair.get(0).and_then(serde_json::Value::as_str) == Some("disallowedTools")
7053 })
7054 })
7055 .and_then(|pair| pair.get(1).and_then(serde_json::Value::as_str))
7056 .unwrap_or_default()
7057 .to_owned()
7058 }
7059
7060 #[test]
7068 fn first_connected_drains_parked_slack_and_echoes_block() {
7069 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
7070 workspace.seed_test_project("slack-drain", "/tmp/slack-drain");
7071
7072 let session_key = SessionSlot::lead("TestOrg", "slack-drain");
7075 let domain =
7076 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
7077 workspace.park_slack(
7078 &session_key,
7079 vec![forge_primitives::slack::SlackMessage {
7080 workspace: "acme".to_owned(),
7081 conversation: "D1".to_owned(),
7082 conversation_label: "U9".to_owned(),
7083 ts: "100.000001".to_owned(),
7084 thread_ts: None,
7085 user: Some("U9".to_owned()),
7086 author: None,
7087 text: "the buffered text".to_owned(),
7088 parent_user_id: None,
7089 latest_reply: None,
7090 edited: None,
7091 files: Vec::new(),
7092 }],
7093 );
7094
7095 let (handle, _agent_cmd_rx) = Agent::testing_stub();
7096 let (_cmd_tx, command_rx) =
7097 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
7098 let update_tx = workspace.update_sender();
7099 let mut task = SessionTask {
7100 key: session_key.clone(),
7101 handle: Arc::new(handle),
7102 command_rx,
7103 domain: Arc::clone(&domain),
7104 update_tx,
7105 connected_once: false,
7106 workspace: Arc::downgrade(&workspace),
7107 compaction_count: 0,
7108 successor: None,
7109 teardown_done: None,
7110 };
7111
7112 workspace.enable_test_dispatch_intercept();
7113 task.translate_event(connected_event(&session_key.display(), "/tmp/slack-drain"));
7114
7115 let dispatched = workspace.drain_test_dispatch_buffer();
7116 assert!(
7117 dispatched.iter().any(|c| matches!(
7118 c, crate::protocol::Command::Prompt { key, text, .. }
7119 | crate::protocol::Command::PromptUnder { key, text, .. }
7120 if *key == session_key && text.contains("the buffered text")
7121 )),
7122 "the buffered message arrives as the session's own prompt: {dispatched:?}",
7123 );
7124
7125 let mut echoed = None;
7126 while let Ok(frame) = update_rx.try_recv() {
7127 if let SessionUpdate::SlackMessageAppended { key, prose, .. } =
7128 std::sync::Arc::unwrap_or_clone(frame.update)
7129 && key == session_key
7130 && prose.contains("the buffered text")
7131 {
7132 echoed = Some(prose);
7133 }
7134 }
7135 let prose = echoed
7136 .expect("an asleep-buffered Slack message echoes a SlackMessageAppended on drain");
7137 assert!(
7138 prose.contains("d old"),
7139 "the drain reads the workspace's stale threshold: {prose}",
7140 );
7141
7142 assert!(
7143 workspace.take_parked_for_slot(&session_key).slack.is_empty(),
7144 "the buffer drains once flushed",
7145 );
7146 }
7147
7148 #[test]
7154 fn first_connected_emits_connected_before_draining_slack() {
7155 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
7156 workspace.seed_test_project("slack-rekey", "/tmp/slack-rekey");
7157 let slot = SessionSlot::lead("TestOrg", "slack-rekey");
7158 let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(slot.clone(), None)));
7159 workspace.park_slack(&slot, vec![buffered_slack("buffered while asleep")]);
7160
7161 let (handle, _agent_cmd_rx) = Agent::testing_stub();
7162 let (_cmd_tx, command_rx) =
7163 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
7164 let update_tx = workspace.update_sender();
7165 let mut task = SessionTask {
7166 key: slot.clone(),
7167 handle: Arc::new(handle),
7168 command_rx,
7169 domain: Arc::clone(&domain),
7170 update_tx,
7171 connected_once: false,
7172 workspace: Arc::downgrade(&workspace),
7173 compaction_count: 0,
7174 successor: None,
7175 teardown_done: None,
7176 };
7177
7178 workspace.enable_test_dispatch_intercept();
7179 task.translate_event(connected_event(&slot.display(), "/tmp/slack-rekey"));
7180
7181 let dispatched = workspace.drain_test_dispatch_buffer();
7182 assert!(
7183 dispatched.iter().any(|c| matches!(
7184 c, crate::protocol::Command::Prompt { key, text, .. }
7185 | crate::protocol::Command::PromptUnder { key, text, .. }
7186 if *key == slot && text.contains("buffered while asleep")
7187 )),
7188 "the drained prompt rides the task's own slot: {dispatched:?}",
7189 );
7190
7191 let mut connected = false;
7192 let mut echo_after_connected = false;
7193 while let Ok(frame) = update_rx.try_recv() {
7194 match std::sync::Arc::unwrap_or_clone(frame.update) {
7195 SessionUpdate::Connected { .. } => connected = true,
7196 SessionUpdate::SlackMessageAppended { key, .. } if key == slot => {
7197 assert!(
7198 connected,
7199 "the session's Connected lands before its drained Slack echo",
7200 );
7201 echo_after_connected = true;
7202 }
7203 _ => {}
7204 }
7205 }
7206 assert!(echo_after_connected, "the drained message still echoes a block");
7207 }
7208
7209 fn drained_notice(
7211 update_rx: &mut mpsc::UnboundedReceiver<SeatUpdate>,
7212 ) -> Option<(SessionSlot, String, usize, String)> {
7213 let mut notice = None;
7214 while let Ok(frame) = update_rx.try_recv() {
7215 if let SessionUpdate::ReviewActivityNotice { key, branch, waiting, message } =
7216 std::sync::Arc::unwrap_or_clone(frame.update)
7217 {
7218 notice = Some((key, branch, waiting, message));
7219 }
7220 }
7221 notice
7222 }
7223
7224 fn result_message(subtype: &str, is_error: bool) -> forge_primitives::Message {
7225 serde_json::from_value(serde_json::json!({
7226 "type": "result",
7227 "subtype": subtype,
7228 "duration_ms": 1,
7229 "duration_api_ms": 1,
7230 "is_error": is_error,
7231 "num_turns": 1,
7232 "session_id": "worker"
7233 }))
7234 .expect("parse result message")
7235 }
7236
7237 fn result_message_with_reason(reason: &str) -> forge_primitives::Message {
7239 serde_json::from_value(serde_json::json!({
7240 "type": "result",
7241 "subtype": "success",
7242 "duration_ms": 1,
7243 "duration_api_ms": 1,
7244 "is_error": false,
7245 "num_turns": 1,
7246 "session_id": "worker",
7247 "terminal_reason": reason,
7248 }))
7249 .expect("parse result message")
7250 }
7251
7252 fn result_message_for_a_local_command() -> forge_primitives::Message {
7255 serde_json::from_value(serde_json::json!({
7256 "type": "result",
7257 "subtype": "success",
7258 "duration_ms": 1,
7259 "duration_api_ms": 0,
7260 "is_error": false,
7261 "num_turns": 0,
7262 "session_id": "worker",
7263 }))
7264 .expect("parse result message")
7265 }
7266
7267 fn result_message_with_errors(errors: &[&str]) -> forge_primitives::Message {
7270 serde_json::from_value(serde_json::json!({
7271 "type": "result",
7272 "subtype": "error_during_execution",
7273 "duration_ms": 1,
7274 "duration_api_ms": 1,
7275 "is_error": true,
7276 "num_turns": 1,
7277 "session_id": "worker",
7278 "errors": errors,
7279 }))
7280 .expect("parse result message")
7281 }
7282
7283 fn api_retry_message(error: &str, status: Option<u16>) -> forge_primitives::Message {
7286 serde_json::from_value(serde_json::json!({
7287 "type": "system",
7288 "subtype": "api_retry",
7289 "session_id": "worker",
7290 "attempt": 1,
7291 "max_retries": 4,
7292 "retry_delay_ms": 500,
7293 "error_status": status,
7294 "error": error,
7295 }))
7296 .expect("parse api_retry message")
7297 }
7298
7299 #[tokio::test]
7304 async fn turn_end_result_drains_review_activity_to_a_notice() {
7305 let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
7306 let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
7307
7308 task.translate_event(AgentEvent::SdkMessage {
7309 session_id: "worker".to_owned(),
7310 msg: result_message("success", false),
7311 });
7312
7313 let (key, branch, waiting, message) = drained_notice(&mut update_rx)
7314 .expect("a ReviewActivityNotice emits on the turn's Result");
7315 assert_eq!(key, reviewer, "the notice routes to the submit origin, not the worker");
7316 assert_eq!(branch, "feat", "the notice names the branch it is about");
7317 assert_eq!(waiting, 1, "the replied-to thread now awaits the reviewer");
7318 assert!(message.contains("review #1"), "the notice names the review: {message}");
7319 assert!(message.contains("1 replied"), "the tally counts the reply: {message}");
7320 }
7321
7322 #[tokio::test]
7328 async fn a_results_end_announces_the_turn_complete() {
7329 let (workspace, _rx) = crate::Workspace::testing_stub();
7330 let key = SessionSlot::from_str_for_test("turn-ends");
7331 let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7332
7333 task.translate_event(AgentEvent::SdkMessage {
7334 session_id: "turn-ends".to_owned(),
7335 msg: result_message("success", false),
7336 });
7337
7338 let mut said = Vec::new();
7339 while let Ok(frame) = update_rx.try_recv() {
7340 match std::sync::Arc::unwrap_or_clone(frame.update) {
7341 SessionUpdate::TurnComplete { key, terminal_reason } => {
7342 said.push(("complete", key, terminal_reason));
7343 }
7344 SessionUpdate::TurnCancelled { key } => said.push(("cancelled", key, None)),
7345 _ => {}
7346 }
7347 }
7348 assert_eq!(said.len(), 1, "exactly one end frame, saw {said:?}");
7349 assert_eq!(said[0].0, "complete", "an ordinary end, not the cancel's frame");
7350 assert_eq!(said[0].1, key, "announced on the seat its turn ended");
7351 assert_eq!(said[0].2, None, "the test's Result carries no terminal reason");
7352 }
7353
7354 #[tokio::test]
7359 async fn a_results_terminal_reason_rides_the_frame() {
7360 let (workspace, _rx) = crate::Workspace::testing_stub();
7361 let key = SessionSlot::from_str_for_test("turn-reason");
7362 let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7363
7364 task.translate_event(AgentEvent::SdkMessage {
7365 session_id: "turn-reason".to_owned(),
7366 msg: result_message_with_reason("completed"),
7367 });
7368 task.translate_event(AgentEvent::SdkMessage {
7369 session_id: "turn-reason".to_owned(),
7370 msg: result_message_for_a_local_command(),
7371 });
7372
7373 let mut said = Vec::new();
7374 while let Ok(frame) = update_rx.try_recv() {
7375 if let SessionUpdate::TurnComplete { terminal_reason, .. } =
7376 std::sync::Arc::unwrap_or_clone(frame.update)
7377 {
7378 said.push(terminal_reason);
7379 }
7380 }
7381 assert_eq!(
7382 said,
7383 [Some(forge_primitives::runtime::TerminalReason::Completed), None],
7384 "the reason rides the frame, and a turn-less result announces without one",
7385 );
7386 }
7387
7388 #[tokio::test]
7392 async fn a_transport_error_announces_no_end() {
7393 let (workspace, _rx) = crate::Workspace::testing_stub();
7394 let key = SessionSlot::from_str_for_test("turn-error");
7395 let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7396
7397 task.translate_event(AgentEvent::SdkMessage {
7398 session_id: "turn-error".to_owned(),
7399 msg: forge_primitives::Message::Error {
7400 error: "stream closed".to_owned(),
7401 extras: serde_json::Map::new(),
7402 },
7403 });
7404
7405 let mut said = Vec::new();
7406 while let Ok(frame) = update_rx.try_recv() {
7407 match std::sync::Arc::unwrap_or_clone(frame.update) {
7408 SessionUpdate::TurnComplete { .. } => said.push("complete"),
7409 SessionUpdate::TurnCancelled { .. } => said.push("cancelled"),
7410 _ => {}
7411 }
7412 }
7413 assert!(said.is_empty(), "a transport error is not the Result's frame: {said:?}");
7414 }
7415
7416 #[tokio::test]
7420 async fn a_cancelled_results_end_announces_the_turn_cancelled() {
7421 let (workspace, _rx) = crate::Workspace::testing_stub();
7422 let key = SessionSlot::from_str_for_test("turn-cancelled");
7423 let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7424
7425 task.domain.lock().pending_cancel = true;
7426 task.translate_event(AgentEvent::SdkMessage {
7427 session_id: "turn-cancelled".to_owned(),
7428 msg: result_message("error_during_execution", true),
7429 });
7430
7431 let mut said = Vec::new();
7432 while let Ok(frame) = update_rx.try_recv() {
7433 match std::sync::Arc::unwrap_or_clone(frame.update) {
7434 SessionUpdate::TurnComplete { .. } => said.push("complete"),
7435 SessionUpdate::TurnCancelled { .. } => said.push("cancelled"),
7436 _ => {}
7437 }
7438 }
7439 assert_eq!(said, ["cancelled"], "the reader's cancel is its own frame");
7440 }
7441
7442 #[tokio::test]
7446 async fn cancelled_turn_result_drains_review_activity() {
7447 let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
7448 let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
7449
7450 task.translate_event(AgentEvent::SdkMessage {
7451 session_id: "worker".to_owned(),
7452 msg: result_message("error_during_execution", true),
7453 });
7454
7455 let (key, ..) = drained_notice(&mut update_rx).expect("a cancelled turn still notifies");
7456 assert_eq!(key, reviewer);
7457 }
7458
7459 #[test]
7463 fn an_errored_result_marks_the_slot_unless_the_reader_cancelled() {
7464 let (workspace, _rx) = crate::Workspace::testing_stub();
7465 let key = SessionSlot::from_str_for_test("failed-rail");
7466 let (mut task, _update_rx) = review_task_for(&workspace, &key);
7467
7468 task.translate_event(AgentEvent::SdkMessage {
7469 session_id: "failed-rail".to_owned(),
7470 msg: result_message("error_during_execution", true),
7471 });
7472 assert!(
7473 task.domain.lock().failed_turn_at.is_some(),
7474 "a genuine error marks the slot for the rail",
7475 );
7476
7477 task.domain.lock().failed_turn_at = None;
7480 task.domain.lock().pending_cancel = true;
7481 task.translate_event(AgentEvent::SdkMessage {
7482 session_id: "failed-rail".to_owned(),
7483 msg: result_message("error_during_execution", true),
7484 });
7485 assert!(
7486 task.domain.lock().failed_turn_at.is_none(),
7487 "a cancelled turn is the reader's own act, not a failure to flag",
7488 );
7489 assert!(!task.domain.lock().pending_cancel, "the stamp is spent at the result");
7490
7491 task.domain.lock().pending_cancel = true;
7494 task.translate_event(AgentEvent::SdkMessage {
7495 session_id: "failed-rail".to_owned(),
7496 msg: result_message("success", false),
7497 });
7498 task.translate_event(AgentEvent::SdkMessage {
7499 session_id: "failed-rail".to_owned(),
7500 msg: result_message("error_during_execution", true),
7501 });
7502 assert!(
7503 task.domain.lock().failed_turn_at.is_some(),
7504 "a success spends the stamp, so the next real failure marks",
7505 );
7506 }
7507
7508 #[test]
7511 fn a_plain_failure_is_continued_with_the_cli_errors_as_its_reason() {
7512 let (workspace, _rx) = crate::Workspace::testing_stub();
7513 let key = SessionSlot::from_str_for_test("nudge-rail");
7514 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
7515
7516 task.translate_event(AgentEvent::SdkMessage {
7517 session_id: "nudge-rail".to_owned(),
7518 msg: result_message_with_errors(&["API Error: 400 ...", "request too large"]),
7519 });
7520
7521 let prompts = captured_prompts(&workspace);
7522 assert_eq!(prompts.len(), 1, "the failure's continuation goes out: {prompts:?}");
7523 assert!(
7524 prompts[0].contains("API Error: 400 ...; request too large"),
7525 "the CLI's own errors are the reason, in the order it reported them: {prompts:?}",
7526 );
7527 assert!(task.domain.lock().failed_turn_at.is_some(), "the rail's mark is set with it");
7528 }
7529
7530 #[test]
7534 fn a_cancelled_turn_arms_no_continuation() {
7535 let (workspace, _rx) = crate::Workspace::testing_stub();
7536 let key = SessionSlot::from_str_for_test("cancelled-rail");
7537 let (mut task, _update_rx) = review_task_for(&workspace, &key);
7538 task.domain.lock().pending_cancel = true;
7539
7540 task.translate_event(AgentEvent::SdkMessage {
7541 session_id: "cancelled-rail".to_owned(),
7542 msg: result_message_with_errors(&["aborted_streaming"]),
7543 });
7544
7545 let domain = task.domain.lock();
7546 assert!(domain.failed_turn_at.is_none(), "a cancelled turn is not a failure to mark");
7547 assert!(domain.auto_continue.is_none(), "and nothing is armed to pick up");
7548 }
7549
7550 #[test]
7553 fn a_result_without_errors_is_named_by_its_subtype_in_the_nudge() {
7554 let (workspace, _rx) = crate::Workspace::testing_stub();
7555 let key = SessionSlot::from_str_for_test("subtype-rail");
7556 let (mut task, _update_rx) = review_task_for(&workspace, &key);
7557
7558 task.translate_event(AgentEvent::SdkMessage {
7559 session_id: "subtype-rail".to_owned(),
7560 msg: result_message("error_max_turns", true),
7561 });
7562 let reason = task.domain.lock().auto_continue.clone().expect("armed").reason;
7563 assert_eq!(reason, "error_max_turns", "no errors reported, so the subtype names it");
7564
7565 task.domain.lock().auto_continue = None;
7566 task.translate_event(AgentEvent::SdkMessage {
7567 session_id: "subtype-rail".to_owned(),
7568 msg: result_message_with_errors(&[]),
7569 });
7570 let reason = task.domain.lock().auto_continue.clone().expect("armed").reason;
7571 assert_eq!(reason, "error_during_execution", "an empty list names nothing either");
7572 }
7573
7574 #[test]
7578 fn the_failures_reason_prefers_the_cli_errors_then_the_subtype() {
7579 assert_eq!(
7580 super::failure_reason(Some(&["API Error: 400 ...".to_owned()]), "error_max_turns"),
7581 "API Error: 400 ...",
7582 "the CLI's own errors are the reason",
7583 );
7584 assert_eq!(
7585 super::failure_reason(None, "error_max_turns"),
7586 "error_max_turns",
7587 "a result with no errors is named by its subtype",
7588 );
7589 assert_eq!(
7590 super::failure_reason(None, "success"),
7591 "an error",
7592 "a subtype that names nothing falls back to plain wording",
7593 );
7594 assert_eq!(super::failure_reason(None, ""), "an error", "and so does an empty subtype");
7595 }
7596
7597 #[test]
7600 fn a_completed_turn_clears_the_spend_for_the_next_failure() {
7601 let (workspace, _rx) = crate::Workspace::testing_stub();
7602 let key = SessionSlot::from_str_for_test("spent-rail");
7603 let (mut task, _update_rx) = review_task_for(&workspace, &key);
7604 task.domain.lock().auto_continue_spent = true;
7605
7606 task.translate_event(AgentEvent::SdkMessage {
7607 session_id: "spent-rail".to_owned(),
7608 msg: result_message("success", false),
7609 });
7610
7611 let domain = task.domain.lock();
7612 assert!(!domain.auto_continue_spent, "a completed turn ends the episode");
7613 }
7614
7615 fn nudge_task_for(
7620 workspace: &Arc<crate::Workspace>,
7621 key: &SessionSlot,
7622 ) -> (
7623 SessionTask,
7624 mpsc::UnboundedReceiver<SeatUpdate>,
7625 mpsc::UnboundedReceiver<forge_primitives::AgentCommand>,
7626 ) {
7627 workspace.enable_test_dispatch_intercept();
7628 let (handle, to_agent) = Agent::testing_stub();
7629 let handle = Arc::new(handle);
7630 let domain = workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
7631 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
7632 let update_tx = UpdateFanout::default();
7633 let update_rx = update_tx.subscribe(SubscriberRole::Answering);
7634 let task = SessionTask {
7635 key: key.clone(),
7636 handle,
7637 command_rx,
7638 domain,
7639 update_tx,
7640 connected_once: false,
7641 workspace: Arc::downgrade(workspace),
7642 compaction_count: 0,
7643 successor: None,
7644 teardown_done: None,
7645 };
7646 (task, update_rx, to_agent)
7647 }
7648
7649 fn captured_prompts(workspace: &Arc<crate::Workspace>) -> Vec<String> {
7651 workspace
7652 .drain_test_dispatch_buffer()
7653 .into_iter()
7654 .filter_map(|cmd| match cmd {
7655 crate::protocol::Command::PromptUnder { text, .. } => Some(text),
7656 _ => None,
7657 })
7658 .collect()
7659 }
7660
7661 fn captured_prompt_commands(
7663 workspace: &Arc<crate::Workspace>,
7664 ) -> Vec<crate::protocol::Command> {
7665 workspace
7666 .drain_test_dispatch_buffer()
7667 .into_iter()
7668 .filter(|cmd| matches!(cmd, crate::protocol::Command::PromptUnder { .. }))
7669 .collect()
7670 }
7671
7672 fn continuation_reaches_the_cli(label: &str, replace: bool) -> bool {
7676 let (workspace, _rx) = crate::Workspace::testing_stub();
7677 let key = SessionSlot::from_str_for_test(label);
7678 let (mut task, _update_rx, mut to_agent) = nudge_task_for(&workspace, &key);
7679 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-occupant"));
7680
7681 task.translate_event(AgentEvent::SdkMessage {
7682 session_id: label.to_owned(),
7683 msg: result_message("error_during_execution", true),
7684 });
7685 let captured = captured_prompt_commands(&workspace);
7686 let [crate::protocol::Command::PromptUnder { text, uuid, .. }] = captured.as_slice() else {
7687 panic!("the failure's continuation is dispatched: {captured:?}");
7688 };
7689 let (text, uuid) = (text.clone(), uuid.clone());
7690
7691 if replace {
7692 task.translate_event(AgentEvent::Connected {
7695 session_id: "new-occupant".to_owned(),
7696 cwd: "/tmp/continuation-occupant".to_owned(),
7697 current_model: forge_primitives::CurrentModel {
7698 resolved_id: "claude".to_owned(),
7699 display_name_short: "claude".to_owned(),
7700 display_name_long: "claude".to_owned(),
7701 requested_id: None,
7702 catalog_id: None,
7703 supports_effort: false,
7704 supported_effort_levels: Vec::new(),
7705 supports_auto_mode: None,
7706 supports_adaptive_thinking: None,
7707 is_authoritative: true,
7708 },
7709 available_models: Vec::new(),
7710 mode: None,
7711 history_updates: None,
7712 compaction_count: 0,
7713 });
7714 }
7715
7716 task.execute_command(crate::protocol::Command::PromptUnder {
7717 key: key.clone(),
7718 text,
7719 attachments: Vec::new(),
7720 uuid: uuid.clone(),
7721 source: crate::protocol::PromptSource::Forge,
7722 dispatch_id: None,
7723 });
7724 loop {
7730 match to_agent.try_recv() {
7731 Ok(forge_primitives::AgentCommand::PromptWithImages { uuid: sent, .. })
7732 if sent == uuid =>
7733 {
7734 return true;
7735 }
7736 Ok(_) => {}
7737 Err(_) => return false,
7738 }
7739 }
7740 }
7741
7742 #[test]
7748 fn a_continuation_is_dropped_when_the_occupant_changed_under_it() {
7749 assert!(
7750 continuation_reaches_the_cli("kept-rail", false),
7751 "control: with the occupant unchanged the continuation reaches the CLI",
7752 );
7753 assert!(
7754 !continuation_reaches_the_cli("replaced-rail", true),
7755 "the replacement is not handed words about a history it does not have",
7756 );
7757 }
7758
7759 fn drain_updates(rx: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> Vec<SessionUpdate> {
7761 let mut seen = Vec::new();
7762 while let Ok(frame) = rx.try_recv() {
7763 seen.push(std::sync::Arc::unwrap_or_clone(frame.update));
7764 }
7765 seen
7766 }
7767
7768 #[test]
7775 fn a_server_error_failure_is_nudged_at_once() {
7776 let (workspace, mut updates) = crate::Workspace::testing_stub();
7777 let key = SessionSlot::from_str_for_test("server-rail");
7778 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
7779
7780 task.translate_event(AgentEvent::SdkMessage {
7781 session_id: "server-rail".to_owned(),
7782 msg: api_retry_message("server_error", Some(529)),
7783 });
7784 task.translate_event(AgentEvent::SdkMessage {
7785 session_id: "server-rail".to_owned(),
7786 msg: result_message("error_during_execution", true),
7787 });
7788
7789 let prompts = captured_prompts(&workspace);
7790 assert_eq!(
7791 prompts.len(),
7792 1,
7793 "the failed result fires the continuation itself: {prompts:?}"
7794 );
7795 assert!(
7796 prompts[0].contains("error_during_execution"),
7797 "the prompt names the failure it answers: {prompts:?}",
7798 );
7799 let domain = task.domain.lock();
7800 assert!(domain.auto_continue.is_none(), "the fire took the armed continuation");
7801 assert!(domain.auto_continue_spent, "the episode is spent: one nudge per failure");
7802
7803 let drawn = drain_updates(&mut updates);
7804 assert!(
7805 drawn.iter().any(|update| matches!(
7806 update,
7807 SessionUpdate::ChatAppended { msg: forge_primitives::Message::User { .. }, .. }
7808 )),
7809 "the continuation's words draw in every view: {drawn:?}",
7810 );
7811 }
7812
7813 #[test]
7823 fn a_live_frame_crosses_stamped_and_keeps_the_stamp_it_came_with() {
7824 let (workspace, _updates) = crate::Workspace::testing_stub();
7825 let key = SessionSlot::from_str_for_test("stamp-crossing");
7826 let (mut task, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
7827
7828 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
7829 task.translate_event(AgentEvent::SdkMessage {
7830 session_id: "stamp-crossing".to_owned(),
7831 msg,
7832 });
7833 };
7834
7835 let prompt: forge_primitives::Message = serde_json::from_value(serde_json::json!({
7837 "type": "user",
7838 "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
7839 "session_id": "stamp-crossing",
7840 }))
7841 .expect("a prompt frame");
7842 send(&mut task, prompt);
7843
7844 let spoke: forge_primitives::Message = serde_json::from_value(serde_json::json!({
7846 "type": "assistant",
7847 "uuid": "a1",
7848 "timestamp": "2026-10-15T05:18:15.544Z",
7849 "session_id": "stamp-crossing",
7850 "message": {
7851 "id": "m1",
7852 "role": "assistant",
7853 "model": "claude-opus-5",
7854 "content": [{"type": "text", "text": "working"}],
7855 },
7856 }))
7857 .expect("an assistant frame");
7858 send(&mut task, spoke);
7859
7860 let drawn: Vec<forge_primitives::Message> = drain_updates(&mut updates)
7861 .into_iter()
7862 .filter_map(|update| match update {
7863 SessionUpdate::ChatAppended { msg, .. } => Some(msg),
7864 _ => None,
7865 })
7866 .collect();
7867 assert_eq!(drawn.len(), 2, "both frames are drawn: {drawn:?}");
7868
7869 let prompt_stamp = serde_json::to_value(&drawn[0]).expect("encode")["timestamp"]
7870 .as_str()
7871 .map(str::to_owned)
7872 .expect("the prompt row carries a stamp for a clock to anchor on");
7873 assert!(
7874 time::OffsetDateTime::parse(
7875 &prompt_stamp,
7876 &time::format_description::well_known::Rfc3339,
7877 )
7878 .is_ok(),
7879 "and it is a time the client can read: {prompt_stamp}",
7880 );
7881 assert_eq!(
7882 serde_json::to_value(&drawn[1]).expect("encode")["timestamp"].as_str(),
7883 Some("2026-10-15T05:18:15.544Z"),
7884 "while the frame the CLI stamped keeps its own",
7885 );
7886 }
7887
7888 #[test]
7892 fn a_rate_limited_failure_is_nudged_at_once() {
7893 let (workspace, _updates) = crate::Workspace::testing_stub();
7894 let key = SessionSlot::from_str_for_test("rate-limit-rail");
7895 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
7896
7897 task.translate_event(AgentEvent::SdkMessage {
7898 session_id: "rate-limit-rail".to_owned(),
7899 msg: api_retry_message("rate_limit", Some(429)),
7900 });
7901 task.translate_event(AgentEvent::SdkMessage {
7902 session_id: "rate-limit-rail".to_owned(),
7903 msg: result_message("error_during_execution", true),
7904 });
7905
7906 let prompts = captured_prompts(&workspace);
7907 assert_eq!(prompts.len(), 1, "a 429 failure is nudged like any other: {prompts:?}");
7908 }
7909
7910 #[test]
7916 fn a_rate_limit_event_not_allowed_reports_the_gateway_for_the_session_key() {
7917 let (workspace, _rx) = crate::Workspace::testing_stub();
7918 let session_id = "w-uuid";
7919 let key = SessionSlot::from_str_for_test(session_id);
7920 let (mut task, _update_rx) = review_task_for(&workspace, &key);
7921 task.domain.lock().session_id = Some(forge_primitives::SessionId::new(session_id));
7922
7923 workspace.gateway.bindings.bind(
7924 key.org(),
7925 key.project(),
7926 session_id,
7927 forge_gateway::AccountKey("A".to_owned()),
7928 );
7929
7930 let rejected = forge_primitives::Message::RateLimitEvent {
7931 rate_limit_info: forge_primitives::RateLimitInfo {
7932 status: forge_primitives::RateLimitStatus::Rejected,
7933 resets_at: Some(1_800_000_000),
7934 rate_limit_type: None,
7935 utilization: None,
7936 overage_status: None,
7937 overage_resets_at: None,
7938 overage_disabled_reason: None,
7939 raw: serde_json::Map::new(),
7940 },
7941 uuid: "u1".to_owned(),
7942 session_id: session_id.to_owned(),
7943 extras: serde_json::Map::new(),
7944 };
7945 task.translate_event(AgentEvent::SdkMessage {
7946 session_id: session_id.to_owned(),
7947 msg: rejected,
7948 });
7949
7950 assert!(
7951 workspace.gateway.bindings.binding_for(key.org(), key.project(), session_id).is_none(),
7952 "the report drops the session's own binding",
7953 );
7954
7955 workspace.gateway.bindings.bind(
7960 key.org(),
7961 key.project(),
7962 session_id,
7963 forge_gateway::AccountKey("C".to_owned()),
7964 );
7965 task.translate_event(AgentEvent::SdkMessage {
7966 session_id: session_id.to_owned(),
7967 msg: forge_primitives::Message::RateLimitEvent {
7968 rate_limit_info: forge_primitives::RateLimitInfo {
7969 status: forge_primitives::RateLimitStatus::Allowed,
7970 resets_at: None,
7971 rate_limit_type: None,
7972 utilization: None,
7973 overage_status: None,
7974 overage_resets_at: None,
7975 overage_disabled_reason: None,
7976 raw: serde_json::Map::new(),
7977 },
7978 uuid: "u2".to_owned(),
7979 session_id: session_id.to_owned(),
7980 extras: serde_json::Map::new(),
7981 },
7982 });
7983 assert_eq!(
7984 workspace.gateway.bindings.binding_for(key.org(), key.project(), session_id),
7985 Some(forge_gateway::AccountKey("C".to_owned())),
7986 "an allowed frame rotates nothing",
7987 );
7988 }
7989
7990 #[tokio::test]
7993 async fn transport_error_drains_review_activity() {
7994 let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
7995 let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
7996
7997 task.translate_event(AgentEvent::SdkMessage {
7998 session_id: "worker".to_owned(),
7999 msg: forge_primitives::Message::Error {
8000 error: "stream closed".to_owned(),
8001 extras: serde_json::Map::new(),
8002 },
8003 });
8004
8005 let (key, ..) =
8006 drained_notice(&mut update_rx).expect("a transport error still notifies the reviewer");
8007 assert_eq!(key, reviewer);
8008 }
8009
8010 #[tokio::test]
8014 async fn session_replacement_drains_review_activity() {
8015 let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
8016 let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
8017 task.connected_once = true;
8018
8019 task.translate_event(connected_event("worker-replacement", "/tmp/proj"));
8020
8021 let (key, ..) = drained_notice(&mut update_rx)
8022 .expect("the replaced identity flushes what its turn touched");
8023 assert_eq!(key, reviewer);
8024 assert!(
8025 workspace.drain_review_activity(&worker).is_empty(),
8026 "nothing strands under the worker's own slot",
8027 );
8028 }
8029
8030 #[tokio::test]
8033 async fn task_teardown_drains_review_activity() {
8034 let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
8035 let (task, mut update_rx) = review_task_for(&workspace, &worker);
8036
8037 drop(task);
8038
8039 let (key, ..) =
8040 drained_notice(&mut update_rx).expect("teardown flushes the stranded activity");
8041 assert_eq!(key, reviewer);
8042 }
8043
8044 #[tokio::test]
8055 async fn run_exiting_on_a_closed_channel_drains_review_activity() {
8056 let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
8057 let (task, mut update_rx) = review_task_for(&workspace, &worker);
8058
8059 task.run().await;
8060
8061 let (key, ..) =
8062 drained_notice(&mut update_rx).expect("the run loop's exit flushes the activity");
8063 assert_eq!(key, reviewer);
8064 }
8065
8066 #[tokio::test]
8074 async fn connection_failed_releases_registrations_and_terminates() {
8075 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8076 let key = SessionSlot::from_str_for_test("dead-spawn");
8077 let (handle, _cmds) = Agent::testing_stub();
8078 let handle = Arc::new(handle);
8079 let (cmd_tx, command_rx) = mpsc::unbounded_channel();
8080 let update_tx = UpdateFanout::default();
8081 let mut update_rx = update_tx.subscribe(SubscriberRole::Answering);
8082 workspace.pool.lock().insert(
8083 key.clone(),
8084 crate::workspace::PooledAgent {
8085 handle: Arc::clone(&handle),
8086 account: forge_gateway::AccountKey("Acct".to_owned()),
8087 permission_mode: None,
8088 registration: None,
8089 session_id: "pooled-session".to_owned(),
8090 },
8091 );
8092 workspace.command_senders.lock().insert(key.clone(), cmd_tx);
8093 workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
8094 let mut task = SessionTask {
8095 key: key.clone(),
8096 handle,
8097 command_rx,
8098 domain: Arc::new(Mutex::new(DomainSession::new(key.clone(), None))),
8099 update_tx,
8100 connected_once: false,
8101 workspace: Arc::downgrade(&workspace),
8102 compaction_count: 0,
8103 successor: None,
8104 teardown_done: None,
8105 };
8106
8107 let continues = task.translate_event(AgentEvent::ConnectionFailed {
8108 message: "spawn failed".to_owned(),
8109 kind: SpawnFailureKind::Unclassified,
8110 });
8111
8112 assert!(!continues, "ConnectionFailed must terminate the task");
8113 assert!(
8114 !workspace.pool.lock().contains_key(&key),
8115 "pool entry released so a retry spawns fresh"
8116 );
8117 assert!(!workspace.command_senders.lock().contains_key(&key), "command sender released");
8118 let update = update_rx.try_recv().expect("an update emits");
8119 assert!(
8120 matches!(
8121 &*update.update,
8122 SessionUpdate::ConnectionFailed { key: emitted, .. } if *emitted == key
8123 ),
8124 "the ConnectionFailed envelope still goes out"
8125 );
8126 assert!(
8130 matches!(
8131 update_rx.try_recv().map(|frame| std::sync::Arc::unwrap_or_clone(frame.update)),
8132 Ok(SessionUpdate::MachineTurn { machine: None, .. })
8133 ),
8134 "the machine turn ends with the session",
8135 );
8136 }
8137
8138 #[tokio::test]
8142 async fn connection_failed_releases_the_session_registrations() {
8143 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8144 let key = SessionSlot::from_str_for_test("real-key");
8145 let (handle, _cmds) = Agent::testing_stub();
8146 let handle = Arc::new(handle);
8147 let (cmd_tx, command_rx) = mpsc::unbounded_channel();
8148 let update_tx = UpdateFanout::default();
8149 let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8150 workspace.pool.lock().insert(
8151 key.clone(),
8152 crate::workspace::PooledAgent {
8153 handle: Arc::clone(&handle),
8154 account: forge_gateway::AccountKey("Acct".to_owned()),
8155 permission_mode: None,
8156 registration: None,
8157 session_id: "pooled-session".to_owned(),
8158 },
8159 );
8160 workspace.command_senders.lock().insert(key.clone(), cmd_tx);
8161 let mut task = SessionTask {
8162 key: key.clone(),
8163 handle: Arc::clone(&handle),
8164 command_rx,
8165 domain: Arc::new(Mutex::new(DomainSession::new(key.clone(), None))),
8166 update_tx,
8167 connected_once: false,
8168 workspace: Arc::downgrade(&workspace),
8169 compaction_count: 0,
8170 successor: None,
8171 teardown_done: None,
8172 };
8173
8174 let continues = task.translate_event(AgentEvent::ConnectionFailed {
8175 message: "spawn failed".to_owned(),
8176 kind: SpawnFailureKind::Unclassified,
8177 });
8178
8179 assert!(!continues);
8180 assert!(
8181 !workspace.pool.lock().contains_key(&key),
8182 "pool entry under the real key released"
8183 );
8184 assert!(
8185 !workspace.command_senders.lock().contains_key(&key),
8186 "command sender under the real key released"
8187 );
8188 }
8189
8190 #[tokio::test]
8196 async fn connection_failed_expires_the_buffers_parked_for_its_slot() {
8197 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8198 let key = test_slot();
8199 let (handle, _cmds) = Agent::testing_stub();
8200 let handle = Arc::new(handle);
8201 let (cmd_tx, command_rx) = mpsc::unbounded_channel();
8202 let update_tx = UpdateFanout::default();
8203 let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8204 workspace.pool.lock().insert(
8205 key.clone(),
8206 crate::workspace::PooledAgent {
8207 handle: Arc::clone(&handle),
8208 account: forge_gateway::AccountKey("Acct".to_owned()),
8209 permission_mode: None,
8210 registration: None,
8211 session_id: "pooled-session".to_owned(),
8212 },
8213 );
8214 workspace.command_senders.lock().insert(key.clone(), cmd_tx);
8215 workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
8216 workspace.park_slack(&test_slot(), vec![buffered_slack("parked while spawning")]);
8217
8218 let mut task = SessionTask {
8219 key: key.clone(),
8220 handle: Arc::clone(&handle),
8221 command_rx,
8222 domain: Arc::new(Mutex::new(DomainSession::new(key.clone(), None))),
8223 update_tx,
8224 connected_once: false,
8225 workspace: Arc::downgrade(&workspace),
8226 compaction_count: 0,
8227 successor: None,
8228 teardown_done: None,
8229 };
8230
8231 let continues = task.translate_event(AgentEvent::ConnectionFailed {
8232 message: "spawn failed".to_owned(),
8233 kind: SpawnFailureKind::Unclassified,
8234 });
8235
8236 assert!(!continues);
8237 assert!(
8238 workspace.take_parked_for_slot(&test_slot()).slack.is_empty(),
8239 "the message parked for this slot is expired, not left for a later session",
8240 );
8241 }
8242
8243 fn notices_of(
8245 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
8246 ) -> Vec<(NoticeSeverity, String)> {
8247 drain_updates(updates)
8248 .into_iter()
8249 .filter_map(|update| match update {
8250 SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
8251 _ => None,
8252 })
8253 .collect()
8254 }
8255
8256 fn record_death_via_arm(task: &mut SessionTask, occupant: &str, message: &str) {
8259 task.connected_once = true;
8260 task.domain.lock().session_id = Some(forge_primitives::SessionId::new(occupant));
8261 task.translate_event(AgentEvent::ConnectionFailed {
8262 message: message.to_owned(),
8263 kind: SpawnFailureKind::Unclassified,
8264 });
8265 }
8266
8267 #[test]
8277 fn a_recovery_after_a_death_announces_what_forge_did() {
8278 let message = "the claude subprocess closed its output stream";
8279
8280 let (workspace, _rx) = crate::Workspace::testing_stub();
8282 let key = SessionSlot::from_str_for_test("died-and-restarted");
8283 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8284 record_death_via_arm(&mut task, "dead-occupant", message);
8285
8286 let (mut revived, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8288 revived.translate_event(connected_event("fresh-occupant", "/tmp/recovery-announced"));
8289 let notices = notices_of(&mut updates);
8290 assert_eq!(notices.len(), 1, "one line, and only one: {notices:?}");
8291 let (severity, text) = ¬ices[0];
8292 assert_eq!(
8293 *severity,
8294 NoticeSeverity::Error,
8295 "a death reads at failure level: the client leads the row with the severity word",
8296 );
8297 assert_eq!(
8298 text,
8299 "The agent's session ended; forge restarted it under a new id (dead-occupant -> fresh-occupant).",
8300 "the restart names both ids, in pinned words",
8301 );
8302
8303 revived.translate_event(connected_event("second-occupant", "/tmp/recovery-announced"));
8306 assert!(
8307 notices_of(&mut updates).is_empty(),
8308 "a later connect does not re-announce the death",
8309 );
8310
8311 assert_eq!(
8315 workspace.spawn_failure(&key),
8316 None,
8317 "a recovered seat that still read Failed would be the same lie, mirrored",
8318 );
8319 assert!(
8320 workspace.death_stands(&key),
8321 "the death nobody was shown rides until a reader sees it",
8322 );
8323 assert_eq!(
8324 workspace.death_reason(&key).as_deref(),
8325 Some(message),
8326 "and it carries the reason the seat died with",
8327 );
8328 assert!(
8329 workspace.spend_death(&key),
8330 "showing the seat takes the death, and answers that it did",
8331 );
8332 assert!(!workspace.death_stands(&key), "the record goes with the spend");
8333 assert_eq!(workspace.death_reason(&key), None, "and the reason with it");
8334
8335 let (workspace, _rx) = crate::Workspace::testing_stub();
8337 let key = SessionSlot::from_str_for_test("died-and-resumed");
8338 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8339 record_death_via_arm(&mut task, "kept-occupant", message);
8340 let (mut revived, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8341 revived.translate_event(connected_event("kept-occupant", "/tmp/recovery-announced"));
8342 let notices = notices_of(&mut updates);
8343 assert_eq!(
8344 notices.first().map(|(_, text)| text.as_str()),
8345 Some("The agent's session ended; forge resumed it (kept-occupant)."),
8346 "the same occupant coming back reads as a resume: {notices:?}",
8347 );
8348
8349 let (workspace, _rx) = crate::Workspace::testing_stub();
8351 let key = SessionSlot::from_str_for_test("connected-quiet");
8352 let (mut task, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8353 task.translate_event(connected_event("first-occupant", "/tmp/plain-connect"));
8354 assert!(notices_of(&mut updates).is_empty(), "a plain connect announces nothing");
8355 }
8356
8357 #[test]
8362 fn a_spent_death_still_announces_on_the_recovery_that_answers_the_click() {
8363 let (workspace, _rx) = crate::Workspace::testing_stub();
8364 let key = SessionSlot::from_str_for_test("spent-then-recovered");
8365 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8366 record_death_via_arm(
8367 &mut task,
8368 "dead-occupant",
8369 "the claude subprocess closed its output stream",
8370 );
8371
8372 assert!(workspace.spend_death(&key), "the show took the trace");
8375 assert!(!workspace.death_stands(&key), "and the row's mark is gone");
8376
8377 let (mut revived, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8379 revived.translate_event(connected_event("fresh-occupant", "/tmp/recovery-announced"));
8380 let notices = notices_of(&mut updates);
8381 assert_eq!(
8382 notices.first().map(|(_, text)| text.as_str()),
8383 Some(
8384 "The agent's session ended; forge restarted it under a new id (dead-occupant -> fresh-occupant)."
8385 ),
8386 "the click's own recovery is still announced: {notices:?}",
8387 );
8388 assert!(
8389 !workspace.death_stands(&key),
8390 "spent and announced leaves no standing death behind",
8391 );
8392 }
8393
8394 #[derive(Default, Clone)]
8398 struct LoggedEvent(std::sync::Arc<std::sync::Mutex<Vec<(tracing::Level, String)>>>);
8399
8400 struct AllFields<'a>(&'a mut String);
8401
8402 impl tracing::field::Visit for AllFields<'_> {
8403 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
8404 use std::fmt::Write as _;
8405 let _ = write!(self.0, " {}={value:?}", field.name());
8406 }
8407 }
8408
8409 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for LoggedEvent {
8410 fn on_event(
8411 &self,
8412 event: &tracing::Event<'_>,
8413 _ctx: tracing_subscriber::layer::Context<'_, S>,
8414 ) {
8415 let mut fields = String::new();
8416 event.record(&mut AllFields(&mut fields));
8417 self.0.lock().expect("capture").push((*event.metadata().level(), fields));
8418 }
8419 }
8420
8421 #[test]
8425 fn a_death_warns_at_warn_with_the_seat_and_the_occupant() {
8426 use tracing_subscriber::layer::SubscriberExt;
8427
8428 let (workspace, _rx) = crate::Workspace::testing_stub();
8429 let key = SessionSlot::from_str_for_test("death-warns");
8430 let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8431
8432 let capture = LoggedEvent::default();
8433 let subscriber = tracing_subscriber::registry().with(capture.clone());
8434 tracing::subscriber::with_default(subscriber, || {
8435 record_death_via_arm(
8436 &mut task,
8437 "dead-occupant",
8438 "the claude subprocess closed its output stream",
8439 );
8440 });
8441
8442 let events = capture.0.lock().expect("capture");
8443 let (level, fields) = events
8444 .iter()
8445 .find(|(_, fields)| fields.contains("event_name=\"session_ended\""))
8446 .unwrap_or_else(|| panic!("no session_ended event; saw {events:?}"));
8447 assert_eq!(*level, tracing::Level::WARN, "a session's end reads at WARN: {fields}");
8448 for needle in [
8449 "slot=TestOrg/test-project/death-warns",
8450 "session_id=dead-occupant",
8451 "error=the claude subprocess closed its output stream",
8452 ] {
8453 assert!(fields.contains(needle), "the line carries `{needle}`: {fields}");
8454 }
8455 }
8456
8457 #[tokio::test]
8461 async fn respond_permission_round_trips_to_the_agent() {
8462 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8463 let (handle, mut agent_rx) = Agent::testing_stub();
8464 let handle = Arc::new(handle);
8465 let key = SessionSlot::from_str_for_test("perm");
8466 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8467 let update_tx = UpdateFanout::default();
8468 let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8469 let mut task = SessionTask {
8470 key: key.clone(),
8471 handle: Arc::clone(&handle),
8472 command_rx,
8473 domain: Arc::new(Mutex::new(DomainSession::new(
8474 key.clone(),
8475 Some(Arc::clone(&handle)),
8476 ))),
8477 update_tx,
8478 connected_once: false,
8479 workspace: Arc::downgrade(&workspace),
8480 compaction_count: 0,
8481 successor: None,
8482 teardown_done: None,
8483 };
8484
8485 task.translate_event(AgentEvent::PermissionRequest {
8486 session_id: key.display(),
8487 request: permission_request_fixture("tu-1"),
8488 });
8489 assert!(
8490 task.domain.lock().pending_interactions.contains_key("tu-1"),
8491 "the request parks a pending permission slot"
8492 );
8493 assert_eq!(
8494 workspace.pending_request_seq(&key),
8495 1,
8496 "the permission has its own identity on the wire",
8497 );
8498
8499 task.execute_command(Command::RespondPermission {
8500 key: key.clone(),
8501 tool_id: "tu-1".to_owned(),
8502 outcome: forge_primitives::PermissionOutcome::Cancelled,
8503 });
8504
8505 let cmd = tokio::time::timeout(std::time::Duration::from_secs(2), agent_rx.recv())
8506 .await
8507 .expect("the forwarded outcome reaches the agent promptly")
8508 .expect("command channel open");
8509 assert!(
8510 matches!(
8511 cmd,
8512 forge_primitives::AgentCommand::PermissionResponse { tool_call_id, .. }
8513 if tool_call_id == "tu-1"
8514 ),
8515 "the outcome forwards to the bridge with the right tool id"
8516 );
8517 assert!(
8518 !task.domain.lock().pending_interactions.contains_key("tu-1"),
8519 "the slot is consumed"
8520 );
8521 }
8522
8523 #[tokio::test]
8530 async fn a_permission_request_with_only_an_observer_fails_closed() {
8531 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8532 let (handle, mut agent_rx) = Agent::testing_stub();
8533 let handle = Arc::new(handle);
8534 let key = SessionSlot::from_str_for_test("perm-observer");
8535 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8536 let update_tx = UpdateFanout::default();
8537 let mut observer = update_tx.subscribe(SubscriberRole::Observing);
8538 let mut task = SessionTask {
8539 key: key.clone(),
8540 handle: Arc::clone(&handle),
8541 command_rx,
8542 domain: Arc::new(Mutex::new(DomainSession::new(
8543 key.clone(),
8544 Some(Arc::clone(&handle)),
8545 ))),
8546 update_tx,
8547 connected_once: false,
8548 workspace: Arc::downgrade(&workspace),
8549 compaction_count: 0,
8550 successor: None,
8551 teardown_done: None,
8552 };
8553
8554 task.translate_event(AgentEvent::PermissionRequest {
8555 session_id: key.display(),
8556 request: permission_request_fixture("tu-obs"),
8557 });
8558
8559 assert!(
8560 !task.domain.lock().pending_interactions.contains_key("tu-obs"),
8561 "the slot is resolved rather than parked, so the turn cannot hang",
8562 );
8563 assert!(
8564 matches!(
8565 observer.try_recv(),
8566 Ok(SeatUpdate { update, .. })
8567 if matches!(&*update, SessionUpdate::PermissionRequest { tool_id, .. } if tool_id == "tu-obs")
8568 ),
8569 "the observer is still delivered the request it cannot answer",
8570 );
8571 let cmd = tokio::time::timeout(std::time::Duration::from_secs(2), agent_rx.recv())
8572 .await
8573 .expect("the cancelled outcome reaches the agent")
8574 .expect("command channel open");
8575 assert!(
8576 matches!(
8577 &cmd,
8578 forge_primitives::AgentCommand::PermissionResponse {
8579 tool_call_id,
8580 outcome: forge_primitives::PermissionOutcome::Cancelled,
8581 ..
8582 } if tool_call_id == "tu-obs"
8583 ),
8584 "the orphaned permission answers the bridge Cancelled, got {cmd:?}",
8585 );
8586 assert!(
8587 tokio::time::timeout(std::time::Duration::from_millis(200), agent_rx.recv())
8588 .await
8589 .is_err(),
8590 "a second permission response is the duplicate this catches",
8591 );
8592 }
8593
8594 #[tokio::test]
8600 async fn a_question_request_with_only_an_observer_fails_closed() {
8601 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8602 let (handle, mut agent_rx) = Agent::testing_stub();
8603 let handle = Arc::new(handle);
8604 let key = SessionSlot::from_str_for_test("question-observer");
8605 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8606 let update_tx = UpdateFanout::default();
8607 let mut observer = update_tx.subscribe(SubscriberRole::Observing);
8608 let mut task = SessionTask {
8609 key: key.clone(),
8610 handle: Arc::clone(&handle),
8611 command_rx,
8612 domain: Arc::new(Mutex::new(DomainSession::new(
8613 key.clone(),
8614 Some(Arc::clone(&handle)),
8615 ))),
8616 update_tx,
8617 connected_once: false,
8618 workspace: Arc::downgrade(&workspace),
8619 compaction_count: 0,
8620 successor: None,
8621 teardown_done: None,
8622 };
8623
8624 task.translate_event(AgentEvent::QuestionRequest {
8625 session_id: key.display(),
8626 request: question_request_fixture("tu-q-obs"),
8627 });
8628
8629 assert!(
8630 !task.domain.lock().pending_interactions.contains_key("tu-q-obs"),
8631 "the question slot is resolved rather than parked, so the turn cannot hang",
8632 );
8633 assert!(
8634 matches!(
8635 observer.try_recv(),
8636 Ok(frame)
8637 if matches!(&*frame.update, SessionUpdate::QuestionRequest { tool_id, .. } if tool_id == "tu-q-obs")
8638 ),
8639 "the observer is still delivered the question it cannot answer",
8640 );
8641 let cmd = tokio::time::timeout(std::time::Duration::from_secs(2), agent_rx.recv())
8642 .await
8643 .expect("the cancelled outcome reaches the agent")
8644 .expect("command channel open");
8645 assert!(
8646 matches!(
8647 &cmd,
8648 forge_primitives::AgentCommand::QuestionResponse {
8649 tool_call_id,
8650 outcome: forge_primitives::QuestionOutcome::Cancelled,
8651 ..
8652 } if tool_call_id == "tu-q-obs"
8653 ),
8654 "the orphaned question answers the bridge Cancelled, got {cmd:?}",
8655 );
8656 assert!(
8657 tokio::time::timeout(std::time::Duration::from_millis(200), agent_rx.recv())
8658 .await
8659 .is_err(),
8660 "a second question response is the duplicate this catches",
8661 );
8662 }
8663
8664 #[tokio::test]
8669 async fn respond_permission_against_a_question_slot_is_dropped() {
8670 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8671 let (handle, mut agent_rx) = Agent::testing_stub();
8672 let handle = Arc::new(handle);
8673 let key = SessionSlot::from_str_for_test("xkind");
8674 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8675 let update_tx = UpdateFanout::default();
8676 let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8677 let mut task = SessionTask {
8678 key: key.clone(),
8679 handle: Arc::clone(&handle),
8680 command_rx,
8681 domain: Arc::new(Mutex::new(DomainSession::new(
8682 key.clone(),
8683 Some(Arc::clone(&handle)),
8684 ))),
8685 update_tx,
8686 connected_once: false,
8687 workspace: Arc::downgrade(&workspace),
8688 compaction_count: 0,
8689 successor: None,
8690 teardown_done: None,
8691 };
8692
8693 task.translate_event(AgentEvent::QuestionRequest {
8694 session_id: key.display(),
8695 request: question_request_fixture("tu-q1"),
8696 });
8697 assert_eq!(
8698 workspace.pending_request_seq(&key),
8699 1,
8700 "the question has its own identity on the wire",
8701 );
8702
8703 task.execute_command(Command::RespondPermission {
8704 key: key.clone(),
8705 tool_id: "tu-q1".to_owned(),
8706 outcome: forge_primitives::PermissionOutcome::Cancelled,
8707 });
8708
8709 assert!(
8710 task.domain.lock().pending_interactions.contains_key("tu-q1"),
8711 "the question slot survives the mismatched permission response"
8712 );
8713 assert!(agent_rx.try_recv().is_err(), "nothing forwards to the agent on a kind mismatch");
8714 }
8715
8716 #[tokio::test]
8725 async fn an_answered_questions_resolution_names_its_round() {
8726 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8727 let (handle, _agent_rx) = Agent::testing_stub();
8728 let handle = Arc::new(handle);
8729 let key = SessionSlot::from_str_for_test("ask-round");
8730 let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8731 let update_tx = UpdateFanout::default();
8732 let mut observer = update_tx.subscribe(SubscriberRole::Answering);
8733 let mut task = SessionTask {
8734 key: key.clone(),
8735 handle: Arc::clone(&handle),
8736 command_rx,
8737 domain: Arc::new(Mutex::new(DomainSession::new(
8738 key.clone(),
8739 Some(Arc::clone(&handle)),
8740 ))),
8741 update_tx,
8742 connected_once: false,
8743 workspace: Arc::downgrade(&workspace),
8744 compaction_count: 0,
8745 successor: None,
8746 teardown_done: None,
8747 };
8748
8749 let mut request = question_request_fixture("tu-round");
8750 request.question_index = 3;
8751 task.translate_event(AgentEvent::QuestionRequest { session_id: key.display(), request });
8752
8753 task.execute_command(Command::RespondQuestion {
8754 key: key.clone(),
8755 tool_id: "tu-round".to_owned(),
8756 outcome: forge_primitives::QuestionOutcome::Cancelled,
8757 });
8758
8759 let mut resolved = None;
8760 while let Ok(frame) = observer.try_recv() {
8761 if let SessionUpdate::PendingInteractionResolved { question_index, .. } =
8762 std::sync::Arc::unwrap_or_clone(frame.update)
8763 {
8764 resolved = Some(question_index);
8765 }
8766 }
8767 assert_eq!(resolved, Some(Some(3)), "the resolution names the round it ends");
8768 }
8769
8770 fn permission_request_fixture(tool_id: &str) -> forge_primitives::PermissionRequest {
8771 forge_primitives::PermissionRequest {
8772 tool_call: forge_primitives::ToolCall {
8773 tool_call_id: tool_id.to_owned(),
8774 title: "Read".to_owned(),
8775 kind: forge_primitives::ToolKind::Read,
8776 status: forge_primitives::ToolCallStatus::Pending,
8777 content: Vec::new(),
8778 raw_input: None,
8779 raw_output: None,
8780 output_metadata: None,
8781 task_metadata: None,
8782 locations: Vec::new(),
8783 meta: None,
8784 },
8785 options: Vec::new(),
8786 display: None,
8787 }
8788 }
8789
8790 fn question_request_fixture(tool_id: &str) -> forge_primitives::QuestionRequest {
8791 forge_primitives::QuestionRequest {
8792 tool_call: forge_primitives::ToolCall {
8793 tool_call_id: tool_id.to_owned(),
8794 title: "AskUserQuestion".to_owned(),
8795 kind: forge_primitives::ToolKind::Other,
8796 status: forge_primitives::ToolCallStatus::Pending,
8797 content: Vec::new(),
8798 raw_input: None,
8799 raw_output: None,
8800 output_metadata: None,
8801 task_metadata: None,
8802 locations: Vec::new(),
8803 meta: None,
8804 },
8805 prompt: forge_primitives::QuestionPrompt {
8806 question: "Which?".to_owned(),
8807 header: "Pick".to_owned(),
8808 multi_select: false,
8809 options: Vec::new(),
8810 },
8811 question_index: 0,
8812 total_questions: 1,
8813 }
8814 }
8815
8816 #[tokio::test]
8820 async fn set_model_failed_and_turn_error_map_to_keyed_updates() {
8821 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8822 let (mut task, mut update_rx) =
8823 review_task_for(&workspace, &SessionSlot::from_str_for_test("m"));
8824
8825 task.translate_event(AgentEvent::SetModelFailed {
8826 session_id: "m".to_owned(),
8827 model: "claude-attempted".to_owned(),
8828 message: "not available".to_owned(),
8829 });
8830 assert!(
8831 matches!(
8832 update_rx.try_recv(),
8833 Ok(frame)
8834 if matches!(&*frame.update, SessionUpdate::SetModelFailed { model, message, .. } if model == "claude-attempted" && message == "not available")
8835 ),
8836 "SetModelFailed keeps model + message for the rollback reducer"
8837 );
8838
8839 task.translate_event(AgentEvent::TurnError {
8840 session_id: "m".to_owned(),
8841 message: "stdin write failed".to_owned(),
8842 class: forge_primitives::TurnErrorClass::Other,
8843 });
8844 assert!(
8845 matches!(
8846 update_rx.try_recv(),
8847 Ok(frame)
8848 if matches!(&*frame.update, SessionUpdate::TurnError { message, .. } if message == "stdin write failed")
8849 ),
8850 "TurnError carries the failure text so the spinner unwinds"
8851 );
8852 }
8853
8854 #[tokio::test]
8858 async fn turn_error_carries_its_class_onto_the_update_stream() {
8859 let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8860 let (mut task, mut update_rx) =
8861 review_task_for(&workspace, &SessionSlot::from_str_for_test("m"));
8862
8863 for class in [
8864 forge_primitives::TurnErrorClass::AuthRequired,
8865 forge_primitives::TurnErrorClass::Other,
8866 ] {
8867 task.translate_event(AgentEvent::TurnError {
8868 session_id: "m".to_owned(),
8869 message: "interrupt not acknowledged by the CLI".to_owned(),
8870 class,
8871 });
8872 assert!(
8873 matches!(
8874 update_rx.try_recv(),
8875 Ok(frame)
8876 if matches!(&*frame.update, SessionUpdate::TurnError { class: Some(on_wire), .. } if *on_wire == class)
8877 ),
8878 "the class has to reach the stream as {class:?}, not as None a reader must guess past",
8879 );
8880 }
8881 }
8882
8883 #[tokio::test]
8886 async fn teardown_after_a_normal_turn_end_emits_nothing_further() {
8887 let (_dir, workspace, _reviewer, worker) = workspace_with_pending_review_activity();
8888 let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
8889
8890 task.translate_event(AgentEvent::SdkMessage {
8891 session_id: "worker".to_owned(),
8892 msg: result_message("success", false),
8893 });
8894 assert!(drained_notice(&mut update_rx).is_some(), "the turn's own notice fired");
8895
8896 drop(task);
8897
8898 assert!(drained_notice(&mut update_rx).is_none(), "teardown adds no second notice");
8899 }
8900
8901 #[test]
8907 fn connected_records_the_id_the_cli_adopted() {
8908 let dir = tempfile::tempdir().expect("tempdir");
8909 let root = dir.path().join("solo");
8910 std::fs::create_dir_all(&root).expect("root");
8911 let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
8912 std::fs::write(
8913 forge_dir.join("forge.toml"),
8914 format!(
8915 r#"
8916[[orgs]]
8917name = "TestOrg"
8918accounts = ["Stargate"]
8919[[orgs.projects]]
8920name = "forge"
8921path = "{root}"
8922[[accounts]]
8923display_name = "Stargate"
8924token = "t"
8925models = ["claude-sonnet-5"]
8926provider = "anthropic"
8927"#,
8928 root = root.display()
8929 ),
8930 )
8931 .expect("write forge.toml");
8932 let workspace =
8933 Arc::new(crate::Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
8934 let key = SessionSlot::lead("TestOrg", "forge");
8935 workspace.seed_test_bound_session(&key, "Stargate");
8936 {
8937 let db = workspace.db.lock();
8938 let db = db.as_ref().expect("db");
8939 crate::store::sessions::put(
8940 db,
8941 &crate::store::sessions::SessionRecord {
8942 org: "TestOrg".to_owned(),
8943 project: "forge".to_owned(),
8944 label: "lead".to_owned(),
8945 session_id: None,
8946 charter: None,
8947 kick: None,
8948 resume_kick: None,
8949 interactive: None,
8950 seat_state: None,
8951 is_git_repo: None,
8952 mcp_families: None,
8953 cwd: None,
8954 },
8955 )
8956 .expect("seed the row");
8957 }
8958
8959 let stored = || {
8960 let db = workspace.db.lock();
8961 let db = db.as_ref().expect("db");
8962 crate::store::sessions::get(db, "TestOrg", "forge", "lead")
8963 .expect("read")
8964 .and_then(|row| row.session_id)
8965 };
8966
8967 let (mut task, _updates) = review_task_for(&workspace, &key);
8968 task.translate_event(connected_event("first-id", "/proj"));
8969 assert_eq!(
8970 stored().as_deref(),
8971 Some("first-id"),
8972 "the first Connected records the id the CLI adopted",
8973 );
8974
8975 task.translate_event(connected_event("second-id", "/proj"));
8979 assert_eq!(
8980 stored().as_deref(),
8981 Some("second-id"),
8982 "so does a replacement, which is the id the next boot has to resume",
8983 );
8984 }
8985
8986 #[test]
8992 fn translate_connected_stamps_session_id() {
8993 let mut domain = empty_domain();
8994 assert!(domain.session_id.is_none());
8995
8996 apply_event_to_domain(
8997 &mut domain,
8998 &AgentEvent::Connected {
8999 session_id: "real-uuid-1".to_owned(),
9000 cwd: "/proj".to_owned(),
9001 current_model: forge_primitives::CurrentModel {
9002 resolved_id: "claude".to_owned(),
9003 display_name_short: "claude".to_owned(),
9004 display_name_long: "claude".to_owned(),
9005 requested_id: None,
9006 catalog_id: None,
9007 supports_effort: false,
9008 supported_effort_levels: Vec::new(),
9009 supports_auto_mode: None,
9010 supports_adaptive_thinking: None,
9011 is_authoritative: true,
9012 },
9013 available_models: Vec::new(),
9014 mode: None,
9015 history_updates: None,
9016 compaction_count: 0,
9017 },
9018 );
9019
9020 assert_eq!(
9021 domain.session_id.as_ref().map(std::string::ToString::to_string),
9022 Some("real-uuid-1".to_owned())
9023 );
9024 }
9025
9026 #[test]
9034 fn a_thinking_frame_on_the_wire_opens_the_turn() {
9035 let mut domain = empty_domain();
9036 assert!(!domain.turn_in_flight(), "precondition: nothing is running");
9037
9038 apply_event_to_domain(
9039 &mut domain,
9040 &AgentEvent::SdkMessage {
9041 session_id: "s".to_owned(),
9042 msg: forge_primitives::Message::ThinkingTokens {
9043 estimated_tokens: 40,
9044 estimated_tokens_delta: 40,
9045 uuid: "tokens-1".to_owned(),
9046 session_id: "s".to_owned(),
9047 extras: serde_json::Map::new(),
9048 },
9049 },
9050 );
9051
9052 assert!(
9053 domain.turn_in_flight(),
9054 "the frame did not reach the liveness mirror, so the header reads idle",
9055 );
9056 }
9057
9058 #[test]
9062 fn connection_failed_clears_domain_turn_state() {
9063 let mut domain = empty_domain();
9064 domain.runtime_state = Some(forge_primitives::RuntimeSessionState::Running);
9065 domain.turn_pending = true;
9066
9067 apply_event_to_domain(
9068 &mut domain,
9069 &AgentEvent::ConnectionFailed {
9070 message: "reader died".to_owned(),
9071 kind: SpawnFailureKind::Unclassified,
9072 },
9073 );
9074
9075 assert_eq!(domain.runtime_state, None, "runtime_state cleared on ConnectionFailed");
9076 assert!(!domain.turn_pending, "turn_pending cleared on ConnectionFailed");
9077 }
9078
9079 fn announced_monitors(
9081 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9082 ) -> Vec<Vec<forge_primitives::MonitorRecord>> {
9083 let mut announced = Vec::new();
9084 while let Ok(frame) = updates.try_recv() {
9085 if let SessionUpdate::MonitorsChanged { monitors, .. } =
9086 std::sync::Arc::unwrap_or_clone(frame.update)
9087 {
9088 announced.push(monitors);
9089 }
9090 }
9091 announced
9092 }
9093
9094 fn announced_tasks(
9096 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9097 ) -> Vec<Vec<forge_workspace::BackgroundTask>> {
9098 let mut announced = Vec::new();
9099 while let Ok(frame) = updates.try_recv() {
9100 if let SessionUpdate::BackgroundTasksChanged { tasks, .. } =
9101 std::sync::Arc::unwrap_or_clone(frame.update)
9102 {
9103 announced.push(tasks);
9104 }
9105 }
9106 announced
9107 }
9108
9109 fn assistant_tool_use(
9111 tool: &str,
9112 parent_tool_use_id: Option<&str>,
9113 ) -> forge_primitives::Message {
9114 serde_json::from_value(serde_json::json!({
9115 "type": "assistant",
9116 "message": {
9117 "id": "msg-dispatch",
9118 "role": "assistant",
9119 "model": "claude-sonnet-5",
9120 "content": [{
9121 "type": "tool_use",
9122 "id": "tu-dispatch",
9123 "name": tool,
9124 "input": {"description": "investigate"},
9125 }],
9126 },
9127 "session_id": "s",
9128 "parent_tool_use_id": parent_tool_use_id,
9129 }))
9130 .expect("parse an assistant tool_use")
9131 }
9132
9133 fn dispatch_frame(parent_tool_use_id: Option<&str>) -> forge_primitives::Message {
9135 assistant_tool_use("Task", parent_tool_use_id)
9136 }
9137
9138 fn announced_cards(
9140 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9141 ) -> Vec<Vec<forge_primitives::runtime::SubagentCard>> {
9142 let mut announced = Vec::new();
9143 while let Ok(frame) = updates.try_recv() {
9144 if let SessionUpdate::SubagentCardsChanged { cards, .. } =
9145 std::sync::Arc::unwrap_or_clone(frame.update)
9146 {
9147 announced.push(cards);
9148 }
9149 }
9150 announced
9151 }
9152
9153 #[test]
9159 fn a_dispatch_announces_a_card_and_the_roster_settles_it() {
9160 let dir = tempfile::tempdir().expect("tempdir");
9161 let (workspace, _rx) =
9162 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9163 let slot = SessionSlot::lead("TestOrg", "forge");
9164 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9165 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9166
9167 task.translate_event(sdk_message(dispatch_frame(None)));
9168 let announced = announced_cards(&mut updates);
9169 assert_eq!(announced.len(), 1, "the dispatch announces the list once");
9170 assert_eq!(announced[0].len(), 1, "with one instance");
9171 assert!(announced[0][0].running, "running, because nothing has said it ended");
9172 assert_eq!(announced[0][0].name, "investigate", "named by the dispatch's description");
9173
9174 task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9177 task_id: "t-a".to_owned(),
9178 description: "investigate".to_owned(),
9179 uuid: "u-start".to_owned(),
9180 session_id: "s".to_owned(),
9181 tool_use_id: Some("tu-dispatch".to_owned()),
9182 task_type: Some("local_agent".to_owned()),
9183 extras: serde_json::Map::new(),
9184 }));
9185 assert!(
9186 announced_cards(&mut updates).is_empty(),
9187 "a frame that moves no fact on the list announces nothing",
9188 );
9189
9190 task.translate_event(sdk_message(forge_primitives::Message::TaskUpdated {
9192 task_id: "t-a".to_owned(),
9193 patch: forge_primitives::messages::TaskUpdatePatch {
9194 status: Some("completed".to_owned()),
9195 end_time: Some(1_700_000_000_123),
9196 extras: serde_json::Map::new(),
9197 },
9198 uuid: "u-upd".to_owned(),
9199 session_id: "s".to_owned(),
9200 extras: serde_json::Map::new(),
9201 }));
9202 let announced = announced_cards(&mut updates);
9203 assert_eq!(announced.len(), 1, "the ending announces the list once");
9204 assert!(!announced[0][0].running, "and the card is settled");
9205 assert_eq!(announced[0][0].ended_at_ms, Some(1_700_000_000_123));
9206 }
9207
9208 #[test]
9218 fn a_subagents_refusal_names_the_dispatch_it_belongs_under() {
9219 let dir = tempfile::tempdir().expect("tempdir");
9220 let (workspace, _rx) =
9221 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9222 let slot = SessionSlot::lead("TestOrg", "forge");
9223 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9224 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9225
9226 task.translate_event(sdk_message(agent_call("call-dispatch", None)));
9227 task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9228 task_id: "agent-1".to_owned(),
9229 description: "review the fold".to_owned(),
9230 uuid: "u-start".to_owned(),
9231 session_id: "s".to_owned(),
9232 tool_use_id: Some("call-dispatch".to_owned()),
9233 task_type: Some("local_agent".to_owned()),
9234 extras: serde_json::Map::new(),
9235 }));
9236 task.translate_event(sdk_message(refusal(Some("agent-1"))));
9237
9238 let drawn = drawn_refusals(&mut updates);
9239 assert_eq!(drawn.len(), 1, "the refusal is drawn: {drawn:?}");
9240 assert_eq!(
9241 drawn[0].get("parent_tool_use_id").and_then(serde_json::Value::as_str),
9242 Some("call-dispatch"),
9243 "under the Task call whose instance it refused",
9244 );
9245
9246 task.translate_event(sdk_message(agent_call("call-inner", Some("call-dispatch"))));
9252 task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9253 task_id: "agent-2".to_owned(),
9254 description: "inner".to_owned(),
9255 uuid: "u-start-2".to_owned(),
9256 session_id: "s".to_owned(),
9257 tool_use_id: Some("call-inner".to_owned()),
9258 task_type: Some("local_agent".to_owned()),
9259 extras: {
9260 let mut extras = serde_json::Map::new();
9261 extras.insert("spawn_depth".to_owned(), serde_json::Value::from(2));
9262 extras
9263 },
9264 }));
9265 task.translate_event(sdk_message(refusal(Some("agent-2"))));
9266
9267 let drawn = drawn_refusals(&mut updates);
9268 assert_eq!(drawn.len(), 1, "the nested refusal is drawn: {drawn:?}");
9269 assert_eq!(
9270 drawn[0].get("parent_tool_use_id").and_then(serde_json::Value::as_str),
9271 Some("call-dispatch"),
9272 "on the owning instance's row, not the cardless dispatch it ran under",
9273 );
9274
9275 task.translate_event(sdk_message(refusal(Some("agent-unknown"))));
9279 let drawn = drawn_refusals(&mut updates);
9280 assert_eq!(drawn.len(), 1, "an unplaceable refusal is drawn too");
9281 assert_eq!(
9282 drawn[0].get("parent_tool_use_id"),
9283 None,
9284 "and an unlinked task's refusal stays the session's own row",
9285 );
9286
9287 task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9290 task_id: "agent-orphan".to_owned(),
9291 description: "orphan".to_owned(),
9292 uuid: "u-start-orphan".to_owned(),
9293 session_id: "s".to_owned(),
9294 tool_use_id: Some("call-orphan".to_owned()),
9295 task_type: Some("local_agent".to_owned()),
9296 extras: serde_json::Map::new(),
9297 }));
9298 task.translate_event(sdk_message(refusal(Some("agent-orphan"))));
9299 let drawn = drawn_refusals(&mut updates);
9300 assert_eq!(drawn.len(), 1, "an orphaned task's refusal is drawn too");
9301 assert_eq!(
9302 drawn[0].get("parent_tool_use_id"),
9303 None,
9304 "and it stays the session's own row: no row owns that call",
9305 );
9306
9307 task.translate_event(sdk_message(refusal(None)));
9308 let drawn = drawn_refusals(&mut updates);
9309 assert_eq!(drawn.len(), 1, "a session's own refusal is drawn too");
9310 assert_eq!(
9311 drawn[0].get("parent_tool_use_id"),
9312 None,
9313 "and the session's own refusal names no dispatch",
9314 );
9315 }
9316
9317 #[test]
9325 fn a_third_level_agents_frames_name_the_card_that_owns_them() {
9326 let dir = tempfile::tempdir().expect("tempdir");
9327 let (workspace, _rx) =
9328 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9329 let slot = SessionSlot::lead("TestOrg", "forge");
9330 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9331 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9332
9333 task.translate_event(sdk_message(agent_call("call-dispatch", None)));
9336 task.translate_event(sdk_message(task_started("agent-1", Some("call-dispatch"))));
9337 task.translate_event(sdk_message(agent_call("call-inner", Some("call-dispatch"))));
9338 task.translate_event(sdk_message(task_started("agent-2", Some("call-inner"))));
9339 task.translate_event(sdk_message(agent_call("call-deeper", Some("call-inner"))));
9340 task.translate_event(sdk_message(task_started("agent-3", Some("call-deeper"))));
9341 let _ = drain_updates(&mut updates);
9342
9343 task.translate_event(sdk_message(agent_said(Some("call-deeper"))));
9344 assert_eq!(
9345 drawn_frame_parents(&mut updates),
9346 [Some("call-dispatch".to_owned())],
9347 "the third-level agent's frame names the card that owns its dispatch",
9348 );
9349
9350 task.translate_event(sdk_message(user_result(Some("call-deeper"))));
9351 task.translate_event(sdk_message(hook_summary(Some("call-deeper"))));
9352 assert_eq!(
9353 drawn_frame_parents(&mut updates),
9354 [Some("call-dispatch".to_owned()), Some("call-dispatch".to_owned())],
9355 "its tool result and hook summary name the same card",
9356 );
9357
9358 task.translate_event(sdk_message(refusal(Some("agent-3"))));
9359 let drawn = drawn_refusals(&mut updates);
9360 assert_eq!(drawn.len(), 1, "the third-level refusal is drawn: {drawn:?}");
9361 assert_eq!(
9362 drawn[0].get("parent_tool_use_id").and_then(serde_json::Value::as_str),
9363 Some("call-dispatch"),
9364 "and it names the card, not the dispatch two levels down",
9365 );
9366 }
9367
9368 fn agent_said(parent: Option<&str>) -> forge_primitives::Message {
9370 serde_json::from_value(serde_json::json!({
9371 "type": "assistant",
9372 "message": {
9373 "id": "msg-said",
9374 "role": "assistant",
9375 "model": "claude-sonnet-5",
9376 "content": [{"type": "text", "text": "working"}],
9377 },
9378 "session_id": "s",
9379 "parent_tool_use_id": parent,
9380 }))
9381 .expect("an assistant frame")
9382 }
9383
9384 fn user_result(parent: Option<&str>) -> forge_primitives::Message {
9386 serde_json::from_value(serde_json::json!({
9387 "type": "user",
9388 "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call", "content": "done"}]},
9389 "session_id": "s",
9390 "parent_tool_use_id": parent,
9391 }))
9392 .expect("a user frame")
9393 }
9394
9395 fn hook_summary(parent: Option<&str>) -> forge_primitives::Message {
9397 forge_primitives::Message::StopHookSummary {
9398 actions: 1,
9399 hook_infos: Vec::new(),
9400 hook_errors: Vec::new(),
9401 has_output: false,
9402 level: "suggestion".to_owned(),
9403 prevented_continuation: false,
9404 stop_reason: String::new(),
9405 tool_use_id: String::new(),
9406 parent_tool_use_id: parent.map(str::to_owned),
9407 session_id: "s".to_owned(),
9408 uuid: "u-hook".to_owned(),
9409 extras: serde_json::Map::new(),
9410 }
9411 }
9412
9413 fn drawn_frame_parents(
9415 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9416 ) -> Vec<Option<String>> {
9417 drain_updates(updates)
9418 .into_iter()
9419 .filter_map(|update| match update {
9420 SessionUpdate::ChatAppended {
9421 msg:
9422 forge_primitives::Message::Assistant { parent_tool_use_id, .. }
9423 | forge_primitives::Message::User { parent_tool_use_id, .. }
9424 | forge_primitives::Message::StopHookSummary { parent_tool_use_id, .. },
9425 ..
9426 } => Some(parent_tool_use_id),
9427 _ => None,
9428 })
9429 .collect()
9430 }
9431
9432 fn agent_call(call: &str, parent: Option<&str>) -> forge_primitives::Message {
9435 serde_json::from_value(serde_json::json!({
9436 "type": "assistant",
9437 "message": {
9438 "id": "msg-call",
9439 "role": "assistant",
9440 "model": "claude-sonnet-5",
9441 "content": [{
9442 "type": "tool_use",
9443 "id": call,
9444 "name": "Agent",
9445 "input": {"description": "dispatch"},
9446 }],
9447 },
9448 "session_id": "s",
9449 "parent_tool_use_id": parent,
9450 }))
9451 .expect("an agent call")
9452 }
9453
9454 fn refusal(agent: Option<&str>) -> forge_primitives::Message {
9457 let mut frame = serde_json::json!({
9458 "type": "system",
9459 "subtype": "permission_denied",
9460 "tool_name": "Bash",
9461 "tool_use_id": "call-refused",
9462 "decision_reason_type": "subcommandResults",
9463 "message": "Permission to use Bash with command rm -rf /tmp/x has been denied.",
9464 "uuid": "u-denied",
9465 "session_id": "s",
9466 });
9467 if let Some(agent) = agent {
9468 frame["agent_id"] = serde_json::Value::String(agent.to_owned());
9469 }
9470 serde_json::from_value(frame).expect("a permission_denied frame")
9471 }
9472
9473 fn drawn_refusals(updates: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> Vec<serde_json::Value> {
9475 drain_updates(updates)
9476 .into_iter()
9477 .filter_map(|update| match update {
9478 SessionUpdate::ChatAppended { msg, .. } => Some(msg),
9479 _ => None,
9480 })
9481 .filter(|msg| matches!(msg, forge_primitives::Message::PermissionDenied { .. }))
9482 .map(|msg| serde_json::to_value(&msg).expect("encode a refusal"))
9483 .collect()
9484 }
9485
9486 #[test]
9490 fn a_connect_seeds_the_cards_without_announcing() {
9491 let dir = tempfile::tempdir().expect("tempdir");
9492 let (workspace, _rx) =
9493 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9494 let slot = SessionSlot::lead("TestOrg", "forge");
9495 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9496 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9497
9498 task.translate_event(AgentEvent::Connected {
9499 session_id: "resumed-uuid".to_owned(),
9500 cwd: "/proj".to_owned(),
9501 current_model: forge_primitives::CurrentModel::new("claude-opus-5", "Opus", "Claude"),
9502 available_models: Vec::new(),
9503 mode: None,
9504 history_updates: Some(vec![dispatch_frame(None)]),
9505 compaction_count: 0,
9506 });
9507
9508 assert_eq!(
9509 task.domain.lock().cards_snapshot.len(),
9510 1,
9511 "a history that dispatched seeds one card",
9512 );
9513 assert!(
9514 announced_cards(&mut updates).is_empty(),
9515 "and the seed is not announced: the read on this event answers it",
9516 );
9517
9518 task.translate_event(AgentEvent::Connected {
9519 session_id: "new-uuid".to_owned(),
9520 cwd: "/proj".to_owned(),
9521 current_model: forge_primitives::CurrentModel::new("claude-opus-5", "Opus", "Claude"),
9522 available_models: Vec::new(),
9523 mode: None,
9524 history_updates: None,
9525 compaction_count: 0,
9526 });
9527
9528 assert!(task.domain.lock().cards_snapshot.is_empty(), "a fresh /new leaves none");
9529 assert!(announced_cards(&mut updates).is_empty(), "and the clear is not announced");
9530 }
9531
9532 fn announced_commands(
9534 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9535 ) -> Vec<Vec<forge_primitives::runtime::AvailableCommand>> {
9536 let mut announced = Vec::new();
9537 while let Ok(frame) = updates.try_recv() {
9538 if let SessionUpdate::SlashCommandsChanged { commands, .. } =
9539 std::sync::Arc::unwrap_or_clone(frame.update)
9540 {
9541 announced.push(commands);
9542 }
9543 }
9544 announced
9545 }
9546
9547 fn announced_agents(
9549 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9550 ) -> Vec<Vec<forge_primitives::runtime::AvailableAgent>> {
9551 let mut announced = Vec::new();
9552 while let Ok(frame) = updates.try_recv() {
9553 if let SessionUpdate::SubagentsChanged { subagents, .. } =
9554 std::sync::Arc::unwrap_or_clone(frame.update)
9555 {
9556 announced.push(subagents);
9557 }
9558 }
9559 announced
9560 }
9561
9562 #[test]
9570 fn a_command_catalogue_that_moved_is_announced() {
9571 let dir = tempfile::tempdir().expect("tempdir");
9572 let (workspace, _rx) =
9573 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9574 let slot = SessionSlot::lead("TestOrg", "forge");
9575 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9576 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9577
9578 task.translate_event(sdk_message(init_frame(&["/help"], &[])));
9579 let announced = announced_commands(&mut updates);
9580 assert_eq!(announced.len(), 1, "the init that fills the catalogue announces it");
9581 assert_eq!(
9582 announced[0],
9583 task.domain.lock().available_commands,
9584 "and what it announces is what the core holds",
9585 );
9586
9587 task.translate_event(sdk_message(result_message("success", false)));
9588 task.translate_event(sdk_message(init_frame(&["/help"], &[])));
9589 assert_eq!(
9590 announced_commands(&mut updates).len(),
9591 0,
9592 "a turn's init repeating the same list is not a move, so nothing is announced",
9593 );
9594
9595 task.translate_event(sdk_message(forge_primitives::Message::CommandsChanged {
9596 commands: vec![serde_json::json!({"name": "/reload", "description": "Reloaded"})],
9597 uuid: "cmd-uuid".to_owned(),
9598 session_id: "s".to_owned(),
9599 extras: serde_json::Map::new(),
9600 }));
9601 let reloaded = announced_commands(&mut updates);
9602 assert_eq!(reloaded.len(), 1, "the reload's list is a move");
9603 assert_eq!(reloaded[0], task.domain.lock().available_commands);
9604 }
9605
9606 #[test]
9609 fn an_agent_catalogue_that_moved_is_announced() {
9610 let dir = tempfile::tempdir().expect("tempdir");
9611 let (workspace, _rx) =
9612 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9613 let slot = SessionSlot::lead("TestOrg", "forge");
9614 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9615 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9616
9617 task.translate_event(sdk_message(init_frame(&[], &["reviewer"])));
9618 let announced = announced_agents(&mut updates);
9619 assert_eq!(announced.len(), 1, "the init that fills the catalogue announces it");
9620 assert_eq!(
9621 announced[0],
9622 task.domain.lock().available_agents,
9623 "and what it announces is what the core holds",
9624 );
9625
9626 task.translate_event(sdk_message(result_message("success", false)));
9627 task.translate_event(sdk_message(init_frame(&[], &["reviewer"])));
9628 assert_eq!(
9629 announced_agents(&mut updates).len(),
9630 0,
9631 "an init repeating the same catalogue is not a move, so nothing is announced",
9632 );
9633
9634 task.translate_event(sdk_message(result_message("success", false)));
9635 task.translate_event(sdk_message(init_frame(&[], &["reviewer", "researcher"])));
9636 let grown = announced_agents(&mut updates);
9637 assert_eq!(grown.len(), 1, "an agent the CLI started advertising is a move");
9638 assert_eq!(grown[0], task.domain.lock().available_agents);
9639 }
9640
9641 #[test]
9648 fn a_monitor_set_that_moved_is_announced() {
9649 let dir = tempfile::tempdir().expect("tempdir");
9650 let (workspace, _rx) =
9651 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9652 let slot = SessionSlot::lead("TestOrg", "forge");
9653 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9654 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9655
9656 task.translate_event(sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
9658 let announced = announced_monitors(&mut updates);
9659 assert_eq!(announced.len(), 1, "the call that starts a watch announces the set");
9660 assert_eq!(
9661 announced[0],
9662 task.domain.lock().monitors,
9663 "and what it announces is what the core holds",
9664 );
9665
9666 task.translate_event(sdk_message(task_started("t-mon", Some("tu-mon"))));
9668 assert_eq!(
9669 announced_monitors(&mut updates).len(),
9670 1,
9671 "stamping the task id moves the set, so it is announced",
9672 );
9673 task.translate_event(sdk_message(task_started("t-mon", Some("tu-mon"))));
9674 assert_eq!(
9675 announced_monitors(&mut updates).len(),
9676 0,
9677 "and a frame that moves nothing says nothing",
9678 );
9679
9680 task.translate_event(sdk_message(task_updated("t-mon", "completed")));
9684 let settled = announced_monitors(&mut updates);
9685 assert_eq!(settled.len(), 1, "settling a monitor announces the settled set");
9686 assert_eq!(
9687 settled[0][0].status,
9688 forge_primitives::MonitorStatus::Completed,
9689 "with the status the frame gave it",
9690 );
9691 task.translate_event(sdk_message(task_updated("t-mon", "completed")));
9692 assert_eq!(
9693 announced_monitors(&mut updates).len(),
9694 0,
9695 "and the same settle again, on a record already settled, says nothing",
9696 );
9697
9698 task.translate_event(sdk_message(task_notification("t-mon")));
9701 let drained = announced_monitors(&mut updates);
9702 assert_eq!(drained.len(), 1, "the drain announces");
9703 assert!(drained[0].is_empty(), "with the empty set, which is what the core now holds");
9704 }
9705
9706 #[test]
9709 fn a_background_registry_that_moved_is_announced() {
9710 let dir = tempfile::tempdir().expect("tempdir");
9711 let (workspace, _rx) =
9712 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9713 let slot = SessionSlot::lead("TestOrg", "forge");
9714 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9715 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9716
9717 task.translate_event(sdk_message(background_tasks(one_live_task())));
9718 let announced = announced_tasks(&mut updates);
9719 assert_eq!(announced.len(), 1, "the frame that fills the registry announces it");
9720 assert_eq!(
9721 announced[0],
9722 task.domain.lock().task_rows(),
9723 "and what it announces is what the core holds",
9724 );
9725
9726 task.translate_event(sdk_message(background_tasks(one_live_task())));
9727 assert_eq!(
9728 announced_tasks(&mut updates).len(),
9729 0,
9730 "the same set again is not a move, so nothing is announced",
9731 );
9732
9733 task.translate_event(AgentEvent::ConnectionFailed {
9736 message: "reader died".to_owned(),
9737 kind: SpawnFailureKind::Unclassified,
9738 });
9739 let cleared = announced_tasks(&mut updates);
9740 assert_eq!(cleared.len(), 1, "and the clear is announced");
9741 assert!(cleared[0].is_empty(), "with the empty set the core now holds");
9742 }
9743
9744 fn announced_walks(
9746 updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9747 ) -> Vec<forge_agent::env::processes::ProcessSnapshot> {
9748 let mut announced = Vec::new();
9749 while let Ok(frame) = updates.try_recv() {
9750 if let SessionUpdate::ProcessesChanged { snapshot, .. } =
9751 std::sync::Arc::unwrap_or_clone(frame.update)
9752 {
9753 announced.push(snapshot);
9754 }
9755 }
9756 announced
9757 }
9758
9759 #[test]
9765 fn a_dead_sessions_walk_is_cleared_and_announced() {
9766 let dir = tempfile::tempdir().expect("tempdir");
9767 let (workspace, _rx) =
9768 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9769 let slot = SessionSlot::lead("TestOrg", "forge");
9770 let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9771 let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9772
9773 let walk = || forge_agent::env::processes::ProcessSnapshot {
9774 processes: vec![forge_agent::env::processes::ProcessEntry {
9775 pid: 4242,
9776 parent_pid: 1,
9777 name: "claude".to_owned(),
9778 command: "claude".to_owned(),
9779 memory_bytes: 1,
9780 }],
9781 scanned_at: std::time::SystemTime::now(),
9782 };
9783 task.domain.lock().process_snapshot = Some(walk());
9784
9785 task.translate_event(sdk_message(background_tasks(one_live_task())));
9788 assert!(
9789 announced_walks(&mut updates).is_empty(),
9790 "a frame that did not end the run announces no walk",
9791 );
9792 assert!(task.domain.lock().process_snapshot.is_some(), "and clears none");
9793
9794 task.translate_event(AgentEvent::ConnectionFailed {
9795 message: "reader died".to_owned(),
9796 kind: SpawnFailureKind::Unclassified,
9797 });
9798
9799 let cleared = announced_walks(&mut updates);
9800 assert_eq!(cleared.len(), 1, "the death announces the walk's clear");
9801 assert!(cleared[0].processes.is_empty(), "with the empty walk the core now holds");
9802 assert!(
9803 task.domain.lock().process_snapshot.is_none(),
9804 "and the store is cleared, so a read answers null rather than a dead tree",
9805 );
9806 }
9807
9808 #[test]
9814 fn repository_frames_ask_for_the_nudge_they_need() {
9815 let mut domain = empty_domain();
9816
9817 let vcs = apply_event_to_domain(
9818 &mut domain,
9819 &sdk_message(forge_primitives::Message::VcsStateChanged {
9820 kind: "commit".to_owned(),
9821 branch: Some("main".to_owned()),
9822 cwd: "/tmp/repo".to_owned(),
9823 uuid: "u1".to_owned(),
9824 session_id: "s".to_owned(),
9825 extras: forge_primitives::messages::Extras::new(),
9826 }),
9827 );
9828 assert_eq!(vcs.repo_nudge, Some(false), "a mutation re-reads the tree only");
9829
9830 let published = apply_event_to_domain(
9831 &mut domain,
9832 &sdk_message(forge_primitives::Message::CodeChangePublished {
9833 provider: "github".to_owned(),
9834 url: "https://example.test/pull/1".to_owned(),
9835 repo: "a/b".to_owned(),
9836 identifier: "1".to_owned(),
9837 action: Some("created".to_owned()),
9838 branch: Some("main".to_owned()),
9839 uuid: "u2".to_owned(),
9840 session_id: "s".to_owned(),
9841 extras: forge_primitives::messages::Extras::new(),
9842 }),
9843 );
9844 assert_eq!(published.repo_nudge, Some(true), "a published change also looks the PR up");
9845
9846 let other = apply_event_to_domain(
9847 &mut domain,
9848 &sdk_message(forge_primitives::Message::System {
9849 subtype: "init".to_owned(),
9850 session_id: Some("s".to_owned()),
9851 data: serde_json::json!({}),
9852 }),
9853 );
9854 assert_eq!(other.repo_nudge, None, "every other event asks for neither");
9855 }
9856
9857 #[test]
9863 fn a_published_frame_clears_the_stored_pr_attempt_through_the_task() {
9864 let dir = tempfile::tempdir().expect("tempdir");
9865 let (workspace, _rx) =
9866 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9867 workspace.install_db_for_test(
9868 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
9869 );
9870 let slot = SessionSlot::from_str_for_test("test");
9871 let (handle, _agent_cmds) = Agent::testing_stub();
9872 let arc = Arc::new(handle);
9873 let domain = workspace.register_domain_session(slot.clone(), Some(Arc::clone(&arc)));
9874 domain.lock().work_snapshot = Some(crate::work::WorkSnapshot {
9875 diff: forge_primitives::git_diff::GitDiffSnapshot {
9876 branch: forge_primitives::git::GitBranch::Detached,
9877 pushed_sha: None,
9878 pr_fetched_at: Some(std::time::SystemTime::now()),
9879 default_branch: None,
9880 repo_gate: forge_primitives::git_diff::RepoGate::InRepo,
9881 worktree: forge_primitives::git_diff::LayerState::Clean,
9882 branch_ahead: forge_primitives::git_diff::LayerState::Clean,
9883 pr: None,
9884 closes: Vec::new(),
9885 },
9886 cwd: std::path::PathBuf::from("/tmp/repo"),
9887 read_at: std::time::Instant::now(),
9888 base: None,
9889 base_at: None,
9890 base_error: None,
9891 });
9892
9893 let (_cmd_tx, command_rx) = tokio::sync::mpsc::unbounded_channel();
9894 let mut task = SessionTask {
9895 key: slot.clone(),
9896 handle: arc,
9897 command_rx,
9898 domain: Arc::clone(&domain),
9899 update_tx: workspace.update_sender(),
9900 connected_once: false,
9901 workspace: std::sync::Arc::downgrade(&workspace),
9902 compaction_count: 0,
9903 successor: None,
9904 teardown_done: None,
9905 };
9906
9907 task.translate_event(sdk_message(forge_primitives::Message::CodeChangePublished {
9908 provider: "github".to_owned(),
9909 url: "https://example.test/pull/1".to_owned(),
9910 repo: "a/b".to_owned(),
9911 identifier: "1".to_owned(),
9912 action: Some("created".to_owned()),
9913 branch: Some("main".to_owned()),
9914 uuid: "u2".to_owned(),
9915 session_id: "s".to_owned(),
9916 extras: forge_primitives::messages::Extras::new(),
9917 }));
9918
9919 assert!(
9920 domain
9921 .lock()
9922 .work_snapshot
9923 .as_ref()
9924 .is_some_and(|held| held.diff.pr_fetched_at.is_none()),
9925 "the frame's nudge reached the workspace that clears the attempt",
9926 );
9927 }
9928
9929 #[test]
9934 fn connection_failed_drops_the_domain_monitor_set() {
9935 let mut domain = empty_domain();
9936 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
9937 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-mon", Some("tu-mon"))));
9938 assert_eq!(domain.monitors.len(), 1, "the monitor is seeded before the failure");
9939 assert_eq!(
9940 domain.monitors[0].status,
9941 forge_primitives::MonitorStatus::Running,
9942 "and it is still running, which is what makes it unsettleable",
9943 );
9944
9945 apply_event_to_domain(
9946 &mut domain,
9947 &AgentEvent::ConnectionFailed {
9948 message: "reader died".to_owned(),
9949 kind: SpawnFailureKind::Unclassified,
9950 },
9951 );
9952
9953 assert!(
9954 domain.monitors.is_empty(),
9955 "the run's monitor set does not outlive the run that held it",
9956 );
9957 }
9958
9959 #[test]
9965 fn a_second_connected_drops_background_work() {
9966 let mut domain = empty_domain();
9967 domain.background_work = true;
9968
9969 apply_event_to_domain(&mut domain, &connected_event("new-uuid", "/proj"));
9970
9971 assert!(
9972 !domain.background_work,
9973 "a replaced occupant's background work must not survive it",
9974 );
9975 }
9976
9977 #[test]
9981 fn translate_second_connected_overwrites_session_id() {
9982 let mut domain = empty_domain();
9983 domain.session_id = Some(SessionId::new("old-uuid"));
9984
9985 apply_event_to_domain(
9986 &mut domain,
9987 &AgentEvent::Connected {
9988 session_id: "new-uuid".to_owned(),
9989 cwd: "/proj".to_owned(),
9990 current_model: forge_primitives::CurrentModel {
9991 resolved_id: "claude".to_owned(),
9992 display_name_short: "claude".to_owned(),
9993 display_name_long: "claude".to_owned(),
9994 requested_id: None,
9995 catalog_id: None,
9996 supports_effort: false,
9997 supported_effort_levels: Vec::new(),
9998 supports_auto_mode: None,
9999 supports_adaptive_thinking: None,
10000 is_authoritative: true,
10001 },
10002 available_models: Vec::new(),
10003 mode: None,
10004 history_updates: None,
10005 compaction_count: 0,
10006 },
10007 );
10008
10009 assert_eq!(
10010 domain.session_id.as_ref().map(std::string::ToString::to_string),
10011 Some("new-uuid".to_owned()),
10012 "second Connected must overwrite session_id mirror",
10013 );
10014 }
10015
10016 #[test]
10021 fn translate_second_connected_drains_pending_interactions() {
10022 use tokio::sync::oneshot;
10023
10024 let (handle, _commands_rx) = Agent::testing_stub();
10025 let (_cmd_tx, command_rx) =
10026 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10027 let update_tx = UpdateFanout::default();
10028 let mut update_rx = update_tx.subscribe(SubscriberRole::Answering);
10029 let domain = Arc::new(parking_lot::Mutex::new(empty_domain()));
10030 let (response_tx, mut response_rx) =
10031 oneshot::channel::<forge_primitives::PermissionOutcome>();
10032 domain.lock().pending_interactions.insert(
10033 "stale_tool_id".to_owned(),
10034 crate::workspace::testing::test_permission(response_tx),
10035 );
10036 let mut task = SessionTask {
10037 key: SessionSlot::from_str_for_test("old-uuid"),
10038 handle: Arc::new(handle),
10039 command_rx,
10040 domain: Arc::clone(&domain),
10041 update_tx,
10042 connected_once: true,
10043 workspace: std::sync::Weak::new(),
10044 compaction_count: 0,
10045 successor: None,
10046 teardown_done: None,
10047 };
10048
10049 task.translate_event(AgentEvent::Connected {
10050 session_id: "new-uuid".to_owned(),
10051 cwd: "/proj".to_owned(),
10052 current_model: forge_primitives::CurrentModel {
10053 resolved_id: "claude".to_owned(),
10054 display_name_short: "claude".to_owned(),
10055 display_name_long: "claude".to_owned(),
10056 requested_id: None,
10057 catalog_id: None,
10058 supports_effort: false,
10059 supported_effort_levels: Vec::new(),
10060 supports_auto_mode: None,
10061 supports_adaptive_thinking: None,
10062 is_authoritative: true,
10063 },
10064 available_models: Vec::new(),
10065 mode: None,
10066 history_updates: None,
10067 compaction_count: 0,
10068 });
10069
10070 assert!(
10071 domain.lock().pending_interactions.is_empty(),
10072 "second Connected must clear stale pending_interactions",
10073 );
10074 assert!(
10075 matches!(
10076 response_rx.try_recv(),
10077 Err(tokio::sync::oneshot::error::TryRecvError::Closed)
10078 ),
10079 "forwarder receiver must observe Closed after the sender was dropped"
10080 );
10081 let announced: Vec<String> = std::iter::from_fn(|| update_rx.try_recv().ok())
10085 .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
10086 SessionUpdate::PendingInteractionResolved { key, tool_id, .. } => {
10087 assert_eq!(key, SessionSlot::from_str_for_test("old-uuid"));
10088 Some(tool_id)
10089 }
10090 _ => None,
10091 })
10092 .collect();
10093 assert_eq!(
10094 announced,
10095 vec!["stale_tool_id".to_owned()],
10096 "a dropped prompt is announced with its own tool id",
10097 );
10098 }
10099
10100 #[tokio::test]
10107 async fn first_connected_drains_parked_peer_prompts_in_fifo_order() {
10108 use crate::mcp::peers::types::{MessageId, WrappedKind, WrappedPrompt};
10109
10110 let (workspace, _update_rx) = crate::Workspace::testing_stub();
10111 let sender = SessionSlot::from_str_for_test("sender-proj");
10112
10113 let session_key = test_slot();
10115 let domain =
10116 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10117 let bodies = ["first", "second", "third"];
10118 for body in bodies {
10119 workspace.park_peer_prompt(
10120 &session_key,
10121 &sender,
10122 WrappedPrompt {
10123 id: MessageId::mint(),
10124 kind: WrappedKind::Message,
10125 sender_name: "forge".to_owned(),
10126 sender_org: "Default".to_owned(),
10127 body: body.to_owned(),
10128 },
10129 );
10130 }
10131
10132 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10136 let (_cmd_tx, command_rx) =
10137 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10138 let update_tx = workspace.update_sender();
10139 let mut task = SessionTask {
10140 key: session_key.clone(),
10141 handle: Arc::new(handle),
10142 command_rx,
10143 domain: Arc::clone(&domain),
10144 update_tx,
10145 connected_once: false,
10146 workspace: Arc::downgrade(&workspace),
10147 compaction_count: 0,
10148 successor: None,
10149 teardown_done: None,
10150 };
10151
10152 workspace.enable_test_dispatch_intercept();
10153 task.translate_event(AgentEvent::Connected {
10154 session_id: session_key.display(),
10155 cwd: "/tmp/drain".to_owned(),
10156 current_model: forge_primitives::CurrentModel {
10157 resolved_id: "claude".to_owned(),
10158 display_name_short: "claude".to_owned(),
10159 display_name_long: "claude".to_owned(),
10160 requested_id: None,
10161 catalog_id: None,
10162 supports_effort: false,
10163 supported_effort_levels: Vec::new(),
10164 supports_auto_mode: None,
10165 supports_adaptive_thinking: None,
10166 is_authoritative: true,
10167 },
10168 available_models: Vec::new(),
10169 mode: None,
10170 history_updates: None,
10171 compaction_count: 0,
10172 });
10173
10174 let buffered = workspace.drain_test_dispatch_buffer();
10178 let drained_bodies: Vec<String> = buffered
10179 .into_iter()
10180 .filter_map(|cmd| match cmd {
10181 crate::protocol::Command::Prompt { text, .. }
10182 | crate::protocol::Command::PromptUnder { text, .. } => Some(text),
10183 _ => None,
10184 })
10185 .collect();
10186 assert_eq!(
10187 drained_bodies.len(),
10188 bodies.len(),
10189 "one Command::Prompt per buffered wrapped prompt"
10190 );
10191 for (i, expected_body) in bodies.iter().enumerate() {
10192 assert!(
10193 drained_bodies[i].contains(expected_body),
10194 "drain position {i}: expected body '{expected_body}' in dispatched text '{}'",
10195 drained_bodies[i],
10196 );
10197 }
10198 assert!(
10199 workspace.take_parked_for_slot(&session_key).peer.is_empty(),
10200 "the parked peer prompts are drained after first-Connected"
10201 );
10202 }
10203
10204 fn buffered_slack(text: &str) -> forge_primitives::slack::SlackMessage {
10206 forge_primitives::slack::SlackMessage {
10207 workspace: "acme".to_owned(),
10208 conversation: "D1".to_owned(),
10209 conversation_label: "U9".to_owned(),
10210 ts: "100.000001".to_owned(),
10211 thread_ts: None,
10212 user: Some("U9".to_owned()),
10213 author: None,
10214 text: text.to_owned(),
10215 parent_user_id: None,
10216 latest_reply: None,
10217 edited: None,
10218 files: Vec::new(),
10219 }
10220 }
10221
10222 fn connected_event(session_id: &str, cwd: &str) -> AgentEvent {
10223 AgentEvent::Connected {
10224 session_id: session_id.to_owned(),
10225 cwd: cwd.to_owned(),
10226 current_model: forge_primitives::CurrentModel {
10227 resolved_id: "claude".to_owned(),
10228 display_name_short: "claude".to_owned(),
10229 display_name_long: "claude".to_owned(),
10230 requested_id: None,
10231 catalog_id: None,
10232 supports_effort: false,
10233 supported_effort_levels: Vec::new(),
10234 supports_auto_mode: None,
10235 supports_adaptive_thinking: None,
10236 is_authoritative: true,
10237 },
10238 available_models: Vec::new(),
10239 mode: None,
10240 history_updates: None,
10241 compaction_count: 0,
10242 }
10243 }
10244
10245 fn cron_worker_entry(
10246 label: &str,
10247 slot: crate::SessionSlot,
10248 ) -> crate::mcp::workers::types::WorkerEntry {
10249 crate::mcp::workers::types::WorkerEntry {
10250 label: label.to_owned(),
10251 charter: "c".to_owned(),
10252 spawned_by: crate::SessionSlot::lead(slot.org(), slot.project()),
10253 slot,
10254 session_id: None,
10255 status: forge_primitives::WorkerLiveness::Running,
10256 spawned_at: std::time::SystemTime::UNIX_EPOCH,
10257 needs_tag: false,
10258 is_git_repo_at_spawn: false,
10259 cwd: None,
10260 diagnostic: None,
10261 kick: None,
10262 }
10263 }
10264
10265 fn test_cron(id: &str, prompt: &str) -> forge_primitives::cron::CronEntry {
10268 use forge_primitives::cron::{CronEntry, CronId, CronKind};
10269 CronEntry {
10270 id: CronId::from(id),
10271 project_name: "cron-drain".to_owned(),
10272 kind: CronKind::Recurring("0 9 * * *".to_owned()),
10273 prompt: prompt.to_owned(),
10274 created_at: std::time::SystemTime::UNIX_EPOCH,
10275 description: Some(format!("{id} summary")),
10276 last_fire: None,
10277 next_fire: std::time::SystemTime::UNIX_EPOCH,
10278 team_role: None,
10279 wake: true,
10280 }
10281 }
10282
10283 #[tokio::test]
10289 async fn first_connected_drains_pending_cron_prompts_and_echoes_block() {
10290 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10291 workspace.seed_test_project("cron-drain", "/tmp/cron-drain");
10292 let session_key = SessionSlot::lead("TestOrg", "cron-drain");
10295 workspace.park_cron(&session_key, &test_cron("c1", "morning reminder"), false);
10296
10297 let domain =
10298 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10299
10300 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10301 let (_cmd_tx, command_rx) =
10302 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10303 let update_tx = workspace.update_sender();
10304 let mut task = SessionTask {
10305 key: session_key.clone(),
10306 handle: Arc::new(handle),
10307 command_rx,
10308 domain: Arc::clone(&domain),
10309 update_tx,
10310 connected_once: false,
10311 workspace: Arc::downgrade(&workspace),
10312 compaction_count: 0,
10313 successor: None,
10314 teardown_done: None,
10315 };
10316
10317 workspace.enable_test_dispatch_intercept();
10318 task.translate_event(connected_event(&session_key.display(), "/tmp/cron-drain"));
10319
10320 let buffered = workspace.drain_test_dispatch_buffer();
10322 assert!(
10323 buffered.iter().any(|c| matches!(
10324 c, crate::protocol::Command::Prompt { text, .. }
10325 | crate::protocol::Command::PromptUnder { text, .. }
10326 if text == "morning reminder"
10327 )),
10328 "the buffered cron prompt is dispatched on first-Connected",
10329 );
10330
10331 let mut echoed = None;
10335 while let Ok(frame) = update_rx.try_recv() {
10336 if let SessionUpdate::CronPromptAppended { key, text, cron_id, description, .. } =
10337 std::sync::Arc::unwrap_or_clone(frame.update)
10338 && key == session_key
10339 && text == "morning reminder"
10340 {
10341 echoed = Some((cron_id, description));
10342 }
10343 }
10344 let (cron_id, description) =
10345 echoed.expect("an asleep-fired cron echoes a CronPromptAppended on drain");
10346 assert_eq!(cron_id, "c1", "the drained echo names the entry that fired");
10347 assert_eq!(description.as_deref(), Some("c1 summary"), "and its description");
10348
10349 assert!(
10350 workspace.take_parked_for_slot(&session_key).cron.is_empty(),
10351 "the slot's cron bucket is drained after first-Connected",
10352 );
10353 }
10354
10355 #[tokio::test]
10361 async fn worker_first_connected_drains_its_own_bucket_with_missed_marker() {
10362 let (workspace, _rx) = crate::Workspace::testing_stub();
10363 workspace.seed_test_project("wdp", "/tmp/wdp");
10364 let key =
10365 workspace.list_projects().into_iter().find(|v| v.name == "wdp").expect("view").key;
10366
10367 let worker_slot = crate::SessionSlot::worker("TestOrg", "wdp", "reviewer");
10369 let lead_slot = crate::SessionSlot::lead("TestOrg", "wdp");
10370 workspace.insert_live_worker(&key, cron_worker_entry("reviewer", worker_slot.clone()));
10371 workspace.park_cron(&worker_slot, &test_cron("c-worker", "worker work"), true);
10372 workspace.park_cron(&lead_slot, &test_cron("c-lead", "lead work"), false);
10373
10374 let session_key = worker_slot.clone();
10375 let domain =
10376 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10377 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10378 let (_cmd_tx, command_rx) =
10379 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10380 let update_tx = workspace.update_sender();
10381 let mut task = SessionTask {
10382 key: session_key.clone(),
10383 handle: Arc::new(handle),
10384 command_rx,
10385 domain: Arc::clone(&domain),
10386 update_tx,
10387 connected_once: false,
10388 workspace: Arc::downgrade(&workspace),
10389 compaction_count: 0,
10390 successor: None,
10391 teardown_done: None,
10392 };
10393
10394 workspace.enable_test_dispatch_intercept();
10395 task.translate_event(connected_event(&worker_slot.display(), "/tmp/wdp"));
10396
10397 let dispatched = workspace.drain_test_dispatch_buffer();
10398 assert!(
10399 dispatched.iter().any(|c| matches!(
10400 c, crate::protocol::Command::Prompt { key, text, .. }
10401 | crate::protocol::Command::PromptUnder { key, text, .. }
10402 if *key == session_key && text == "[missed cron] worker work"
10403 )),
10404 "the worker drains its own missed cron with the marker applied",
10405 );
10406 let lead_bucket = workspace.take_parked_for_slot(&lead_slot).cron;
10408 assert_eq!(lead_bucket.len(), 1, "the lead's cron stays buffered");
10409 assert_eq!(lead_bucket[0].text, "lead work");
10410 }
10411
10412 #[tokio::test]
10418 async fn a_drain_names_each_entry_in_a_multi_fire_bucket() {
10419 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10420 workspace.seed_test_project("cron-drain", "/tmp/cron-drain");
10421 let session_key = SessionSlot::lead("TestOrg", "cron-drain");
10422 workspace.park_cron(&session_key, &test_cron("c1", "morning reminder"), false);
10423 workspace.park_cron(&session_key, &test_cron("c2", "worker digest"), true);
10424 let mut bare = test_cron("c3", "check the queue");
10428 bare.description = None;
10429 workspace.park_cron(&session_key, &bare, false);
10430
10431 let domain =
10432 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10433 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10434 let (_cmd_tx, command_rx) =
10435 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10436 let update_tx = workspace.update_sender();
10437 let mut task = SessionTask {
10438 key: session_key.clone(),
10439 handle: Arc::new(handle),
10440 command_rx,
10441 domain: Arc::clone(&domain),
10442 update_tx,
10443 connected_once: false,
10444 workspace: Arc::downgrade(&workspace),
10445 compaction_count: 0,
10446 successor: None,
10447 teardown_done: None,
10448 };
10449
10450 workspace.enable_test_dispatch_intercept();
10451 task.translate_event(connected_event(&session_key.display(), "/tmp/cron-drain"));
10452
10453 let mut heard: Vec<(String, String, Option<String>)> = Vec::new();
10454 while let Ok(frame) = update_rx.try_recv() {
10455 if let SessionUpdate::CronPromptAppended { key, text, cron_id, description, .. } =
10456 std::sync::Arc::unwrap_or_clone(frame.update)
10457 && key == session_key
10458 {
10459 heard.push((text, cron_id, description));
10460 }
10461 }
10462 heard.sort();
10463 assert_eq!(
10464 heard,
10465 vec![
10466 (
10467 "[missed cron] worker digest".to_owned(),
10468 "c2".to_owned(),
10469 Some("c2 summary".to_owned())
10470 ),
10471 ("check the queue".to_owned(), "c3".to_owned(), None),
10472 ("morning reminder".to_owned(), "c1".to_owned(), Some("c1 summary".to_owned())),
10473 ],
10474 "each echo carries its own entry's identity, and a missing description stays null",
10475 );
10476 }
10477
10478 #[tokio::test]
10488 async fn a_resumed_connect_hands_the_window_not_the_whole_read() {
10489 let cap = crate::conversation_window::CONVERSATION_CAP;
10490 let slack = crate::conversation_window::CONVERSATION_SLACK;
10491 let read = cap + slack + 1;
10492
10493 for connected_once in [false, true] {
10496 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10497 let session_key = SessionSlot::from_str_for_test("handover-window-uuid");
10498 let domain =
10499 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10500 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10501 let (_cmd_tx, command_rx) =
10502 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10503 let mut task = SessionTask {
10504 key: session_key.clone(),
10505 handle: Arc::new(handle),
10506 command_rx,
10507 domain,
10508 update_tx: workspace.update_sender(),
10509 connected_once,
10510 workspace: Arc::downgrade(&workspace),
10511 compaction_count: 0,
10512 successor: None,
10513 teardown_done: None,
10514 };
10515 let frame = |at: usize| {
10516 serde_json::from_value::<forge_primitives::Message>(serde_json::json!({
10517 "type": "user",
10518 "message": {"role": "user", "content": format!("frame {at}")},
10519 "session_id": session_key.display(),
10520 }))
10521 .expect("a user frame")
10522 };
10523 let mut event = connected_event(&session_key.display(), "/tmp/handed-over");
10524 if let AgentEvent::Connected { history_updates, .. } = &mut event {
10525 *history_updates = Some((0..read).map(frame).collect());
10526 }
10527 task.translate_event(event);
10528
10529 let mut handed = None;
10530 while let Ok(frame) = update_rx.try_recv() {
10531 match std::sync::Arc::unwrap_or_clone(frame.update) {
10532 SessionUpdate::Connected { history, dropped, .. }
10533 | SessionUpdate::SessionReplaced { history, dropped, .. } => {
10534 handed = Some((history, dropped));
10535 }
10536 _ => {}
10537 }
10538 }
10539 let (handed, cut) = handed.expect("a connect crosses as Connected or SessionReplaced");
10540 assert!(
10541 (cap..=cap + slack).contains(&handed.len()),
10542 "the update hands over the window the task keeps, not the read: {} messages of \
10543 the {read} the connect carried (connected_once: {connected_once})",
10544 handed.len(),
10545 );
10546 assert_eq!(
10547 cut,
10548 read - handed.len(),
10549 "and the cut the window left behind rides with it: a consumer holding the window \
10550 alone reads its front as the conversation's, so a walk below it would stop \
10551 there instead of crossing into the transcript (connected_once: {connected_once})",
10552 );
10553 assert!(
10554 handed.last().is_some_and(|message| {
10555 matches!(message, forge_primitives::Message::User { message, .. }
10556 if message.content.iter().any(|block| matches!(block,
10557 forge_primitives::ContentBlock::Text { text, .. }
10558 if text == &format!("frame {}", read - 1))))
10559 }),
10560 "and it is the NEWEST window rather than the oldest: the transcript's own tail",
10561 );
10562 }
10563 }
10564
10565 #[tokio::test]
10570 async fn a_resumed_connect_folds_the_cut_thinking_into_the_window() {
10571 let read = 5_100;
10572 for connected_once in [false, true] {
10573 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10574 let session_key = SessionSlot::from_str_for_test("handover-thinking-uuid");
10575 let domain =
10576 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10577 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10578 let (_cmd_tx, command_rx) =
10579 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10580 let mut task = SessionTask {
10581 key: session_key.clone(),
10582 handle: Arc::new(handle),
10583 command_rx,
10584 domain,
10585 update_tx: workspace.update_sender(),
10586 connected_once,
10587 workspace: Arc::downgrade(&workspace),
10588 compaction_count: 0,
10589 successor: None,
10590 teardown_done: None,
10591 };
10592 let counter = |at: usize| {
10593 serde_json::from_value::<forge_primitives::Message>(serde_json::json!({
10594 "type": "system",
10595 "subtype": "thinking_tokens",
10596 "estimated_tokens": 1,
10597 "estimated_tokens_delta": 1,
10598 "uuid": format!("t-{at}"),
10599 "session_id": session_key.display(),
10600 }))
10601 .expect("a counter frame")
10602 };
10603 let mut history = vec![
10604 serde_json::from_value::<forge_primitives::Message>(serde_json::json!({
10605 "type": "user",
10606 "message": {"role": "user", "content": "the thought-heavy turn"},
10607 "session_id": session_key.display(),
10608 }))
10609 .expect("a user frame"),
10610 ];
10611 history.extend((0..read).map(counter));
10612 let mut event = connected_event(&session_key.display(), "/tmp/handed-over");
10613 if let AgentEvent::Connected { history_updates, .. } = &mut event {
10614 *history_updates = Some(history);
10615 }
10616 task.translate_event(event);
10617
10618 let mut handed = None;
10619 while let Ok(frame) = update_rx.try_recv() {
10620 match std::sync::Arc::unwrap_or_clone(frame.update) {
10621 SessionUpdate::Connected { history, .. }
10622 | SessionUpdate::SessionReplaced { history, .. } => {
10623 handed = Some(history);
10624 }
10625 _ => {}
10626 }
10627 }
10628 let handed = handed.expect("a connect crosses as Connected or SessionReplaced");
10629 let sum: i64 = handed
10630 .iter()
10631 .filter_map(|message| match message {
10632 forge_primitives::Message::ThinkingTokens {
10633 estimated_tokens_delta, ..
10634 } => Some(*estimated_tokens_delta),
10635 _ => None,
10636 })
10637 .sum();
10638 assert_eq!(
10639 sum,
10640 i64::try_from(read).expect("the test's count fits"),
10641 "the handover carries the whole turn, not the suffix (connected_once: \
10642 {connected_once})",
10643 );
10644 }
10645 }
10646
10647 #[tokio::test]
10653 async fn translate_event_carries_a_non_zero_compaction_count_on_both_arms() {
10654 for (connected_once, arm) in [(false, "Connected"), (true, "SessionReplaced")] {
10655 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10656 let session_key = SessionSlot::from_str_for_test("count-through-uuid");
10657 let domain =
10658 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10659 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10660 let (_cmd_tx, command_rx) =
10661 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10662 let mut task = SessionTask {
10663 key: session_key.clone(),
10664 handle: Arc::new(handle),
10665 command_rx,
10666 domain: Arc::clone(&domain),
10667 update_tx: workspace.update_sender(),
10668 connected_once,
10669 workspace: Arc::downgrade(&workspace),
10670 compaction_count: 0,
10671 successor: None,
10672 teardown_done: None,
10673 };
10674
10675 let mut event = connected_event(&session_key.display(), "/tmp/count");
10676 if let AgentEvent::Connected { compaction_count, .. } = &mut event {
10677 *compaction_count = 7;
10678 }
10679 task.translate_event(event);
10680
10681 let mut seen = None;
10682 while let Ok(frame) = update_rx.try_recv() {
10683 match std::sync::Arc::unwrap_or_clone(frame.update) {
10684 SessionUpdate::Connected { compaction_count, .. }
10685 | SessionUpdate::SessionReplaced { compaction_count, .. } => {
10686 seen = Some(compaction_count);
10687 }
10688 _ => {}
10689 }
10690 }
10691 assert_eq!(seen, Some(7), "{arm} must carry the count through unchanged");
10692 }
10693 }
10694
10695 #[tokio::test]
10700 async fn subagent_frames_ride_the_connected_history() {
10701 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10702 let session_key = SessionSlot::from_str_for_test("sidecar-chat-uuid");
10703 let domain =
10704 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10705 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10706 let (_cmd_tx, command_rx) =
10707 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10708 let mut task = SessionTask {
10709 key: session_key.clone(),
10710 handle: Arc::new(handle),
10711 command_rx,
10712 domain: Arc::clone(&domain),
10713 update_tx: workspace.update_sender(),
10714 connected_once: false,
10715 workspace: Arc::downgrade(&workspace),
10716 compaction_count: 0,
10717 successor: None,
10718 teardown_done: None,
10719 };
10720 let dispatch = serde_json::from_value(serde_json::json!({
10721 "type": "assistant",
10722 "message": {"id": "msg_1", "type": "message", "role": "assistant",
10723 "model": "claude-sonnet-5", "content": [
10724 {"type": "tool_use", "id": "toolu_a", "name": "Agent",
10725 "input": {"prompt": "explore"}}
10726 ]},
10727 "session_id": "sidecar-chat",
10728 "uuid": "a1",
10729 }))
10730 .expect("the dispatch decodes");
10731 let child = serde_json::from_value(serde_json::json!({
10732 "type": "assistant",
10733 "message": {"id": "msg_2", "type": "message", "role": "assistant",
10734 "model": "claude-sonnet-5", "content": [
10735 {"type": "tool_use", "id": "toolu_child", "name": "Bash",
10736 "input": {"command": "ls"}}
10737 ]},
10738 "parent_tool_use_id": "toolu_a",
10739 "session_id": "sidecar-chat",
10740 "uuid": "c1",
10741 }))
10742 .expect("the sub-agent frame decodes");
10743 let dispatch_ack = serde_json::from_value(serde_json::json!({
10744 "type": "user",
10745 "message": {"role": "user", "content": [
10746 {"type": "tool_result", "tool_use_id": "toolu_a",
10747 "content": "Async agent launched successfully"}
10748 ]},
10749 "session_id": "sidecar-chat",
10750 "uuid": "u1",
10751 }))
10752 .expect("the dispatch ack decodes");
10753 let child_result = serde_json::from_value(serde_json::json!({
10754 "type": "user",
10755 "message": {"role": "user", "content": [
10756 {"type": "tool_result", "tool_use_id": "toolu_child", "content": "total 0"}
10757 ]},
10758 "session_id": "sidecar-chat",
10759 "uuid": "u2",
10760 }))
10761 .expect("the child result decodes");
10762 let mut event = connected_event("sidecar-chat", "/tmp/sidecar");
10763 if let AgentEvent::Connected { history_updates, .. } = &mut event {
10764 *history_updates = Some(vec![dispatch, dispatch_ack, child, child_result]);
10765 }
10766
10767 task.translate_event(event);
10768
10769 let mut history_len = None;
10770 while let Ok(frame) = update_rx.try_recv() {
10771 if let SessionUpdate::Connected { history, .. } = &*frame.update {
10772 history_len = Some(history.len());
10773 }
10774 }
10775 assert_eq!(history_len, Some(4), "the sub-agent frames ride the page as the row expansion");
10776 let cards = domain.lock().cards_snapshot.clone();
10777 assert_eq!(cards.len(), 1, "the folded instance gets its card");
10778 assert_eq!(cards[0].calls, 1, "and its call is the card's");
10779 assert!(
10780 !cards[0].running,
10781 "every sidecar call settled means the instance is settled, not hidden",
10782 );
10783 }
10784
10785 #[tokio::test]
10795 async fn a_git_worker_without_a_worktree_is_failed_before_the_first_connect_effects() {
10796 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10797 let session_key = SessionSlot::worker("TestOrg", "proj-x", "builder");
10798 let project_key = crate::ProjectKey::new("proj-x");
10799 let root = tempfile::tempdir().expect("root");
10800 let worktree = root.path().join(".claude/worktrees/builder");
10801 workspace.insert_live_worker(
10802 &project_key,
10803 crate::mcp::workers::types::WorkerEntry {
10804 label: "builder".to_owned(),
10805 charter: "charter".to_owned(),
10806 slot: session_key.clone(),
10807 session_id: Some(forge_primitives::SessionId::new("builder-session")),
10808 status: forge_primitives::WorkerLiveness::Spawning,
10809 spawned_at: std::time::SystemTime::UNIX_EPOCH,
10810 spawned_by: SessionSlot::lead("TestOrg", "proj-x"),
10811 needs_tag: true,
10812 is_git_repo_at_spawn: true,
10813 cwd: Some(root.path().to_path_buf()),
10814 diagnostic: None,
10815 kick: None,
10816 },
10817 );
10818 let (handle, _commands) = Agent::testing_stub();
10819 let handle = Arc::new(handle);
10820 workspace.pool.lock().insert(
10821 session_key.clone(),
10822 crate::workspace::PooledAgent {
10823 handle: Arc::clone(&handle),
10824 account: forge_gateway::AccountKey("acct-a".to_owned()),
10825 permission_mode: None,
10826 registration: None,
10827 session_id: "builder-session".to_owned(),
10828 },
10829 );
10830 let (commands, mut routed) = tokio::sync::mpsc::unbounded_channel();
10831 workspace.command_senders.lock().insert(session_key.clone(), commands);
10832 let (_task_tx, task_rx) =
10833 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10834 let domain =
10835 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10836 let mut task = SessionTask {
10837 key: session_key.clone(),
10838 handle,
10839 command_rx: task_rx,
10840 domain: Arc::clone(&domain),
10841 update_tx: workspace.update_sender(),
10842 connected_once: false,
10843 workspace: Arc::downgrade(&workspace),
10844 compaction_count: 0,
10845 successor: None,
10846 teardown_done: None,
10847 };
10848 let alive =
10850 task.translate_event(connected_event("builder-session", &worktree.to_string_lossy()));
10851
10852 assert!(!alive, "the task stops rather than continue on a failed worker");
10853 let mut connected = false;
10854 while let Ok(frame) = update_rx.try_recv() {
10855 if matches!(&*frame.update, SessionUpdate::Connected { .. }) {
10856 connected = true;
10857 }
10858 }
10859 assert!(!connected, "the dead worker is never announced live");
10860 assert!(routed.try_recv().is_err(), "and nothing is dispatched into its session");
10861 let entry = workspace
10862 .list_live_workers(&project_key)
10863 .into_iter()
10864 .find(|entry| entry.label == "builder")
10865 .expect("the row stays as the visible failure");
10866 assert_eq!(entry.status, forge_primitives::WorkerLiveness::Failed);
10867 let reason = entry.diagnostic.expect("the row names the missing worktree");
10868 let worktree = worktree.display().to_string();
10869 assert!(reason.contains(&worktree), "the row names the worktree: {reason}");
10870 assert!(
10871 !reason.contains("builder/.claude/worktrees/builder"),
10872 "the reason is not the connect cwd composed a second time: {reason}",
10873 );
10874 assert!(
10875 !workspace.session_is_pooled(&session_key),
10876 "and the session is released with the failure",
10877 );
10878 }
10879
10880 #[tokio::test]
10889 async fn a_compaction_boundary_moves_the_tasks_count() {
10890 let (workspace, _update_rx) = crate::Workspace::testing_stub();
10891 let session_key = SessionSlot::from_str_for_test("compaction-count-uuid");
10892 let domain =
10893 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10894 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10895 let (_cmd_tx, command_rx) =
10896 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10897 let mut task = SessionTask {
10898 key: session_key.clone(),
10899 handle: Arc::new(handle),
10900 command_rx,
10901 domain,
10902 update_tx: workspace.update_sender(),
10903 connected_once: false,
10904 workspace: Arc::downgrade(&workspace),
10905 compaction_count: 0,
10906 successor: None,
10907 teardown_done: None,
10908 };
10909
10910 let mut event = connected_event(&session_key.display(), "/tmp/count");
10911 if let AgentEvent::Connected { compaction_count, .. } = &mut event {
10912 *compaction_count = 4;
10913 }
10914 task.translate_event(event);
10915 assert_eq!(task.compaction_count, 4, "a resumed connect seeds the count it carries");
10916
10917 task.translate_event(AgentEvent::SdkMessage {
10918 session_id: session_key.display(),
10919 msg: forge_primitives::Message::CompactBoundary {
10920 trigger: "auto".to_owned(),
10921 pre_tokens: 1,
10922 post_tokens: 1,
10923 uuid: "c1".to_owned(),
10924 session_id: session_key.display(),
10925 metadata_extras: serde_json::Map::new(),
10926 extras: serde_json::Map::new(),
10927 },
10928 });
10929 assert_eq!(task.compaction_count, 5, "and the boundary the session emits moves it");
10930 }
10931
10932 #[tokio::test]
10938 async fn connected_once_seed_emits_session_replaced_with_resumed_history() {
10939 use forge_primitives::Message;
10940
10941 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10942 let session_key = SessionSlot::from_str_for_test("replacement-visible-uuid");
10943 let domain =
10944 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10945
10946 let (handle, _agent_cmd_rx) = Agent::testing_stub();
10947 let (_cmd_tx, command_rx) =
10948 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10949 let update_tx = workspace.update_sender();
10950 let mut task = SessionTask {
10951 key: session_key.clone(),
10952 handle: Arc::new(handle),
10953 command_rx,
10954 domain: Arc::clone(&domain),
10955 update_tx,
10956 connected_once: true,
10958 workspace: Arc::downgrade(&workspace),
10959 compaction_count: 0,
10960 successor: None,
10961 teardown_done: None,
10962 };
10963
10964 let history = vec![Message::System {
10966 subtype: "info".to_owned(),
10967 session_id: Some(session_key.display()),
10968 data: serde_json::json!({ "body": "earlier turn" }),
10969 }];
10970
10971 task.translate_event(AgentEvent::Connected {
10972 session_id: session_key.display(),
10973 cwd: "/tmp/respawn".to_owned(),
10974 current_model: forge_primitives::CurrentModel {
10975 resolved_id: "claude".to_owned(),
10976 display_name_short: "claude".to_owned(),
10977 display_name_long: "claude".to_owned(),
10978 requested_id: None,
10979 catalog_id: None,
10980 supports_effort: false,
10981 supported_effort_levels: Vec::new(),
10982 supports_auto_mode: None,
10983 supports_adaptive_thinking: None,
10984 is_authoritative: true,
10985 },
10986 available_models: Vec::new(),
10987 mode: None,
10988 history_updates: Some(history),
10989 compaction_count: 0,
10990 });
10991
10992 let mut replaced_history_len = None;
10993 let mut saw_plain_connected = false;
10994 while let Ok(frame) = update_rx.try_recv() {
10995 match std::sync::Arc::unwrap_or_clone(frame.update) {
10996 SessionUpdate::SessionReplaced { key, history, .. } => {
10997 assert_eq!(key, session_key, "SessionReplaced targets the re-spawned session");
10998 replaced_history_len = Some(history.len());
10999 }
11000 SessionUpdate::Connected { .. } => saw_plain_connected = true,
11001 _ => {}
11002 }
11003 }
11004 assert_eq!(
11005 replaced_history_len,
11006 Some(1),
11007 "connected_once=true emits SessionReplaced carrying the resumed conversation",
11008 );
11009 assert!(
11010 !saw_plain_connected,
11011 "a session-replacing re-spawn must not emit a fresh Connected"
11012 );
11013 }
11014
11015 #[tokio::test]
11019 async fn a_replaced_seat_tells_the_successor_it_is_heard() {
11020 struct Silent;
11021 impl forge_tts::Engine for Silent {
11022 fn speak(
11023 &self,
11024 _request: forge_tts::SpeakRequest,
11025 ) -> tokio::sync::mpsc::Receiver<Result<forge_tts::Chunk, forge_tts::Error>>
11026 {
11027 let (sender, receiver) = tokio::sync::mpsc::channel(1);
11028 drop(sender);
11029 receiver
11030 }
11031 }
11032
11033 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
11034 workspace.install_voice_engine_for_test(Arc::new(Silent));
11035 let session_key = SessionSlot::from_str_for_test("voice-respawn-uuid");
11036 workspace.voice_service().set_listening(&session_key, 1, true, false);
11037 while let Ok(frame) = update_rx.try_recv() {
11040 assert!(
11041 !matches!(&*frame.update, SessionUpdate::ChatAppended { .. }),
11042 "a note that was not delivered draws no row",
11043 );
11044 }
11045 let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel();
11046 workspace.command_senders.lock().insert(session_key.clone(), command_tx);
11047
11048 let domain =
11049 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
11050 let (handle, _agent_cmd_rx) = Agent::testing_stub();
11051 let (_cmd_tx, command_rx_task) =
11052 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
11053 let update_tx = workspace.update_sender();
11054 let mut task = SessionTask {
11055 key: session_key.clone(),
11056 handle: Arc::new(handle),
11057 command_rx: command_rx_task,
11058 domain,
11059 update_tx,
11060 connected_once: true,
11061 workspace: Arc::downgrade(&workspace),
11062 compaction_count: 0,
11063 successor: None,
11064 teardown_done: None,
11065 };
11066
11067 task.translate_event(AgentEvent::Connected {
11068 session_id: session_key.display(),
11069 cwd: "/tmp/respawn".to_owned(),
11070 current_model: forge_primitives::CurrentModel {
11071 resolved_id: "claude".to_owned(),
11072 display_name_short: "claude".to_owned(),
11073 display_name_long: "claude".to_owned(),
11074 requested_id: None,
11075 catalog_id: None,
11076 supports_effort: false,
11077 supported_effort_levels: Vec::new(),
11078 supports_auto_mode: None,
11079 supports_adaptive_thinking: None,
11080 is_authoritative: true,
11081 },
11082 available_models: Vec::new(),
11083 mode: None,
11084 history_updates: Some(Vec::new()),
11085 compaction_count: 0,
11086 });
11087
11088 let mut note = None;
11089 let mut note_uuid = None;
11090 while let Ok(command) = command_rx.try_recv() {
11091 if let crate::protocol::Command::PromptUnder { text, uuid, .. } = command {
11092 note = Some(text);
11093 note_uuid = Some(uuid);
11094 }
11095 }
11096 assert!(
11097 note.as_deref().is_some_and(|text| text.contains("The user is listening")),
11098 "the successor is told it is heard, got: {note:?}",
11099 );
11100
11101 let mut row = None;
11102 while let Ok(frame) = update_rx.try_recv() {
11103 if let SessionUpdate::ChatAppended { key, msg, source, .. } = &*frame.update
11104 && key == &session_key
11105 {
11106 row = Some(((*msg).clone(), *source));
11107 }
11108 }
11109 let (row, source) = row.expect("the delivered note forges the row a page draws");
11110 assert_eq!(source, Some(PromptSource::Forge), "the row is forge's own delivery");
11111 let Message::User { message, uuid, timestamp, .. } = &row else {
11112 panic!("a voice note draws as an information row, not the reader's turn: {row:?}");
11113 };
11114 assert_eq!(uuid, ¬e_uuid, "the row and the prompt share one id");
11115 assert!(timestamp.is_some(), "the row carries forge's clock");
11116 let text = message
11117 .content
11118 .iter()
11119 .find_map(|block| match block {
11120 ContentBlock::Text { text, .. } => Some(text.as_str()),
11121 _ => None,
11122 })
11123 .expect("the row carries the note's words");
11124 assert_eq!(Some(text), note.as_deref(), "the row carries exactly the prose the agent got");
11125 assert!(
11126 text.starts_with("[Voice]\n\n"),
11127 "the envelope is what makes the view draw it as information, got: {text:?}",
11128 );
11129 }
11130
11131 #[tokio::test]
11135 async fn a_replaced_identity_drops_its_overrides() {
11136 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
11137 let session_key = SessionSlot::from_str_for_test("dictate-rekey-uuid");
11138 let domain =
11139 Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
11140 domain.lock().dictate_overrides.styling = Some(forge_dictate::normalize::Styling::Formal);
11141
11142 let (handle, _agent_cmd_rx) = Agent::testing_stub();
11143 let (_cmd_tx, command_rx) =
11144 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
11145 let mut task = SessionTask {
11146 key: session_key.clone(),
11147 handle: Arc::new(handle),
11148 command_rx,
11149 domain: Arc::clone(&domain),
11150 update_tx: workspace.update_sender(),
11151 connected_once: true,
11152 workspace: Arc::downgrade(&workspace),
11153 compaction_count: 0,
11154 successor: None,
11155 teardown_done: None,
11156 };
11157
11158 task.translate_event(AgentEvent::Connected {
11159 session_id: session_key.display(),
11160 cwd: "/tmp/respawn".to_owned(),
11161 current_model: forge_primitives::CurrentModel {
11162 resolved_id: "claude".to_owned(),
11163 display_name_short: "claude".to_owned(),
11164 display_name_long: "claude".to_owned(),
11165 requested_id: None,
11166 catalog_id: None,
11167 supports_effort: false,
11168 supported_effort_levels: Vec::new(),
11169 supports_auto_mode: None,
11170 supports_adaptive_thinking: None,
11171 is_authoritative: true,
11172 },
11173 available_models: Vec::new(),
11174 mode: None,
11175 history_updates: None,
11176 compaction_count: 0,
11177 });
11178
11179 assert_eq!(domain.lock().dictate_overrides, crate::dictate::DictateOverrides::default());
11180
11181 let mut replaced_seen = false;
11182 while let Ok(frame) = update_rx.try_recv() {
11183 replaced_seen |= matches!(&*frame.update, SessionUpdate::SessionReplaced { .. });
11184 }
11185 assert!(
11186 replaced_seen,
11187 "the scenario must still be a replacement, or the test proves nothing"
11188 );
11189 }
11190
11191 #[test]
11199 fn a_login_wait_comes_from_the_typed_signals() {
11200 let held = |event: &AgentEvent| {
11201 let mut domain = empty_domain();
11202 apply_event_to_domain(&mut domain, event);
11203 domain.awaiting_login
11204 };
11205
11206 assert!(
11207 held(&sdk_message(assistant_with("authentication_failed"))),
11208 "the CLI attributes the failure to the assistant message, and that is typed",
11209 );
11210 assert!(
11211 held(&sdk_message(api_retry("authentication_failed"))),
11212 "and names it on the retry frame as well",
11213 );
11214 assert!(
11215 !held(&sdk_message(assistant_with("server_error"))),
11216 "another class is not a login wait",
11217 );
11218
11219 let mut domain = empty_domain();
11223 apply_event_to_domain(&mut domain, &sdk_message(assistant_with("authentication_failed")));
11224 assert!(domain.awaiting_login, "precondition: held");
11225 apply_event_to_domain(
11226 &mut domain,
11227 &sdk_message(result_message("error_during_execution", true)),
11228 );
11229 assert!(
11230 domain.awaiting_login,
11231 "a turn that died on the missing credential does not clear the wait",
11232 );
11233 apply_event_to_domain(&mut domain, &sdk_message(result_message("success", false)));
11234 assert!(!domain.awaiting_login, "a turn that finished does");
11235 }
11236
11237 fn sdk_message(msg: forge_primitives::Message) -> AgentEvent {
11238 AgentEvent::SdkMessage { session_id: "s".to_owned(), msg }
11239 }
11240
11241 fn hook_observation(
11242 permission_mode: Option<&str>,
11243 effort: Option<&str>,
11244 tool_use_id: Option<&str>,
11245 agent_type: Option<&str>,
11246 ) -> AgentEvent {
11247 AgentEvent::HookObservation {
11248 session_id: "s".to_owned(),
11249 tool_use_id: tool_use_id.map(str::to_owned),
11250 permission_mode: permission_mode.map(str::to_owned),
11251 effort: effort.map(str::to_owned),
11252 agent_id: tool_use_id.map(|_| "agent-1".to_owned()),
11253 agent_type: agent_type.map(str::to_owned),
11254 }
11255 }
11256
11257 fn init_frame_naming_the_model(model: &str) -> forge_primitives::Message {
11258 serde_json::from_value(serde_json::json!({
11259 "type": "system",
11260 "subtype": "init",
11261 "session_id": "s",
11262 "model": model,
11263 }))
11264 .expect("parse an init frame naming a model")
11265 }
11266
11267 fn mcp_server(name: &str) -> forge_primitives::McpServerStatus {
11268 serde_json::from_value(serde_json::json!({ "name": name, "status": "connected" }))
11269 .expect("parse an MCP server status")
11270 }
11271
11272 fn monitor_tool_use(tool_use_id: &str, description: &str) -> forge_primitives::Message {
11273 serde_json::from_value(serde_json::json!({
11274 "type": "assistant",
11275 "message": {
11276 "id": "msg-mon",
11277 "role": "assistant",
11278 "model": "claude-sonnet-5",
11279 "content": [{
11280 "type": "tool_use",
11281 "id": tool_use_id,
11282 "name": "Monitor",
11283 "input": {
11284 "description": description,
11285 "command": "gh run watch 1",
11286 "persistent": true,
11287 },
11288 }],
11289 },
11290 "session_id": "s",
11291 }))
11292 .expect("parse a Monitor tool_use")
11293 }
11294
11295 fn task_started(task_id: &str, tool_use_id: Option<&str>) -> forge_primitives::Message {
11296 serde_json::from_value(serde_json::json!({
11297 "type": "system",
11298 "subtype": "task_started",
11299 "task_id": task_id,
11300 "description": "watch CI",
11301 "uuid": "u-task",
11302 "session_id": "s",
11303 "tool_use_id": tool_use_id,
11304 }))
11305 .expect("parse a task_started")
11306 }
11307
11308 fn task_updated(task_id: &str, status: &str) -> forge_primitives::Message {
11309 serde_json::from_value(serde_json::json!({
11310 "type": "system",
11311 "subtype": "task_updated",
11312 "task_id": task_id,
11313 "patch": { "status": status },
11314 "uuid": "u-upd",
11315 "session_id": "s",
11316 }))
11317 .expect("parse a task_updated")
11318 }
11319
11320 fn task_updated_at(task_id: &str, status: &str, end_ms: u64) -> forge_primitives::Message {
11323 serde_json::from_value(serde_json::json!({
11324 "type": "system",
11325 "subtype": "task_updated",
11326 "task_id": task_id,
11327 "patch": { "status": status, "end_time": end_ms },
11328 "uuid": "u-upd",
11329 "session_id": "s",
11330 }))
11331 .expect("parse a task_updated carrying an end time")
11332 }
11333
11334 fn task_notification(task_id: &str) -> forge_primitives::Message {
11335 task_notification_with_status(task_id, "completed")
11336 }
11337
11338 fn task_notification_with_status(task_id: &str, status: &str) -> forge_primitives::Message {
11339 serde_json::from_value(serde_json::json!({
11340 "type": "system",
11341 "subtype": "task_notification",
11342 "task_id": task_id,
11343 "status": status,
11344 "output_file": "/tmp/forge-test-monitor.out",
11345 "summary": "Monitor stream ended",
11346 "uuid": "u-note",
11347 "session_id": "s",
11348 }))
11349 .expect("parse a task_notification")
11350 }
11351
11352 #[test]
11357 fn an_unparseable_commands_changed_payload_keeps_the_retained_commands() {
11358 let mut domain = empty_domain();
11359 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &[])));
11360
11361 apply_event_to_domain(
11362 &mut domain,
11363 &sdk_message(forge_primitives::Message::CommandsChanged {
11364 commands: vec![serde_json::json!({"no_name": "x"}), serde_json::json!(7)],
11365 uuid: "cmd-uuid".to_owned(),
11366 session_id: "s".to_owned(),
11367 extras: serde_json::Map::new(),
11368 }),
11369 );
11370
11371 let commands: Vec<&str> =
11372 domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11373 assert_eq!(commands, vec!["/help"], "the prior list stands rather than being wiped");
11374 }
11375
11376 #[test]
11379 fn an_empty_commands_changed_payload_clears_the_retained_commands() {
11380 let mut domain = empty_domain();
11381 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &[])));
11382
11383 apply_event_to_domain(
11384 &mut domain,
11385 &sdk_message(forge_primitives::Message::CommandsChanged {
11386 commands: Vec::new(),
11387 uuid: "cmd-uuid".to_owned(),
11388 session_id: "s".to_owned(),
11389 extras: serde_json::Map::new(),
11390 }),
11391 );
11392
11393 assert!(domain.available_commands.is_empty(), "an empty payload clears the list");
11394 }
11395
11396 #[test]
11408 fn a_new_occupant_advertises_no_catalogues_until_its_own_init() {
11409 let mut domain = empty_domain();
11410 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &["reviewer"])));
11411
11412 apply_event_to_domain(&mut domain, &connected_event("new-occupant", "/proj"));
11413
11414 assert!(
11415 domain.available_commands.is_empty(),
11416 "the previous occupant's command list does not stand",
11417 );
11418 assert!(domain.available_agents.is_empty(), "nor the agent catalogue it advertised");
11419
11420 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/newhelp"], &["newagent"])));
11421
11422 let commands: Vec<&str> =
11423 domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11424 let agents: Vec<&str> = domain.available_agents.iter().map(|a| a.name.as_str()).collect();
11425 assert_eq!(commands, vec!["/newhelp"], "the new occupant's own init refills the commands");
11426 assert_eq!(agents, vec!["newagent"], "and the catalogue, without waiting a turn");
11427 }
11428
11429 fn init_frame(slash_commands: &[&str], agents: &[&str]) -> forge_primitives::Message {
11432 serde_json::from_value(serde_json::json!({
11433 "type": "system",
11434 "subtype": "init",
11435 "session_id": "s",
11436 "slash_commands": slash_commands,
11437 "agents": agents,
11438 }))
11439 .expect("an init frame")
11440 }
11441
11442 #[test]
11446 fn the_catalogues_the_cli_advertises_are_retained_on_the_domain() {
11447 let mut domain = empty_domain();
11448
11449 apply_event_to_domain(
11450 &mut domain,
11451 &sdk_message(init_frame(&["/help", "memory"], &["reviewer"])),
11452 );
11453
11454 let commands: Vec<&str> =
11455 domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11456 assert_eq!(commands, vec!["/help", "memory"], "the command list is kept whole");
11457 let agents: Vec<&str> = domain.available_agents.iter().map(|a| a.name.as_str()).collect();
11458 assert_eq!(agents, vec!["reviewer"], "and so is the agent catalogue");
11459 }
11460
11461 #[test]
11464 fn a_commands_changed_frame_refreshes_the_retained_commands() {
11465 let mut domain = empty_domain();
11466 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &[])));
11467
11468 let refreshed = forge_primitives::Message::CommandsChanged {
11469 commands: vec![serde_json::json!({"name": "/reload", "description": "Reloaded"})],
11470 uuid: "cmd-uuid".to_owned(),
11471 session_id: "s".to_owned(),
11472 extras: serde_json::Map::new(),
11473 };
11474 apply_event_to_domain(&mut domain, &sdk_message(refreshed));
11475
11476 let commands: Vec<&str> =
11477 domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11478 assert_eq!(commands, vec!["/reload"], "the reload's list replaces the init one");
11479 }
11480
11481 #[test]
11485 fn an_init_frame_with_no_commands_keeps_the_retained_ones() {
11486 let mut domain = empty_domain();
11487 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &["reviewer"])));
11488
11489 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["reviewer"])));
11490
11491 assert_eq!(domain.available_commands.len(), 1, "the earlier list stands");
11492 assert_eq!(domain.available_agents.len(), 1, "and the catalogue with it");
11493 }
11494
11495 #[test]
11499 fn the_agent_catalogue_is_read_once_per_turn() {
11500 let mut domain = empty_domain();
11501 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["reviewer"])));
11502
11503 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["someone-else"])));
11504 assert_eq!(
11505 domain.available_agents.first().map(|a| a.name.as_str()),
11506 Some("reviewer"),
11507 "a re-fire inside the turn does not replace the catalogue",
11508 );
11509
11510 apply_event_to_domain(&mut domain, &sdk_message(result_message("success", false)));
11511 apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["someone-else"])));
11512 assert_eq!(
11513 domain.available_agents.first().map(|a| a.name.as_str()),
11514 Some("someone-else"),
11515 "and the next turn's frame does",
11516 );
11517 }
11518
11519 #[test]
11525 fn a_hook_observation_is_held_on_the_domain() {
11526 let mut domain = empty_domain();
11527
11528 apply_event_to_domain(
11529 &mut domain,
11530 &hook_observation(Some("acceptEdits"), Some("xhigh"), Some("tu-1"), Some("Explore")),
11531 );
11532
11533 assert_eq!(
11534 domain.observed_permission_mode,
11535 Some(forge_primitives::PermissionMode::AcceptEdits),
11536 "the hook's permission mode is kept",
11537 );
11538 assert_eq!(
11539 domain.observed_effort,
11540 Some(forge_primitives::EffortLevel::Xhigh),
11541 "and its effort level",
11542 );
11543 }
11544
11545 #[test]
11548 fn an_unreadable_hook_effort_keeps_the_level_already_held() {
11549 let mut domain = empty_domain();
11550 domain.observed_effort = Some(forge_primitives::EffortLevel::High);
11551
11552 apply_event_to_domain(&mut domain, &hook_observation(None, Some("turbo"), None, None));
11553
11554 assert_eq!(
11555 domain.observed_effort,
11556 Some(forge_primitives::EffortLevel::High),
11557 "an unreadable level leaves the held one standing",
11558 );
11559 }
11560
11561 #[test]
11564 fn the_mcp_snapshot_the_bridge_returns_is_held() {
11565 let mut domain = empty_domain();
11566
11567 apply_event_to_domain(
11568 &mut domain,
11569 &AgentEvent::McpSnapshot {
11570 session_id: "s".to_owned(),
11571 servers: vec![mcp_server("ctx7")],
11572 error: None,
11573 },
11574 );
11575
11576 let held = domain.mcp_servers.as_ref().expect("the snapshot is kept");
11577 assert_eq!(held.servers.len(), 1, "the server list is kept whole");
11578 assert_eq!(held.servers[0].name, "ctx7", "and names the server it carries");
11579 assert_eq!(held.error, None, "with no error standing beside it");
11580 }
11581
11582 #[test]
11586 fn a_failed_mcp_snapshot_replaces_the_one_before_it() {
11587 let mut domain = empty_domain();
11588 apply_event_to_domain(
11589 &mut domain,
11590 &AgentEvent::McpSnapshot {
11591 session_id: "s".to_owned(),
11592 servers: vec![mcp_server("ctx7")],
11593 error: None,
11594 },
11595 );
11596
11597 apply_event_to_domain(
11598 &mut domain,
11599 &AgentEvent::McpSnapshot {
11600 session_id: "s".to_owned(),
11601 servers: Vec::new(),
11602 error: Some("the CLI refused".to_owned()),
11603 },
11604 );
11605
11606 let held = domain.mcp_servers.as_ref().expect("the snapshot is kept");
11607 assert!(held.servers.is_empty(), "the failed read's empty set replaces the old one");
11608 assert_eq!(held.error.as_deref(), Some("the CLI refused"), "and carries why");
11609 }
11610
11611 #[test]
11615 fn the_context_usage_the_bridge_reports_is_held() {
11616 let mut domain = empty_domain();
11617
11618 apply_event_to_domain(
11619 &mut domain,
11620 &AgentEvent::ContextUsage {
11621 session_id: "s".to_owned(),
11622 percentage: Some(62),
11623 max_tokens: Some(1_000_000),
11624 total_tokens: Some(620_000),
11625 effective_max_tokens: Some(1_000_000),
11626 },
11627 );
11628
11629 let held = domain.context_usage.expect("the usage is kept");
11630 assert_eq!(held.percent, Some(62), "the percentage is kept");
11631 assert_eq!(held.max_tokens, Some(1_000_000), "and the window it is a share of");
11632 }
11633
11634 #[test]
11637 fn a_connected_session_holds_the_model_it_reported() {
11638 let mut domain = empty_domain();
11639
11640 apply_event_to_domain(&mut domain, &connected_event("uuid-1", "/proj"));
11641
11642 assert_eq!(
11643 domain.current_model.as_ref().map(|m| m.resolved_id.as_str()),
11644 Some("claude"),
11645 "the model the connect reported is kept",
11646 );
11647 }
11648
11649 #[test]
11654 fn the_model_follows_a_later_turns_init_frame() {
11655 let mut domain = empty_domain();
11656 apply_event_to_domain(&mut domain, &connected_event("uuid-1", "/proj"));
11657 apply_event_to_domain(
11658 &mut domain,
11659 &sdk_message(init_frame_naming_the_model("claude-opus-5-5")),
11660 );
11661
11662 assert_eq!(
11663 domain.current_model.as_ref().map(|model| model.resolved_id.as_str()),
11664 Some("claude-opus-5-5"),
11665 "the model the turn runs under is the model the session is said to be on",
11666 );
11667 }
11668
11669 #[test]
11674 fn an_init_frame_naming_the_same_model_keeps_the_resolved_name() {
11675 let mut domain = empty_domain();
11676 apply_event_to_domain(&mut domain, &connected_event("uuid-1", "/proj"));
11677 domain.current_model = Some(forge_primitives::CurrentModel {
11678 requested_id: Some("Opus (1M context)".to_owned()),
11679 resolved_id: "claude".to_owned(),
11680 display_name_short: "Opus (1M context)".to_owned(),
11681 display_name_long: "Opus (1M context)".to_owned(),
11682 ..forge_primitives::CurrentModel::new("claude", "claude", "claude")
11683 });
11684
11685 apply_event_to_domain(&mut domain, &sdk_message(init_frame_naming_the_model("claude")));
11686
11687 assert_eq!(
11688 domain.current_model.as_ref().map(|model| model.display_name_long.as_str()),
11689 Some("Opus (1M context)"),
11690 "a turn that switched nothing does not rename the session",
11691 );
11692 }
11693
11694 #[test]
11698 fn a_replaced_occupant_clears_the_held_view_facts() {
11699 let mut domain = empty_domain();
11700 apply_event_to_domain(
11701 &mut domain,
11702 &hook_observation(Some("plan"), Some("max"), Some("tu-1"), Some("Explore")),
11703 );
11704 domain.mcp_servers = Some(crate::domain_session::McpServers::default());
11705 domain.context_usage =
11706 Some(crate::domain_session::ContextUsage { percent: Some(10), max_tokens: None });
11707
11708 apply_event_to_domain(&mut domain, &connected_event("uuid-2", "/proj"));
11709
11710 assert_eq!(domain.observed_permission_mode, None, "the dead run's mode does not stand");
11711 assert_eq!(domain.observed_effort, None, "nor its effort");
11712 assert_eq!(domain.mcp_servers, None, "nor the servers it had connected");
11713 assert_eq!(domain.context_usage, None, "nor the context it had filled");
11714 }
11715
11716 #[test]
11721 fn a_dead_connection_clears_the_held_view_facts() {
11722 let mut domain = seeded_view_facts();
11723
11724 apply_event_to_domain(
11725 &mut domain,
11726 &AgentEvent::ConnectionFailed {
11727 message: "reader died".to_owned(),
11728 kind: SpawnFailureKind::Unclassified,
11729 },
11730 );
11731
11732 assert_view_facts_cleared(&domain, "the subprocess died");
11733 }
11734
11735 fn seeded_view_facts() -> DomainSession {
11736 let mut domain = empty_domain();
11737 apply_event_to_domain(
11738 &mut domain,
11739 &hook_observation(Some("plan"), Some("max"), Some("tu-1"), Some("Explore")),
11740 );
11741 domain.current_model =
11742 Some(forge_primitives::CurrentModel::new("claude-opus-5", "Opus", "Claude Opus 5"));
11743 domain.mcp_servers = Some(crate::domain_session::McpServers::default());
11744 domain.context_usage =
11745 Some(crate::domain_session::ContextUsage { percent: Some(10), max_tokens: None });
11746 domain
11747 }
11748
11749 fn assert_view_facts_cleared(domain: &DomainSession, why: &str) {
11750 assert_eq!(domain.observed_permission_mode, None, "{why} leaves no mode standing");
11751 assert_eq!(domain.observed_effort, None, "{why} leaves no effort standing");
11752 assert_eq!(domain.current_model, None, "{why} leaves no model standing");
11753 assert_eq!(domain.context_usage, None, "{why} leaves no context reading standing");
11754 assert_eq!(domain.mcp_servers, None, "{why} leaves no server snapshot standing");
11755 }
11756
11757 #[test]
11767 fn a_dead_connection_clears_the_process_walk() {
11768 let mut domain = empty_domain();
11769 domain.process_snapshot = Some(forge_agent::env::processes::ProcessSnapshot {
11770 processes: Vec::new(),
11771 scanned_at: std::time::SystemTime::UNIX_EPOCH,
11772 });
11773
11774 apply_event_to_domain(
11775 &mut domain,
11776 &AgentEvent::ConnectionFailed {
11777 message: "reader died".to_owned(),
11778 kind: SpawnFailureKind::Unclassified,
11779 },
11780 );
11781
11782 assert!(
11783 domain.process_snapshot.is_none(),
11784 "the tree went with the subprocess, so the read has nothing to serve",
11785 );
11786 }
11787
11788 #[test]
11791 fn a_monitor_tool_call_enters_the_held_set() {
11792 let mut domain = empty_domain();
11793
11794 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11795
11796 assert_eq!(domain.monitors.len(), 1, "the call enters the set");
11797 let held = &domain.monitors[0];
11798 assert_eq!(held.tool_use_id, "tu-mon", "keyed by the tool_use that started it");
11799 assert_eq!(held.description, "ci-watch", "carrying its headline");
11800 assert_eq!(held.command, "gh run watch 1", "and the command it watches");
11801 assert_eq!(
11802 held.status,
11803 forge_primitives::MonitorStatus::Running,
11804 "and it starts running, which is the only state the tool call proves",
11805 );
11806 assert_eq!(held.task_id, None, "with no task id the wire has not named yet");
11807 }
11808
11809 #[test]
11813 fn a_monitors_task_transition_settles_its_held_record() {
11814 let mut domain = empty_domain();
11815 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11816
11817 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-1", Some("tu-mon"))));
11818 assert_eq!(
11819 domain.monitors[0].task_id.as_deref(),
11820 Some("t-1"),
11821 "the task id the CLI assigned is stamped on the record",
11822 );
11823
11824 apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-1", "completed")));
11825
11826 assert_eq!(
11827 domain.monitors[0].status,
11828 forge_primitives::MonitorStatus::Completed,
11829 "a clean exit settles the monitor",
11830 );
11831 }
11832
11833 #[test]
11838 fn a_monitors_terminal_transition_stamps_the_instant_it_ended() {
11839 let mut domain = empty_domain();
11840 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11841 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-1", Some("tu-mon"))));
11842
11843 apply_event_to_domain(
11844 &mut domain,
11845 &sdk_message(task_updated_at("t-1", "completed", 1_700_000_000_123)),
11846 );
11847
11848 assert_eq!(
11849 domain.monitors[0].ended_at,
11850 Some(
11851 std::time::SystemTime::UNIX_EPOCH
11852 + std::time::Duration::from_millis(1_700_000_000_123)
11853 ),
11854 "the frame that ends the monitor is the frame that says when",
11855 );
11856 }
11857
11858 #[test]
11861 fn a_running_monitor_carries_no_end_instant() {
11862 let mut domain = empty_domain();
11863 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11864 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-1", Some("tu-mon"))));
11865 apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-1", "running")));
11866
11867 assert_eq!(
11868 domain.monitors[0].ended_at, None,
11869 "a monitor still watching has not ended, so it has nothing to age",
11870 );
11871 }
11872
11873 #[test]
11881 fn a_monitor_folded_from_a_transcript_is_seeded_settled() {
11882 let mut domain = empty_domain();
11883
11884 fold_monitor(
11885 &mut domain,
11886 &monitor_tool_use("tu-mon", "ci-watch"),
11887 MonitorOrigin::Transcript,
11888 );
11889
11890 assert_eq!(domain.monitors.len(), 1, "the transcript's monitor is seeded");
11891 assert_eq!(
11892 domain.monitors[0].status,
11893 forge_primitives::MonitorStatus::Completed,
11894 "settled rather than left falsely running forever",
11895 );
11896 assert_eq!(
11897 domain.monitors[0].task_id, None,
11898 "with no task id, which is why nothing could ever settle it",
11899 );
11900 }
11901
11902 #[test]
11906 fn a_subagents_monitor_call_is_not_seeded() {
11907 let mut domain = empty_domain();
11908 let mut framed = monitor_tool_use("tu-mon", "ci-watch");
11909 if let forge_primitives::Message::Assistant { parent_tool_use_id, .. } = &mut framed {
11910 *parent_tool_use_id = Some("toolu_dispatch".to_owned());
11911 }
11912 let mut event = connected_event("s-mon", "/tmp/mon");
11913 if let AgentEvent::Connected { history_updates, .. } = &mut event {
11914 *history_updates = Some(vec![framed]);
11915 }
11916
11917 apply_event_to_domain(&mut domain, &event);
11918
11919 assert!(domain.monitors.is_empty(), "the instance's monitor is not the session's");
11920 }
11921
11922 #[test]
11925 fn a_monitor_folded_from_the_wire_is_seeded_running() {
11926 let mut domain = empty_domain();
11927
11928 fold_monitor(&mut domain, &monitor_tool_use("tu-mon", "ci-watch"), MonitorOrigin::Wire);
11929
11930 assert_eq!(
11931 domain.monitors[0].status,
11932 forge_primitives::MonitorStatus::Running,
11933 "a call on the live wire is running until the command says otherwise",
11934 );
11935 }
11936
11937 #[test]
11941 fn the_monitor_set_drains_once_every_entry_is_terminal() {
11942 let mut domain = empty_domain();
11943 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-a", "ci-watch")));
11944 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-b", "deploy-gate")));
11945 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-a", Some("tu-a"))));
11946 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-b", Some("tu-b"))));
11947
11948 apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-a", "completed")));
11949 assert_eq!(
11950 domain.monitors.len(),
11951 2,
11952 "one settled monitor keeps the section, holding its tail"
11953 );
11954
11955 apply_event_to_domain(&mut domain, &sdk_message(task_notification("t-b")));
11956
11957 assert!(
11958 domain.monitors.is_empty(),
11959 "every entry terminal drains the set rather than leaving an empty section",
11960 );
11961 }
11962
11963 #[test]
11968 fn an_unnameable_task_status_still_drains_the_set() {
11969 let mut domain = empty_domain();
11970 apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-a", "ci-watch")));
11971 apply_event_to_domain(&mut domain, &sdk_message(task_started("t-a", Some("tu-a"))));
11972 apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-a", "killed")));
11975
11976 apply_event_to_domain(
11977 &mut domain,
11978 &sdk_message(task_notification_with_status("t-a", "reticulating")),
11979 );
11980
11981 assert!(
11982 domain.monitors.is_empty(),
11983 "a notification drains the set whatever its status says",
11984 );
11985 }
11986
11987 #[test]
11990 fn a_resumed_sessions_history_leaves_no_live_monitor() {
11991 let mut domain = empty_domain();
11992 let mut connected = connected_event("uuid-1", "/proj");
11993 if let AgentEvent::Connected { history_updates, .. } = &mut connected {
11994 *history_updates = Some(vec![
11995 monitor_tool_use("tu-mon", "ci-watch"),
11996 monitor_tool_use("tu-other", "deploy-gate"),
11997 ]);
11998 }
11999
12000 apply_event_to_domain(&mut domain, &connected);
12001
12002 assert!(
12003 domain.monitors.is_empty(),
12004 "a transcript cannot say a monitor is still running, so none of them are drawn",
12005 );
12006 }
12007
12008 fn assistant_with(error: &str) -> forge_primitives::Message {
12009 serde_json::from_value(serde_json::json!({
12010 "type": "assistant",
12011 "message": {
12012 "id": "msg-1",
12013 "role": "assistant",
12014 "model": "claude-sonnet-5",
12015 "content": [],
12016 },
12017 "session_id": "s",
12018 "error": error,
12019 }))
12020 .expect("parse an assistant message")
12021 }
12022
12023 fn api_retry(error: &str) -> forge_primitives::Message {
12024 serde_json::from_value(serde_json::json!({
12025 "type": "system",
12026 "subtype": "api_retry",
12027 "session_id": "s",
12028 "attempt": 1,
12029 "max_retries": 3,
12030 "retry_delay_ms": 100,
12031 "error": error,
12032 }))
12033 .expect("parse a retry frame")
12034 }
12035
12036 #[tokio::test]
12051 async fn a_connection_failure_is_recorded_and_a_connected_clears_it() {
12052 let (workspace, _update_rx) = crate::Workspace::testing_stub();
12053
12054 for (key, connected_once) in [
12055 (SessionSlot::from_str_for_test("record-failure-replaced"), true),
12056 (SessionSlot::from_str_for_test("record-failure-fresh"), false),
12057 ] {
12058 let (handle, _agent_cmds) = Agent::testing_stub();
12059 let arc = Arc::new(handle);
12060 let domain = workspace.register_domain_session(key.clone(), Some(Arc::clone(&arc)));
12061 let (_cmd_tx, command_rx) =
12062 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12063 let mut task = SessionTask {
12064 key: key.clone(),
12065 handle: arc,
12066 command_rx,
12067 domain,
12068 update_tx: workspace.update_sender(),
12069 connected_once,
12070 workspace: Arc::downgrade(&workspace),
12071 compaction_count: 0,
12072 successor: None,
12073 teardown_done: None,
12074 };
12075
12076 task.translate_event(AgentEvent::ConnectionFailed {
12077 message: "the bucket died".to_owned(),
12078 kind: SpawnFailureKind::Unclassified,
12079 });
12080 assert_eq!(
12081 workspace.spawn_failure(&key).as_deref(),
12082 Some("the bucket died"),
12083 "a failed connection is recorded where a view can read it ({connected_once})",
12084 );
12085
12086 task.translate_event(connected_event("fresh-uuid", "/proj"));
12087
12088 assert_eq!(
12089 workspace.spawn_failure(&key),
12090 None,
12091 "and a session that comes up clears it rather than reading failed for the \
12092 process ({connected_once})",
12093 );
12094 }
12095 }
12096
12097 #[tokio::test]
12106 async fn respawn_connection_failure_is_nonfatal_and_releases_the_session() {
12107 let (workspace, mut update_rx) = crate::Workspace::testing_stub();
12108 let key = SessionSlot::from_str_for_test("respawn-fail-uuid");
12109
12110 let (handle, _agent_cmds) = Agent::testing_stub();
12111 let arc = Arc::new(handle);
12112 workspace.pool.lock().insert(
12115 key.clone(),
12116 crate::workspace::PooledAgent {
12117 handle: Arc::clone(&arc),
12118 account: forge_gateway::AccountKey("test".to_owned()),
12119 permission_mode: None,
12120 registration: None,
12121 session_id: "pooled-session".to_owned(),
12122 },
12123 );
12124 let domain = workspace.register_domain_session(key.clone(), Some(Arc::clone(&arc)));
12125 let (_cmd_tx, command_rx) =
12126 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12127
12128 let mut task = SessionTask {
12129 key: key.clone(),
12130 handle: arc,
12131 command_rx,
12132 domain,
12133 update_tx: workspace.update_sender(),
12134 connected_once: true, workspace: Arc::downgrade(&workspace),
12136 compaction_count: 0,
12137 successor: None,
12138 teardown_done: None,
12139 };
12140
12141 task.translate_event(AgentEvent::ConnectionFailed {
12143 message: "spawn failed".to_owned(),
12144 kind: SpawnFailureKind::Unclassified,
12145 });
12146
12147 let mut saw_nonfatal = false;
12148 while let Ok(frame) = update_rx.try_recv() {
12149 if let SessionUpdate::ConnectionFailed { key: failed_key, fatal, .. } =
12150 std::sync::Arc::unwrap_or_clone(frame.update)
12151 {
12152 assert!(!fatal, "a failed re-spawn is recoverable, not fatal");
12153 assert_eq!(failed_key, key);
12154 saw_nonfatal = true;
12155 }
12156 }
12157 assert!(saw_nonfatal, "the failed re-spawn emits ConnectionFailed with fatal=false");
12158
12159 assert!(
12162 !workspace.pool.lock().contains_key(&key),
12163 "a failed re-spawn leaves no lingering pooled agent",
12164 );
12165 }
12166
12167 #[tokio::test]
12175 async fn a_named_spawn_failure_kind_reaches_the_worker_handler() {
12176 let (workspace, _update_rx) = crate::Workspace::testing_stub();
12177 workspace.enable_test_dispatch_intercept();
12178 let db_dir = tempfile::tempdir().expect("tempdir");
12179 workspace.install_db_for_test(
12180 crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
12181 );
12182 let project_dir = tempfile::tempdir().expect("project dir");
12183 workspace.seed_test_project("proj-x", &project_dir.path().to_string_lossy());
12184 let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
12185 workspace
12187 .record_worker_row(
12188 &project_key,
12189 "reviewer",
12190 "reviewer-uuid",
12191 "c",
12192 None,
12193 None,
12194 false,
12195 crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
12196 None,
12197 )
12198 .expect("seed the worker's row");
12199
12200 let worker_slot = SessionSlot::from_str_for_test("worker-uuid");
12201 workspace.insert_live_worker(
12202 &project_key,
12203 crate::mcp::workers::types::WorkerEntry {
12204 label: "reviewer".to_owned(),
12205 charter: "c".to_owned(),
12206 slot: worker_slot.clone(),
12207 session_id: Some(forge_primitives::SessionId::new("reviewer-uuid")),
12208 status: forge_primitives::WorkerLiveness::Spawning,
12209 spawned_at: std::time::SystemTime::UNIX_EPOCH,
12210 spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
12211 needs_tag: false,
12212 is_git_repo_at_spawn: true,
12213 cwd: None,
12214 diagnostic: None,
12215 kick: None,
12216 },
12217 );
12218
12219 let (handle, _agent_cmds) = Agent::testing_stub();
12220 let arc = Arc::new(handle);
12221 let domain = workspace.register_domain_session(worker_slot.clone(), Some(Arc::clone(&arc)));
12222 let (_cmd_tx, command_rx) =
12223 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12224 let mut task = SessionTask {
12225 key: worker_slot.clone(),
12226 handle: arc,
12227 command_rx,
12228 domain,
12229 update_tx: workspace.update_sender(),
12230 connected_once: false,
12231 workspace: Arc::downgrade(&workspace),
12232 compaction_count: 0,
12233 successor: None,
12234 teardown_done: None,
12235 };
12236
12237 task.translate_event(AgentEvent::ConnectionFailed {
12238 message: "forge-sdk session resume failed: claude subprocess working directory \
12239 `/x/.claude/worktrees/reviewer` does not exist"
12240 .to_owned(),
12241 kind: SpawnFailureKind::WorkingDirMissing,
12242 });
12243
12244 let rows = workspace.worker_rows_for_project(&project_key);
12245 assert!(
12246 rows.iter().any(|row| row.label == "reviewer"),
12247 "a named missing-working-directory failure keeps the worker's row rather than \
12248 deleting it as a worktree that could not be created; rows left {:?}",
12249 rows.iter().map(|row| row.label.clone()).collect::<Vec<_>>(),
12250 );
12251 }
12252
12253 fn stub_handle_with_rx()
12256 -> (Arc<AgentHandle>, tokio::sync::mpsc::UnboundedReceiver<forge_primitives::AgentCommand>)
12257 {
12258 let (handle, rx) = Agent::testing_stub();
12259 (Arc::new(handle), rx)
12260 }
12261
12262 #[test]
12265 fn execute_prompt_forwards_to_handle() {
12266 let (handle, mut rx) = stub_handle_with_rx();
12267 let key = SessionSlot::from_str_for_test("sess");
12268 execute_command_via_handle(
12269 &handle,
12270 &key,
12271 Some("sess-1"),
12272 None,
12273 Command::Prompt { key: key.clone(), text: "hi".into(), attachments: Vec::new() },
12274 )
12275 .expect("dispatch succeeds");
12276 let cmd = rx.try_recv().expect("command queued");
12277 assert!(matches!(
12278 cmd,
12279 forge_primitives::AgentCommand::PromptWithImages { session_id, .. }
12280 if session_id == "sess-1"
12281 ));
12282 }
12283
12284 #[tokio::test]
12289 async fn executing_a_prompt_records_and_announces_it() {
12290 let (workspace, mut updates) = crate::Workspace::testing_stub();
12291 workspace.seed_test_project("qp", "/tmp/qp");
12292 let key = SessionSlot::from_str_for_test("qp-lead");
12293 let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12294 domain.lock().session_id = Some(forge_primitives::SessionId::new("qp-session"));
12297 let (handle, mut agent_rx) = Agent::testing_stub();
12298 let (_cmd_tx, command_rx) =
12299 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12300 let mut task = SessionTask {
12301 key: key.clone(),
12302 handle: Arc::new(handle),
12303 command_rx,
12304 domain: Arc::clone(&domain),
12305 update_tx: workspace.update_sender(),
12306 connected_once: false,
12307 workspace: Arc::downgrade(&workspace),
12308 compaction_count: 0,
12309 successor: None,
12310 teardown_done: None,
12311 };
12312
12313 task.execute_command(Command::Prompt {
12314 key: key.clone(),
12315 text: "the queued words".to_owned(),
12316 attachments: Vec::new(),
12317 });
12318
12319 let rows = domain.lock().prompt_queue.clone();
12320 assert_eq!(rows.len(), 1, "the prompt is on the seat's queue");
12321 assert_eq!(rows[0].text, "the queued words");
12322 assert_eq!(rows[0].source, PromptSource::You);
12323
12324 let sent = agent_rx.try_recv().expect("the prompt reaches the agent");
12325 let forge_primitives::AgentCommand::PromptWithImages { uuid, .. } = sent else {
12326 panic!("expected a prompt, got {sent:?}");
12327 };
12328 assert_eq!(
12329 uuid, rows[0].uuid,
12330 "the recorded row and the frame share one id, which is what the lifecycle frames resolve against",
12331 );
12332
12333 let announced: Vec<crate::protocol::SessionUpdate> =
12334 std::iter::from_fn(|| updates.try_recv().ok())
12335 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12336 .collect();
12337 assert!(
12338 announced.iter().any(|update| matches!(
12339 update,
12340 crate::protocol::SessionUpdate::PromptQueued { uuid: announced, text, .. }
12341 if announced == &rows[0].uuid && text == "the queued words"
12342 )),
12343 "the row's words ride the announcement, because no lifecycle frame carries them: {announced:?}",
12344 );
12345 }
12346
12347 #[tokio::test]
12351 async fn a_turn_end_delivers_the_prompt_held_for_it() {
12352 let (workspace, _updates) = crate::Workspace::testing_stub();
12353 workspace.seed_test_project("hp", "/tmp/hp");
12354 let key = SessionSlot::from_str_for_test("hp-worker");
12355 let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12356 domain.lock().session_id = Some(forge_primitives::SessionId::new("hp-session"));
12357 domain.lock().turn_pending = true;
12358 let (handle, _agent_rx) = Agent::testing_stub();
12359 let (_cmd_tx, command_rx) =
12360 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12361 let mut task = SessionTask {
12362 key: key.clone(),
12363 handle: Arc::new(handle),
12364 command_rx,
12365 domain: Arc::clone(&domain),
12366 update_tx: workspace.update_sender(),
12367 connected_once: false,
12368 workspace: Arc::downgrade(&workspace),
12369 compaction_count: 0,
12370 successor: None,
12371 teardown_done: None,
12372 };
12373 let held = crate::mcp::peers::types::WrappedPrompt {
12374 id: crate::mcp::peers::types::MessageId::mint(),
12375 kind: crate::mcp::peers::types::WrappedKind::Message,
12376 sender_name: "forge".to_owned(),
12377 sender_org: "Busytools".to_owned(),
12378 body: "the held words".to_owned(),
12379 };
12380 workspace.hold_peer_prompt_unless_turn_ended(
12381 &key,
12382 &SessionSlot::lead("Busytools", "hp"),
12383 held,
12384 || true,
12385 );
12386 workspace.enable_test_dispatch_intercept();
12387
12388 task.translate_event(AgentEvent::SdkMessage {
12389 session_id: "hp-session".to_owned(),
12390 msg: result_message("success", false),
12391 });
12392
12393 let dispatched = workspace.drain_test_dispatch_buffer().into_iter().any(|cmd| {
12394 matches!(
12395 cmd,
12396 crate::protocol::Command::PromptUnder { text, source, .. }
12397 if source == PromptSource::Peer && text.contains("the held words")
12398 )
12399 });
12400 assert!(dispatched, "the held prompt is delivered as the turn ends");
12401 assert!(
12402 workspace.take_turn_held_for_slot(&key).is_empty(),
12403 "and the hold is spent, not redelivered next turn",
12404 );
12405 }
12406
12407 #[tokio::test]
12412 async fn a_transport_death_does_not_drain_the_hold() {
12413 let (workspace, _updates) = crate::Workspace::testing_stub();
12414 workspace.seed_test_project("hd", "/tmp/hd");
12415 let key = SessionSlot::from_str_for_test("hd-worker");
12416 let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12417 domain.lock().session_id = Some(forge_primitives::SessionId::new("hd-session"));
12418 domain.lock().turn_pending = true;
12419 let (handle, _agent_rx) = Agent::testing_stub();
12420 let (_cmd_tx, command_rx) =
12421 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12422 let mut task = SessionTask {
12423 key: key.clone(),
12424 handle: Arc::new(handle),
12425 command_rx,
12426 domain: Arc::clone(&domain),
12427 update_tx: workspace.update_sender(),
12428 connected_once: false,
12429 workspace: Arc::downgrade(&workspace),
12430 compaction_count: 0,
12431 successor: None,
12432 teardown_done: None,
12433 };
12434 let held = crate::mcp::peers::types::WrappedPrompt {
12435 id: crate::mcp::peers::types::MessageId::mint(),
12436 kind: crate::mcp::peers::types::WrappedKind::Message,
12437 sender_name: "forge".to_owned(),
12438 sender_org: "Busytools".to_owned(),
12439 body: "while the transport dies".to_owned(),
12440 };
12441 workspace.hold_peer_prompt_unless_turn_ended(
12442 &key,
12443 &SessionSlot::lead("Busytools", "hd"),
12444 held,
12445 || true,
12446 );
12447 workspace.enable_test_dispatch_intercept();
12448
12449 task.translate_event(AgentEvent::SdkMessage {
12450 session_id: "hd-session".to_owned(),
12451 msg: serde_json::from_value(serde_json::json!({
12452 "type": "error",
12453 "error": "stream closed",
12454 }))
12455 .expect("parse error message"),
12456 });
12457
12458 assert!(
12459 workspace.drain_test_dispatch_buffer().is_empty(),
12460 "nothing is dispatched into a session whose transport just died",
12461 );
12462 assert_eq!(
12463 workspace.take_turn_held_for_slot(&key).len(),
12464 1,
12465 "the hold stays for the death path's expiry to acknowledge",
12466 );
12467 }
12468
12469 #[tokio::test]
12474 async fn a_cli_without_lifecycle_frames_draws_its_prompts_and_drains_the_pile() {
12475 let (workspace, mut updates) = crate::Workspace::testing_stub();
12476 workspace.seed_test_project("nf", "/tmp/nf");
12477 let key = SessionSlot::from_str_for_test("nf-lead");
12478 let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12479 domain.lock().session_id = Some(forge_primitives::SessionId::new("nf-session"));
12480 let (handle, mut agent_rx) = Agent::testing_stub();
12481 let (_cmd_tx, command_rx) =
12482 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12483 let mut task = SessionTask {
12484 key: key.clone(),
12485 handle: Arc::new(handle),
12486 command_rx,
12487 domain: Arc::clone(&domain),
12488 update_tx: workspace.update_sender(),
12489 connected_once: false,
12490 workspace: Arc::downgrade(&workspace),
12491 compaction_count: 0,
12492 successor: None,
12493 teardown_done: None,
12494 };
12495
12496 task.execute_command(Command::Prompt {
12500 key: key.clone(),
12501 text: "the first prompt".to_owned(),
12502 attachments: Vec::new(),
12503 });
12504 assert_eq!(
12505 domain.lock().prompt_queue.len(),
12506 1,
12507 "premise: recorded while the capability was unknown",
12508 );
12509 let announced: Vec<crate::protocol::SessionUpdate> =
12510 std::iter::from_fn(|| updates.try_recv().ok())
12511 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12512 .collect();
12513 let first = announced
12514 .iter()
12515 .find_map(|update| match update {
12516 crate::protocol::SessionUpdate::PromptQueued { uuid, text, .. }
12517 if text == "the first prompt" =>
12518 {
12519 Some(uuid.clone())
12520 }
12521 _ => None,
12522 })
12523 .expect("the first prompt is announced while its capability is unknown");
12524
12525 let init: forge_primitives::Message = serde_json::from_value(serde_json::json!({
12528 "type": "system",
12529 "subtype": "init",
12530 "session_id": "nf-session",
12531 "capabilities": ["interrupt_receipt_v1"],
12532 }))
12533 .expect("parse an init frame");
12534 task.translate_event(forge_agent::AgentEvent::SdkMessage {
12535 session_id: "nf-session".to_owned(),
12536 msg: init,
12537 });
12538 assert!(
12539 domain.lock().prompt_queue.is_empty(),
12540 "the init frame drains the pile it can no longer settle",
12541 );
12542 assert!(
12543 agent_rx.try_recv().is_ok(),
12544 "the prompt itself still reaches the agent: the pile is a view, not the send",
12545 );
12546 let drained: Vec<crate::protocol::SessionUpdate> =
12547 std::iter::from_fn(|| updates.try_recv().ok())
12548 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12549 .collect();
12550 let row = drained.iter().find_map(|update| match update {
12551 crate::protocol::SessionUpdate::ChatAppended { msg, source, .. }
12552 if matches!(msg, forge_primitives::Message::User { .. }) =>
12553 {
12554 Some((msg, source))
12555 }
12556 _ => None,
12557 });
12558 let Some((msg, source)) = row else {
12559 panic!("the init frame draws the waiting prompt: {drained:?}");
12560 };
12561 assert_eq!(*source, Some(crate::protocol::PromptSource::You), "the reader's own send");
12562 let forge_primitives::Message::User { message, uuid, .. } = msg else {
12563 panic!("the flushed row is the user turn it draws: {msg:?}");
12564 };
12565 assert_eq!(uuid.as_deref(), Some(first.as_str()), "under the announcement's own id");
12566 assert!(
12567 message.content.iter().any(|block| matches!(
12568 block,
12569 forge_primitives::ContentBlock::Text { text, .. } if text == "the first prompt"
12570 )),
12571 "carrying the words the dispatch held: {message:?}",
12572 );
12573 assert!(
12574 drained.iter().any(|update| matches!(
12575 update,
12576 crate::protocol::SessionUpdate::PromptLifecycle { uuid, state, .. }
12577 if uuid == &first && state == "started"
12578 )),
12579 "and settles the card it was waiting in: {drained:?}",
12580 );
12581
12582 task.execute_command(Command::Prompt {
12585 key: key.clone(),
12586 text: "the second prompt".to_owned(),
12587 attachments: Vec::new(),
12588 });
12589 assert!(
12590 domain.lock().prompt_queue.is_empty(),
12591 "a prompt is recorded only where the CLI can settle it",
12592 );
12593 assert!(agent_rx.try_recv().is_ok(), "and it still reaches the agent");
12594 let later: Vec<crate::protocol::SessionUpdate> =
12595 std::iter::from_fn(|| updates.try_recv().ok())
12596 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12597 .collect();
12598 assert!(
12599 !later.iter().any(|update| matches!(
12600 update,
12601 crate::protocol::SessionUpdate::PromptQueued { .. }
12602 )),
12603 "nothing is announced for a row that is not kept: {later:?}",
12604 );
12605 let drawn = later.iter().find_map(|update| match update {
12606 crate::protocol::SessionUpdate::ChatAppended { msg, source, .. }
12607 if matches!(msg, forge_primitives::Message::User { .. }) =>
12608 {
12609 Some((msg, source))
12610 }
12611 _ => None,
12612 });
12613 let Some((msg, source)) = drawn else {
12614 panic!("a seat that cannot follow a queue draws its row at dispatch: {later:?}");
12615 };
12616 assert_eq!(*source, Some(crate::protocol::PromptSource::You), "the reader's own send");
12617 let forge_primitives::Message::User { message, .. } = msg else {
12618 panic!("the fallback row is the user turn it draws: {msg:?}");
12619 };
12620 assert!(
12621 message.content.iter().any(|block| matches!(
12622 block,
12623 forge_primitives::ContentBlock::Text { text, .. }
12624 if text == "the second prompt"
12625 )),
12626 "carrying the words the dispatch held: {message:?}",
12627 );
12628 }
12629
12630 #[tokio::test]
12634 async fn a_started_row_reaches_every_fanout_subscriber_for_each_send() {
12635 let dir = tempfile::tempdir().expect("tempdir");
12636 let (workspace, _rx) =
12637 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12638 let slot = SessionSlot::worker("TestOrg", "forge", "fanout-row");
12639 let (mut task, mut view_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12640 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("fanout-session"));
12641 let mut mirror_rx = task.update_tx.subscribe(SubscriberRole::Observing);
12642
12643 for (uuid, text) in [("fanout-1", "first send"), ("fanout-2", "second send")] {
12644 task.execute_command(Command::PromptUnder {
12645 key: slot.clone(),
12646 text: text.to_owned(),
12647 attachments: vec![forge_primitives::ImageAttachment {
12648 data: "iVBORw0KGgo=".to_owned(),
12649 mime_type: "image/png".to_owned(),
12650 }],
12651 uuid: uuid.to_owned(),
12652 source: crate::protocol::PromptSource::You,
12653 dispatch_id: None,
12654 });
12655 }
12656 for uuid in ["fanout-1", "fanout-2"] {
12657 task.translate_event(AgentEvent::SdkMessage {
12658 session_id: "fanout-session".to_owned(),
12659 msg: forge_primitives::Message::CommandLifecycle {
12660 command_uuid: uuid.to_owned(),
12661 state: "started".to_owned(),
12662 uuid: format!("lifecycle-{uuid}"),
12663 session_id: "fanout-session".to_owned(),
12664 extras: forge_primitives::messages::Extras::new(),
12665 },
12666 });
12667 }
12668
12669 for (name, rx) in [("a view", &mut view_rx), ("the transport's mirror", &mut mirror_rx)] {
12670 let mut drawn = Vec::new();
12671 while let Ok(frame) = rx.try_recv() {
12672 if let SessionUpdate::ChatAppended { msg, source, .. } = &*frame.update
12673 && let forge_primitives::Message::User { uuid: Some(uuid), message, .. } = msg
12674 && message
12675 .content
12676 .iter()
12677 .any(|block| matches!(block, forge_primitives::ContentBlock::Image { .. }))
12678 {
12679 drawn.push((uuid.clone(), *source));
12680 }
12681 }
12682 assert_eq!(
12683 drawn,
12684 vec![
12685 ("fanout-1".to_owned(), Some(crate::protocol::PromptSource::You)),
12686 ("fanout-2".to_owned(), Some(crate::protocol::PromptSource::You)),
12687 ],
12688 "{name} hears each send's row once, in order",
12689 );
12690 }
12691 }
12692
12693 #[tokio::test]
12698 async fn a_machinery_prompt_draws_no_started_row() {
12699 let dir = tempfile::tempdir().expect("tempdir");
12700 let (workspace, _rx) =
12701 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12702 let slot = SessionSlot::worker("TestOrg", "forge", "machinery-row");
12703 let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12704 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("machinery-session"));
12705
12706 task.execute_command(Command::PromptUnder {
12707 key: slot.clone(),
12708 text: "machinery words".to_owned(),
12709 attachments: Vec::new(),
12710 uuid: "forge-1".to_owned(),
12711 source: crate::protocol::PromptSource::Forge,
12712 dispatch_id: None,
12713 });
12714 task.translate_event(AgentEvent::SdkMessage {
12715 session_id: "machinery-session".to_owned(),
12716 msg: forge_primitives::Message::CommandLifecycle {
12717 command_uuid: "forge-1".to_owned(),
12718 state: "started".to_owned(),
12719 uuid: "lifecycle-forge-1".to_owned(),
12720 session_id: "machinery-session".to_owned(),
12721 extras: forge_primitives::messages::Extras::new(),
12722 },
12723 });
12724
12725 let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12726 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12727 .collect();
12728 assert!(
12729 !announced.iter().any(|update| matches!(
12730 update,
12731 SessionUpdate::ChatAppended {
12732 msg: forge_primitives::Message::User { uuid: Some(id), .. },
12733 ..
12734 } if id.as_str() == "forge-1"
12735 )),
12736 "a machinery prompt draws no row from the task: {announced:?}",
12737 );
12738 assert!(
12739 announced.iter().any(|update| matches!(
12740 update,
12741 SessionUpdate::PromptLifecycle { uuid, state, .. }
12742 if uuid == "forge-1" && state == "started"
12743 )),
12744 "the lifecycle still advances: {announced:?}",
12745 );
12746 }
12747
12748 #[tokio::test]
12752 async fn a_refused_or_discarded_prompt_draws_no_row() {
12753 let dir = tempfile::tempdir().expect("tempdir");
12754 let (workspace, _rx) =
12755 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12756 let slot = SessionSlot::worker("TestOrg", "forge", "dropped-row");
12757 let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12758 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("dropped-session"));
12759
12760 for (uuid, state) in [("refused-1", "refused"), ("discarded-1", "discarded")] {
12761 task.execute_command(Command::PromptUnder {
12762 key: slot.clone(),
12763 text: format!("{state} words"),
12764 attachments: Vec::new(),
12765 uuid: uuid.to_owned(),
12766 source: crate::protocol::PromptSource::You,
12767 dispatch_id: None,
12768 });
12769 assert_eq!(task.domain.lock().prompt_queue.len(), 1, "premise: the prompt waits");
12770 task.translate_event(AgentEvent::SdkMessage {
12771 session_id: "dropped-session".to_owned(),
12772 msg: forge_primitives::Message::CommandLifecycle {
12773 command_uuid: uuid.to_owned(),
12774 state: state.to_owned(),
12775 uuid: format!("lifecycle-{uuid}"),
12776 session_id: "dropped-session".to_owned(),
12777 extras: forge_primitives::messages::Extras::new(),
12778 },
12779 });
12780 assert!(
12781 task.domain.lock().prompt_queue.is_empty(),
12782 "the settled prompt leaves the pile",
12783 );
12784 let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12785 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12786 .collect();
12787 assert!(
12788 !announced.iter().any(|update| matches!(
12789 update,
12790 SessionUpdate::ChatAppended { msg: forge_primitives::Message::User { uuid: Some(id), .. }, .. } if id.as_str() == uuid
12791 )),
12792 "a {state} prompt draws no row: {announced:?}",
12793 );
12794 }
12795 }
12796
12797 #[tokio::test]
12801 async fn a_failed_dispatch_draws_the_row_and_drains_the_card() {
12802 let dir = tempfile::tempdir().expect("tempdir");
12803 let (workspace, _rx) =
12804 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12805 let slot = SessionSlot::worker("TestOrg", "forge", "failed-dispatch");
12806 let (mut task, mut update_rx, agent_rx) = nudge_task_for(&workspace, &slot);
12807 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("failed-session"));
12808 drop(agent_rx);
12810
12811 task.execute_command(Command::PromptUnder {
12812 key: slot.clone(),
12813 text: "doomed words".to_owned(),
12814 attachments: Vec::new(),
12815 uuid: "failed-1".to_owned(),
12816 source: crate::protocol::PromptSource::You,
12817 dispatch_id: None,
12818 });
12819
12820 assert!(
12821 task.domain.lock().prompt_queue.is_empty(),
12822 "the entry a failed dispatch leaves behind is dropped",
12823 );
12824 let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12825 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12826 .collect();
12827 let row_at = announced
12828 .iter()
12829 .position(|update| {
12830 matches!(
12831 update,
12832 SessionUpdate::ChatAppended {
12833 msg: forge_primitives::Message::User { uuid: Some(id), .. },
12834 source: Some(crate::protocol::PromptSource::You),
12835 ..
12836 } if id == "failed-1"
12837 )
12838 })
12839 .expect("the failed prompt's words draw");
12840 let error_at = announced
12841 .iter()
12842 .position(|update| matches!(update, SessionUpdate::TurnError { .. }))
12843 .expect("the failure draws");
12844 assert!(row_at < error_at, "the words draw above the failure: {announced:?}");
12845 assert!(
12846 announced.iter().any(|update| matches!(
12847 update,
12848 SessionUpdate::PromptLifecycle { uuid, state, .. }
12849 if uuid == "failed-1" && state == "started"
12850 )),
12851 "and the card settles: {announced:?}",
12852 );
12853 }
12854
12855 #[tokio::test]
12862 async fn a_lost_queue_settles_each_prompt_into_the_pile_ending() {
12863 for replaced in [false, true] {
12864 let dir = tempfile::tempdir().expect("tempdir");
12865 let (workspace, _rx) =
12866 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12867 let label = if replaced { "lost-replaced" } else { "lost-failed" };
12868 let slot = SessionSlot::worker("TestOrg", "forge", label);
12869 let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12870 task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-occupant"));
12871 task.connected_once = replaced;
12872 for (uuid, text) in [("lost-1", "first lost"), ("lost-2", "second lost")] {
12873 task.execute_command(Command::PromptUnder {
12874 key: slot.clone(),
12875 text: text.to_owned(),
12876 attachments: Vec::new(),
12877 uuid: uuid.to_owned(),
12878 source: crate::protocol::PromptSource::You,
12879 dispatch_id: None,
12880 });
12881 }
12882 while update_rx.try_recv().is_ok() {}
12883
12884 let event = if replaced {
12885 connected_event("new-occupant", "/proj")
12886 } else {
12887 AgentEvent::ConnectionFailed {
12888 message: "the child exited".to_owned(),
12889 kind: forge_agent::client::SpawnFailureKind::Unclassified,
12890 }
12891 };
12892 task.translate_event(event);
12893
12894 let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12895 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12896 .collect();
12897 let anchor = if replaced {
12898 announced
12899 .iter()
12900 .position(|update| matches!(update, SessionUpdate::SessionReplaced { .. }))
12901 } else {
12902 announced
12903 .iter()
12904 .position(|update| matches!(update, SessionUpdate::ConnectionFailed { .. }))
12905 }
12906 .unwrap_or_else(|| panic!("{label}: the occupant's own frame is missing"));
12907 for (uuid, text) in [("lost-1", "first lost"), ("lost-2", "second lost")] {
12908 let echoed = announced
12909 .iter()
12910 .position(|update| {
12911 matches!(
12912 update,
12913 SessionUpdate::PromptQueued {
12914 uuid: id,
12915 source: crate::protocol::PromptSource::You,
12916 text: said,
12917 ..
12918 } if id == uuid && said == text
12919 )
12920 })
12921 .unwrap_or_else(|| {
12922 panic!("{label}: {uuid}'s words reach the ending line: {announced:?}")
12923 });
12924 let ended = announced
12925 .iter()
12926 .position(|update| {
12927 matches!(
12928 update,
12929 SessionUpdate::PromptLifecycle { uuid: id, state, .. }
12930 if id == uuid && state == "discarded"
12931 )
12932 })
12933 .unwrap_or_else(|| panic!("{label}: {uuid}'s card settles into the ending"));
12934 assert!(
12935 !announced.iter().any(|update| matches!(
12936 update,
12937 SessionUpdate::ChatAppended {
12938 msg: forge_primitives::Message::User { uuid: Some(id), .. },
12939 ..
12940 } if id == uuid
12941 )),
12942 "{label}: {uuid} never draws a forged row",
12943 );
12944 let ordered = if replaced {
12948 echoed > anchor && ended > anchor
12949 } else {
12950 echoed < anchor && ended < anchor
12951 };
12952 assert!(
12953 ordered,
12954 "{label}: the words and their settle sit with the occupant's frame"
12955 );
12956 }
12957 }
12958 }
12959
12960 #[tokio::test]
12964 async fn a_swap_that_fails_settles_the_held_prompt() {
12965 let dir = tempfile::tempdir().expect("tempdir");
12966 let (workspace, _rx) =
12967 crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12968 let slot = SessionSlot::worker("TestOrg", "forge", "held-settles");
12969 let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12970 workspace.begin_swap(&slot);
12971 task.execute_command(Command::PromptUnder {
12972 key: slot.clone(),
12973 text: "held words".to_owned(),
12974 attachments: Vec::new(),
12975 uuid: "held-1".to_owned(),
12976 source: crate::protocol::PromptSource::You,
12977 dispatch_id: None,
12978 });
12979
12980 task.translate_event(AgentEvent::ConnectionFailed {
12981 message: "the child never came up".to_owned(),
12982 kind: forge_agent::client::SpawnFailureKind::Unclassified,
12983 });
12984
12985 let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12986 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12987 .collect();
12988 assert!(
12989 announced.iter().any(|update| matches!(
12990 update,
12991 SessionUpdate::PromptLifecycle { uuid, state, .. }
12992 if uuid == "held-1" && state == "cancelled"
12993 )),
12994 "the held press is settled: {announced:?}",
12995 );
12996 }
12997
12998 #[tokio::test]
13007 async fn the_capability_latch_re_reads_on_a_new_occupant() {
13008 let (workspace, mut updates) = crate::Workspace::testing_stub();
13009 workspace.seed_test_project("latch", "/tmp/latch");
13010 let key = SessionSlot::from_str_for_test("latch-lead");
13011 let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
13012 domain.lock().session_id = Some(forge_primitives::SessionId::new("latch-session"));
13013 let (handle, _agent_rx) = Agent::testing_stub();
13014 let (_cmd_tx, command_rx) =
13015 tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
13016 let mut task = SessionTask {
13017 key: key.clone(),
13018 handle: Arc::new(handle),
13019 command_rx,
13020 domain: Arc::clone(&domain),
13021 update_tx: workspace.update_sender(),
13022 connected_once: false,
13023 workspace: Arc::downgrade(&workspace),
13024 compaction_count: 0,
13025 successor: None,
13026 teardown_done: None,
13027 };
13028
13029 let init = |capabilities: serde_json::Value| -> forge_primitives::Message {
13030 serde_json::from_value(serde_json::json!({
13031 "type": "system",
13032 "subtype": "init",
13033 "session_id": "latch-session",
13034 "capabilities": capabilities,
13035 }))
13036 .expect("parse an init frame")
13037 };
13038
13039 task.translate_event(forge_agent::AgentEvent::SdkMessage {
13041 session_id: "latch-session".to_owned(),
13042 msg: init(serde_json::json!(["interrupt_receipt_v1"])),
13043 });
13044 assert_eq!(domain.lock().lifecycle_frames, Some(false), "the first init is read");
13045
13046 domain.lock().drop_background_tasks();
13048 assert_eq!(domain.lock().lifecycle_frames, None, "nothing has been advertised yet");
13049 assert!(domain.lock().prompt_queue.is_empty(), "nor is anything waiting");
13050
13051 task.translate_event(forge_agent::AgentEvent::SdkMessage {
13054 session_id: "latch-session".to_owned(),
13055 msg: init(serde_json::json!(["msg_lifecycle_v1"])),
13056 });
13057 assert_eq!(
13058 domain.lock().lifecycle_frames,
13059 Some(true),
13060 "the re-read is what gives the new occupant its pile",
13061 );
13062
13063 task.execute_command(Command::Prompt {
13064 key: key.clone(),
13065 text: "recorded now".to_owned(),
13066 attachments: Vec::new(),
13067 });
13068 assert_eq!(
13069 domain.lock().prompt_queue.len(),
13070 1,
13071 "and a row is kept for a CLI that can settle it",
13072 );
13073 let announced: Vec<crate::protocol::SessionUpdate> =
13074 std::iter::from_fn(|| updates.try_recv().ok())
13075 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
13076 .collect();
13077 assert!(
13078 announced.iter().any(|update| matches!(
13079 update,
13080 crate::protocol::SessionUpdate::PromptQueued { text, .. } if text == "recorded now"
13081 )),
13082 "and announced: {announced:?}",
13083 );
13084 }
13085
13086 #[test]
13088 fn execute_cancel_forwards_to_handle() {
13089 let (handle, mut rx) = stub_handle_with_rx();
13090 let key = SessionSlot::from_str_for_test("sess");
13091 execute_command_via_handle(
13092 &handle,
13093 &key,
13094 Some("sess-1"),
13095 None,
13096 Command::Cancel { key: key.clone() },
13097 )
13098 .expect("dispatch succeeds");
13099 let cmd = rx.try_recv().expect("command queued");
13100 assert!(matches!(
13101 cmd,
13102 forge_primitives::AgentCommand::Cancel { session_id } if session_id == "sess-1"
13103 ));
13104 }
13105
13106 #[test]
13109 fn execute_set_mode_uses_wire_form() {
13110 use forge_primitives::permission::PermissionMode;
13111 let (handle, mut rx) = stub_handle_with_rx();
13112 let key = SessionSlot::from_str_for_test("sess");
13113 execute_command_via_handle(
13114 &handle,
13115 &key,
13116 Some("sess-1"),
13117 None,
13118 Command::SetMode { key: key.clone(), mode: PermissionMode::Plan },
13119 )
13120 .expect("dispatch succeeds");
13121 let cmd = rx.try_recv().expect("command queued");
13122 match cmd {
13123 forge_primitives::AgentCommand::SetMode { session_id, mode } => {
13124 assert_eq!(session_id.as_str(), "sess-1");
13125 assert_eq!(mode, PermissionMode::Plan);
13126 }
13127 other => panic!("expected SetMode, got {other:?}"),
13128 }
13129 }
13130
13131 #[test]
13134 fn execute_command_without_session_id_is_dropped() {
13135 let (handle, mut rx) = stub_handle_with_rx();
13136 let key = SessionSlot::from_str_for_test("sess");
13137 let err = execute_command_via_handle(
13138 &handle,
13139 &key,
13140 None,
13141 None,
13142 Command::Cancel { key: key.clone() },
13143 )
13144 .expect_err("a no-session dispatch reports the drop, not Ok");
13145 assert!(err.to_string().contains("no active session"), "the error names the drop: {err}");
13146 assert!(rx.try_recv().is_err());
13148 }
13149
13150 fn background_tasks(tasks: Vec<serde_json::Value>) -> forge_primitives::Message {
13151 forge_primitives::Message::BackgroundTasksChanged {
13152 tasks,
13153 uuid: "u1".to_owned(),
13154 session_id: "worker".to_owned(),
13155 extras: serde_json::Map::new(),
13156 }
13157 }
13158
13159 fn one_live_task() -> Vec<serde_json::Value> {
13160 vec![serde_json::json!({
13161 "task_id": "t1",
13162 "task_type": "local_bash",
13163 "description": "gh run watch",
13164 })]
13165 }
13166
13167 #[test]
13175 fn the_registry_carries_each_tasks_own_command() {
13176 let (workspace, _rx) = crate::Workspace::testing_stub();
13177 let key = SessionSlot::from_str_for_test("w-bg");
13178 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13179 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13180 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13181 };
13182
13183 send(&mut task, a_backgrounded_bash("toolu_1", "gh run watch 123 --exit-status"));
13184 send(&mut task, background_tasks(one_live_task()));
13185 send(&mut task, a_task_started("t1", "toolu_1"));
13186
13187 let held = task.domain.lock();
13188 let tasks = held.background_tasks.clone();
13189 assert_eq!(tasks.len(), 1, "the CLI's own entry is held: {tasks:?}");
13190 assert_eq!(tasks[0].task_id, "t1", "under the id the CLI gave it");
13191 assert_eq!(tasks[0].task_type, "local_bash", "with the kind a row routes on");
13192 assert_eq!(tasks[0].description, "gh run watch", "and the line the row leads with");
13193 assert_eq!(
13194 tasks[0].command.as_deref(),
13195 Some("gh run watch 123 --exit-status"),
13196 "and the command its own card carried, which the scan adopts on",
13197 );
13198 drop(held);
13199
13200 send(&mut task, background_tasks(Vec::new()));
13201 assert!(
13202 task.domain.lock().background_tasks.is_empty(),
13203 "the whole set arrives each change, so an empty snapshot clears",
13204 );
13205 }
13206
13207 #[test]
13211 fn the_registry_carries_the_tool_call_its_link_names() {
13212 let (workspace, _rx) = crate::Workspace::testing_stub();
13213 let key = SessionSlot::from_str_for_test("w-bg");
13214 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13215 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13216 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13217 };
13218
13219 send(&mut task, background_tasks(one_live_task()));
13221 assert_eq!(
13222 task.domain.lock().background_tasks[0].tool_use_id,
13223 None,
13224 "before the link lands the row carries no call rather than a guess",
13225 );
13226 send(&mut task, a_task_started("t1", "toolu_1"));
13227 assert_eq!(
13228 task.domain.lock().background_tasks[0].tool_use_id.as_deref(),
13229 Some("toolu_1"),
13230 "the link fills the call once the registry is held",
13231 );
13232
13233 let key = SessionSlot::from_str_for_test("w-bg2");
13235 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13236 send(&mut task, a_task_started("t1", "toolu_1"));
13237 send(&mut task, background_tasks(one_live_task()));
13238 assert_eq!(
13239 task.domain.lock().background_tasks[0].tool_use_id.as_deref(),
13240 Some("toolu_1"),
13241 "and the snapshot fills from a link that arrived first",
13242 );
13243 }
13244
13245 #[test]
13251 fn a_task_backgrounded_after_its_card_still_gets_its_command() {
13252 let (workspace, _rx) = crate::Workspace::testing_stub();
13253 let key = SessionSlot::from_str_for_test("w-bg");
13254 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13255 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13256 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13257 };
13258
13259 send(&mut task, a_bash_card("toolu_fg", "sleep 30 && echo later", None));
13261 send(&mut task, background_tasks(one_live_task()));
13262 send(&mut task, a_task_started("t1", "toolu_fg"));
13263
13264 let held = task.domain.lock().background_tasks.clone();
13265 assert_eq!(
13266 held[0].command.as_deref(),
13267 Some("sleep 30 && echo later"),
13268 "the command the task's own card carried crosses even though the card did not ask \
13269 for the background: the registry and the link are what scope it",
13270 );
13271 }
13272
13273 #[test]
13278 fn a_rostered_task_reads_only_the_command_its_own_link_names() {
13279 let (workspace, _rx) = crate::Workspace::testing_stub();
13280 let key = SessionSlot::from_str_for_test("w-bg");
13281 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13282 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13283 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13284 };
13285
13286 send(&mut task, a_bash_card("toolu_other", "echo unrelated", None));
13287 send(&mut task, a_bash_card("toolu_ours", "gh run watch 9", None));
13288 send(&mut task, background_tasks(one_live_task()));
13289 send(&mut task, a_task_started("t1", "toolu_ours"));
13290
13291 let held = task.domain.lock().background_tasks.clone();
13292 assert_eq!(held.len(), 1, "one rostered task");
13293 assert_eq!(
13294 held[0].command.as_deref(),
13295 Some("gh run watch 9"),
13296 "the command the task's own link names, not the other card's",
13297 );
13298 }
13299
13300 #[test]
13304 fn a_task_with_no_card_still_carries_its_registry_row() {
13305 let (workspace, _rx) = crate::Workspace::testing_stub();
13306 let key = SessionSlot::from_str_for_test("w-bg");
13307 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13308
13309 task.translate_event(AgentEvent::SdkMessage {
13310 session_id: "worker".to_owned(),
13311 msg: background_tasks(one_live_task()),
13312 });
13313
13314 let held = task.domain.lock();
13315 let tasks = held.background_tasks.clone();
13316 assert_eq!(tasks.len(), 1, "the roster entry is still drawn: {tasks:?}");
13317 assert_eq!(tasks[0].command, None, "and its command is absent rather than invented");
13318 }
13319
13320 #[test]
13326 fn a_foreground_call_is_a_row_while_it_runs() {
13327 let (workspace, _rx) = crate::Workspace::testing_stub();
13328 let key = SessionSlot::from_str_for_test("w-fg");
13329 let (mut task, mut updates) = review_task_for(&workspace, &key);
13330 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13331 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13332 };
13333
13334 send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13335 send(&mut task, a_task_started("t-fg", "toolu_fg"));
13336
13337 let held = task.domain.lock().task_rows();
13338 assert_eq!(held.len(), 1, "the announced foreground call is a row: {held:?}");
13339 assert_eq!(held[0].task_id, "t-fg", "under the id the CLI gave it");
13340 assert!(!held[0].is_backgrounded, "and the CLI ran it in the foreground");
13341 assert_eq!(held[0].task_type, "local_bash", "with the kind a view routes on");
13342 assert_eq!(held[0].description, "gh run watch", "and the line the row leads with");
13343 assert_eq!(
13344 held[0].command.as_deref(),
13345 Some("sleep 110"),
13346 "and the command its own card carried, which the walk matches the process on",
13347 );
13348 assert_eq!(held[0].tool_use_id.as_deref(), Some("toolu_fg"), "its own reveal link");
13349
13350 let announced = announced_tasks(&mut updates);
13351 assert_eq!(announced.len(), 1, "the update that pushes it to a view went out");
13352 assert_eq!(
13353 announced[0].iter().map(|task| task.task_id.as_str()).collect::<Vec<_>>(),
13354 held.iter().map(|task| task.task_id.as_str()).collect::<Vec<_>>(),
13355 "and it carries exactly the set the record reads, not the registry alone",
13356 );
13357 }
13358
13359 #[test]
13362 fn a_foreground_row_is_held_across_a_snapshot_and_ends_with_its_notification() {
13363 let (workspace, _rx) = crate::Workspace::testing_stub();
13364 let key = SessionSlot::from_str_for_test("w-fg");
13365 let (mut task, mut updates) = review_task_for(&workspace, &key);
13366 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13367 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13368 };
13369
13370 send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13371 send(&mut task, a_task_started("t-fg", "toolu_fg"));
13372 send(&mut task, background_tasks(Vec::new()));
13373
13374 assert_eq!(
13375 task.domain.lock().task_rows().len(),
13376 1,
13377 "the registry snapshot carries the background set, so it neither adds nor wipes a \
13378 foreground task",
13379 );
13380
13381 send(&mut task, a_task_notification("t-fg", "completed", None));
13382 assert!(
13383 task.domain.lock().task_rows().is_empty(),
13384 "the notification is that call's ending, so its row leaves",
13385 );
13386 let announced = announced_tasks(&mut updates);
13387 assert_eq!(
13388 announced.last().map(Vec::len),
13389 Some(0),
13390 "and the withdrawal is pushed: {announced:?}",
13391 );
13392 }
13393
13394 #[test]
13397 fn a_background_task_announced_twice_is_one_row() {
13398 let (workspace, _rx) = crate::Workspace::testing_stub();
13399 let key = SessionSlot::from_str_for_test("w-bg");
13400 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13401 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13402 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13403 };
13404
13405 send(&mut task, a_backgrounded_bash("toolu_1", "gh run watch 123"));
13406 send(&mut task, background_tasks(one_live_task()));
13407 send(&mut task, a_task_started("t1", "toolu_1"));
13408
13409 let held = task.domain.lock().task_rows();
13410 assert_eq!(held.len(), 1, "one task, one row: {held:?}");
13411 assert!(
13412 held[0].is_backgrounded,
13413 "the registry's own entry is the one read, and it is backgrounded",
13414 );
13415 assert_eq!(
13416 held[0].command.as_deref(),
13417 Some("gh run watch 123"),
13418 "read from the registry's entry, which filled from the link",
13419 );
13420 }
13421
13422 #[test]
13426 fn a_background_and_a_foreground_call_live_at_once_are_two_rows() {
13427 let (workspace, _rx) = crate::Workspace::testing_stub();
13428 let key = SessionSlot::from_str_for_test("w-both");
13429 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13430 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13431 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13432 };
13433
13434 send(&mut task, a_backgrounded_bash("toolu_bg", "gh run watch 123"));
13435 send(&mut task, background_tasks(one_live_task()));
13436 send(&mut task, a_task_started("t1", "toolu_bg"));
13437 send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13438 send(&mut task, a_task_started("t-fg", "toolu_fg"));
13439
13440 let held = task.domain.lock().task_rows();
13441 assert_eq!(held.len(), 2, "two live calls, two rows: {held:?}");
13442 let background = held.iter().find(|row| row.task_id == "t1").expect("the registry row");
13443 assert!(background.is_backgrounded, "the registry row says backgrounded");
13444 let foreground = held.iter().find(|row| row.task_id == "t-fg").expect("the held row");
13445 assert!(!foreground.is_backgrounded, "the announced call says foreground");
13446 assert_eq!(foreground.command.as_deref(), Some("sleep 110"), "its own command");
13447 }
13448
13449 #[test]
13452 fn a_call_the_registry_names_under_another_task_id_is_one_row() {
13453 let (workspace, _rx) = crate::Workspace::testing_stub();
13454 let key = SessionSlot::from_str_for_test("w-call");
13455 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13456 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13457 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13458 };
13459
13460 send(&mut task, a_bash_card("toolu_shared", "sleep 60", None));
13461 send(&mut task, a_task_started("t-hold", "toolu_shared"));
13462 send(
13464 &mut task,
13465 background_tasks(vec![serde_json::json!({
13466 "task_id": "t-reg",
13467 "task_type": "local_bash",
13468 "description": "sleep 60",
13469 })]),
13470 );
13471 send(&mut task, a_task_started("t-reg", "toolu_shared"));
13472
13473 let held = task.domain.lock().task_rows();
13474 assert_eq!(held.len(), 1, "one call, one row: {held:?}");
13475 assert_eq!(held[0].task_id, "t-reg", "the CLI's own row is the one read");
13476 assert_eq!(held[0].tool_use_id.as_deref(), Some("toolu_shared"));
13477 }
13478
13479 #[test]
13482 fn a_cancelled_turn_settles_its_foreground_row() {
13483 let (workspace, _rx) = crate::Workspace::testing_stub();
13484 let key = SessionSlot::from_str_for_test("w-cancel");
13485 let (mut task, mut updates) = review_task_for(&workspace, &key);
13486 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13487 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13488 };
13489
13490 send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13491 send(&mut task, a_task_started("t-fg", "toolu_fg"));
13492 task.domain.lock().pending_cancel = true;
13493 send(&mut task, result_message("error_during_execution", true));
13494
13495 assert!(
13496 task.domain.lock().task_rows().is_empty(),
13497 "the cancelled turn takes its own foreground hold with it",
13498 );
13499 let announced = announced_tasks(&mut updates);
13500 assert_eq!(
13501 announced.last().map(Vec::len),
13502 Some(0),
13503 "and the withdrawal is pushed: {announced:?}",
13504 );
13505 }
13506
13507 #[test]
13511 fn an_agents_task_is_not_a_process_row() {
13512 let (workspace, _rx) = crate::Workspace::testing_stub();
13513 let key = SessionSlot::from_str_for_test("w-fg");
13514 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13515 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13516 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13517 };
13518
13519 send(
13520 &mut task,
13521 serde_json::from_value(serde_json::json!({
13522 "type": "system",
13523 "subtype": "task_started",
13524 "task_id": "t-agent",
13525 "tool_use_id": "toolu_agent",
13526 "description": "a dispatched agent",
13527 "task_type": "local_agent",
13528 "uuid": "u4",
13529 "session_id": "worker",
13530 }))
13531 .expect("parse a local_agent task_started"),
13532 );
13533 send(
13534 &mut task,
13535 serde_json::from_value(serde_json::json!({
13536 "type": "system",
13537 "subtype": "task_started",
13538 "task_id": "t-sub",
13539 "tool_use_id": "toolu_sub",
13540 "description": "a sub-agent's own bash",
13541 "task_type": "local_bash",
13542 "parent_tool_use_id": "toolu_task",
13543 "uuid": "u5",
13544 "session_id": "worker",
13545 }))
13546 .expect("parse a dispatched task_started"),
13547 );
13548
13549 assert!(
13550 task.domain.lock().task_rows().is_empty(),
13551 "neither an agent's task nor a dispatched call's belongs in the processes feed",
13552 );
13553 }
13554
13555 #[test]
13557 fn a_terminal_task_update_settles_a_hold_and_a_progress_one_does_not() {
13558 let (workspace, _rx) = crate::Workspace::testing_stub();
13559 let key = SessionSlot::from_str_for_test("w-update");
13560 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13561 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13562 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13563 };
13564
13565 send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13566 send(&mut task, a_task_started("t-fg", "toolu_fg"));
13567
13568 send(&mut task, a_task_updated("t-fg", "running"));
13569 assert_eq!(
13570 task.domain.lock().task_rows().len(),
13571 1,
13572 "a progress patch states no ending, so the hold stands",
13573 );
13574
13575 send(&mut task, a_task_updated("t-fg", "completed"));
13576 assert!(task.domain.lock().task_rows().is_empty(), "the terminal patch ends the hold");
13577 }
13578
13579 #[test]
13582 fn a_background_hold_survives_the_turn_end() {
13583 let (workspace, _rx) = crate::Workspace::testing_stub();
13584 let key = SessionSlot::from_str_for_test("w-bg-turn");
13585 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13586 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13587 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13588 };
13589
13590 send(&mut task, a_task_started_with("t-bg", Some("toolu_bg"), true));
13591 task.domain.lock().pending_cancel = true;
13592 send(&mut task, result_message("error_during_execution", true));
13593
13594 let held = task.domain.lock().task_rows();
13595 assert_eq!(held.len(), 1, "the backgrounded hold outlives the turn: {held:?}");
13596 assert!(held[0].is_backgrounded, "and it still says so");
13597 }
13598
13599 #[test]
13602 fn a_new_occupant_clears_the_announced_holds() {
13603 let (workspace, _rx) = crate::Workspace::testing_stub();
13604 let key = SessionSlot::from_str_for_test("w-occupant");
13605 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13606 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13607 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13608 };
13609
13610 send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13611 send(&mut task, a_task_started("t-fg", "toolu_fg"));
13612 assert_eq!(task.domain.lock().task_rows().len(), 1, "the hold is up");
13613
13614 task.translate_event(a_connected("worker-2"));
13615 assert!(
13616 task.domain.lock().task_rows().is_empty(),
13617 "a new occupant starts with no holds of the last one's",
13618 );
13619 }
13620
13621 #[test]
13624 fn two_announcements_of_one_call_are_one_row() {
13625 let (workspace, _rx) = crate::Workspace::testing_stub();
13626 let key = SessionSlot::from_str_for_test("w-call-only");
13627 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13628 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13629 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13630 };
13631
13632 send(&mut task, a_bash_card("toolu_shared", "sleep 60", None));
13633 send(&mut task, a_task_started("t-one", "toolu_shared"));
13634 send(&mut task, a_task_started("t-two", "toolu_shared"));
13635
13636 let held = task.domain.lock().task_rows();
13637 assert_eq!(held.len(), 1, "one call, one row: {held:?}");
13638 assert_eq!(held[0].task_id, "t-one", "the first announcement holds the call");
13639 }
13640
13641 #[test]
13643 fn a_task_with_no_call_still_draws() {
13644 let (workspace, _rx) = crate::Workspace::testing_stub();
13645 let key = SessionSlot::from_str_for_test("w-bare");
13646 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13647 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13648 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13649 };
13650
13651 send(&mut task, a_task_started_with("t-bare", None, false));
13652
13653 let held = task.domain.lock().task_rows();
13654 assert_eq!(held.len(), 1, "a call-less task is still a row: {held:?}");
13655 assert_eq!(held[0].tool_use_id, None, "with no reveal link rather than a guess");
13656 }
13657
13658 #[test]
13661 fn an_ending_frame_settles_the_hold_by_its_call_too() {
13662 let (workspace, _rx) = crate::Workspace::testing_stub();
13663 let key = SessionSlot::from_str_for_test("w-end-call");
13664 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13665 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13666 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13667 };
13668
13669 send(&mut task, a_bash_card("toolu_shared", "sleep 60", None));
13670 send(&mut task, a_task_started("t-one", "toolu_shared"));
13671 send(&mut task, a_task_notification("t-other", "completed", Some("toolu_shared")));
13672
13673 assert!(
13674 task.domain.lock().task_rows().is_empty(),
13675 "the call's own ending settles it, whichever task id it names",
13676 );
13677 }
13678
13679 #[test]
13685 fn a_background_hold_leaves_when_the_registry_stops_naming_its_task() {
13686 let (workspace, _rx) = crate::Workspace::testing_stub();
13687 let key = SessionSlot::from_str_for_test("w-stop");
13688 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13689 let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13690 task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13691 };
13692
13693 send(&mut task, a_backgrounded_bash("toolu_stop", "gh run watch 9"));
13694 send(
13695 &mut task,
13696 background_tasks(vec![serde_json::json!({
13697 "task_id": "t-stop",
13698 "task_type": "local_bash",
13699 "description": "a stopped watch",
13700 })]),
13701 );
13702 send(&mut task, a_task_started_with("t-stop", Some("toolu_stop"), true));
13703 assert_eq!(task.domain.lock().task_rows().len(), 1, "named while it runs");
13704
13705 send(&mut task, background_tasks(Vec::new()));
13708 assert!(
13709 task.domain.lock().task_rows().is_empty(),
13710 "a backgrounded hold is the registry's to keep, and the set that leaves it out takes it",
13711 );
13712 }
13713
13714 fn a_backgrounded_bash(id: &str, command: &str) -> forge_primitives::Message {
13715 a_bash_card(id, command, Some(true))
13716 }
13717
13718 fn a_task_notification(
13719 task_id: &str,
13720 status: &str,
13721 tool_use_id: Option<&str>,
13722 ) -> forge_primitives::Message {
13723 serde_json::from_value(serde_json::json!({
13724 "type": "system",
13725 "subtype": "task_notification",
13726 "task_id": task_id,
13727 "tool_use_id": tool_use_id,
13728 "status": status,
13729 "output_file": "",
13730 "summary": "done",
13731 "uuid": "u3",
13732 "session_id": "worker",
13733 }))
13734 .expect("parse a task_notification message")
13735 }
13736
13737 fn a_bash_card(
13740 id: &str,
13741 command: &str,
13742 run_in_background: Option<bool>,
13743 ) -> forge_primitives::Message {
13744 let mut input = serde_json::json!({ "command": command });
13745 if let Some(flag) = run_in_background {
13746 input["run_in_background"] = serde_json::Value::Bool(flag);
13747 }
13748 serde_json::from_value(serde_json::json!({
13749 "type": "assistant",
13750 "uuid": "a1",
13751 "session_id": "worker",
13752 "message": {
13753 "id": "m1",
13754 "role": "assistant",
13755 "model": "claude-opus-5",
13756 "content": [{
13757 "type": "tool_use",
13758 "id": id,
13759 "name": "Bash",
13760 "input": input,
13761 }],
13762 },
13763 }))
13764 .expect("parse an assistant message")
13765 }
13766
13767 fn a_task_started(task_id: &str, tool_use_id: &str) -> forge_primitives::Message {
13768 a_task_started_with(task_id, Some(tool_use_id), false)
13769 }
13770
13771 fn a_task_started_with(
13774 task_id: &str,
13775 tool_use_id: Option<&str>,
13776 is_backgrounded: bool,
13777 ) -> forge_primitives::Message {
13778 serde_json::from_value(serde_json::json!({
13779 "type": "system",
13780 "subtype": "task_started",
13781 "task_id": task_id,
13782 "tool_use_id": tool_use_id,
13783 "description": "gh run watch",
13784 "task_type": "local_bash",
13785 "is_backgrounded": is_backgrounded,
13786 "uuid": "u2",
13787 "session_id": "worker",
13788 }))
13789 .expect("parse a task_started message")
13790 }
13791
13792 fn a_task_updated(task_id: &str, status: &str) -> forge_primitives::Message {
13793 serde_json::from_value(serde_json::json!({
13794 "type": "system",
13795 "subtype": "task_updated",
13796 "task_id": task_id,
13797 "patch": { "status": status },
13798 "uuid": "u6",
13799 "session_id": "worker",
13800 }))
13801 .expect("parse a task_updated message")
13802 }
13803
13804 fn a_connected(session_id: &str) -> AgentEvent {
13807 AgentEvent::Connected {
13808 session_id: session_id.to_owned(),
13809 cwd: "/tmp/connected".to_owned(),
13810 current_model: forge_primitives::CurrentModel {
13811 resolved_id: "claude".to_owned(),
13812 display_name_short: "claude".to_owned(),
13813 display_name_long: "claude".to_owned(),
13814 requested_id: None,
13815 catalog_id: None,
13816 supports_effort: false,
13817 supported_effort_levels: Vec::new(),
13818 supports_auto_mode: None,
13819 supports_adaptive_thinking: None,
13820 is_authoritative: true,
13821 },
13822 available_models: Vec::new(),
13823 mode: None,
13824 history_updates: None,
13825 compaction_count: 0,
13826 }
13827 }
13828
13829 #[test]
13833 fn background_work_follows_the_cli_snapshot() {
13834 let (workspace, _rx) = crate::Workspace::testing_stub();
13835 let key = SessionSlot::from_str_for_test("w-bg");
13836 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13837
13838 task.translate_event(AgentEvent::SdkMessage {
13839 session_id: "worker".to_owned(),
13840 msg: background_tasks(one_live_task()),
13841 });
13842 assert!(
13843 task.domain.lock().background_work,
13844 "a snapshot naming a live task is background work",
13845 );
13846
13847 task.translate_event(AgentEvent::SdkMessage {
13848 session_id: "worker".to_owned(),
13849 msg: background_tasks(Vec::new()),
13850 });
13851 assert!(
13852 !task.domain.lock().background_work,
13853 "the whole set arrives each change, so an empty snapshot clears it",
13854 );
13855 }
13856
13857 #[test]
13861 fn a_connection_failure_drops_background_work() {
13862 let (workspace, _rx) = crate::Workspace::testing_stub();
13863 let key = SessionSlot::from_str_for_test("w-bg-fail");
13864 let (mut task, _update_rx) = review_task_for(&workspace, &key);
13865 task.translate_event(AgentEvent::SdkMessage {
13866 session_id: "worker".to_owned(),
13867 msg: background_tasks(one_live_task()),
13868 });
13869 assert!(task.domain.lock().background_work, "precondition: the snapshot armed it");
13870
13871 task.translate_event(AgentEvent::ConnectionFailed {
13872 message: "the subprocess exited".to_owned(),
13873 kind: SpawnFailureKind::Unclassified,
13874 });
13875
13876 assert!(
13877 !task.domain.lock().background_work,
13878 "a dead subprocess has no live background work, whatever the last snapshot said",
13879 );
13880 }
13881}
13882
13883#[cfg(test)]
13884#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
13885mod connected_hook_tests {
13886 use super::*;
13887 use crate::Workspace;
13888 use crate::protocol::Command;
13889 use crate::target::ProjectKey;
13890
13891 fn lead_slot(project_name: &str) -> crate::SessionSlot {
13894 crate::SessionSlot::lead("TestOrg", project_name)
13895 }
13896
13897 fn seed_project_with_one_worker_row(
13904 workspace: &Arc<Workspace>,
13905 label: &str,
13906 ) -> tempfile::TempDir {
13907 let dir = tempfile::tempdir().expect("tempdir");
13908 workspace.install_db_for_test(
13909 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
13910 );
13911 let project_path = dir.path().join("proj-x");
13912 std::fs::create_dir_all(&project_path).expect("create the project dir");
13913 let project_path = project_path.to_string_lossy().into_owned();
13914 workspace.seed_test_project("proj-x", &project_path);
13915 let project_key = ProjectKey::new(
13916 forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&project_path)),
13917 );
13918 workspace
13919 .record_worker_row(
13920 &project_key,
13921 label,
13922 &format!("{label}-test-id"),
13923 &format!("charter for {label}"),
13924 None,
13925 None,
13926 false,
13927 crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
13928 None,
13929 )
13930 .expect("seed the worker row");
13931 dir
13932 }
13933
13934 #[test]
13937 fn lead_connected_with_a_worker_row_triggers_respawn() {
13938 let (workspace, _update_rx) = Workspace::testing_stub();
13939 let _db = seed_project_with_one_worker_row(&workspace, "implementer");
13940 workspace.enable_test_dispatch_intercept();
13941
13942 on_connected_for_test(&workspace, &lead_slot("proj-x"), "lead-uuid");
13943
13944 let dispatched = workspace.drain_test_dispatch_buffer();
13945 let spawns: Vec<&Command> =
13946 dispatched.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).collect();
13947 assert_eq!(spawns.len(), 1, "one SpawnWorker per stored worker");
13948 }
13949
13950 #[test]
13954 fn a_closed_lead_does_not_respawn_its_workers() {
13955 let (workspace, _update_rx) = Workspace::testing_stub();
13956 let _db = seed_project_with_one_worker_row(&workspace, "implementer");
13957 workspace.enable_test_dispatch_intercept();
13958 let lead = lead_slot("proj-x");
13959 workspace.record_session_id(lead.org(), lead.project(), lead.label(), "lead-id");
13960 workspace.record_seat_state(&lead, crate::store::sessions::SeatState::Closed);
13961
13962 on_connected_for_test(&workspace, &lead, "lead-uuid");
13963
13964 let dispatched = workspace.drain_test_dispatch_buffer();
13965 assert!(
13966 !dispatched.iter().any(|command| matches!(command, Command::SpawnWorker { .. })),
13967 "the stop stands: no worker comes back for a closed lead",
13968 );
13969 }
13970
13971 #[test]
13981 fn lead_connected_without_a_worker_row_does_nothing() {
13982 let (workspace, _update_rx) = Workspace::testing_stub();
13983 let dir = tempfile::tempdir().expect("tempdir");
13984 workspace.install_db_for_test(
13985 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
13986 );
13987 workspace.enable_test_dispatch_intercept();
13988 workspace.seed_test_project("proj-y", "/tmp/proj-y");
13989
13990 on_connected_for_test(&workspace, &lead_slot("proj-y"), "lead-uuid");
13991
13992 assert!(workspace.drain_test_dispatch_buffer().is_empty());
13993 }
13994
13995 #[test]
14004 fn worker_connected_does_not_trigger_respawn() {
14005 let (workspace, _update_rx) = Workspace::testing_stub();
14006 let dir = tempfile::tempdir().expect("tempdir");
14007 workspace.install_db_for_test(
14008 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
14009 );
14010 let lookalike = "worker_wp_planner_abc";
14012 workspace.seed_test_project(lookalike, "/tmp/wp");
14013 let project_key = ProjectKey::new(
14014 forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/wp")),
14015 );
14016 workspace
14017 .record_worker_row(
14018 &project_key,
14019 "planner",
14020 "planner-test-id",
14021 "charter for planner",
14022 None,
14023 None,
14024 false,
14025 crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
14026 None,
14027 )
14028 .expect("seed the worker row");
14029 workspace.enable_test_dispatch_intercept();
14030
14031 let worker = crate::SessionSlot::worker("TestOrg", lookalike, "planner");
14032 on_connected_for_test(&workspace, &worker, "worker-uuid");
14033
14034 let dispatched = workspace.drain_test_dispatch_buffer();
14035 assert!(
14036 dispatched.iter().all(|c| !matches!(c, Command::SpawnWorker { .. })),
14037 "a worker's own Connected does not respawn the team",
14038 );
14039 }
14040
14041 #[test]
14046 fn second_lead_connected_does_not_double_spawn() {
14047 let (workspace, _update_rx) = Workspace::testing_stub();
14048 let _db = seed_project_with_one_worker_row(&workspace, "implementer");
14049 workspace.enable_test_dispatch_intercept();
14050
14051 let lead = lead_slot("proj-x");
14052
14053 on_connected_for_test(&workspace, &lead, "lead-uuid");
14055 let after_first = workspace.drain_test_dispatch_buffer();
14056 let first_spawns: usize =
14057 after_first.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).count();
14058 assert_eq!(first_spawns, 1, "first Connected respawns the workers");
14059
14060 let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
14065 workspace.insert_live_worker(
14066 &project_key,
14067 crate::mcp::workers::types::WorkerEntry {
14068 label: "implementer".into(),
14069 charter: "test".into(),
14070 slot: SessionSlot::from_str_for_test("worker-uuid"),
14071 session_id: None,
14072 status: forge_primitives::WorkerLiveness::Running,
14073 spawned_at: std::time::SystemTime::UNIX_EPOCH,
14074 spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
14075 needs_tag: false,
14076 is_git_repo_at_spawn: false,
14077 cwd: None,
14078 diagnostic: None,
14079 kick: None,
14080 },
14081 );
14082
14083 on_connected_for_test(&workspace, &lead, "lead-uuid");
14085 let after_second = workspace.drain_test_dispatch_buffer();
14086 let second_spawns: usize =
14087 after_second.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).count();
14088 assert_eq!(second_spawns, 0, "second Connected must not double-spawn");
14089 }
14090
14091 #[cfg(test)]
14094 fn seed_adhoc_worker_with_kick(
14095 workspace: &Arc<Workspace>,
14096 label: &str,
14097 kick: Option<String>,
14098 ) -> crate::SessionSlot {
14099 workspace.seed_test_project("forge", "/tmp/forge");
14100 let project_key = workspace
14101 .list_projects()
14102 .into_iter()
14103 .find(|v| v.name == "forge")
14104 .expect("seeded project present")
14105 .key;
14106 let slot = crate::SessionSlot::worker("TestOrg", "forge", label);
14107 workspace.insert_live_worker(
14108 &project_key,
14109 crate::mcp::workers::types::WorkerEntry {
14110 label: label.to_owned(),
14111 charter: "ad-hoc".into(),
14112 slot: slot.clone(),
14113 session_id: None,
14114 status: forge_primitives::WorkerLiveness::Running,
14115 spawned_at: std::time::SystemTime::UNIX_EPOCH,
14116 spawned_by: crate::SessionSlot::lead("TestOrg", "forge"),
14117 needs_tag: false,
14118 is_git_repo_at_spawn: false,
14119 cwd: None,
14120 diagnostic: None,
14121 kick,
14122 },
14123 );
14124 slot
14125 }
14126
14127 #[tokio::test(start_paused = true)]
14131 async fn worker_with_inline_kick_dispatches_it_as_first_turn() {
14132 let (workspace, _update_rx) = Workspace::testing_stub();
14133 workspace.enable_test_dispatch_intercept();
14134 workspace.start_kick_dispatcher();
14135 let synth = seed_adhoc_worker_with_kick(
14136 &workspace,
14137 "scratch",
14138 Some("Begin: triage the failing test now.".into()),
14139 );
14140
14141 on_connected_for_test(&workspace, &synth, "worker-uuid");
14142 tokio::task::yield_now().await;
14143 tokio::task::yield_now().await;
14144
14145 let dispatched = workspace.drain_test_dispatch_buffer();
14146 let prompts: Vec<&Command> = dispatched
14147 .iter()
14148 .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14149 .collect();
14150 assert_eq!(prompts.len(), 1, "inline-kick worker gets exactly one kick");
14151 if let Command::Prompt { key, text, .. } | Command::PromptUnder { key, text, .. } =
14152 prompts[0]
14153 {
14154 assert_eq!(key, &synth, "kick targets the worker's own slot");
14155 assert_eq!(
14156 text, "Begin: triage the failing test now.",
14157 "inline kick delivered verbatim",
14158 );
14159 }
14160 }
14161
14162 #[tokio::test(start_paused = true)]
14167 async fn a_lead_restart_note_is_kicked_once_and_only_for_a_lead() {
14168 let (workspace, _update_rx) = Workspace::testing_stub();
14169 workspace.enable_test_dispatch_intercept();
14170 workspace.start_kick_dispatcher();
14171 let lead = lead_slot("proj-x");
14172 let mut domain = DomainSession::new(lead.clone(), None);
14173 domain.pending_lead_resume_note = Some("continue where you left off".to_owned());
14174 let domain = Mutex::new(domain);
14175
14176 maybe_kick_lead_on_connected(&workspace, &lead, &domain);
14177 tokio::task::yield_now().await;
14178 tokio::task::yield_now().await;
14179
14180 let dispatched = workspace.drain_test_dispatch_buffer();
14181 let prompts: Vec<&Command> = dispatched
14182 .iter()
14183 .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14184 .collect();
14185 assert_eq!(prompts.len(), 1, "a resumed lead is kicked exactly once");
14186 if let Command::Prompt { key, text, .. } | Command::PromptUnder { key, text, .. } =
14187 prompts[0]
14188 {
14189 assert_eq!(key, &lead, "the note targets the lead's own slot");
14190 assert_eq!(text, "continue where you left off", "the note arrives verbatim");
14191 }
14192
14193 maybe_kick_lead_on_connected(&workspace, &lead, &domain);
14194 let worker = crate::SessionSlot::from_str_for_test("w");
14195 let mut worker_domain = DomainSession::new(worker.clone(), None);
14196 worker_domain.pending_lead_resume_note = Some("never delivered".to_owned());
14197 maybe_kick_lead_on_connected(&workspace, &worker, &Mutex::new(worker_domain));
14198 tokio::time::advance(std::time::Duration::from_secs(2)).await;
14202 tokio::task::yield_now().await;
14203 tokio::task::yield_now().await;
14204 assert!(
14205 workspace.drain_test_dispatch_buffer().is_empty(),
14206 "a reconnect after the note was taken and a worker slot deliver nothing",
14207 );
14208 }
14209
14210 #[tokio::test(start_paused = true)]
14215 async fn worker_with_an_underscore_label_dispatches_its_kick() {
14216 let (workspace, _update_rx) = Workspace::testing_stub();
14217 workspace.enable_test_dispatch_intercept();
14218 workspace.start_kick_dispatcher();
14219 let synth = seed_adhoc_worker_with_kick(
14220 &workspace,
14221 "code_review",
14222 Some("Begin: review the open diff.".into()),
14223 );
14224
14225 on_connected_for_test(&workspace, &synth, "worker-uuid");
14226 tokio::task::yield_now().await;
14227 tokio::task::yield_now().await;
14228
14229 let dispatched = workspace.drain_test_dispatch_buffer();
14230 let prompts: Vec<&Command> = dispatched
14231 .iter()
14232 .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14233 .collect();
14234 assert_eq!(prompts.len(), 1, "an underscore-labelled worker gets its kick");
14235 if let Command::Prompt { text, .. } | Command::PromptUnder { text, .. } = prompts[0] {
14236 assert_eq!(text, "Begin: review the open diff.", "the kick arrives verbatim");
14237 }
14238 }
14239
14240 #[tokio::test(start_paused = true)]
14243 async fn worker_without_inline_kick_for_adhoc_label_does_not_kick() {
14244 let (workspace, _update_rx) = Workspace::testing_stub();
14245 workspace.enable_test_dispatch_intercept();
14246 workspace.start_kick_dispatcher();
14247 let synth = seed_adhoc_worker_with_kick(&workspace, "scratch", None);
14248
14249 on_connected_for_test(&workspace, &synth, "worker-uuid");
14250 tokio::task::yield_now().await;
14251 tokio::task::yield_now().await;
14252
14253 let dispatched = workspace.drain_test_dispatch_buffer();
14254 let prompts: Vec<&Command> = dispatched
14255 .iter()
14256 .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14257 .collect();
14258 assert!(prompts.is_empty(), "a live entry carrying no kick gets none");
14259 }
14260
14261 #[tokio::test(start_paused = true)]
14268 async fn worker_connected_for_an_unmatched_label_does_not_take_another_workers_kick() {
14269 let (workspace, _update_rx) = Workspace::testing_stub();
14270 workspace.enable_test_dispatch_intercept();
14271 workspace.start_kick_dispatcher();
14272 seed_adhoc_worker_with_kick(&workspace, "other", Some("not yours".into()));
14274 let other_label = crate::SessionSlot::worker("TestOrg", "forge", "scratchpad");
14276
14277 on_connected_for_test(&workspace, &other_label, "worker-uuid");
14278 tokio::task::yield_now().await;
14279 tokio::task::yield_now().await;
14280
14281 let dispatched = workspace.drain_test_dispatch_buffer();
14282 assert!(
14283 dispatched
14284 .iter()
14285 .all(|c| !matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. })),
14286 "a label with no entry of its own must not be handed another worker's kick",
14287 );
14288 }
14289
14290 #[tokio::test(start_paused = true)]
14294 async fn a_worker_label_with_an_underscore_keeps_its_kick() {
14295 let (workspace, _update_rx) = Workspace::testing_stub();
14296 workspace.enable_test_dispatch_intercept();
14297 workspace.start_kick_dispatcher();
14298 let slot = seed_adhoc_worker_with_kick(&workspace, "code_review", Some("go".into()));
14299
14300 on_connected_for_test(&workspace, &slot, "worker-uuid");
14301 tokio::task::yield_now().await;
14302 tokio::task::yield_now().await;
14303
14304 let dispatched = workspace.drain_test_dispatch_buffer();
14305 assert!(
14306 dispatched.iter().any(|c| matches!(
14307 c, Command::Prompt { text, .. } | Command::PromptUnder { text, .. }
14308 if text == "go"
14309 )),
14310 "an underscore in the label must not cost the kick: {dispatched:?}",
14311 );
14312 }
14313}