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}