basis-acp 0.4.0

The Agent Client Protocol adapter over basis: the same event stream and the same seams, served to editors and web UIs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! What the server settles before any client is connected.
//!
//! Split out of `server.rs` for its size — the file was past the 800-line
//! ceiling with these inline, and past it again with the handlers still in one
//! module. The handlers are driven end to end over a real connection in
//! `tests/acp/`; what is left here is the pieces that can be checked without
//! one, which is why they sit together rather than beside each handler.

use std::{
    path::{Path, PathBuf},
    sync::Arc,
};

use agent_client_protocol::schema::{
    ProtocolVersion,
    v1::{
        ContentBlock, ErrorCode, InitializeRequest, ListSessionsRequest, ResourceLink,
        SessionCapabilities, TextContent,
    },
};

use super::config::{ServeConfig, SessionSource};
use super::initialize;
use super::lifecycle::{list_sessions, session_info, setup_failed};
use super::turn::prompt_text;
use super::workspaces::{ConfiguredSource, WorkspaceKey};
use crate::mode::ApprovalMode;
use basis::{
    ContextConfig, McpServer, PersistedSession, PreparedRun, RunConfig, RunError, Runtime,
    ToolsConfig, hooks::HooksConfig, mcp::McpConfig, provider::ProviderError, skills::SkillsConfig,
    templates::TemplatesConfig,
};
use mentra::ModelSelector;

/// A source that cannot enumerate, which is what most hosts supplying
/// their own sessions are.
struct Ephemeral;

#[async_trait::async_trait]
impl SessionSource for Ephemeral {
    async fn create(&self, _cwd: PathBuf, _mcp: Vec<McpServer>) -> Result<PreparedRun, RunError> {
        Err(RunError::NoSuchSession)
    }
}

fn capabilities(config: &ServeConfig) -> SessionCapabilities {
    initialize(&InitializeRequest::new(ProtocolVersion::V1), config)
        .agent_capabilities
        .session_capabilities
}

#[test]
fn initialize_advertises_resumable_sessions() {
    let response = initialize(
        &InitializeRequest::new(ProtocolVersion::V1),
        &ServeConfig::default(),
    );

    assert!(
        response.agent_capabilities.load_session,
        "sessions are persisted mentra agents, so a client can reconnect"
    );
    assert_eq!(
        response.protocol_version,
        ProtocolVersion::V1,
        "the client's version is echoed, not overridden"
    );
}

#[test]
fn initialize_advertises_only_the_session_methods_lan_answers() {
    let capabilities = capabilities(&ServeConfig::default());

    assert!(capabilities.resume.is_some());
    assert!(capabilities.close.is_some());
    assert!(
        capabilities.list.is_some(),
        "the default source reads mentra's store, so it can enumerate"
    );
    assert!(
        capabilities.delete.is_none(),
        "mentra's store has no delete; claiming one would promise a deletion that undoes itself"
    );
}

#[test]
fn a_source_that_cannot_enumerate_does_not_claim_a_list() {
    // Reporting "no sessions" for a workspace that has some is worse than
    // -32601, which at least says basis cannot answer.
    assert!(
        capabilities(&ServeConfig::with_source(Ephemeral))
            .list
            .is_none()
    );
}

#[tokio::test]
async fn a_source_that_cannot_enumerate_refuses_the_call_it_never_claimed() {
    // The other half of the same promise, and the half that was missing: the
    // handler is registered whatever the source is, so a client that asks
    // without reading the capability has to hear the same -32601 an
    // unadvertised method gives it — not an empty list for a workspace whose
    // conversations this source simply cannot see.
    let error = list_sessions(
        &ServeConfig::with_source(Ephemeral),
        ListSessionsRequest::new().cwd(PathBuf::from("/repo")),
    )
    .await
    .expect_err("a source with no registry must not answer for one");

    assert_eq!(error.code, ErrorCode::MethodNotFound);
}

#[test]
fn no_authentication_method_is_offered() {
    // basis's credential comes from the environment. Offering a method here
    // would invite a call to `authenticate`, which answers -32601.
    assert!(
        initialize(
            &InitializeRequest::new(ProtocolVersion::V1),
            &ServeConfig::default()
        )
        .auth_methods
        .is_empty()
    );
}

