Skip to main content

basis_host/
source.rs

1//! What basis's own sessions are built on: one runtime for the server process,
2//! one workspace for each directory a client asks about.
3//!
4//! ADR-0018's host shape. The original ACP source used to open a
5//! whole workspace per
6//! session, minting one run from it and dropping it — so
7//! a server holding N editor sessions held N mentra runtimes, N provider
8//! resolutions and N store handles, and no session outlived the workspace
9//! whose MCP connections and hooks it was supposed to be running with. What is
10//! here instead is the two lifetimes the ADR named: the runtime belongs to the
11//! process, the workspace belongs to the repository, and a session is minted
12//! from both.
13//!
14//! # Built late, and once
15//!
16//! Both are built on the first session that needs them, not at
17//! a server is constructed. Neither cell remembers a failure: a request that
18//! failed for want of a credential must fail the same way next time rather
19//! than be answered out of a poisoned cache.
20//!
21//! # What a cached workspace holds open
22//!
23//! Its MCP connections and its hook registrations on the runtime, both of
24//! which have to outlive every session minted from it — a workspace
25//! dropped while a session still runs takes that session's servers and its
26//! `.basis/hooks.json` with it. Nothing here evicts, therefore: `session/close`
27//! reaches basis after the fact, and a turn still unwinding would be the one
28//! paying for the tidiness. The bound is the number of distinct directories one
29//! server is asked about, which is the number of repositories a person has
30//! open.
31
32use std::{
33    collections::{BTreeMap, HashMap},
34    hash::{DefaultHasher, Hash, Hasher},
35    path::{Path, PathBuf},
36    sync::{Arc, Mutex},
37};
38
39use basis::{
40    McpConfig, McpServer, McpSseServerConfig, McpStreamableHttpServerConfig, PersistedSession,
41    PreparedRun, RunError, RunSpec, Runtime, RuntimeBuilder, Workspace, WorkspaceBuilder,
42    mcp::{McpSseLimits, McpStreamableHttpLimits, SecretString},
43};
44use tokio::sync::OnceCell;
45
46use crate::{SessionSource, SessionTemplate};
47
48/// The default source: sessions basis builds itself, on the process's runtime.
49pub struct ConfiguredSource {
50    /// What the operator said and a client cannot: which model and endpoint,
51    /// whether commands are granted, the product's voice. No workspace lives
52    /// here — every session brings its own `cwd`.
53    template: SessionTemplate,
54    /// One per process, however many sessions and workspaces it carries.
55    runtime: OnceCell<Arc<Runtime>>,
56    /// One per [`WorkspaceKey`], each behind its own cell.
57    ///
58    /// A sync lock on the map and a cell per entry, rather than one async lock
59    /// around the whole thing: opening a workspace spawns MCP servers and may
60    /// resolve a model over the network, and two clients on two repositories
61    /// must not queue behind each other for that. The map lock is only ever
62    /// held long enough to look one entry up.
63    workspaces: Mutex<HashMap<WorkspaceKey, Arc<OnceCell<Arc<Workspace>>>>>,
64}
65
66impl ConfiguredSource {
67    pub fn new(template: Option<SessionTemplate>) -> Self {
68        Self {
69            template: template.unwrap_or_default(),
70            runtime: OnceCell::new(),
71            workspaces: Mutex::new(HashMap::new()),
72        }
73    }
74
75    /// The same source over a runtime the caller already built.
76    ///
77    /// Test-only because building one resolves a provider credential, which is
78    /// exactly what an offline test cannot do and exactly what
79    /// [`RuntimeBuilder::with_api_key`](basis::RuntimeBuilder::with_api_key)
80    /// answers — and it is not on [`SessionTemplate`], which is the only thing
81    /// a configured source takes. A host that wants to supply its own runtime
82    /// supplies its own [`SessionSource`], which is what that seam is for.
83    #[cfg(test)]
84    pub fn on_runtime(runtime: Arc<Runtime>, template: Option<SessionTemplate>) -> Self {
85        let source = Self::new(template);
86        source
87            .runtime
88            .set(runtime)
89            .unwrap_or_else(|_| unreachable!("a runtime that was just constructed is empty"));
90        source
91    }
92
93    /// Builds the two halves of one session, in the client's working
94    /// directory: the workspace to open and the per-run spec to mint on it.
95    ///
96    /// Nothing here says anything about approval. A runtime's authorizer is
97    /// fixed for its life, so basis installs one that surfaces every
98    /// consequential call and answers none of them; which of those the client
99    /// actually sees is the session's mode, which can still change (see
100    /// [`SessionApproval`](crate::SessionApproval)).
101    fn parts_for(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> (WorkspaceBuilder, RunSpec) {
102        let template = &self.template;
103
104        let mut builder = Workspace::builder(cwd).with_shell(template.shell);
105        if let Some(model) = &template.model {
106            builder = builder.with_model(model.clone());
107        }
108        if let Some(system_prompt) = &template.system_prompt {
109            builder = builder.with_system_prompt(system_prompt.clone());
110        }
111        // Discovery is the template's promise (`SessionTemplate::with_discovery`)
112        // — the operator's to point, defaulting to basis's own roots.
113        let discovery = &template.discovery;
114        let builder = builder
115            .with_context(discovery.context.clone())
116            .with_skills(discovery.skills.clone())
117            .with_templates(discovery.templates.clone())
118            .with_hooks(discovery.hooks.clone())
119            .with_tools(discovery.tools.clone());
120
121        // The client's servers outrank the workspace's own: it is answering
122        // for this session in particular. Discovery still runs, so a
123        // `.mcp.json` the client said nothing about is still honored.
124        let builder = builder.with_mcp(self.session_mcp(mcp));
125
126        let mut spec = RunSpec::default();
127        if let Some(session_name) = &template.session_name {
128            spec = spec.with_session_name(session_name.clone());
129        }
130        if let Some(effort) = template.effort {
131            spec = spec.with_effort(effort);
132        }
133
134        (builder, spec)
135    }
136
137    /// One session's MCP config: where discovery starts, with the client's
138    /// servers landing on top — they outrank the workspace's own, because the
139    /// client is answering for this session in particular.
140    fn session_mcp(&self, mcp: Vec<McpServer>) -> McpConfig {
141        self.template.discovery.mcp.clone().with_supplied(mcp)
142    }
143
144    /// The workspace this session is minted from, and the per-run half that
145    /// mints it.
146    async fn workspace_for(
147        &self,
148        cwd: PathBuf,
149        mcp: Vec<McpServer>,
150    ) -> Result<(Arc<Workspace>, RunSpec), RunError> {
151        let key = WorkspaceKey::new(&cwd, &mcp);
152        let (builder, spec) = self.parts_for(cwd, mcp);
153
154        Ok((self.open(key, builder).await?, spec))
155    }
156
157    /// The workspace for `key`, opening it if this is the first session to ask.
158    async fn open(
159        &self,
160        key: WorkspaceKey,
161        builder: WorkspaceBuilder,
162    ) -> Result<Arc<Workspace>, RunError> {
163        let cell = Arc::clone(self.lock().entry(key).or_default());
164
165        // Concurrent openers of one key wait on this cell and share its result;
166        // openers of different keys never meet. A failed open leaves the cell
167        // empty, so the next session tries again rather than inheriting a
168        // failure that may have been about the network.
169        let workspace = cell
170            .get_or_try_init(|| async move {
171                let runtime = Arc::clone(self.runtime().await?);
172
173                Ok::<_, RunError>(Arc::new(builder.with_runtime(runtime).open().await?))
174            })
175            .await?;
176
177        Ok(Arc::clone(workspace))
178    }
179
180    /// The process's runtime, built on the first session that needs one.
181    async fn runtime(&self) -> Result<&Arc<Runtime>, RunError> {
182        self.runtime
183            .get_or_try_init(|| async { Ok(Arc::new(self.recipe().build()?)) })
184            .await
185    }
186
187    /// The process half of the template: what ADR-0018 moved onto the runtime.
188    ///
189    /// Provider and endpoint seed the private runtime's recipe, plus the model
190    /// as this runtime's *policy*. [`parts_for`](Self::parts_for) restates
191    /// that model per workspace as an override, so the policy is belt to its
192    /// braces — but a runtime that reported a model none of its workspaces use
193    /// would be describing a process nobody is running.
194    fn recipe(&self) -> RuntimeBuilder {
195        let template = &self.template;
196
197        let mut recipe = Runtime::builder();
198        if let Some(model) = &template.model {
199            recipe = recipe.with_model(model.clone());
200        }
201        if let Some(provider) = template.provider {
202            recipe = recipe.with_provider(provider);
203        }
204        if let Some(base_url) = &template.base_url {
205            recipe = recipe.with_base_url(base_url.clone());
206        }
207
208        recipe
209    }
210
211    fn lock(
212        &self,
213    ) -> std::sync::MutexGuard<'_, HashMap<WorkspaceKey, Arc<OnceCell<Arc<Workspace>>>>> {
214        // Poisoned means some other task panicked mid-insert. The map is still
215        // structurally sound, and refusing every later session over one panic
216        // would turn it into a dead connection — the same ruling the session
217        // registry makes.
218        self.workspaces
219            .lock()
220            .unwrap_or_else(|poisoned| poisoned.into_inner())
221    }
222
223    /// The workspaces this source has open, for the acceptance test that one
224    /// directory is one workspace however many sessions were minted on it.
225    #[cfg(test)]
226    pub fn opened(&self) -> Vec<Arc<Workspace>> {
227        self.lock()
228            .values()
229            .filter_map(|cell| cell.get().map(Arc::clone))
230            .collect()
231    }
232}
233
234#[async_trait::async_trait]
235impl SessionSource for ConfiguredSource {
236    async fn create(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> Result<PreparedRun, RunError> {
237        let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
238
239        workspace.prepare(spec)
240    }
241
242    /// The workspace comes from the client's `cwd` and the conversation from
243    /// an id the client kept, and nothing here pairs them: a client is free to
244    /// send a `cwd` that has nothing to do with the session it names.
245    /// `Workspace::resume` is what refuses that pairing — it compares the
246    /// persisted agent's own base directory against the workspace's identity
247    /// and answers `RunError::WorkspaceMismatch` — which is where the check
248    /// belongs, since a resume states *that* workspace's policy and tool
249    /// audience onto whatever it picks up.
250    async fn resume(
251        &self,
252        agent_id: &str,
253        cwd: PathBuf,
254        mcp: Vec<McpServer>,
255    ) -> Result<PreparedRun, RunError> {
256        let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
257
258        workspace.resume(agent_id, spec)
259    }
260
261    fn lists_sessions(&self) -> bool {
262        true
263    }
264
265    /// Reads mentra's store directly. Building a session to enumerate sessions
266    /// would resolve a model over the network to answer a question about files
267    /// on disk.
268    ///
269    /// This depends on a conversation being tagged with
270    /// [`store::runtime_identifier`](basis::store::runtime_identifier) for its
271    /// workspace, and every session opened here is: `Runtime::mint` states the
272    /// identifier per session, so the shared runtime this source runs on files
273    /// each `session/new` under the `cwd` it was opened for.
274    ///
275    /// A conversation that has been *resumed* and run again stays on this
276    /// list too: mentra 0.27 retains a persisted agent's own stored runtime
277    /// identifier through every later save ([`basis::store`] has the account),
278    /// so a row this source's shared runtime resumed re-files under this
279    /// workspace's own tag rather than the runtime's.
280    async fn list_sessions(&self, cwd: PathBuf) -> Result<Vec<PersistedSession>, RunError> {
281        basis::store::list(&cwd)
282    }
283
284    fn deletes_sessions(&self) -> bool {
285        true
286    }
287
288    /// Writes to mentra's store directly, for the reason
289    /// [`list_sessions`](Self::list_sessions) reads it directly: opening a
290    /// workspace to delete a record would resolve a model over the network to
291    /// answer a question about files on disk — and would fail for want of a
292    /// credential on a connection that has done nothing but list.
293    async fn delete(&self, agent_id: &str) -> Result<(), RunError> {
294        basis::store::forget(agent_id)
295    }
296}
297
298/// What makes two sessions the same workspace.
299///
300/// The directory is ADR-0018's key and the obvious half. The servers are the
301/// other half: `mcpServers` arrives per session, and it is the client
302/// answering for *this session in particular*, so two sessions on one
303/// repository need not answer alike. Keying on the directory alone would hand
304/// the second session the first one's servers and silently drop its own —
305/// because a session that came up without its servers looks exactly like one
306/// whose servers had nothing to offer.
307///
308/// Two workspaces on one directory is the cost, and it is bounded: they differ
309/// only in their supplied servers, so they discover the same hooks and carry
310/// the same command posture.
311///
312/// That equality is load-bearing now, and worth stating. A workspace registers
313/// its interception chain live, for its own tool audience, and two opens of one
314/// directory share that audience — so basis counts holders of one chain rather
315/// than registering two, and refuses a same-root open whose chain differs
316/// (`RunError::WorkspaceGuardConflict`). Nothing *this source decides* can
317/// provoke that refusal: every session's hooks come from one `SessionTemplate`'s
318/// discovery configuration, which does not vary per session, and the servers a
319/// key varies on are not part of a chain at all. What the configuration points
320/// at is another matter — the chain is read off disk at each open, so editing
321/// `.basis/hooks.json` between two opens of one directory *does* refuse the
322/// second, and the refusal names the directory. That is the honest boundary:
323/// this source never asks for two chains, and cannot promise a repository
324/// stopped changing under it.
325///
326/// What two live opens of one directory cost is what they always cost — a
327/// second set of MCP connections and a second discovery — and not a second pass
328/// through the repository's hook programs on every call.
329#[derive(Debug, PartialEq, Eq, Hash)]
330struct WorkspaceKey {
331    workspace: PathBuf,
332    /// A digest, so a `{:?}` of this key repeats nothing a client configured.
333    supplied: u64,
334}
335
336impl WorkspaceKey {
337    fn new(cwd: &Path, supplied: &[McpServer]) -> Self {
338        Self {
339            // Canonicalized so a symlinked spelling and its target are one
340            // workspace rather than two, and used as written when it does not
341            // resolve — the same ruling as `store::runtime_identifier`, for the
342            // same reason: keying a map is not the place to validate a path.
343            workspace: std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()),
344            supplied: digest(supplied),
345        }
346    }
347}
348
349/// A digest of the servers a client supplied, values included.
350///
351/// Everything that decides *what runs with what authority* is hashed: two
352/// sessions that named one server with different commands, or one command with
353/// a different token, are not asking for the same workspace. A digest rather
354/// than the values because this is a map key that outlives the request, and
355/// nothing a client configured should be held here in the clear — the same
356/// line [`McpServer`]'s own `Debug` draws.
357fn digest(servers: &[McpServer]) -> u64 {
358    let mut hasher = DefaultHasher::new();
359
360    for server in servers {
361        match server {
362            McpServer::Stdio(config) => {
363                "stdio".hash(&mut hasher);
364                config.name.hash(&mut hasher);
365                config.command.hash(&mut hasher);
366                config.args.hash(&mut hasher);
367                config.cwd.hash(&mut hasher);
368
369                // A `HashMap` has no order of its own, and a key that depended
370                // on one would differ between two identical requests.
371                let mut env: Vec<(&String, &String)> = config.env.iter().collect();
372                env.sort_unstable();
373                env.hash(&mut hasher);
374            }
375            // Both remote arms destructure exhaustively — config and limits —
376            // so a field mentra adds upstream is a compile error here rather
377            // than a value two different sessions silently share a workspace
378            // over. The discriminant is hashed like the values: two servers
379            // sharing a name and URL but not a transport are not asking for
380            // the same workspace.
381            McpServer::Sse(config) => {
382                let McpSseServerConfig {
383                    name,
384                    url,
385                    headers,
386                    allow_plaintext_credentials,
387                    limits,
388                } = config;
389                hash_remote(
390                    &mut hasher,
391                    "sse",
392                    name,
393                    url,
394                    headers,
395                    *allow_plaintext_credentials,
396                );
397
398                let McpSseLimits {
399                    connect_timeout,
400                    initialize_timeout,
401                    list_tools_timeout,
402                    call_tool_timeout,
403                    stream_idle_timeout,
404                    max_event_bytes,
405                    max_endpoint_bytes,
406                    max_tool_pages,
407                    max_tools,
408                } = limits;
409                (
410                    connect_timeout,
411                    initialize_timeout,
412                    list_tools_timeout,
413                    call_tool_timeout,
414                    stream_idle_timeout,
415                )
416                    .hash(&mut hasher);
417                (
418                    max_event_bytes,
419                    max_endpoint_bytes,
420                    max_tool_pages,
421                    max_tools,
422                )
423                    .hash(&mut hasher);
424            }
425            McpServer::Http(config) => {
426                let McpStreamableHttpServerConfig {
427                    name,
428                    url,
429                    headers,
430                    allow_plaintext_credentials,
431                    limits,
432                } = config;
433                hash_remote(
434                    &mut hasher,
435                    "http",
436                    name,
437                    url,
438                    headers,
439                    *allow_plaintext_credentials,
440                );
441
442                let McpStreamableHttpLimits {
443                    connect_timeout,
444                    initialize_timeout,
445                    list_tools_timeout,
446                    call_tool_timeout,
447                    stream_idle_timeout,
448                    max_event_bytes,
449                    max_response_bytes,
450                    max_tool_pages,
451                    max_tools,
452                } = limits;
453                (
454                    connect_timeout,
455                    initialize_timeout,
456                    list_tools_timeout,
457                    call_tool_timeout,
458                    stream_idle_timeout,
459                )
460                    .hash(&mut hasher);
461                (
462                    max_event_bytes,
463                    max_response_bytes,
464                    max_tool_pages,
465                    max_tools,
466                )
467                    .hash(&mut hasher);
468            }
469            // A transport this build does not know — the enum is
470            // `#[non_exhaustive]`. The discriminant separates two unknown
471            // *variants*; the name separates servers within one. What neither
472            // separates — two unknown servers of one variant differing only
473            // below the name — will key one workspace until this match learns
474            // the variant, and the consequence is bounded: the second session
475            // inherits the first one's connections rather than being handed a
476            // credential. basis's own same-crate matches (`McpServer::name`,
477            // its hand-written `Debug`) fail to compile the moment the
478            // variant lands, which is what forces this arm to become a real
479            // one.
480            server => {
481                std::mem::discriminant(server).hash(&mut hasher);
482                "unknown-transport".hash(&mut hasher);
483                server.name().hash(&mut hasher);
484            }
485        }
486    }
487
488    hasher.finish()
489}
490
491/// The remote transports' shared shape: discriminant tag, name, URL, ordered
492/// headers with their secrets fed into the hash, and the plaintext override —
493/// which decides where a credential may travel, so two configs differing only
494/// there are not the same authority.
495fn hash_remote(
496    hasher: &mut DefaultHasher,
497    transport: &str,
498    name: &str,
499    url: &str,
500    headers: &BTreeMap<String, SecretString>,
501    allow_plaintext_credentials: bool,
502) {
503    transport.hash(hasher);
504    name.hash(hasher);
505    url.hash(hasher);
506    // Already ordered: mentra types these as a `BTreeMap`.
507    for (header, value) in headers {
508        header.hash(hasher);
509        value.expose_secret().hash(hasher);
510    }
511    allow_plaintext_credentials.hash(hasher);
512}
513
514#[cfg(test)]
515mod tests {
516    use std::{
517        path::{Path, PathBuf},
518        sync::Arc,
519    };
520
521    use basis::{
522        ContextConfig, McpConfig, McpServer, Runtime, ToolsConfig, hooks::HooksConfig,
523        skills::SkillsConfig, templates::TemplatesConfig,
524    };
525    use mentra::{
526        McpServerConfig, McpSseServerConfig, McpStreamableHttpServerConfig, ModelSelector,
527    };
528
529    use super::{ConfiguredSource, WorkspaceKey, digest};
530    use crate::{Discovery, SessionSource, SessionTemplate};
531
532    #[test]
533    fn two_servers_differing_only_in_transport_hash_apart() {
534        // Without the discriminant in the hash, an SSE server and a Streamable
535        // HTTP server sharing a name and URL would key one workspace, and the
536        // second client's transport would silently become the first's.
537        let sse = McpServer::Sse(McpSseServerConfig::new("api", "https://example.com/mcp"));
538        let http = McpServer::Http(McpStreamableHttpServerConfig::new(
539            "api",
540            "https://example.com/mcp",
541        ));
542
543        assert_ne!(digest(&[sse]), digest(&[http]));
544    }
545
546    #[test]
547    fn the_plaintext_override_reaches_the_digest() {
548        // Latent until basis exposes the knob, armed now: two configs that
549        // differ in where a credential may travel are two authorities.
550        let base = || McpStreamableHttpServerConfig::new("api", "http://127.0.0.1/mcp");
551
552        assert_ne!(
553            digest(&[McpServer::Http(base())]),
554            digest(&[McpServer::Http(base().allowing_plaintext_credentials())]),
555        );
556    }
557
558    #[test]
559    fn the_clients_mcp_servers_reach_the_config() {
560        let source = ConfiguredSource::new(None);
561
562        let built = source.session_mcp(vec![McpServer::Stdio(McpServerConfig {
563            name: "fs".to_string(),
564            command: "/bin/mcp-fs".to_string(),
565            args: Vec::new(),
566            env: Default::default(),
567            cwd: None,
568        })]);
569
570        let supplied: Vec<&str> = built.supplied.iter().map(McpServer::name).collect();
571        assert_eq!(
572            supplied,
573            vec!["fs"],
574            "the session request is where a client says which servers it wants"
575        );
576    }
577
578    fn offline_runtime() -> Arc<Runtime> {
579        Arc::new(
580            Runtime::builder()
581                .with_base_url("http://127.0.0.1:1/v1")
582                .with_api_key("test-key")
583                .with_ephemeral_history()
584                .build()
585                .expect("a runtime builds without touching the network"),
586        )
587    }
588
589    fn offline_template() -> SessionTemplate {
590        SessionTemplate::new().with_model(ModelSelector::Id("test-model".to_string()))
591    }
592
593    fn offline_discovery() -> Discovery {
594        Discovery {
595            context: ContextConfig {
596                file_name: "AGENTS.md".to_string(),
597                global_dir: None,
598                walk_parents: false,
599            },
600            skills: SkillsConfig {
601                workspace_subdir: Some(PathBuf::from(".basis/skills")),
602                shared_workspace_dir: true,
603                global_dir: None,
604                shared_home_dir: false,
605            },
606            templates: TemplatesConfig {
607                workspace_subdir: PathBuf::from(".basis/templates"),
608                global_dir: None,
609            },
610            hooks: HooksConfig {
611                workspace_file: PathBuf::from(".basis/hooks.json"),
612                global_dir: None,
613                supplied: Vec::new(),
614            },
615            tools: ToolsConfig {
616                workspace_file: PathBuf::from(".basis/tools.json"),
617                global_dir: None,
618                supplied: Vec::new(),
619            },
620            mcp: McpConfig {
621                workspace_file: PathBuf::from(".mcp.json"),
622                global_dir: None,
623                supplied: Vec::new(),
624            },
625        }
626    }
627
628    #[tokio::test]
629    async fn two_sessions_on_one_workspace_share_one_runtime() {
630        let repository = tempfile::tempdir().expect("tempdir");
631        let runtime = offline_runtime();
632        let source = ConfiguredSource::on_runtime(
633            Arc::clone(&runtime),
634            Some(offline_template().with_discovery(offline_discovery())),
635        );
636
637        let first = source
638            .create(repository.path().to_path_buf(), Vec::new())
639            .await
640            .expect("the first session opens");
641        let second = source
642            .create(repository.path().to_path_buf(), Vec::new())
643            .await
644            .expect("the second session opens");
645
646        assert_ne!(
647            first.agent_id(),
648            second.agent_id(),
649            "two sessions, not one conversation handed out twice"
650        );
651
652        let opened = source.opened();
653        assert_eq!(
654            opened.len(),
655            1,
656            "one directory is one workspace, however many sessions were minted on it"
657        );
658        assert!(
659            std::ptr::eq(opened[0].mentra_runtime(), runtime.mentra_runtime()),
660            "and both were minted on the one runtime this process built"
661        );
662    }
663
664    /// The claim `list_sessions` is built on, and the one nothing checked: a
665    /// session this source opens is filed under the directory it was opened
666    /// for, so a client asking about that directory gets it back.
667    ///
668    /// Worth a test of its own because the doc beside `list_sessions` asserted
669    /// the opposite for two releases after it stopped being true — the tag is a
670    /// per-session fact now (`Runtime::mint`), and a shared runtime files each
671    /// workspace's conversations apart.
672    ///
673    /// Read through `store::list_in` rather than through `list_sessions`
674    /// itself, which resolves the store root the process would use. Opening
675    /// that directory in a test is a legitimate refusal on any machine that
676    /// ran basis 0.6 (`RunError::LegacyStore`), and that the two roots are one
677    /// path is pinned in `basis`'s own `store` tests.
678    #[tokio::test]
679    async fn a_session_this_source_opens_is_listed_for_its_own_directory() {
680        let store = tempfile::tempdir().expect("tempdir");
681        let repository = tempfile::tempdir().expect("tempdir");
682        let elsewhere = tempfile::tempdir().expect("tempdir");
683        let runtime = Arc::new(
684            Runtime::builder()
685                .with_base_url("http://127.0.0.1:1/v1")
686                .with_api_key("test-key")
687                .with_store_dir(store.path())
688                .build()
689                .expect("a runtime builds without touching the network"),
690        );
691        let source = ConfiguredSource::on_runtime(
692            runtime,
693            Some(offline_template().with_discovery(offline_discovery())),
694        );
695
696        let opened = source
697            .create(repository.path().to_path_buf(), Vec::new())
698            .await
699            .expect("the session opens");
700
701        let listed: Vec<String> = basis::store::list_in(store.path(), repository.path())
702            .expect("lists")
703            .into_iter()
704            .map(|session| session.agent_id)
705            .collect();
706        assert_eq!(
707            listed,
708            vec![opened.agent_id().to_string()],
709            "a client asking about this directory must be handed the session it opened here"
710        );
711        assert!(
712            basis::store::list_in(store.path(), elsewhere.path())
713                .expect("lists")
714                .is_empty(),
715            "and no other directory may claim it"
716        );
717    }
718
719    #[test]
720    fn a_session_that_asked_for_different_servers_is_a_different_workspace() {
721        let server = |command: &str| {
722            vec![McpServer::Stdio(McpServerConfig {
723                name: "fs".to_string(),
724                command: command.to_string(),
725                args: Vec::new(),
726                env: Default::default(),
727                cwd: None,
728            })]
729        };
730        let key = |mcp: Vec<McpServer>| WorkspaceKey::new(Path::new("/repo"), &mcp);
731
732        assert_eq!(
733            key(Vec::new()),
734            key(Vec::new()),
735            "the same directory asked about the same way is one workspace"
736        );
737        assert_ne!(
738            key(Vec::new()),
739            key(server("/bin/mcp-fs")),
740            "a supplied server is not none"
741        );
742        assert_ne!(
743            key(server("/bin/mcp-fs")),
744            key(server("/bin/other-fs")),
745            "one name, two commands: a client that named a different program must get it"
746        );
747        assert_ne!(
748            key(Vec::new()),
749            WorkspaceKey::new(Path::new("/other-repo"), &[]),
750            "and a directory is a key"
751        );
752    }
753}