Refuse a fan-out with no prompt, and stop three more invented defaults

83236d3deb0f · AtlantisPleb · · parent d10db9e7eda7

Refuse a fan-out with no prompt, and stop three more invented defaults

Four places `oa` supplied a value the caller never gave, on paths that
then spend money or write to disk — the same family as the `oa coder
--headless` prompt fabrication fixed in 4f3a557fd8.

`oa delegate` invented the identical literal, `Analyze workspace and run
tests`, and this one is worse: it spawns N children, each getting a real
`git worktree add` on disk, and on the default lane each opens a thread
and mints a grant. There was no trim, so `oa delegate "   "` shipped
whitespace to N paid children, and `describe()` labelled the run
"delegated task" so the header read plausibly while it happened. The
same operation exposed as a *model* tool already refuses this, trim
included, so the CLI was more permissive than the model-facing surface.
Both doors onto the engine — `oa delegate` and `oa coder --delegate` —
now refuse the omission before anything is prepared, spawned, or billed.

`oa repo create` with neither `--public` nor `--private` created a
**public** repository, on a default disclosed only in `--public`'s own
help text. `RepoAction::Import` twelve lines below keeps the
`Option<bool>` and lets the server decide. Publishing a repository is
not undoable by the reader who did not know they asked for it, so the
omission is refused alongside the two checks that already run before the
create, and `--public` no longer advertises itself as the default.

Outside a git checkout, `worktree` isolation is silently a plain empty
directory — `WorkspacePlan::resolve`'s only substitution — while the
header printed the isolation that was *asked* for, so a run announced
isolation it did not have. `WorkspacePlan::isolation()` existed for
exactly this and had no production caller. The plan is now resolved
before the header, the header reports the resolution, the substitution
is said out loud, and the plan that was reported is the one handed to
the dispatch rather than a second one resolved later. `dispatch_streaming`
keeps its signature and resolves its own plan, so nothing else changes.

The lane is the one of the four that is reported rather than refused,
and the reasoning is: `ox-alpha` is a dispatch default, not content only
the caller has, and `oa coder` defaults its lane the same way — refusing
here would make one of two sibling commands demand a flag the other
supplies. But it is the one lane that opens a thread per child and
spends this account's grant, and the header names the lane either way,
so it cannot distinguish a lane that was chosen from one that was
assumed. A fan-out given no `--lane` now says which lane it picked and
what that costs, before a child exists, and `--lane`'s help discloses
the default the way `--isolation`'s already did.

Each test was verified by reverting its fix. The prompt test asserts the
disk and the server before the exit code — a fan-out that spawns first
and fails after exits non-zero too — and reverted, it fails first on a
workspace left in the temporary directory it was pointed at and, with
that assertion muted, on the thread the child opened. The create test
asserts the requests the stub received (none) before the status, and
that `--private` and `--public` put different values on the wire;
reverted, `POST /api/v1/repos` shows up in a listing that must be empty.
The isolation test reads two things the binary printed — the header and
the child's own `started ... in ...` line — in a directory that is not a
checkout and in one that is; reverted, the header claims `worktree` over
a child line reading `in directory`.

Not done here: `runtime.rs` and `auth.rs` were left alone, other agents
are in them. The pre-existing wall-clock flake in
`delegate_test.rs::count_is_real_concurrency_and_the_cap_is_real_too`
and `a_child_streams_while_it_is_still_running` was reproduced on the
unmodified baseline under parallel test-binary load and is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/delegate.rs
  • modified crates/openagents-cli/tests/flags.rs
  • modified crates/openagents-cli/tests/project_lifecycle_test.rs

Diff

4 files changed, +487 -28

crates/openagents-cli/src/cli.rs modified +14 -3