#[test]
fn a_listed_session_is_reported_in_the_workspace_it_was_listed_for() {
    let info = session_info(
        PersistedSession {
            agent_id: "agent-1".to_string(),
            name: "basis acp".to_string(),
            messages: 4,
        },
        Path::new("/repo"),
    );

    assert_eq!(
        &*info.session_id.0, "agent-1",
        "the agent id is the session id"
    );
    assert_eq!(
        info.cwd,
        PathBuf::from("/repo"),
        "a conversation is in this list because it belongs to this workspace"
    );
    assert_eq!(info.title.as_deref(), Some("basis acp"));
    assert_eq!(
        info.updated_at, None,
        "mentra exposes no timestamp, and a made-up one would sort a picker by nothing"
    );
}

#[test]
fn prompt_text_joins_text_blocks() {
    let text = prompt_text(&[
        ContentBlock::Text(TextContent::new("first".to_string())),
        ContentBlock::Text(TextContent::new("second".to_string())),
    ]);

    assert_eq!(text, "first\nsecond");
}

#[test]
fn a_resource_link_is_named_rather_than_dropped() {
    let text = prompt_text(&[ContentBlock::ResourceLink(ResourceLink::new(
        "notes.md".to_string(),
        "file:///repo/notes.md".to_string(),
    ))]);

    assert!(
        text.contains("notes.md") && text.contains("file:///repo/notes.md"),
        "what the user attached must survive into the prompt: {text}"
    );
}

#[test]
fn an_empty_prompt_produces_no_text() {
    assert!(prompt_text(&[]).is_empty());
}

#[test]
fn the_config_template_takes_the_clients_working_directory() {
    // Denied rather than granted, so the assertion below has something to
    // catch: granted is the default, and a template that was dropped
    // entirely would still look right.
    let source = ConfiguredSource::new(Some(
        RunConfig::new("/placeholder", "").with_shell(basis::ShellAccess::Denied),
    ));

    let built = source.config_for(PathBuf::from("/repo"), Vec::new());

    assert_eq!(built.workspace, PathBuf::from("/repo"));
    assert_eq!(
        built.shell,
        basis::ShellAccess::Denied,
        "everything the client cannot say must carry through"
    );
}

#[test]
fn the_clients_mcp_servers_reach_the_config() {
    let source = ConfiguredSource::new(None);

    let built = source.config_for(
        PathBuf::from("/repo"),
        vec![McpServer::Stdio(mentra::McpServerConfig {
            name: "fs".to_string(),
            command: "/bin/mcp-fs".to_string(),
            args: Vec::new(),
            env: Default::default(),
            cwd: None,
        })],
    );

    let supplied: Vec<&str> = built.mcp.supplied.iter().map(McpServer::name).collect();
    assert_eq!(
        supplied,
        vec!["fs"],
        "session/new is where a client says which servers it wants"
    );
}

#[test]
fn a_session_opens_asking_unless_the_operator_says_otherwise() {
    // The library default is to allow everything, which is right for a
    // headless run and wrong here: a client that can be asked should be.
    assert_eq!(ServeConfig::default().initial_mode, ApprovalMode::Prompt);
    assert_eq!(
        ServeConfig::new(RunConfig::new("/repo", "")).initial_mode,
        ApprovalMode::Prompt,
        "a template says what a run is, not how much it may do without asking"
    );
    assert_eq!(
        ServeConfig::default()
            .with_initial_mode(ApprovalMode::Never)
            .initial_mode,
        ApprovalMode::Never,
        "`basis acp --approve never` opens every session read-only"
    );
}

#[test]
fn a_missing_credential_is_an_authentication_failure_not_an_internal_one() {
    let error = setup_failed(RunError::Provider(ProviderError::NoCredential));

    assert_eq!(error.code, ErrorCode::AuthRequired);
    assert!(
        error
            .data
            .as_ref()
            .and_then(serde_json::Value::as_str)
            .is_some_and(|data| data.contains("ANTHROPIC_API_KEY")),
        "the actionable part is which variable to set: {:?}",
        error.data
    );
}

#[test]
fn other_setup_failures_stay_internal_errors() {
    // Reporting these as `auth_required` would send a client looking for a
    // login that would not have helped.
    let error = setup_failed(RunError::NoSuchSession);

    assert_eq!(error.code, ErrorCode::InternalError);
}

/// A runtime that resolves offline: a loopback endpoint nothing here dials and
/// a placeholder credential, so building it does no more than pick a provider.
/// Its history is ephemeral, so a test suite writes no database.
fn offline_runtime() -> Arc<Runtime> {
    Arc::new(
        Runtime::builder()
            .with_base_url("http://127.0.0.1:1/v1")
            .with_api_key("test-key")
            .with_ephemeral_history()
            .build()
            .expect("a runtime builds without touching the network"),
    )
}

