basis 0.9.0

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! What `.basis/config.json` decides once something applies it.
//!
//! `basis/src/config/tests.rs` pins what the files *say*. This pins what
//! happens next: which model a workspace resolves, which effort a turn asks
//! the provider for, and — the point of the whole precedence chain — that a
//! caller who named either still gets what it named.
//!
//! Every builder here looks nowhere except where the test put something, the
//! pinning `tests/workspace.rs` explains: a developer's own
//! `~/.config/basis/config.json` must not be able to move an assertion.

use std::{
    io::{Read, Write},
    net::{TcpListener, TcpStream},
    path::{Path, PathBuf},
    sync::{
        Arc, Mutex,
        atomic::{AtomicUsize, Ordering},
    },
    thread,
};

use basis::{
    CollectingSink, Config, ContextConfig, Effort, MemoryConfig, Runtime, Workspace,
    WorkspaceBuilder, hooks::HooksConfig, skills::SkillsConfig, templates::TemplatesConfig,
    tools::declared::ToolsConfig,
};
use mentra::ModelSelector;

/// A builder that discovers nothing it was not shown — except the config file,
/// which is what these tests are about.
fn pinned(workspace: &Path) -> WorkspaceBuilder {
    Workspace::builder(workspace)
        .with_context(ContextConfig {
            file_name: "AGENTS.md".to_string(),
            // Also the directory config discovery reads its global file from,
            // so this one line keeps a real one out of every assertion below.
            global_dir: None,
            walk_parents: false,
        })
        .with_skills(SkillsConfig {
            workspace_subdir: Some(PathBuf::from(".basis/skills")),
            shared_workspace_dir: true,
            global_dir: None,
            shared_home_dir: false,
        })
        .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,
        })
        // A malformed file in the developer's own ~/.config/basis/memory must
        // never be able to fail this suite (G1); this test is not about
        // memory at all.
        .with_memory(MemoryConfig::disabled())
}

/// A runtime that resolves its provider locally and reaches a closed port if
/// anything tries to use it. Every model below is named by id, which resolves
/// without asking the provider for a list.
fn offline() -> 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("builds offline"),
    )
}

fn write_config(workspace: &Path, body: &str) {
    let path = workspace.join(".basis").join("config.json");
    std::fs::create_dir_all(path.parent().expect("a parent")).expect("create .basis");
    std::fs::write(path, body).expect("write config");
}

