Skip to main content

forge_workspace/workspace/
testing.rs

1//! Test-only constructors, seed helpers and dispatch interception for
2//! `Workspace`, so tests here and cross-crate tests can drive a workspace
3//! without a real subprocess.
4
5use 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
21/// Drive a seat's frame window down, so a fixture reaches the transcript
22/// fallback without emitting a window's worth of frames. The window is
23/// otherwise the constant it ships as, and the module this sits in is the
24/// gate that keeps it out of production.
25pub fn set_seat_ring_cap(workspace: &Workspace, cap: usize) {
26    workspace.update_tx.set_ring_cap(cap);
27}
28
29/// The tool id a parked test prompt answers to, so a test addresses the one
30/// it seeded rather than a key it has to know how the fixture built.
31#[cfg(any(test, feature = "testing"))]
32pub const TEST_TOOL_ID: &str = "test-tool";
33
34/// The path a seeded document hand-off parks at. The registration IS the
35/// state a seeder produces - nothing reads the file - so the fixture names
36/// one rather than touching disk.
37#[cfg(any(test, feature = "testing"))]
38const TEST_DOCUMENT_PATH: &str = "/proj/docs/test.md";
39
40/// A permission prompt for a fixture to park on a seat: the smallest one a
41/// dock can draw, carrying the id [`TEST_TOOL_ID`] answers to. A fixture is
42/// the only place a prompt is invented, so it says what it invents.
43#[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/// The tool call a fixture's prompt is about.
65#[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/// The same, for a question.
87#[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/// [`test_permission`] or [`test_question`] with a receiver nobody keeps.
113///
114/// `None` for every other kind: each parks in a registry of the workspace's
115/// own rather than in the session's pending set, and the seeders below
116/// register it there instead - so a test that wants the HELD state gets the
117/// real one.
118#[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    /// Mark a session as having completed its Connected handshake by
141    /// stamping `session_id` on its `DomainSession` (registering one if
142    /// absent). Delivery paths gate dispatch-vs-buffer on this, so tests
143    /// that assert a live worker/lead receives a prompt need it set -
144    /// in production every Running session has it stamped by `Connected`.
145    #[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    /// Construct a stub `AgentHandle` plus the matching
154    /// `Receiver<forge_primitives::AgentCommand>` that drains every command
155    /// dispatched to it. Tests use this to wire a stub connection
156    /// without spinning up a real subprocess; the bridge underneath is
157    /// `forge_agent::Agent::testing_stub` - same shape as before, now
158    /// reachable from a view via `forge_workspace::Workspace::*`
159    /// so a view crate needs no direct `forge-agent` dep.
160    ///
161    /// The returned `Receiver` carries `forge_primitives::AgentCommand`
162    /// because that's what the bridge's dispatcher accepts; this is
163    /// distinct from [`crate::protocol::Command`] (the workspace's
164    /// outer envelope) that wraps these primitives under a
165    /// `SessionSlot`.
166    pub fn testing_stub_handle()
167    -> (forge_agent::AgentHandle, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>) {
168        forge_agent::Agent::testing_stub()
169    }
170
171    /// Record a slot's failed spawn, as the connection-failure path does
172    /// before it releases the session. Test-only.
173    #[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    /// Stamp a slot's failed-turn record the way an errored `Result`
179    /// does, so a cross-crate test can read the row the surface builds.
180    /// Test-only.
181    #[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    /// Record a slot's death alone - the state a recovering connect leaves:
189    /// the failure cleared, the death riding with the reason it left. Add
190    /// [`Self::record_spawn_failure_for_test`] for the crashed-and-still-
191    /// down shape, where no connect has cleared anything. Test-only.
192    #[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    /// Push one update onto the fan-out, so a test can watch a view react
198    /// to the core without driving a session. Test-only.
199    #[cfg(any(test, feature = "testing"))]
200    pub fn emit_for_test(&self, update: SessionUpdate) {
201        let _ = self.update_tx.send(update);
202    }
203
204    /// Park a Slack draft on `key` as a blocked Slack tool call would. The
205    /// receiver is dropped on purpose: the tool call is the test's to
206    /// simulate, and the note's own path is covered where a handler awaits
207    /// it. Test-only.
208    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    /// [`Self::emit_for_test`], reporting whether a subscriber was there to
217    /// take it.
218    ///
219    /// A dead subscriber is dropped from the registry on the next send, so
220    /// the answer is `false` once nothing is listening - which is how a test
221    /// tells a subscription still attached from one that went with its
222    /// socket.
223    pub fn emit_for_test_reported(&self, update: SessionUpdate) -> bool {
224        self.update_tx.send(update)
225    }
226
227    /// How many subscribers are attached right now. Test-only: a test that
228    /// watches a subscription arrive and leave reads this rather than asking
229    /// whether an emit landed, which a permanently attached listener answers
230    /// for everything.
231    #[cfg(any(test, feature = "testing"))]
232    pub fn test_subscriber_count(&self) -> usize {
233        self.update_tx.count()
234    }
235
236    /// How many subscribers could answer a prompt right now. Test-only: the
237    /// role decides whether the core parks a turn on a reply, and a test that
238    /// watches a client declare it has nothing else to read.
239    #[cfg(any(test, feature = "testing"))]
240    pub fn test_answering_count(&self) -> usize {
241        self.update_tx.answering_count()
242    }
243
244    /// Whether the usage poller is registered and running. Test-only: the
245    /// poller's first tick is a minute out and its pass reaches the network,
246    /// so a cross-crate boot test reads the registry rather than watching it
247    /// poll.
248    #[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    /// Whether the dictate prewarm's start has run. Test-only: the start
257    /// reaches the network for models this fixture has none of, so a
258    /// cross-crate boot test reads the guard rather than watching it fetch.
259    #[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    /// Whether the voice prewarm's start has run. Test-only: the start
265    /// loads multi-gigabyte weights this fixture has none of, so a
266    /// cross-crate boot test reads the guard rather than watching it load.
267    #[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    /// Hold a claude version snapshot, so a cross-crate test can render the
273    /// version line without a real `claude --version` and npm probe.
274    /// Test-only.
275    #[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    /// Hold the last fatal, as the boot spawn's failure records it, so a
284    /// cross-crate test can read the home's encoding without a dying spawn.
285    /// Test-only.
286    #[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    /// Register a fresh testing-stub agent against `key`'s
292    /// `DomainSession`. Returns the matching
293    /// `forge_primitives::AgentCommand` receiver so tests can assert on
294    /// the commands the workspace routes through it (the same shape
295    /// as `testing_stub_handle()`, but with the handle installed in
296    /// one step so a view's test code doesn't have to touch
297    /// `AgentHandle` directly).
298    ///
299    /// Auto-creates a `DomainSession` for `key` when none is
300    /// registered yet; otherwise overwrites the existing
301    /// `DomainSession.conn` slot.
302    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    /// Put a prompt in a seat's queue exactly as a dispatch leaves it: the row
316    /// a client attaching mid-queue reads, with no dispatch to drive.
317    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    /// Construct an empty `Workspace` for use in unit tests. Skips
331    /// the on-disk `forge.toml` load + catalog scan that
332    /// [`Workspace::new`] performs; the returned workspace carries an
333    /// empty project list and an empty pool. Tests register a domain
334    /// session via [`Self::register_domain_session`] before
335    /// exercising any code path that needs one.
336    ///
337    /// Returns the workspace alongside the `SeatUpdate` receiver
338    /// this constructor subscribed. The workspace's `subscribe()` slot
339    /// is `None`, so a later `subscribe()` mints a stream of its own
340    /// rather than handing back this one.
341    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    /// Like `testing_stub` but with a caller-supplied `config_dir` so
346    /// tests that probe disk-side APIs (catalog scan, JSONL reads)
347    /// can point the stub at a tempdir of their own.
348    pub fn testing_stub_with_config_dir(
349        config_dir: PathBuf,
350    ) -> (Arc<Self>, mpsc::UnboundedReceiver<SeatUpdate>) {
351        // `empty_for_test` carries no `[[slack]]` entries, so there is
352        // nothing here for `from_config` to check.
353        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    /// Like `testing_stub_with_config_dir` but injects a caller-built
362    /// `LoadedConfig` so tests can drive the project-resolution paths
363    /// (`project_for_target` / `default_project`) that read the live
364    /// config's `projects`, which the `test_extra_projects` overlay
365    /// does not populate. Build the config via
366    /// `crate::config::load_from_dir` on a tempdir `forge.toml` fixture;
367    /// `db` stays `None`, so nothing touches the real machine store.
368    ///
369    /// Clients come from the injected config through
370    /// `SlackWorkspaces::from_config`, the only thing that checks a
371    /// `[[slack]]` entry; `load_from_dir` parses the section straight
372    /// through. An entry `from_config` refuses is returned as its bare
373    /// error rather than degrading to a workspace with no clients, the
374    /// way `new` refuses to boot on the same error. A malformed fixture
375    /// has to fail where the test that built it can see it.
376    ///
377    /// `#[cfg(test)]` because `LoadedConfig` is crate-private and only
378    /// this crate's own test modules call it; the feature build, which
379    /// exists for the cross-crate constructors above, has no use for it.
380    #[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    /// `Self::testing_stub_with_config` with the Slack clients supplied
397    /// by the caller, so a test can drive a real facade against a double
398    /// rather than a live workspace.
399    #[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        // Mirror the boot-time `ensure_forge_data_dir`: stub-based tests
407        // that exercise the cron / state stores expect `forge/` present.
408        let _ = crate::config::ensure_forge_data_dir(&config_dir);
409        let update_tx = UpdateFanout::default();
410        // A stub's caller stands in for the frontend, so it answers.
411        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        // A stub workspace never binds the listener, so nothing forwards
416        // through this client; the leg's own construction is covered by the
417        // gateway's tests.
418        let gateway = std::sync::Arc::new(forge_gateway::forward::Gateway::new(
419            std::sync::Arc::clone(&accounts),
420            reqwest::Client::new(),
421        ));
422        // `new_for_test`'s convention: the stub's whole app-support base
423        // sits under the test's own config dir.
424        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            // A stub reaches no network either: the refill has nothing to
442            // fetch.
443            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            // A stub workspace pretends the listener is bound: the
450            // tests driving it never open a socket, and the boot gate
451            // must read open for spawns to proceed.
452            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    /// Hold the CLI's per-user preferences document, so a cross-crate test
568    /// reads an ignore preference without the machine's own
569    /// `$HOME/.claude.json`. Test-only.
570    #[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    /// Give a fixture seat a live `claude` pid, so its held loop walks a real
576    /// process tree with no CLI behind it. Test-only.
577    #[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    /// Hold every stale read at `gate` until the fixture adds permits, so a
583    /// test can say what a hold answers while its scans are still in flight.
584    /// Test-only.
585    #[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    /// Report the seat's tree moved, as the file watch's own mark does, so a
591    /// cross-crate test drives the watch's pass instead of waiting on the OS
592    /// daemon's delivery, which has no budget to derive a wait from.
593    /// Test-only.
594    #[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    /// The mark a held seat's watch raises at the end of every pass, so a test
600    /// waits for a pass rather than for a clock. `None` for a seat held by
601    /// nobody. Test-only.
602    #[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    /// Hold the next [`Self::live_seats`] capture between its mint and its
608    /// reads: the returned receiver fires once it is held, and a message on
609    /// the returned sender releases it. Test-only.
610    #[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    /// Hold this capture where a test armed [`Self::pause_next_live_seats_capture`].
621    /// Test-only: a production capture never meets a gate.
622    #[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/// Test-mode command interception. Feature-gated and `pub` for the same
633/// reason the seeds below are: a view's tests reach for these to
634/// assert what boot WOULD have spawned, without starting a subprocess.
635#[cfg(any(test, feature = "testing"))]
636impl Workspace {
637    /// Enable test-mode app-level command interception. After this
638    /// call, every `Command` routed through the app-level branch of
639    /// `dispatch` is buffered in lieu of running the spawn handler;
640    /// drain via `drain_test_dispatch_buffer`. No-op if already
641    /// enabled. Test-only - tests use this to assert what would
642    /// have been dispatched without spinning up real subprocesses.
643    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    /// Whether the rotation announcer is registered and running, so a test
651    /// pins that the boot call actually reaches the one watcher.
652    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    /// Drain every app-level `Command` captured since the last call.
660    /// Returns empty when no intercept was enabled or no commands
661    /// were dispatched. Test-only.
662    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    /// Plant a session row in the seat state a boot test derives from, so
674    /// the wave can be driven without running a real spawn first. Test-only.
675    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    /// Park a terminal hand-off on `slot` as a blocked `terminal__hand_off`
702    /// call would, so a transport test can type through the hand-off's door.
703    /// Test-only.
704    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    /// Answer a parked terminal hand-off with Done, as the dock's button
714    /// would. Test-only.
715    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    /// Append a synthetic project to the test overlay searched first
729    /// by `find_project_view_by_name`. Used by respawn tests
730    /// to drive the Connected-hook worker-spawn trigger without
731    /// writing a real `forge.toml`. Test-only.
732    pub fn seed_test_project(&self, name: &str, path: &str) {
733        self.seed_test_project_inner(name, path, None, None);
734    }
735
736    /// Drop a project seeded by [`Self::seed_test_project`], so a test can
737    /// exercise the paths that react to a project leaving the config.
738    pub fn remove_test_project(&self, name: &str) {
739        self.test_extra_projects.lock().retain(|project| project.name != name);
740    }
741
742    /// [`Workspace::seed_test_project`] with a Slack binding, so a facade
743    /// test can drive the slot-to-workspace resolution. Test-only.
744    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    /// [`Workspace::seed_test_project`] with the project's
749    /// `max_workers` override, so facade tests can
750    /// exercise the override arm of the capacity read. Test-only.
751    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    /// Set the gateway bind error, so a cross-crate test can render
784    /// the failed state of the preflight gateway row. Test-only.
785    #[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    /// Set the gateway listener's ready flag, so a cross-crate test
791    /// can render each of the preflight gateway row's states. Test-only.
792    #[cfg(any(test, feature = "testing"))]
793    /// Stand in a stub handle for the next cold spawn, so a test can read
794    /// the launch settings the spawn hands its child. Consumed by that
795    /// spawn: a test that asserts on one installs one.
796    pub fn install_test_spawn_handle(&self, handle: forge_agent::AgentHandle) {
797        *self.test_spawn_handle.lock() = Some(handle);
798    }
799
800    /// The installed stand-in, taken so one spawn cannot use it twice.
801    pub(crate) fn take_test_spawn_handle(&self) -> Option<forge_agent::AgentHandle> {
802        self.test_spawn_handle.lock().take()
803    }
804
805    /// The `forge` MCP server the last spawn composed, so a test can drive a
806    /// tool through the spawn's OWN wiring rather than a copy of it.
807    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    /// Set the Gotify stream's connection flag, so a cross-crate test can
816    /// give the two connectors' liveness flags different values. Two
817    /// false-valued bools read as agreement, which lets a read wired to
818    /// the wrong connector pass unnoticed. Test-only.
819    #[cfg(any(test, feature = "testing"))]
820    pub fn seed_test_gotify_connected(&self, connected: bool) {
821        *self.gotify_connected.lock() = connected;
822    }
823
824    /// Mark `account` Ready, so a cross-crate test can render chip-bearing
825    /// rows without driving the real account loader. Test-only.
826    #[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    /// Drive `account` to `state` directly, so a cross-crate test can
833    /// render a mid-flight or bailed preflight screen without the real
834    /// loader. Test-only.
835    #[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    /// Record a probe failure on `account`, so a cross-crate test can
841    /// render bailed-row copy keyed on why the account bailed. Test-only.
842    #[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    /// Replace the dictation preflight snapshot, so a cross-crate test
852    /// can render any of its states without fetching 3 GB. Test-only.
853    #[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    /// Arm dictation with an engine over `models_dir`: its weights are
859    /// absent, so a take registers, meters and resolves without a model.
860    /// The caller holds the directory for as long as the engine is
861    /// wanted. Test-only.
862    ///
863    /// **The read-aloud set moves here too**, because its own resolution
864    /// falls back to the machine's app-support dir whatever config dir a
865    /// fixture gave the workspace: a test that kept a recording would write
866    /// into the real one and the next run would read it back.
867    #[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    /// Mark `key` live the way a started session is - a command sender
881    /// with nothing behind it - so a liveness gate reads true without a
882    /// CLI. Test-only.
883    #[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        // Held for the process's length: a dropped receiver reads as a
887        // session that has gone, which is the very state this marks the
888        // absence of.
889        Box::leak(Box::new(receiver));
890        self.command_senders.lock().insert(key.clone(), sender);
891    }
892
893    /// A `testing_stub` whose `[dictate] enabled` is true, so a
894    /// cross-crate test exercises the key handler's enabled path
895    /// without a model download. Test-only.
896    #[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    /// Pool `key` as a live session and bind `account` to it, so a
909    /// cross-crate test can render the account panel against a real
910    /// binding without spawning a CLI. Test-only.
911    #[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    /// Pool `key` under `session_id`, so a cross-crate test can read the
936    /// transcript that id names. Test-only.
937    #[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    /// Record `session_id` as the store's row for `key` with nothing live
953    /// behind it: the seat a wake resumes, whose transcript is the last
954    /// session it ran. Test-only.
955    #[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    /// Advertise `commands` and `agents` for `slot`, registering its
966    /// domain if it has none, so a cross-crate test can read what the
967    /// CLI's init frame would have left behind without driving the wire.
968    /// Test-only.
969    #[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    /// Park one ask of `kind` on `slot`, so a test can read the state a held
985    /// turn produces without driving the wire. A prompt's domain is
986    /// registered when it has none; a registry ask parks in its own registry
987    /// and needs no domain. Test-only.
988    #[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    /// Let go of everything parked on `slot`, which is half of what
994    /// answering does: the other half is the update that says so. Test-only.
995    #[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    /// Hold `slot` waiting to be let in, the state a session that cannot
1003    /// authenticate reads as. Test-only.
1004    #[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    /// Park `count` unanswered asks of `kind` on `slot` under distinct ids,
1013    /// the way a session holding a queue of them reads. Test-only.
1014    #[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    /// Park `count` asks of `kind` in the place that kind really lives - the
1025    /// session's pending set for a prompt, its own registry for everything
1026    /// else - so the fixture is the state, not a stand-in. A registry ask is
1027    /// inserted directly: the hand-offs and the draft carry a sender whose
1028    /// receiver nobody kept, and the settings and vault asks, whose sender is
1029    /// optional, carry `None`. Each parked ask then takes its number, the way
1030    /// every production registration does, so a seeded ask reads the identity
1031    /// the app would give it. It is the parking that makes the seat need a
1032    /// person, and no test answers a seeded ask through its own oneshot.
1033    #[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                // The registration parks the ask and takes its number.
1057                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    /// Persist a dynamic-worker row directly, bypassing `agents__spawn`.
1139    /// Cross-crate test access to the otherwise `pub(crate)` store write
1140    /// so a view can render launchpad worker rows against a seeded row.
1141    #[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            // Non-git: the seeded row nests under its project in the
1152            // launchpad without needing a worktree on disk.
1153            crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
1154            None,
1155        );
1156    }
1157
1158    /// [`Self::seed_test_worker_row`] with the worker pointed at its own
1159    /// tree, the way a spawn with `cwd` records it.
1160    #[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    /// [`Self::seed_test_worker_row`] for a worker that was spawned in a
1182    /// git repo, so its tree is the worktree under the project rather than
1183    /// the project root. The flag is what [`crate::Workspace::cwd_for_session`]
1184    /// composes the directory from, so a fixture that seeds the row without
1185    /// it hands the test the project's own tree.
1186    #[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    /// Attach a store to the stub. Every other testing constructor leaves
1202    /// `db` as `None` so nothing touches the real machine store; this one
1203    /// opens the caller's own redb, for the tests that read persisted rows
1204    /// (the worker-seat set, the launchpad's labels). Test-only.
1205    #[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    /// Write a session row for `slot` carrying `charter`, bypassing a
1211    /// spawn. A test that drives `/new` needs the store to already hold
1212    /// the worker's mission, which is what the re-delivery reads; the
1213    /// `interactive` flag is what its respawn reads to decide whether the
1214    /// worker may put a question to its user.
1215    #[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    /// A `[[slack]]` fixture `from_config` refuses: the entry names a
1245    /// workspace but carries no token.
1246    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    /// A fixture `from_config` refuses must not come back as a workspace
1260    /// whose Slack surface is quietly empty: a test written against it
1261    /// would pass while exercising nothing.
1262    #[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    /// The boot leg: a configured `[systemone]` section must reach the
1278    /// workspace as a client, and an absent one must leave the tools
1279    /// unregistered.
1280    #[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}