/// A template that looks nowhere except where a test put something.
///
/// Every discovery root is pinned, global ones to `None`: an unpinned one would
/// read the developer's own configuration — and, for MCP, spawn the servers
/// their `mcp.json` names. The model is an id rather than "newest available",
/// which is what keeps resolution off the network.
fn offline_template() -> RunConfig {
    RunConfig::new("/placeholder", "")
        .with_model(ModelSelector::Id("test-model".to_string()))
        .with_context(ContextConfig {
            file_name: "AGENTS.md".to_string(),
            global_dir: None,
            walk_parents: false,
        })
        .with_skills(SkillsConfig {
            workspace_subdir: PathBuf::from(".basis/skills"),
            global_dir: None,
        })
        .with_templates(TemplatesConfig {
            workspace_subdir: PathBuf::from(".basis/templates"),
            global_dir: None,
        })
        .with_hooks(HooksConfig {
            workspace_file: PathBuf::from(".basis/hooks.json"),
            global_dir: None,
        })
        .with_tools(ToolsConfig {
            workspace_file: PathBuf::from(".basis/tools.json"),
            global_dir: None,
        })
        .with_mcp(McpConfig {
            workspace_file: PathBuf::from(".mcp.json"),
            global_dir: None,
            supplied: Vec::new(),
        })
}

/// ADR-0018's acceptance for basis-acp: a server holding two sessions on one
/// `cwd` holds one runtime and one workspace, not two of each.
///
/// Identity rather than a count of store files, which cannot tell the two
/// shapes apart — N private runtimes still share mentra's one default
/// directory. What a runtime per session actually cost was a second provider
/// resolution, a second store handle, and a second copy of every MCP server
/// and hook the repository configures; pointer identity is what says none of
/// that happened twice.
#[tokio::test]
async fn two_sessions_on_one_workspace_share_one_runtime() {
    let repository = tempfile::tempdir().expect("tempdir");
    let runtime = offline_runtime();
    let source = ConfiguredSource::on_runtime(Arc::clone(&runtime), Some(offline_template()));

    let first = source
        .create(repository.path().to_path_buf(), Vec::new())
        .await
        .expect("the first session opens");
    let second = source
        .create(repository.path().to_path_buf(), Vec::new())
        .await
        .expect("the second session opens");

    assert_ne!(
        first.agent_id(),
        second.agent_id(),
        "two sessions, not one conversation handed out twice"
    );

    let opened = source.opened();
    assert_eq!(
        opened.len(),
        1,
        "one directory is one workspace, however many sessions were minted on it"
    );
    assert!(
        std::ptr::eq(opened[0].mentra_runtime(), runtime.mentra_runtime()),
        "and both were minted on the one runtime this process built"
    );
}

/// The other half of the same key: two sessions share a workspace only when
/// they asked for the same servers.
///
/// Sharing on the directory alone would hand the second session the first
/// one's roster and drop what it asked for, which reads exactly like a server
/// with nothing to offer. Asserted on the key rather than on two opened
/// workspaces, because opening one spawns the programs it names.
#[test]
fn a_session_that_asked_for_different_servers_is_a_different_workspace() {
    let source = ConfiguredSource::new(Some(offline_template()));
    let server = |command: &str| {
        vec![McpServer::Stdio(mentra::McpServerConfig {
            name: "fs".to_string(),
            command: command.to_string(),
            args: Vec::new(),
            env: Default::default(),
            cwd: None,
        })]
    };
    let key = |mcp| WorkspaceKey::of(&source.config_for(PathBuf::from("/repo"), mcp));

    assert_eq!(
        key(Vec::new()),
        key(Vec::new()),
        "the same directory asked about the same way is one workspace"
    );
    assert_ne!(
        key(Vec::new()),
        key(server("/bin/mcp-fs")),
        "a supplied server is not none"
    );
    assert_ne!(
        key(server("/bin/mcp-fs")),
        key(server("/bin/other-fs")),
        "one name, two commands: a client that named a different program must get it"
    );
    assert_ne!(
        key(Vec::new()),
        WorkspaceKey::of(&source.config_for(PathBuf::from("/other-repo"), Vec::new())),
        "and a directory is a key"
    );
}