#[tokio::test]
async fn a_workspace_file_decides_the_model_when_nothing_else_did() {
    // The whole point of the file: without it, "no `--model`" means whatever
    // the provider lists newest today, which is not a thing a repository chose.
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(dir.path(), r#"{"schema": 1, "model": "from-the-file"}"#);

    let workspace = pinned(dir.path())
        .with_runtime(offline())
        .open()
        .await
        .expect("opens offline");

    assert_eq!(workspace.model(), "from-the-file");
    assert_eq!(workspace.config_files().len(), 1);
    assert_eq!(workspace.config_files()[0].scope, "workspace");
    assert_eq!(
        workspace
            .config()
            .model
            .as_ref()
            .map(|model| model.value.as_str()),
        Some("from-the-file"),
        "the workspace keeps the answer and the file that gave it"
    );
}

#[tokio::test]
async fn an_explicit_model_outranks_the_file() {
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(dir.path(), r#"{"schema": 1, "model": "from-the-file"}"#);

    let workspace = pinned(dir.path())
        .with_runtime(offline())
        .with_model(ModelSelector::Id("from-the-caller".to_string()))
        .open()
        .await
        .expect("opens offline");

    assert_eq!(workspace.model(), "from-the-caller");
}

#[tokio::test]
async fn an_empty_config_is_the_off_switch() {
    // A host whose own configuration is the only configuration hands in a
    // `Config` that says nothing, and the file on disk stops being read.
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(dir.path(), r#"{"schema": 1, "model": "from-the-file"}"#);

    let runtime = Arc::new(
        Runtime::builder()
            .with_base_url("http://127.0.0.1:1/v1")
            .with_api_key("test-key")
            .with_ephemeral_history()
            .with_model(ModelSelector::Id("the-runtime-policy".to_string()))
            .build()
            .expect("builds offline"),
    );

    let workspace = pinned(dir.path())
        .with_runtime(runtime)
        .with_config(Config::default())
        .open()
        .await
        .expect("opens offline");

    assert_eq!(workspace.model(), "the-runtime-policy");
    assert!(
        workspace.config_files().is_empty(),
        "nothing was read, so nothing may be reported"
    );
}

#[tokio::test]
async fn a_base_url_in_a_committed_file_fails_the_open_by_name() {
    // The refusal, at the surface that matters: not a warning, not an ignored
    // key — the workspace does not open, and the message names the file.
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(
        dir.path(),
        r#"{"schema": 1, "base_url": "http://127.0.0.1:1/v1"}"#,
    );

    let error = pinned(dir.path())
        .with_runtime(offline())
        .open()
        .await
        .expect_err("refused");

    let rendered = error.to_string();
    assert!(rendered.contains("config.json"), "{rendered}");
    assert!(rendered.contains("base_url"), "{rendered}");
}

#[tokio::test]
async fn a_malformed_file_fails_the_open_rather_than_running_another_model() {
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(dir.path(), "{not json");

    let error = pinned(dir.path())
        .with_runtime(offline())
        .open()
        .await
        .expect_err("refused");

    assert!(error.to_string().contains("config.json"), "{error}");
}

#[tokio::test]
async fn the_workspace_files_effort_reaches_the_provider() {
    // The far side of the wiring: an `effort` in the file has to arrive as the
    // reasoning options on the request the model actually receives, because
    // nothing between here and there reports it.
    let endpoint = ScriptedEndpoint::start();
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(
        dir.path(),
        r#"{"schema": 1, "model": "test-model", "effort": "high"}"#,
    );

    let workspace = pinned(dir.path())
        .with_runtime(endpoint.runtime())
        .open()
        .await
        .expect("opens against the scripted endpoint");

    let mut run = workspace.prepare("go").expect("mints");
    run.execute(CollectingSink::default())
        .await
        .expect("the scripted turn runs");

    let request = endpoint.first_request();
    assert!(
        request.contains(r#""reasoning_effort":"high""#),
        "the file's effort never reached the request: {request}"
    );
}

#[tokio::test]
async fn a_run_that_asked_for_an_effort_keeps_its_own() {
    // A flag describes this invocation and the file describes the repository,
    // so the more specific one holds — the ordering every key in the file has.
    let endpoint = ScriptedEndpoint::start();
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(
        dir.path(),
        r#"{"schema": 1, "model": "test-model", "effort": "high"}"#,
    );

    let workspace = pinned(dir.path())
        .with_runtime(endpoint.runtime())
        .open()
        .await
        .expect("opens against the scripted endpoint");

    let mut run = workspace
        .prepare(basis::workspace::RunSpec::new("go").with_effort(Effort::Low))
        .expect("mints");
    run.execute(CollectingSink::default())
        .await
        .expect("the scripted turn runs");

    let request = endpoint.first_request();
    assert!(
        request.contains(r#""reasoning_effort":"low""#),
        "the run's own answer must win: {request}"
    );
}

#[tokio::test]
async fn a_run_reports_the_effort_it_was_opened_at() {
    // The reader a picker needs, and the case that makes it worth having: the
    // effort was applied at mint, from the repository's own file, and nothing
    // on this run ever asked for one. A `PreparedRun` that answered from what
    // it had been *told* would report "no effort requested" for a session that
    // is demonstrably at `high` — and an ACP client would draw its picker on
    // the wrong value.
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(
        dir.path(),
        r#"{"schema": 1, "model": "test-model", "effort": "high"}"#,
    );

    let workspace = pinned(dir.path())
        .with_runtime(offline())
        .open()
        .await
        .expect("opens");

    let mut run = workspace.prepare("go").expect("mints");
    assert_eq!(run.effort(), Some(Effort::High));

    run.set_effort(Some(Effort::Low)).expect("sets");
    assert_eq!(run.effort(), Some(Effort::Low), "and it follows a change");

    run.set_effort(None).expect("clears");
    assert_eq!(
        run.effort(),
        None,
        "cleared means the provider's own default, which basis has no name for"
    );
}

#[tokio::test]
async fn a_run_nobody_asked_an_effort_of_reports_none() {
    // The other half: `None` has to mean "no level is being requested", not
    // "nobody has called `set_effort` yet".
    let dir = tempfile::tempdir().expect("tempdir");
    write_config(dir.path(), r#"{"schema": 1, "model": "test-model"}"#);

    let workspace = pinned(dir.path())
        .with_runtime(offline())
        .open()
        .await
        .expect("opens");

    assert_eq!(workspace.prepare("go").expect("mints").effort(), None);
}

/// The smallest endpoint that is a finished turn, with the request kept.
struct ScriptedEndpoint {
    base_url: String,
    requests: Arc<Mutex<Vec<String>>>,
}

impl ScriptedEndpoint {
    fn start() -> Self {
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test endpoint");
        let address = listener.local_addr().expect("read endpoint address");
        let requests = Arc::new(Mutex::new(Vec::new()));

        let recorded = Arc::clone(&requests);
        let turns = Arc::new(AtomicUsize::new(0));
        thread::spawn(move || {
            while let Ok((stream, _)) = listener.accept() {
                let turns = Arc::clone(&turns);
                let recorded = Arc::clone(&recorded);
                thread::spawn(move || answer(stream, &turns, &recorded));
            }
        });

        Self {
            base_url: format!("http://{address}/"),
            requests,
        }
    }

    fn runtime(&self) -> Arc<Runtime> {
        Arc::new(
            Runtime::builder()
                .with_base_url(&self.base_url)
                .with_api_key("test-key")
                .with_ephemeral_history()
                .build()
                .expect("builds against the scripted endpoint"),
        )
    }

    fn first_request(&self) -> String {
        self.requests
            .lock()
            .expect("requests")
            .first()
            .cloned()
            .expect("the model was asked something")
    }
}

/// A pinned model is looked up in the provider's listing before the first
/// turn (mentra `bfe952b`), which is one `GET …/models` per run that is
/// neither a turn nor scripted. Answered with a listing that names the test
/// model, so the lookup succeeds the way a real provider's would, and never
/// counted or recorded as a turn.
fn model_listing(request: &str) -> Option<String> {
    let line = request.lines().next()?;
    let target = line.split_whitespace().nth(1)?;
    (line.starts_with("GET ") && target.ends_with("/models")).then(|| {
        let body = r#"{"object":"list","data":[{"id":"test-model","object":"model"}]}"#;
        format!(
            "HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
            body.len()
        )
    })
}

fn answer(mut stream: TcpStream, turns: &AtomicUsize, recorded: &Mutex<Vec<String>>) {
    let request = read_http_request(&mut stream);
    if let Some(listing) = model_listing(&request) {
        let _ = stream.write_all(listing.as_bytes());
        return;
    }
    let index = turns.fetch_add(1, Ordering::SeqCst) + 1;
    recorded.lock().expect("requests").push(request);

    let body = format!(
        concat!(
            "data: {{\"id\":\"chatcmpl_{0}\",\"model\":\"test-model\",\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\",\"content\":\"done\"}}}}]}}\n\n",
            "data: {{\"id\":\"chatcmpl_{0}\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\n\n",
            "data: [DONE]\n\n"
        ),
        index
    );
    let response = format!(
        "HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{body}",
        body.len()
    );
    let _ = stream.write_all(response.as_bytes());
}

fn read_http_request(stream: &mut TcpStream) -> String {
    let mut bytes = Vec::new();
    let mut buffer = [0_u8; 4096];
    let mut header_end = None;
    let mut content_length = 0_usize;

    loop {
        let read = stream.read(&mut buffer).expect("read request");
        if read == 0 {
            break;
        }
        bytes.extend_from_slice(&buffer[..read]);
        if header_end.is_none()
            && let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n")
        {
            let end = index + 4;
            header_end = Some(end);
            let headers = String::from_utf8_lossy(&bytes[..end]);
            content_length = headers
                .lines()
                .find_map(|line| {
                    let (name, value) = line.split_once(':')?;
                    name.eq_ignore_ascii_case("content-length")
                        .then(|| value.trim().parse::<usize>().expect("content length"))
                })
                .unwrap_or_default();
        }
        if header_end.is_some_and(|end| bytes.len() >= end + content_length) {
            break;
        }
    }

    String::from_utf8(bytes).expect("request should be utf8")
}