@@ -516,7 +516,7 @@ pub enum RepoAction {

516 516
        name: String,
517 517
        #[arg(long, help = "Set the repository description")]
518 518
        description: Option<String>,
519
        #[arg(long, help = "Create a public repository (the default)")]
519
        #[arg(long, help = "Create a public repository")]
520 520
        public: bool,
521 521
        #[arg(long, help = "Create a private repository")]
522 522
        private: bool,

@@ -906,7 +906,7 @@ pub struct DelegateArgs {

906 906
907 907
    #[arg(
908 908
        long,
909
        help = "Target harness lane (e.g. ox-alpha, gemini, devin, claude, codex)"
909
        help = "Target harness lane (e.g. ox-alpha, gemini, devin, claude, codex). Defaults to ox-alpha, which spends this account's grant"
910 910
    )]
911 911
    pub lane: Option<String>,
912 912

@@ -2073,7 +2073,18 @@ async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialSto

2073 2073
                )));
2074 2074
            }
2075 2075
2076
            let is_private = visibility(public, private).unwrap_or(false);
2076
            // Naming neither flag used to create a *public* repository, a
2077
            // default disclosed only in `--public`'s own help text. Publishing
2078
            // a repository is not undoable by the reader who did not know they
2079
            // asked for it, so the omission is refused here — before the
2080
            // create, like the two checks above — rather than resolved in the
2081
            // direction that exposes it.
2082
            let Some(is_private) = visibility(public, private) else {
2083
                fail(
2084
                    "say whether the repository is public or private: \
2085
                     `oa repo create <name> --private` or `--public`",
2086
                );
2087
            };
