1use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use parking_lot::Mutex;
10use tokio::sync::mpsc;
11
12use crate::config::LoadedConfig;
13use crate::domain_session::PendingInteractions;
14use crate::protocol::SessionUpdate;
15use crate::target::{ProjectKey, SessionSlot};
16use crate::update_fanout::{SeatUpdate, SubscriberRole, UpdateFanout};
17use crate::workspace::{KickRequest, PendingInteractionKind, PooledAgent, Workspace};
18use forge_gateway::AccountKey;
19use forge_primitives::SessionId;
20
21pub fn set_seat_ring_cap(workspace: &Workspace, cap: usize) {
26 workspace.update_tx.set_ring_cap(cap);
27}
28
29#[cfg(any(test, feature = "testing"))]
32pub const TEST_TOOL_ID: &str = "test-tool";
33
34#[cfg(any(test, feature = "testing"))]
38const TEST_DOCUMENT_PATH: &str = "/proj/docs/test.md";
39
40#[cfg(any(test, feature = "testing"))]
44pub(crate) fn test_permission(
45 tx: tokio::sync::oneshot::Sender<forge_primitives::PermissionOutcome>,
46) -> crate::protocol::PendingInteractionSlot {
47 use forge_primitives::permission_interaction::{
48 PermissionAction, PermissionOption, PermissionOptionKind,
49 };
50
51 let request = forge_primitives::PermissionRequest {
52 tool_call: test_tool_call("Bash", forge_primitives::ToolKind::Execute, Some("ls")),
53 options: vec![PermissionOption {
54 option_id: "allow_once".to_owned(),
55 name: "Allow once".to_owned(),
56 kind: PermissionOptionKind::Allow,
57 action: PermissionAction::Allow,
58 }],
59 display: None,
60 };
61 crate::protocol::PendingInteractionSlot::Permission { tx, request: Box::new(request) }
62}
63
64#[cfg(any(test, feature = "testing"))]
66fn test_tool_call(
67 title: &str,
68 kind: forge_primitives::ToolKind,
69 command: Option<&str>,
70) -> forge_primitives::ToolCall {
71 forge_primitives::ToolCall {
72 tool_call_id: TEST_TOOL_ID.to_owned(),
73 title: title.to_owned(),
74 kind,
75 status: forge_primitives::ToolCallStatus::Pending,
76 content: Vec::new(),
77 raw_input: command.map(|command| serde_json::json!({ "command": command })),
78 raw_output: None,
79 output_metadata: None,
80 task_metadata: None,
81 locations: Vec::new(),
82 meta: None,
83 }
84}
85
86#[cfg(any(test, feature = "testing"))]
88pub(crate) fn test_question(
89 tx: tokio::sync::oneshot::Sender<forge_primitives::QuestionOutcome>,
90) -> crate::protocol::PendingInteractionSlot {
91 use forge_primitives::question::{QuestionOption, QuestionPrompt};
92
93 let request = forge_primitives::QuestionRequest {
94 tool_call: test_tool_call("AskUserQuestion", forge_primitives::ToolKind::Think, None),
95 prompt: QuestionPrompt {
96 question: "Which environment?".to_owned(),
97 header: "Environments".to_owned(),
98 multi_select: false,
99 options: vec![QuestionOption {
100 option_id: "staging".to_owned(),
101 label: "Staging".to_owned(),
102 description: None,
103 preview: None,
104 }],
105 },
106 question_index: 0,
107 total_questions: 1,
108 };
109 crate::protocol::PendingInteractionSlot::Question { tx, request: Box::new(request) }
110}
111
112#[cfg(any(test, feature = "testing"))]
119fn test_prompt(kind: PendingInteractionKind) -> Option<crate::protocol::PendingInteractionSlot> {
120 match kind {
121 PendingInteractionKind::Permission => {
122 let (tx, _rx) = tokio::sync::oneshot::channel();
123 Some(test_permission(tx))
124 }
125 PendingInteractionKind::Question => {
126 let (tx, _rx) = tokio::sync::oneshot::channel();
127 Some(test_question(tx))
128 }
129 PendingInteractionKind::SlackDraft
130 | PendingInteractionKind::BrowserHandOff
131 | PendingInteractionKind::TerminalHandOff
132 | PendingInteractionKind::DocumentHandOff
133 | PendingInteractionKind::SettingsWrite
134 | PendingInteractionKind::Vault => None,
135 }
136}
137
138#[cfg(any(test, feature = "testing"))]
139impl Workspace {
140 #[cfg(test)]
146 pub(crate) fn mark_session_connected_for_test(&self, key: &SessionSlot, session_id: &str) {
147 let domain = self
148 .domain_session_for(key)
149 .unwrap_or_else(|| self.register_domain_session(key.clone(), None));
150 domain.lock().session_id = Some(forge_primitives::SessionId::new(session_id));
151 }
152
153 pub fn testing_stub_handle()
167 -> (forge_agent::AgentHandle, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>) {
168 forge_agent::Agent::testing_stub()
169 }
170
171 #[cfg(any(test, feature = "testing"))]
174 pub fn record_spawn_failure_for_test(&self, slot: &SessionSlot, message: &str) {
175 self.record_spawn_failure(slot, message);
176 }
177
178 #[cfg(any(test, feature = "testing"))]
182 pub fn record_failed_turn_for_test(&self, slot: &SessionSlot, at: std::time::SystemTime) {
183 if let Some(domain) = self.domain_session_for(slot) {
184 domain.lock().failed_turn_at = Some(at);
185 }
186 }
187
188 #[cfg(any(test, feature = "testing"))]
193 pub fn record_death_for_test(&self, slot: &SessionSlot, occupant: &str, message: &str) {
194 self.record_death(slot, SessionId::new(occupant), message);
195 }
196
197 #[cfg(any(test, feature = "testing"))]
200 pub fn emit_for_test(&self, update: SessionUpdate) {
201 let _ = self.update_tx.send(update);
202 }
203
204 pub fn park_slack_draft_for_test(
209 &self,
210 key: &SessionSlot,
211 draft: forge_primitives::slack::SlackDraft,
212 ) {
213 let _ = self.register_slack_draft(key, draft);
214 }
215
216 pub fn emit_for_test_reported(&self, update: SessionUpdate) -> bool {
224 self.update_tx.send(update)
225 }
226
227 #[cfg(any(test, feature = "testing"))]
232 pub fn test_subscriber_count(&self) -> usize {
233 self.update_tx.count()
234 }
235
236 #[cfg(any(test, feature = "testing"))]
240 pub fn test_answering_count(&self) -> usize {
241 self.update_tx.answering_count()
242 }
243
244 #[cfg(any(test, feature = "testing"))]
249 pub fn test_usage_poller_started(&self) -> bool {
250 self.supervisor.snapshot().iter().any(|member| {
251 member.name == "usage_poller"
252 && matches!(&member.state, forge_supervise::MemberState::Running { .. })
253 })
254 }
255
256 #[cfg(any(test, feature = "testing"))]
260 pub fn test_dictate_preflight_started(&self) -> bool {
261 self.dictate_preflight_started.load(std::sync::atomic::Ordering::Acquire)
262 }
263
264 #[cfg(any(test, feature = "testing"))]
268 pub fn test_voice_prewarm_started(&self) -> bool {
269 self.voice_prewarm_started.load(std::sync::atomic::Ordering::Acquire)
270 }
271
272 #[cfg(any(test, feature = "testing"))]
276 pub fn seed_test_cli_version(&self, installed: Option<&str>, latest: Option<&str>) {
277 *self.cli_version.lock() = Some(forge_agent::env::cli_version::CliVersionInfo {
278 installed: installed.map(str::to_owned),
279 latest: latest.map(str::to_owned),
280 });
281 }
282
283 #[cfg(any(test, feature = "testing"))]
287 pub fn seed_test_fatal_error(&self, error: forge_primitives::error::AppError) {
288 self.record_fatal_error(error);
289 }
290
291 pub fn install_testing_stub(
303 &self,
304 key: &SessionSlot,
305 ) -> mpsc::UnboundedReceiver<forge_primitives::AgentCommand> {
306 let (handle, rx) = forge_agent::Agent::testing_stub();
307 let arc = Arc::new(handle);
308 let domain = self
309 .domain_session_for(key)
310 .unwrap_or_else(|| self.register_domain_session(key.clone(), None));
311 domain.lock().conn = Some(arc);
312 rx
313 }
314
315 pub fn seed_queued_prompt(
318 &self,
319 key: &SessionSlot,
320 uuid: &str,
321 source: crate::protocol::PromptSource,
322 text: &str,
323 ) {
324 let domain = self
325 .domain_session_for(key)
326 .unwrap_or_else(|| self.register_domain_session(key.clone(), None));
327 domain.lock().record_queued_prompt(uuid, source, text, Vec::new());
328 }
329
330 pub fn testing_stub() -> (Arc<Self>, mpsc::UnboundedReceiver<SeatUpdate>) {
342 Self::testing_stub_with_config_dir(PathBuf::from("/tmp/forge-testing-stub"))
343 }
344
345 pub fn testing_stub_with_config_dir(
349 config_dir: PathBuf,
350 ) -> (Arc<Self>, mpsc::UnboundedReceiver<SeatUpdate>) {
351 Self::testing_stub_with_slack(
354 config_dir,
355 LoadedConfig::empty_for_test(),
356 Arc::new(crate::slack::SlackWorkspaces::default()),
357 None,
358 )
359 }
360
361 #[cfg(test)]
381 pub(crate) fn testing_stub_with_config(
382 config_dir: PathBuf,
383 config: LoadedConfig,
384 ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<SeatUpdate>), String> {
385 let slack =
386 crate::slack::SlackWorkspaces::from_config(&config.slack, &reqwest::Client::new())?;
387 let systemone = crate::systemone::client_for(
388 config.systemone.as_ref(),
389 &crate::config::forge_data_dir(&config_dir).join("forge.toml"),
390 )
391 .map_err(|error| error.to_string())?
392 .map(Arc::new);
393 Ok(Self::testing_stub_with_slack(config_dir, config, Arc::new(slack), systemone))
394 }
395
396 #[cfg(any(test, feature = "testing"))]
400 pub(crate) fn testing_stub_with_slack(
401 config_dir: PathBuf,
402 config: LoadedConfig,
403 slack: Arc<crate::slack::SlackWorkspaces>,
404 systemone: Option<Arc<forge_system_one::SystemOneClient>>,
405 ) -> (Arc<Self>, mpsc::UnboundedReceiver<SeatUpdate>) {
406 let _ = crate::config::ensure_forge_data_dir(&config_dir);
409 let update_tx = UpdateFanout::default();
410 let update_rx = update_tx.subscribe(SubscriberRole::Answering);
412 let (kick_dispatcher_tx, kick_dispatcher_rx) = mpsc::unbounded_channel::<KickRequest>();
413 let config_dictate = config.dictate.clone();
414 let accounts = std::sync::Arc::new(forge_gateway::AccountPool::empty_for_test());
415 let gateway = std::sync::Arc::new(forge_gateway::forward::Gateway::new(
419 std::sync::Arc::clone(&accounts),
420 reqwest::Client::new(),
421 ));
422 let app_support = Some(config_dir.join("app-support"));
425 let (settings, _settings_rx) = tokio::sync::watch::channel(None);
426 let workspace = Self {
427 config_dir,
428 config: parking_lot::RwLock::new(Arc::new(config)),
429 settings,
430 settings_version: std::sync::OnceLock::new(),
431 settings_write: parking_lot::Mutex::new(()),
432 reload_generation: std::sync::atomic::AtomicU64::new(0),
433 reload_gate: std::sync::Mutex::new(()),
434 systemone: parking_lot::RwLock::new(systemone),
435 vault: parking_lot::RwLock::new(None),
436 voice: std::sync::OnceLock::new(),
437 voice_preview_error: std::sync::Mutex::new(None),
438 catalog: Arc::new(Mutex::new(HashMap::new())),
439 app_support,
440 pool: Mutex::new(HashMap::new()),
441 pricing_fetcher: crate::workspace::default_pricing_fetcher(),
444 #[cfg(any(test, feature = "testing"))]
445 test_spawn_handle: Mutex::new(None),
446 test_spawn_server: Mutex::new(None),
447 accounts,
448 gateway,
449 gateway_ready: std::sync::atomic::AtomicBool::new(true),
453 gateway_port: crate::config::DEFAULT_GATEWAY_PORT,
454 gateway_url: Mutex::new(Some(format!(
455 "http://127.0.0.1:{}",
456 crate::config::DEFAULT_GATEWAY_PORT
457 ))),
458 gateway_bind_error: Mutex::new(None),
459 dictate: Arc::new(crate::dictate::DictateState::new(&config_dictate)),
460 dictate_runtime: Mutex::new(crate::dictate::DictateRuntime::default()),
461 browser_host: std::sync::OnceLock::new(),
462 live_seats_lock: std::sync::Mutex::new(()),
463 live_seats_generation: std::sync::atomic::AtomicU64::new(0),
464 live_seats_gate: Mutex::new(None),
465 attached_clients: std::sync::atomic::AtomicUsize::new(0),
466 document_handoffs: parking_lot::Mutex::new(HashMap::new()),
467 dictate_catalogue: Mutex::new(crate::catalogue::CatalogueState::default()),
468 dictate_install: Mutex::new(crate::install::InstallState::default()),
469 plugin_cli: Mutex::new(crate::plugins::PluginCli::real()),
470 plugin_run: Mutex::new(None),
471 plugin_action_running: std::sync::atomic::AtomicBool::new(false),
472 dictate_activate: Mutex::new(crate::install::ActivateState::default()),
473 dictate_bench: Mutex::new(crate::bench::BenchState::default()),
474 dictate_bench_cancel: std::sync::atomic::AtomicBool::new(false),
475 speaking_bench: Mutex::new(crate::bench::SpeakingBenchState::default()),
476 speaking_bench_cancel: std::sync::atomic::AtomicBool::new(false),
477 bench_claim: std::sync::atomic::AtomicBool::new(false),
478 read_aloud_error: Mutex::new(None),
479 #[cfg(any(test, feature = "testing"))]
480 test_catalogue_source: Mutex::new(None),
481 #[cfg(any(test, feature = "testing"))]
482 test_cleanup_source: Mutex::new(None),
483 #[cfg(any(test, feature = "testing"))]
484 test_catalogue_dir: Mutex::new(None),
485 #[cfg(any(test, feature = "testing"))]
486 test_read_aloud_dir: Mutex::new(None),
487 update_tx,
488 command_senders: Mutex::new(HashMap::new()),
489 session_teardowns: Mutex::new(HashMap::new()),
490 shutting_down: std::sync::atomic::AtomicBool::new(false),
491 live_workers: Mutex::new(HashMap::new()),
492 idle_sweeps: Mutex::new(HashMap::new()),
493 worktree_claims: std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new())),
494 despawn_cleanups: Mutex::new(HashSet::new()),
495 spawn_failures: Mutex::new(HashMap::new()),
496 deaths: Mutex::new(HashMap::new()),
497 heal_progress: Mutex::new(HashMap::new()),
498 healing: std::sync::OnceLock::new(),
499 held_work_seats: crate::work::HeldSeats::default(),
500 domain_handles: Mutex::new(HashMap::new()),
501 review_origin: Mutex::new(HashMap::new()),
502 review_activity: Mutex::new(HashMap::new()),
503 supervisor: Arc::new(forge_supervise::Supervisor::new()),
504 dictate_preflight_started: std::sync::atomic::AtomicBool::new(false),
505 voice_prewarm_started: std::sync::atomic::AtomicBool::new(false),
506 voice_prewarm_dispatched: std::sync::atomic::AtomicBool::new(false),
507 kick_dispatcher_tx,
508 kick_dispatcher_rx_slot: Mutex::new(Some(kick_dispatcher_rx)),
509 _single_instance_lock: None,
510 crons: Mutex::new(Vec::new()),
511 crons_load_failed: std::sync::atomic::AtomicBool::new(false),
512 tasks: Mutex::new(Vec::new()),
513 parked_by_slot: Mutex::new(HashMap::new()),
514 pending_handoffs: Mutex::new(HashMap::new()),
515 swapping: Mutex::new(HashMap::new()),
516 swap_epochs: std::sync::atomic::AtomicU64::new(0),
517 held_prompts: Mutex::new(HashMap::new()),
518 gotify_subs: Mutex::new(Vec::new()),
519 db: Arc::new(Mutex::new(None)),
520 catalog_loaded: Arc::new(std::sync::atomic::AtomicBool::new(false)),
521 catalog_scan_started: std::sync::atomic::AtomicBool::new(false),
522 cli_version: Arc::new(Mutex::new(None)),
523 last_fatal_error: Mutex::new(None),
524 service_status: Arc::new(Mutex::new(None)),
525 service_status_probe_started: std::sync::atomic::AtomicBool::new(false),
526 gotify_connected: Mutex::new(false),
527 gotify_app_index: Mutex::new(HashMap::new()),
528 gotify_subsystem: Mutex::new(None),
529 slack,
530 slack_subs: Mutex::new(Vec::new()),
531 slack_subsystem: Mutex::new(std::collections::BTreeMap::new()),
532 slack_connected: Mutex::new(std::collections::BTreeMap::new()),
533 slack_inflight: Mutex::new(std::collections::HashMap::new()),
534 slack_user_ids: Mutex::new(std::collections::BTreeMap::new()),
535 slack_user_names: Mutex::new(std::collections::BTreeMap::new()),
536 slack_author_failures: Mutex::new(std::collections::HashSet::new()),
537 slack_draft_user_failures: Mutex::new(std::collections::HashSet::new()),
538 slack_thread_failures: Mutex::new(std::collections::HashSet::new()),
539 slack_thread_full_reads: Mutex::new(std::collections::HashMap::new()),
540 slack_thread_first_walks: Mutex::new(std::collections::HashSet::new()),
541 slack_thread_edit_watermarks: Mutex::new(std::collections::HashMap::new()),
542 slack_ledger_failures: Mutex::new(std::collections::HashSet::new()),
543 slack_drafts: Mutex::new(HashMap::new()),
544 browser_handoffs: Mutex::new(HashMap::new()),
545 terminals: Mutex::new(HashMap::new()),
546 terminal_handoffs: Mutex::new(HashMap::new()),
547 next_terminal_id: std::sync::atomic::AtomicU16::new(1),
548 settings_writes: Mutex::new(HashMap::new()),
549 parked_vault_asks: Mutex::new(HashMap::new()),
550 pending_seq: Mutex::new(HashMap::new()),
551 slack_recently_delivered: Mutex::new(HashMap::new()),
552 slack_load_failed: std::sync::atomic::AtomicBool::new(false),
553 slack_user_id_retries: Mutex::new(std::collections::BTreeMap::new()),
554 slack_verification_started: std::sync::atomic::AtomicBool::new(false),
555 respawn_in_flight: Mutex::new(std::collections::HashSet::new()),
556 unwakeable_crons: Mutex::new(std::collections::HashSet::new()),
557 cooling_crons: Mutex::new(std::collections::HashMap::new()),
558 command_intercept: Mutex::new(None),
559 test_extra_projects: Mutex::new(Vec::new()),
560 test_user_preferences: Mutex::new(None),
561 test_claude_pid: Mutex::new(HashMap::new()),
562 test_scan_gate: Mutex::new(None),
563 };
564 (Arc::new(workspace), update_rx)
565 }
566
567 #[cfg(any(test, feature = "testing"))]
571 pub fn seed_test_user_preferences(&self, preferences: serde_json::Value) {
572 *self.test_user_preferences.lock() = Some(preferences);
573 }
574
575 #[cfg(any(test, feature = "testing"))]
578 pub fn seed_test_claude_pid(&self, key: &SessionSlot, pid: u32) {
579 self.test_claude_pid.lock().insert(key.clone(), pid);
580 }
581
582 #[cfg(any(test, feature = "testing"))]
586 pub fn seed_test_scan_gate(&self, gate: Arc<tokio::sync::Semaphore>) {
587 *self.test_scan_gate.lock() = Some(gate);
588 }
589
590 #[cfg(any(test, feature = "testing"))]
595 pub fn note_tree_moved_for_test(&self, key: &SessionSlot) {
596 self.held_work_seats.mark_tree_moved(key);
597 }
598
599 #[cfg(any(test, feature = "testing"))]
603 pub fn watch_pass_done_for_test(&self, key: &SessionSlot) -> Option<Arc<tokio::sync::Notify>> {
604 self.held_work_seats.pass_done(key)
605 }
606
607 #[cfg(any(test, feature = "testing"))]
611 pub fn pause_next_live_seats_capture(
612 &self,
613 ) -> (std::sync::mpsc::Receiver<()>, std::sync::mpsc::Sender<()>) {
614 let (reached_tx, reached_rx) = std::sync::mpsc::channel();
615 let (resume_tx, resume_rx) = std::sync::mpsc::channel();
616 *self.live_seats_gate.lock() = Some((reached_tx, resume_rx));
617 (reached_rx, resume_tx)
618 }
619
620 #[cfg(any(test, feature = "testing"))]
623 pub(crate) fn pass_live_seats_gate(&self) {
624 let gate = self.live_seats_gate.lock().take();
625 if let Some((reached, resume)) = gate {
626 let _ = reached.send(());
627 let _ = resume.recv();
628 }
629 }
630}
631
632#[cfg(any(test, feature = "testing"))]
636impl Workspace {
637 pub fn enable_test_dispatch_intercept(&self) {
644 let mut intercept = self.command_intercept.lock();
645 if intercept.is_none() {
646 *intercept = Some(Vec::new());
647 }
648 }
649
650 pub fn test_rotation_announcer_started(&self) -> bool {
653 self.supervisor.snapshot().iter().any(|member| {
654 member.name == "rotation_announcer"
655 && matches!(&member.state, forge_supervise::MemberState::Running { .. })
656 })
657 }
658
659 pub fn drain_test_dispatch_buffer(&self) -> Vec<crate::protocol::Command> {
663 let mut intercept = self.command_intercept.lock();
664 match intercept.as_mut() {
665 Some(buffer) => std::mem::take(buffer),
666 None => Vec::new(),
667 }
668 }
669}
670
671#[cfg(any(test, feature = "testing"))]
672impl Workspace {
673 pub fn seed_seat_state_for_test(
676 &self,
677 org: &str,
678 project: &str,
679 label: &str,
680 state: crate::store::sessions::SeatState,
681 ) -> anyhow::Result<()> {
682 let store = self.db.lock();
683 let db = store.as_ref().ok_or_else(|| anyhow::anyhow!("the fixture has no store"))?;
684 let record = crate::store::sessions::SessionRecord {
685 org: org.to_owned(),
686 project: project.to_owned(),
687 label: label.to_owned(),
688 session_id: Some(format!("{label}-id")),
689 charter: None,
690 kick: None,
691 resume_kick: None,
692 interactive: None,
693 seat_state: Some(state),
694 is_git_repo: None,
695 mcp_families: None,
696 cwd: None,
697 };
698 crate::store::sessions::put(db, &record)
699 }
700
701 pub fn park_terminal_hand_off(
705 &self,
706 slot: &SessionSlot,
707 terminal: u16,
708 ) -> Result<String, String> {
709 self.register_terminal_hand_off(slot, terminal, "a transport test parks this")
710 .map(|(id, _answer)| id.to_string())
711 }
712
713 pub fn answer_terminal_hand_off(&self, id: &str, slot: &SessionSlot) {
716 let Ok(id) = uuid::Uuid::parse_str(id) else { return };
717 let _ = self.resolve_terminal_hand_off(
718 id,
719 slot,
720 forge_primitives::terminal::TerminalHandOffAnswer {
721 ending: forge_primitives::browser::HandOffEnding::Done,
722 source: forge_primitives::browser::HandOffSource::Unstated,
723 client: None,
724 },
725 );
726 }
727
728 pub fn seed_test_project(&self, name: &str, path: &str) {
733 self.seed_test_project_inner(name, path, None, None);
734 }
735
736 pub fn remove_test_project(&self, name: &str) {
739 self.test_extra_projects.lock().retain(|project| project.name != name);
740 }
741
742 pub fn seed_test_project_with_slack_workspace(&self, name: &str, path: &str, workspace: &str) {
745 self.seed_test_project_inner(name, path, None, Some(workspace));
746 }
747
748 pub fn seed_test_project_with_max_workers(&self, name: &str, path: &str, max_workers: usize) {
752 self.seed_test_project_inner(name, path, Some(max_workers), None);
753 }
754
755 fn seed_test_project_inner(
756 &self,
757 name: &str,
758 path: &str,
759 max_workers: Option<usize>,
760 slack_workspace: Option<&str>,
761 ) {
762 self.test_extra_projects.lock().push(crate::config::LoadedProject {
763 name: name.to_owned(),
764 path: std::path::PathBuf::from(path),
765 display_path: path.to_owned(),
766 org: "TestOrg".to_owned(),
767 accounts: vec!["acct-a".to_owned()],
768 fallback_accounts: Vec::new(),
769 model: None,
770 env: std::collections::HashMap::new(),
771 env_file: None,
772 max_workers,
773 permission_mode: forge_primitives::permission::PermissionMode::Auto,
774 document_roots: Vec::new(),
775 handoff: None,
776 browser_profile: forge_primitives::browser::ProfileKey::from_parts_for_test(
777 "TestOrg", "default",
778 ),
779 slack_workspace: slack_workspace.map(str::to_owned),
780 });
781 }
782
783 #[cfg(any(test, feature = "testing"))]
786 pub fn seed_test_gateway_bind_error(&self, error: Option<String>) {
787 *self.gateway_bind_error.lock() = error;
788 }
789
790 #[cfg(any(test, feature = "testing"))]
793 pub fn install_test_spawn_handle(&self, handle: forge_agent::AgentHandle) {
797 *self.test_spawn_handle.lock() = Some(handle);
798 }
799
800 pub(crate) fn take_test_spawn_handle(&self) -> Option<forge_agent::AgentHandle> {
802 self.test_spawn_handle.lock().take()
803 }
804
805 pub fn test_spawn_server(&self) -> Option<forge_sdk::mcp::server::McpServer> {
808 self.test_spawn_server.lock().clone()
809 }
810
811 pub fn seed_test_gateway_ready(&self, ready: bool) {
812 self.gateway_ready.store(ready, std::sync::atomic::Ordering::Release);
813 }
814
815 #[cfg(any(test, feature = "testing"))]
820 pub fn seed_test_gotify_connected(&self, connected: bool) {
821 *self.gotify_connected.lock() = connected;
822 }
823
824 #[cfg(any(test, feature = "testing"))]
827 pub fn seed_test_ready_account(&self, account: &str) {
828 self.accounts
829 .set_loading(&AccountKey(account.to_owned()), forge_gateway::LoadingState::Ready);
830 }
831
832 #[cfg(any(test, feature = "testing"))]
836 pub fn seed_test_account_state(&self, account: &str, state: forge_gateway::LoadingState) {
837 self.accounts.set_loading(&AccountKey(account.to_owned()), state);
838 }
839
840 #[cfg(any(test, feature = "testing"))]
843 pub fn seed_test_account_failure(
844 &self,
845 account: &str,
846 status: forge_gateway::UsageFetchStatus,
847 ) {
848 self.accounts.set_last_error(&AccountKey(account.to_owned()), status, None);
849 }
850
851 #[cfg(any(test, feature = "testing"))]
854 pub fn seed_test_dictate_snapshot(&self, snapshot: crate::dictate::DictateSnapshot) {
855 *self.dictate.snapshot.lock() = snapshot;
856 }
857
858 #[cfg(any(test, feature = "testing"))]
868 pub fn install_test_dictate_engine(
869 &self,
870 models_dir: &std::path::Path,
871 ) -> Result<(), forge_dictate::Error> {
872 let engine = forge_dictate::Engine::new(
873 forge_dictate::ConfigBuilder::new().models_dir(models_dir).normalizer(None).build(),
874 )?;
875 *self.dictate.engine.lock() = Some(engine);
876 *self.test_read_aloud_dir.lock() = Some(models_dir.join("dictate-read-aloud"));
877 Ok(())
878 }
879
880 #[cfg(any(test, feature = "testing"))]
884 pub fn mark_test_session_live(&self, key: &SessionSlot) {
885 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
886 Box::leak(Box::new(receiver));
890 self.command_senders.lock().insert(key.clone(), sender);
891 }
892
893 #[cfg(any(test, feature = "testing"))]
897 pub fn testing_stub_with_dictate_enabled() -> (Arc<Self>, mpsc::UnboundedReceiver<SeatUpdate>) {
898 let mut config = LoadedConfig::empty_for_test();
899 config.dictate.enabled = true;
900 Self::testing_stub_with_slack(
901 PathBuf::from("/tmp/forge-testing-stub-dictate"),
902 config,
903 Arc::new(crate::slack::SlackWorkspaces::default()),
904 None,
905 )
906 }
907
908 #[cfg(any(test, feature = "testing"))]
912 pub fn seed_test_bound_session(&self, key: &SessionSlot, account: &str) {
913 let (handle, _rx) = forge_agent::Agent::testing_stub();
914 let account = AccountKey(account.to_owned());
915 let session_id = "test-bound-session".to_owned();
916 self.pool.lock().insert(
917 key.clone(),
918 PooledAgent {
919 handle: Arc::new(handle),
920 account: account.clone(),
921 permission_mode: None,
922 registration: Some(forge_gateway::binding::Registration {
923 org: key.org().to_owned(),
924 project: key.project().to_owned(),
925 session: session_id.clone(),
926 account: account.clone(),
927 provider: forge_primitives::account::Provider::Anthropic,
928 }),
929 session_id: session_id.clone(),
930 },
931 );
932 self.gateway.bindings.bind(key.org(), key.project(), &session_id, account);
933 }
934
935 #[cfg(any(test, feature = "testing"))]
938 pub fn seed_test_running_session_id(&self, key: &SessionSlot, session_id: &str) {
939 let (handle, _rx) = forge_agent::Agent::testing_stub();
940 self.pool.lock().insert(
941 key.clone(),
942 PooledAgent {
943 handle: Arc::new(handle),
944 account: AccountKey("TestAccount".to_owned()),
945 permission_mode: None,
946 registration: None,
947 session_id: session_id.to_owned(),
948 },
949 );
950 }
951
952 #[cfg(any(test, feature = "testing"))]
956 pub fn seed_test_stored_session_id(&self, key: &SessionSlot, session_id: &str) {
957 self.record_session_id(key.org(), key.project(), key.label(), session_id);
958 }
959
960 #[cfg(any(test, feature = "testing"))]
961 pub fn seed_test_usage(&self, account: &str, snapshot: forge_primitives::usage::UsageSnapshot) {
962 self.accounts.set_usage(&AccountKey(account.to_owned()), snapshot);
963 }
964
965 #[cfg(any(test, feature = "testing"))]
970 pub fn seed_test_advertised_catalogues(
971 &self,
972 slot: &SessionSlot,
973 commands: Vec<forge_primitives::AvailableCommand>,
974 agents: Vec<forge_primitives::AvailableAgent>,
975 ) {
976 let domain = self
977 .domain_session_for(slot)
978 .unwrap_or_else(|| self.register_domain_session(slot.clone(), None));
979 let mut domain = domain.lock();
980 domain.available_commands = commands;
981 domain.available_agents = agents;
982 }
983
984 #[cfg(any(test, feature = "testing"))]
989 pub fn seed_test_pending_interaction(&self, slot: &SessionSlot, kind: PendingInteractionKind) {
990 self.seed_test_asks(slot, kind, 1);
991 }
992
993 #[cfg(any(test, feature = "testing"))]
996 pub fn clear_test_pending(&self, slot: &SessionSlot) {
997 if let Some(domain) = self.domain_session_for(slot) {
998 domain.lock().pending_interactions = PendingInteractions::default();
999 }
1000 }
1001
1002 #[cfg(any(test, feature = "testing"))]
1005 pub fn seed_test_awaiting_login(&self, slot: &SessionSlot) {
1006 let domain = self
1007 .domain_session_for(slot)
1008 .unwrap_or_else(|| self.register_domain_session(slot.clone(), None));
1009 domain.lock().awaiting_login = true;
1010 }
1011
1012 #[cfg(any(test, feature = "testing"))]
1015 pub fn seed_test_prompt_queue(
1016 &self,
1017 slot: &SessionSlot,
1018 kind: PendingInteractionKind,
1019 count: usize,
1020 ) {
1021 self.seed_test_asks(slot, kind, count);
1022 }
1023
1024 #[cfg(any(test, feature = "testing"))]
1034 fn seed_test_asks(&self, slot: &SessionSlot, kind: PendingInteractionKind, count: usize) {
1035 use forge_primitives::browser::HandOffEnding;
1036 match kind {
1037 PendingInteractionKind::Question | PendingInteractionKind::Permission => {
1038 let domain = self
1039 .domain_session_for(slot)
1040 .unwrap_or_else(|| self.register_domain_session(slot.clone(), None));
1041 {
1042 let mut domain = domain.lock();
1043 for index in 0..count {
1044 if let Some(prompt) = test_prompt(kind) {
1045 domain
1046 .pending_interactions
1047 .insert(format!("test-tool-{index}"), prompt);
1048 }
1049 }
1050 }
1051 for _ in 0..count {
1052 self.note_ask(slot);
1053 }
1054 }
1055 PendingInteractionKind::DocumentHandOff => {
1056 self.note_client_attached();
1058 for _ in 0..count {
1059 let _ = self.register_document_hand_off(slot, TEST_DOCUMENT_PATH, true);
1060 }
1061 }
1062 PendingInteractionKind::SlackDraft => {
1063 for _ in 0..count {
1064 let (sender, _receiver) = tokio::sync::oneshot::channel();
1065 let draft = forge_primitives::slack::SlackDraft {
1066 id: uuid::Uuid::new_v4(),
1067 workspace: "test".to_owned(),
1068 conversation: "C-test".to_owned(),
1069 conversation_label: "test".to_owned(),
1070 thread_ts: None,
1071 text: "test".to_owned(),
1072 operation: forge_primitives::slack::SlackDraftOperation::Post,
1073 thread: None,
1074 tagged: Vec::new(),
1075 };
1076 self.slack_drafts.lock().insert(draft.id, (slot.clone(), draft, sender));
1077 self.note_ask(slot);
1078 }
1079 }
1080 PendingInteractionKind::BrowserHandOff => {
1081 for _ in 0..count {
1082 let (sender, _receiver) = tokio::sync::oneshot::channel::<HandOffEnding>();
1083 let handoff = forge_primitives::browser::HandOff {
1084 id: uuid::Uuid::new_v4(),
1085 reason: "test".to_owned(),
1086 tab: 1,
1087 target: "test-target".to_owned(),
1088 };
1089 self.browser_handoffs
1090 .lock()
1091 .insert(handoff.id, (slot.clone(), handoff, sender));
1092 self.note_ask(slot);
1093 }
1094 }
1095 PendingInteractionKind::TerminalHandOff => {
1096 for _ in 0..count {
1097 let (sender, _receiver) = tokio::sync::oneshot::channel::<HandOffEnding>();
1098 let handoff = forge_primitives::terminal::TerminalHandOff {
1099 id: uuid::Uuid::new_v4(),
1100 reason: "test".to_owned(),
1101 terminal: 1,
1102 secret: false,
1103 };
1104 self.terminal_handoffs
1105 .lock()
1106 .insert(handoff.id, (slot.clone(), handoff, sender));
1107 self.note_ask(slot);
1108 }
1109 }
1110 PendingInteractionKind::SettingsWrite => {
1111 for _ in 0..count {
1112 let ask = forge_primitives::settings::SettingsWriteAsk {
1113 id: uuid::Uuid::new_v4(),
1114 edits: vec![forge_primitives::settings::SettingsWriteChange {
1115 key: "sessions.heal_attempts".to_owned(),
1116 current: serde_json::json!(2),
1117 proposed: serde_json::json!(4),
1118 unset: false,
1119 }],
1120 };
1121 self.settings_writes.lock().insert(ask.id, (slot.clone(), ask, None));
1122 self.note_ask(slot);
1123 }
1124 }
1125 PendingInteractionKind::Vault => {
1126 for _ in 0..count {
1127 let draft = forge_primitives::vault::VaultDraft::new(
1128 forge_primitives::vault::VaultTool::Search,
1129 "test",
1130 );
1131 self.parked_vault_asks.lock().insert(draft.id, (slot.clone(), draft, None));
1132 self.note_ask(slot);
1133 }
1134 }
1135 }
1136 }
1137
1138 #[cfg(any(test, feature = "testing"))]
1142 pub fn seed_test_worker_row(&self, project_key: &ProjectKey, label: &str) {
1143 let _ = self.record_worker_row(
1144 project_key,
1145 label,
1146 &format!("{label}-test-id"),
1147 &format!("charter for {label}"),
1148 None,
1149 None,
1150 false,
1151 crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
1154 None,
1155 );
1156 }
1157
1158 #[cfg(any(test, feature = "testing"))]
1161 pub fn seed_test_worker_row_at(
1162 &self,
1163 project_key: &ProjectKey,
1164 label: &str,
1165 cwd: &str,
1166 is_git_repo: bool,
1167 ) {
1168 let _ = self.record_worker_row(
1169 project_key,
1170 label,
1171 &format!("{label}-test-id"),
1172 &format!("charter for {label}"),
1173 None,
1174 None,
1175 false,
1176 crate::mcp::workers::types::WorkerTree { is_git_repo, cwd: Some(cwd.to_owned()) },
1177 None,
1178 );
1179 }
1180
1181 #[cfg(any(test, feature = "testing"))]
1187 pub fn seed_test_git_worker_row(&self, project_key: &ProjectKey, label: &str) {
1188 let _ = self.record_worker_row(
1189 project_key,
1190 label,
1191 &format!("{label}-test-id"),
1192 &format!("charter for {label}"),
1193 None,
1194 None,
1195 false,
1196 crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
1197 None,
1198 );
1199 }
1200
1201 #[cfg(any(test, feature = "testing"))]
1206 pub fn attach_store_for_test(&self, db: crate::store::Db) {
1207 *self.db.lock() = Some(db);
1208 }
1209
1210 #[cfg(any(test, feature = "testing"))]
1216 pub fn seed_test_session_charter(&self, slot: &SessionSlot, charter: &str, interactive: bool) {
1217 let guard = self.db.lock();
1218 let Some(db) = guard.as_ref() else { return };
1219 let _ = crate::store::sessions::put(
1220 db,
1221 &crate::store::sessions::SessionRecord {
1222 org: slot.org().to_owned(),
1223 project: slot.project().to_owned(),
1224 label: slot.label().to_owned(),
1225 session_id: None,
1226 charter: Some(charter.to_owned()),
1227 kick: None,
1228 resume_kick: None,
1229 interactive: Some(interactive),
1230 seat_state: None,
1231 is_git_repo: None,
1232 mcp_families: None,
1233 cwd: None,
1234 },
1235 );
1236 }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::*;
1242 use forge_primitives::slack::SlackConfig;
1243
1244 fn config_with_a_tokenless_slack_entry() -> LoadedConfig {
1247 let mut config = LoadedConfig::empty_for_test();
1248 config.slack = vec![SlackConfig {
1249 workspace: "acme".to_owned(),
1250 token: String::new(),
1251 poll_seconds: 30,
1252 dm_poll_seconds: 150,
1253 thread_idle_days: 14,
1254 stale_after_hours: 24,
1255 }];
1256 config
1257 }
1258
1259 #[test]
1263 fn a_tokenless_slack_fixture_does_not_build_a_stub() {
1264 let dir = tempfile::tempdir().expect("tempdir");
1265 let error = Workspace::testing_stub_with_config(
1266 dir.path().to_path_buf(),
1267 config_with_a_tokenless_slack_entry(),
1268 )
1269 .err()
1270 .expect("a tokenless [[slack]] fixture must fail rather than yield an empty client set");
1271 assert!(
1272 error.contains("has an empty token"),
1273 "the error names the malformed fixture, got: {error}",
1274 );
1275 }
1276
1277 #[test]
1281 fn a_configured_systemone_section_builds_a_client() {
1282 let dir = tempfile::tempdir().expect("tempdir");
1283 let mut config = LoadedConfig::empty_for_test();
1284 config.systemone = Some(forge_system_one::SystemOneConfig {
1285 base_url: "https://api.typesafe.ai".to_owned(),
1286 api_key: None,
1287 model: "jev-latest".to_owned(),
1288 timeout_ms: 30_000,
1289 });
1290 let (workspace, _rx) =
1291 Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
1292 .expect("a valid systemone section builds");
1293 assert!(
1294 workspace.systemone.read().is_some(),
1295 "a configured section must reach the workspace"
1296 );
1297 }
1298
1299 #[test]
1300 fn an_absent_systemone_section_leaves_no_client() {
1301 let dir = tempfile::tempdir().expect("tempdir");
1302 let (workspace, _rx) = Workspace::testing_stub_with_config(
1303 dir.path().to_path_buf(),
1304 LoadedConfig::empty_for_test(),
1305 )
1306 .expect("an empty config builds");
1307 assert!(workspace.systemone.read().is_none(), "an absent section must not build a client");
1308 }
1309}