| 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
|