2077 2088
            let (owner, repository_name) = if name.contains('/') {
2078 2089
                let (owner, repository_name) = or_fail(crate::repo::parse_repository_target(&name));
2079 2090
                (Some(owner), repository_name)
crates/openagents-cli/src/delegate.rs modified +124 -23

@@ -95,6 +95,16 @@ pub enum ChildEvent {

95 95
    Finished(Box<ChildWorkerResult>),
96 96
}
97 97
98
/// The lane a fan-out runs on when `--lane` names none.
99
///
100
/// It is not a neutral choice: `ox-alpha` is this process on the OpenAgents
101
/// proxy, so every child on it opens a thread and spends this account's grant.
102
/// The other lanes shell out to a harness the reader installed and pays for
103
/// themselves. That is why omitting `--lane` is reported at the point of
104
/// spending rather than left to be inferred from the header — see
105
/// [`run_delegation`].
106
pub const DEFAULT_CHILD_LANE: &str = "ox-alpha";
107
98 108
/// Which harness and model a child runs on.
99 109
#[derive(Debug, Clone, PartialEq, Eq)]
100 110
pub enum ChildLane {

@@ -334,6 +344,40 @@ impl DelegationSupervisor {

334 344
        results.unwrap_or_default()
335 345
    }
336 346
347
    /// Where children will work, or why there is nowhere for them to.
348
    ///
349
    /// `--dir` names it. It has to exist before a worktree can be prepared
350
    /// under it, so a path that is not a directory is a refusal rather than a
351
    /// fan-out that silently ran somewhere else.
352
    fn workdir(&self) -> Result<PathBuf, String> {
353
        match &self.directory {
354
            Some(directory) => {
355
                if !directory.is_dir() {
356
                    return Err(format!(
357
                        "{} is not a directory, so there is nowhere for the children to work.",
358
                        directory.display()
359
                    ));
360
                }
361
                Ok(directory.clone())
362
            }
363
            None => Ok(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
364
        }
365
    }
366
367
    /// Work out where every child will go, without building anything yet.
368
    ///
369
    /// Separate from the dispatch so a caller can read
370
    /// [`WorkspacePlan::isolation`] — the isolation children will *actually*
371
    /// get — before it announces one. `oa delegate` printed the isolation it
372
    /// was asked for, which outside a git checkout was `worktree` over a run
373
    /// that gave each child a plain empty directory.
374
    ///
375
    /// Nothing is written to disk here; the plan only names a base directory.
376
    /// [`WorkspacePlan::prepare`] is what creates anything.
377
    pub async fn plan(&self) -> Result<WorkspacePlan, String> {
378
        Ok(WorkspacePlan::resolve(self.workdir()?, self.isolation).await)
379
    }
380
337 381
    /// Run the fan-out, reporting each child as it goes.
338 382
    ///
339 383
    /// Returns `Err` only when no child could be started at all — a workspace

@@ -345,24 +389,23 @@ impl DelegationSupervisor {

345 389
        prompt: &str,
346 390
        events: mpsc::UnboundedSender<ChildEvent>,
347 391
        cancel: watch::Receiver<bool>,
392
    ) -> Result<Vec<ChildWorkerResult>, String> {
393
        let plan = self.plan().await?;
394
        self.dispatch_with_plan(plan, prompt, events, cancel).await
395
    }
396
397
    /// The same fan-out, over a plan the caller already resolved.
398
    ///
399
    /// A caller that reported the isolation runs the plan it reported, rather
400
    /// than resolving a second one that could answer differently.
401
    pub async fn dispatch_with_plan(
402
        &self,
403
        plan: WorkspacePlan,
404
        prompt: &str,
405
        events: mpsc::UnboundedSender<ChildEvent>,
406
        cancel: watch::Receiver<bool>,
348 407
    ) -> Result<Vec<ChildWorkerResult>, String> {
349 408
        let lane = ChildLane::parse(&self.lane);
350
        // `--dir` names where children work. It has to exist before a worktree
351
        // can be prepared under it, so a path that is not a directory is a
352
        // refusal rather than a fan-out that silently ran somewhere else.
353
        let cwd = match &self.directory {
354
            Some(directory) => {
355
                if !directory.is_dir() {
356
                    return Err(format!(
357
                        "{} is not a directory, so there is nowhere for the children to work.",
358
                        directory.display()
359
                    ));
360
                }
361
                directory.clone()
362
            }
363
            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
364
        };
365
        let plan = WorkspacePlan::resolve(cwd, self.isolation).await;
366 409
        let workspaces = plan.prepare(self.count).await?;
367 410
368 411
        let gate = Arc::new(Semaphore::new(self.max_parallel.max(1)));

@@ -1262,11 +1305,47 @@ pub async fn run_delegation(

1262 1305
            "{requested} children were asked for and this command runs at most {MAX_DELEGATE_COUNT}."
1263 1306
        ));
1264 1307
    }
1265
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
1266
    let prompt = args
1308
    // Refused before anything is prepared, spawned, or billed. A missing
1309
    // prompt used to become the literal `Analyze workspace and run tests`,
1310
    // which every child then ran: N `git worktree add`s on disk and, on the
1311
    // default lane, N threads and N grants spent on an instruction nobody
1312
    // gave. `describe()` even named the run "delegated task", so the header
1313
    // read plausibly while it happened.
1314
    //
1315
    // The same operation exposed as a model tool already refuses this:
1316
    // `tools.rs` answers `No children were started: \`prompt\` is required and
1317
    // must say what the child does.`, and trims before it decides. A CLI more
1318
    // permissive than the model-facing surface is backwards, so both now
1319
    // answer the omission the same way — whitespace included, which is the
1320
    // same omission with a space in it.
1321
    let Some(prompt) = args
1267 1322
        .prompt
1268
        .clone()
1269
        .unwrap_or_else(|| "Analyze workspace and run tests".to_string());
1323
        .as_deref()
1324
        .map(str::trim)
1325
        .filter(|given| !given.is_empty())
1326
        .map(str::to_string)
1327
    else {
1328
        fail(
1329
            "a delegation runs one prompt in every child, and there is nothing to run \
1330
             without one. Give it one: `oa delegate \"<prompt>\"`, or \
1331
             `oa coder --delegate \"<prompt>\"`",
1332
        );
1333
    };
1334
    // The lane decides who pays. Naming none is not refused — `oa coder`
1335
    // defaults its lane too, and a fan-out with no lane is a reasonable
1336
    // thing to ask for — but it is said out loud, here, before a child
1337
    // exists. The header below names the lane either way and so cannot
1338
    // distinguish a lane that was chosen from one that was assumed.
1339
    let lane_name = match args.lane.clone() {
1340
        Some(named) => named,
1341
        None => {
1342
            say(format!(
1343
                "No --lane given, so children run on {DEFAULT_CHILD_LANE}, which opens a \
1344
                 thread per child and spends this account's grant. Name another with --lane."
1345
            ));
1346
            DEFAULT_CHILD_LANE.to_string()
1347
        }
1348
    };
1270 1349
1271 1350
    if !ChildLane::known(&lane_name) {
1272 1351
        fail(&format!(

@@ -1275,7 +1354,7 @@ pub async fn run_delegation(

1275 1354
    }
1276 1355
    let lane = ChildLane::parse(&lane_name);
1277 1356
1278
    let isolation = match args.isolation.as_deref() {
1357
    let asked_isolation = match args.isolation.as_deref() {
1279 1358
        None => Isolation::Worktree,
1280 1359
        Some(named) => match Isolation::parse(named) {
1281 1360
            Some(isolation) => isolation,

@@ -1301,12 +1380,31 @@ pub async fn run_delegation(

1301 1380
    let description = describe(args.description.as_deref(), &prompt);
1302 1381
1303 1382
    let supervisor = DelegationSupervisor::new(requested, &lane_name, user_token)
1304
        .with_isolation(isolation)
1383
        .with_isolation(asked_isolation)
1305 1384
        .with_max_parallel(args.max_parallel.unwrap_or(requested))
1306 1385
        .keeping_workspaces(args.keep_workspaces)
1307 1386
        .in_directory(args.directory.as_deref().map(PathBuf::from))
1308 1387
        .with_child_options(child);
1309 1388
1389
    // Resolved before the header, and the header reports the resolution.
1390
    // `worktree` outside a git checkout is a plain empty directory per child;
1391
    // this used to print the value that was asked for, so a run announced an
1392
    // isolation it did not have and nothing later said otherwise. The plan is
1393
    // then handed to the dispatch, so what was reported is what runs.
1394
    let plan = match supervisor.plan().await {
1395
        Ok(plan) => plan,
1396
        Err(error) => fail(&format!("no children were started: {error}")),
1397
    };
1398
    let isolation = plan.isolation();
1399
    if isolation != asked_isolation {
1400
        say(format!(
1401
            "`{}` isolation is not available here — this is not a git checkout — so children get \
1402
             `{}` instead.",
1403
            asked_isolation.name(),
1404
            isolation.name(),
1405
        ));
1406
    }
1407
1310 1408
    say(format!(
1311 1409
        "Delegating {}: {} {} on {}, {} at a time, isolation: {}.",
1312 1410
        description,

@@ -1368,7 +1466,10 @@ pub async fn run_delegation(

1368 1466
        }
1369 1467
    });
1370 1468
1371
    let results = match supervisor.dispatch_streaming(&prompt, events, cancel).await {
1469
    let results = match supervisor
1470
        .dispatch_with_plan(plan, &prompt, events, cancel)
1471
        .await
1472
    {
1372 1473
        Ok(results) => results,
1373 1474
        // No child ran at all, so there is nothing to report but the reason.
1374 1475
        Err(error) => fail(&format!("no children were started: {error}")),
crates/openagents-cli/tests/flags.rs modified +299

@@ -1088,6 +1088,305 @@ fn a_missing_prompt_is_refused_the_same_way_offline_and_headless() {

1088 1088
    }
1089 1089
}
1090 1090
1091
// ------------------------------------------------- `oa delegate` with no prompt
1092
1093
/// A directory of this test's own, empty, and not a git checkout.
1094
fn scratch(name: &str) -> PathBuf {
1095
    let at = std::env::temp_dir().join(format!(
1096
        "oa-flags-{name}-{}-{}",
1097
        std::process::id(),
1098
        SystemTime::now()
1099
            .duration_since(UNIX_EPOCH)
1100
            .map(|d| d.as_nanos())
1101
            .unwrap_or(0)
1102
    ));
1103
    std::fs::create_dir_all(&at).expect("make a scratch directory");
1104
    at
1105
}
1106
1107
/// Every entry a fan-out left in the temporary directory it was pointed at.
1108
///
1109
/// `WorkspacePlan` lays its children out under `std::env::temp_dir()` as
1110
/// `oa-delegate-<pid>-…`, and `TMPDIR` is what decides where that is. Pointing
1111
/// it at a directory this test owns turns "was a workspace built on disk" into
1112
/// something readable.
1113
fn delegate_workspaces(tmp: &Path) -> Vec<String> {
1114
    let Ok(entries) = std::fs::read_dir(tmp) else {
1115
        return Vec::new();
1116
    };
1117
    let mut found: Vec<String> = entries
1118
        .filter_map(Result::ok)
1119
        .map(|entry| entry.file_name().to_string_lossy().into_owned())
1120
        .filter(|name| name.starts_with("oa-delegate-"))
1121
        .collect();
1122
    found.sort();
1123
    found
1124
}
1125
1126
/// `oa delegate` with no prompt starts no child: no worktree, no thread.
1127
///
1128
/// It used to substitute the literal `Analyze workspace and run tests` and run
1129
/// it in every child — a real `git worktree add` each, and on the default lane
1130
/// a thread and a grant each, spent on an instruction nobody gave. The exit
1131
/// code alone does not catch that; a fan-out that spawns and then fails exits
1132
/// non-zero too. What catches it is the disk and the server, so both are
1133
/// asserted before the status is.
1134
///
1135
/// The same omission is refused through `oa coder --delegate`, which is the
1136
/// other door onto the same engine.
1137
#[test]
1138
fn delegate_without_a_prompt_starts_no_child_and_opens_no_thread() {
1139
    let server = RouteServer::start(coder_routes);
1140
    let origin = server.origin();
1141
    let api_base = format!("{origin}/api/v1");
1142
    let tmp = scratch("delegate-refused");
1143
    let tmp_path = tmp.to_string_lossy().into_owned();
1144
1145
    for bare in [
1146
        vec!["--api-url", origin.as_str(), "delegate"],
1147
        // Whitespace is the same omission with a space in it. Untrimmed, this
1148
        // shipped `   ` to every child.
1149
        vec!["--api-url", origin.as_str(), "delegate", "   "],
1150
        // Two children, so a version that spawns first would leave two
1151
        // workspaces and open two threads rather than one of each.
1152
        vec!["--api-url", origin.as_str(), "delegate", "--agents", "2"],
1153
        vec!["--api-url", origin.as_str(), "coder", "--delegate"],
1154
    ] {
1155
        let run = oa_env(
1156
            &bare,
1157
            &[
1158
                ("OPENAGENTS_TOKEN", "t"),
1159
                ("OPENAGENTS_API_BASE", api_base.as_str()),
1160
                ("TMPDIR", tmp_path.as_str()),
1161
                ("HOME", &isolated_home().to_string_lossy()),
1162
            ],
1163
        );
1164
        assert_eq!(
1165
            delegate_workspaces(&tmp),
1166
            Vec::<String>::new(),
1167
            "{bare:?} built a workspace for a prompt nobody gave"
1168
        );
1169
        let paths: Vec<String> = server.hits().into_iter().map(|hit| hit.path).collect();
1170
        assert!(
1171
            !paths.iter().any(|p| p == "/api/v1/threads"),
1172
            "{bare:?} still opened a thread: {paths:?}"
1173
        );
1174
        assert!(
1175
            !run.stdout.contains("Analyze workspace"),
1176
            "a prompt nobody gave was run anyway: {}",
1177
            run.stdout
1178
        );
1179
        assert_eq!(run.status, Some(2), "{bare:?} stdout: {}", run.stdout);
1180
        assert!(
1181
            run.stderr.contains("<prompt>"),
1182
            "the refusal did not show the form that works: {}",
1183
            run.stderr
1184
        );
1185
    }
1186
1187
    // The control, on the same fixture and the same temporary directory: with
1188
    // a prompt, a child does start. Without this the assertions above would
1189
    // also pass against a binary that could reach neither the server nor the
1190
    // disk.
1191
    let given = oa_env(
1192
        &["--api-url", &origin, "delegate", "hello"],
1193
        &[
1194
            ("OPENAGENTS_TOKEN", "t"),
1195
            ("OPENAGENTS_API_BASE", api_base.as_str()),
1196
            ("TMPDIR", tmp_path.as_str()),
1197
            ("HOME", &isolated_home().to_string_lossy()),
1198
        ],
1199
    );
1200
    assert_eq!(given.status, Some(0), "stderr: {}", given.stderr);
1201
    let paths: Vec<String> = server.hits().into_iter().map(|hit| hit.path).collect();
1202
    assert!(
1203
        paths.iter().any(|p| p == "/api/v1/threads"),
1204
        "the fixture never opens a thread, so the test proves nothing: {paths:?}"
1205
    );
1206
    let _ = std::fs::remove_dir_all(&tmp);
1207
}
1208
1209
// ------------------------------------------- the isolation a fan-out reports
1210
1211
/// The isolation in the header is the one the children got.
1212
///
1213
/// `worktree` outside a git checkout is silently a plain empty directory —
1214
/// [`WorkspacePlan::resolve`]'s only substitution — and the header printed the
1215
/// value that was *asked* for, so the run announced isolation it did not have.
1216
/// Each case below reads two things the binary printed: the header, and the
1217
/// `[child 1] started … in …` line, which names the workspace kind the child
1218
/// was actually handed. A run whose header disagrees with its own child line
1219
/// is the defect.
1220
#[test]
1221
fn the_reported_isolation_is_the_one_the_children_get() {
1222
    let server = RouteServer::start(coder_routes);
1223
    let origin = server.origin();
1224
    let api_base = format!("{origin}/api/v1");
1225
    let tmp = scratch("delegate-isolation");
1226
    let tmp_path = tmp.to_string_lossy().into_owned();
1227
1228
    // Not a git checkout, so `worktree` is not available here.
1229
    let plain = scratch("delegate-plain");
1230
    // A checkout of its own, so it is. Made here rather than borrowed from the
1231
    // repository this test runs in: registering a worktree in that one would
1232
    // leave the developer's `git worktree list` holding this test's children.
1233
    let repo = scratch("delegate-repo");
1234
    for argv in [
1235
        vec!["init", "--quiet", "-b", "main"],
1236
        vec!["config", "user.email", "test@example.test"],
1237
        vec!["config", "user.name", "Test"],
1238
        vec!["commit", "--quiet", "--allow-empty", "-m", "root"],
1239
    ] {
1240
        let done = Command::new("git")
1241
            .args(&argv)
1242
            .current_dir(&repo)
1243
            .output()
1244
            .expect("run git");
1245
        assert!(done.status.success(), "git {argv:?}: {done:?}");
1246
    }
1247
1248
    for (directory, expected, workspace_line) in [
1249
        (&plain, "directory", "in directory "),
1250
        (&repo, "worktree", "in git worktree "),
1251
    ] {
1252
        let run = oa_env(
1253
            &[
1254
                "--api-url",
1255
                &origin,
1256
                "delegate",
1257
                "hello",
1258
                "--dir",
1259
                &directory.to_string_lossy(),
1260
            ],
1261
            &[
1262
                ("OPENAGENTS_TOKEN", "t"),
1263
                ("OPENAGENTS_API_BASE", api_base.as_str()),
1264
                ("TMPDIR", tmp_path.as_str()),
1265
                ("HOME", &isolated_home().to_string_lossy()),
1266
            ],
1267
        );
1268
        assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1269
        // What the child was handed, read from the child's own line.
1270
        assert!(
1271
            run.stdout.contains(workspace_line),
1272
            "no child reported a `{workspace_line}` workspace in {}: {}",
1273
            directory.display(),
1274
            run.stdout
1275
        );
1276
        // And what the header claimed, which has to be the same word.
1277
        assert!(
1278
            run.stdout.contains(&format!("isolation: {expected}.")),
1279
            "the header did not report `{expected}` in {}: {}",
1280
            directory.display(),
1281
            run.stdout
1282
        );
1283
        for other in ["directory", "worktree", "none"] {
1284
            if other != expected {
1285
                assert!(
1286
                    !run.stdout.contains(&format!("isolation: {other}.")),
1287
                    "the header reported `{other}` as well as `{expected}`: {}",
1288
                    run.stdout
1289
                );
1290
            }
1291
        }
1292
    }
1293
1294
    // The substitution is not merely reported in the header; it is said out
1295
    // loud, because `--isolation worktree` was asked for by default and was
1296
    // not what happened.
1297
    let run = oa_env(
1298
        &[
1299
            "--api-url",
1300
            &origin,
1301
            "delegate",
1302
            "hello",
1303
            "--dir",
1304
            &plain.to_string_lossy(),
1305
        ],
1306
        &[
1307
            ("OPENAGENTS_TOKEN", "t"),
1308
            ("OPENAGENTS_API_BASE", api_base.as_str()),
1309
            ("TMPDIR", tmp_path.as_str()),
1310
            ("HOME", &isolated_home().to_string_lossy()),
1311
        ],
1312
    );
1313
    assert!(
1314
        run.stdout.contains("not a git checkout"),
1315
        "the run substituted an isolation without saying so: {}",
1316
        run.stdout
1317
    );
1318
1319
    for at in [&tmp, &plain, &repo] {
1320
        let _ = std::fs::remove_dir_all(at);
1321
    }
1322
}
1323
1324
// -------------------------------------------- the lane a fan-out bills
1325
1326
/// A fan-out that was given no `--lane` says which one it chose, and why that
1327
/// matters, before a child exists.
1328
///
1329
/// `ox-alpha` is the one lane that opens a thread per child and spends this
1330
/// account's grant; the others shell out to a harness the reader installed.
1331
/// The header names the lane either way, so it cannot distinguish a lane that
1332
/// was chosen from one that was assumed — this line is what does.
1333
#[test]
1334
fn a_fan_out_with_no_lane_says_which_lane_it_picked() {
1335
    let server = RouteServer::start(coder_routes);
1336
    let origin = server.origin();
1337
    let api_base = format!("{origin}/api/v1");
1338
    let tmp = scratch("delegate-lane");
1339
    let tmp_path = tmp.to_string_lossy().into_owned();
1340
    let plain = scratch("delegate-lane-dir");
1341
1342
    let run = |lane: Option<&str>| {
1343
        let directory = plain.to_string_lossy().into_owned();
1344
        let mut argv = vec![
1345
            "--api-url",
1346
            origin.as_str(),
1347
            "delegate",
1348
            "hello",
1349
            "--dir",
1350
            directory.as_str(),
1351
        ];
1352
        if let Some(lane) = lane {
1353
            argv.extend_from_slice(&["--lane", lane]);
1354
        }
1355
        oa_env(
1356
            &argv,
1357
            &[
1358
                ("OPENAGENTS_TOKEN", "t"),
1359
                ("OPENAGENTS_API_BASE", api_base.as_str()),
1360
                ("TMPDIR", tmp_path.as_str()),
1361
                ("HOME", &isolated_home().to_string_lossy()),
1362
            ],
1363
        )
1364
    };
1365
1366
    let assumed = run(None);
1367
    assert_eq!(assumed.status, Some(0), "stderr: {}", assumed.stderr);
1368
    assert!(
1369
        assumed.stdout.contains("No --lane given")
1370
            && assumed.stdout.contains("spends this account's grant"),
1371
        "the run chose the billing lane without saying so: {}",
1372
        assumed.stdout
1373
    );
1374
1375
    // Named explicitly, it is not a substitution and there is nothing to
1376
    // report — otherwise this line would be noise on every run.
1377
    let named = run(Some("ox-alpha"));
1378
    assert_eq!(named.status, Some(0), "stderr: {}", named.stderr);
1379
    assert!(
1380
        !named.stdout.contains("No --lane given"),
1381
        "a lane the caller named was reported as a default: {}",
1382
        named.stdout
1383
    );
1384
1385
    for at in [&tmp, &plain] {
1386
        let _ = std::fs::remove_dir_all(at);
1387
    }
1388
}
1389
1091 1390
// ----------------------------------------------------------------- `--model`
1092 1391
1093 1392
/// `--model` decides the id sent at thread open; without it the default lane's
crates/openagents-cli/tests/project_lifecycle_test.rs modified +50 -2

@@ -311,7 +311,7 @@ fn a_named_owner_is_sent_to_the_server_rather_than_routed_on_a_guess() {

311 311
        let server = StubServer::start(vec![(201, ready_repository(&full_name))]);
312 312
        let origin = server.origin();
313 313
314
        let run = oa(&origin, &["repo", "create", &full_name]);
314
        let run = oa(&origin, &["repo", "create", &full_name, "--private"]);
315 315
        assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
316 316
317 317
        let hits = server.hits();

@@ -332,10 +332,58 @@ fn a_bare_name_carries_no_owner() {

332 332
    let server = StubServer::start(vec![(201, ready_repository("octavia/thing"))]);
333 333
    let origin = server.origin();
334 334
335
    let run = oa(&origin, &["repo", "create", "thing"]);
335
    let run = oa(&origin, &["repo", "create", "thing", "--private"]);
336 336
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
337 337
338 338
    let hits = server.hits();
339 339
    assert_eq!(hits[0].route(), "POST /api/v1/repos");
340 340
    assert_eq!(hits[0].json().get("owner"), None);
341 341
}
342
343
/// `repo create` will not choose the visibility for you, and what it sends is
344
/// what you named.
345
///
346
/// Naming neither flag used to send `"private": false` — a public repository,
347
/// on a default disclosed only in `--public`'s help. The exit code alone does
348
/// not catch that: it is the absence of a request that says nothing was
349
/// created, and the body of the requests that do go out that says the flags
350
/// are read rather than merely accepted.
351
#[test]
352
fn create_refuses_to_pick_a_visibility_and_sends_the_one_it_was_given() {
353
    let server = StubServer::start(vec![(201, ready_repository("octavia/thing"))]);
354
    let origin = server.origin();
355
356
    let silent = oa(&origin, &["repo", "create", "thing"]);
357
    // Asserted first, and about the server rather than the exit code: a
358
    // create that posts and then fails afterwards exits non-zero too, and the
359
    // repository is on the server either way.
360
    let asked_for: Vec<String> = server.hits().iter().map(Hit::route).collect();
361
    assert_eq!(
362
        asked_for,
363
        Vec::<String>::new(),
364
        "a repository was created without anyone saying whether it is public"
365
    );
366
    assert_eq!(silent.status, Some(2), "stdout: {}", silent.stdout);
367
    assert!(
368
        silent.stderr.contains("--private") && silent.stderr.contains("--public"),
369
        "the refusal did not name the two flags that answer it: {}",
370
        silent.stderr
371
    );
372
373
    // Both flags reach the wire, and they disagree with each other — which is
374
    // what a test that only asserted "some `private` field was sent" would
375
    // miss.
376
    for (flag, expected) in [("--private", true), ("--public", false)] {
377
        let server = StubServer::start(vec![(201, ready_repository("octavia/thing"))]);
378
        let origin = server.origin();
379
        let run = oa(&origin, &["repo", "create", "thing", flag]);
380
        assert_eq!(run.status, Some(0), "{flag} stderr: {}", run.stderr);
381
        let hits = server.hits();
382
        assert_eq!(hits.len(), 1, "{flag}: unexpected requests: {hits:?}");
383
        assert_eq!(
384
            hits[0].json()["private"],
385
            serde_json::json!(expected),
386
            "{flag} sent the wrong visibility"
387
        );
388
    }
389
}

This page updates live while a promote is in flight · changelog