| 507 |
507
|
|
REVOCATION_HELD.as_millis()
|
| 508 |
508
|
|
);
|
| 509 |
509
|
|
}
|
|
510
|
+ |
|
|
511
|
+ |
// ──────────────────────────────────────── the `--child-*` flags on `oa coder`
|
|
512
|
+ |
|
|
513
|
+ |
/// Parse a real `oa coder` command line and give back the child options a
|
|
514
|
+ |
/// fan-out started from it would run with.
|
|
515
|
+ |
///
|
|
516
|
+ |
/// The whole chain, argv first: clap parses the flags, `DelegationRequest`
|
|
517
|
+ |
/// carries them off the coder command, and `ChildOptions::resolve` settles
|
|
518
|
+ |
/// them. `oa coder --delegate` declared none of these flags, so every one of
|
|
519
|
+ |
/// them used to stop at the parser — or rather never reach it.
|
|
520
|
+ |
fn child_options_from(argv: &[&str]) -> openagents_cli::delegate::ChildOptions {
|
|
521
|
+ |
use clap::Parser;
|
|
522
|
+ |
let parsed = openagents_cli::cli::Cli::parse_from(argv);
|
|
523
|
+ |
let Some(openagents_cli::cli::Commands::Coder(coder)) = parsed.command else {
|
|
524
|
+ |
panic!("{argv:?} did not parse as `oa coder`");
|
|
525
|
+ |
};
|
|
526
|
+ |
let request = openagents_cli::delegate::DelegationRequest::from_coder(coder);
|
|
527
|
+ |
openagents_cli::delegate::ChildOptions::resolve(
|
|
528
|
+ |
request.child_model,
|
|
529
|
+ |
request.child_command,
|
|
530
|
+ |
request.child_config,
|
|
531
|
+ |
request.child_ask,
|
|
532
|
+ |
)
|
|
533
|
+ |
}
|
|
534
|
+ |
|
|
535
|
+ |
/// A stand-in that reports the argv and the `OPENCODE_CONFIG` it was started
|
|
536
|
+ |
/// with, in the claude lane's wire shape.
|
|
537
|
+ |
///
|
|
538
|
+ |
/// The marker is the point: if `--child-command` did not reach the child, the
|
|
539
|
+ |
/// `claude` on this machine ran instead and the marker is absent.
|
|
540
|
+ |
fn reporting_stand_in(name: &str) -> PathBuf {
|
|
541
|
+ |
stand_in(
|
|
542
|
+ |
name,
|
|
543
|
+ |
r#"#!/bin/sh
|
|
544
|
+ |
printf '{"type":"result","is_error":false,"result":"MARK argv=%s config=%s END"}\n' \
|
|
545
|
+ |
"$*" "${OPENCODE_CONFIG:-unset}"
|
|
546
|
+ |
"#,
|
|
547
|
+ |
)
|
|
548
|
+ |
}
|
|
549
|
+ |
|
|
550
|
+ |
/// What one child of a fan-out configured by `argv` actually reported.
|
|
551
|
+ |
///
|
|
552
|
+ |
/// Under the shared guard: these start real child processes, and the streaming
|
|
553
|
+ |
/// test above asserts against a wall clock, so a fan-out running alongside it
|
|
554
|
+ |
/// is measured as its latency.
|
|
555
|
+ |
async fn one_child_reports(argv: &[&str]) -> String {
|
|
556
|
+ |
let _exclusive = exclusive();
|
|
557
|
+ |
let options = child_options_from(argv);
|
|
558
|
+ |
let supervisor = DelegationSupervisor::new(1, "claude", None)
|
|
559
|
+ |
.with_isolation(Isolation::Directory)
|
|
560
|
+ |
.with_child_options(options);
|
|
561
|
+ |
let (results, _) = run(&supervisor, "ignored", None).await;
|
|
562
|
+ |
assert!(results[0].success, "the child failed: {}", results[0].output);
|
|
563
|
+ |
let said = results[0].output.clone();
|
|
564
|
+ |
assert!(
|
|
565
|
+ |
said.contains("MARK"),
|
|
566
|
+ |
"the stand-in did not run, so --child-command never reached the child: {said}"
|
|
567
|
+ |
);
|
|
568
|
+ |
said
|
|
569
|
+ |
}
|
|
570
|
+ |
|
|
571
|
+ |
/// `--child-config` reaches the child's environment.
|
|
572
|
+ |
///
|
|
573
|
+ |
/// This is the one with consequences: the CLI deliberately stores no provider
|
|
574
|
+ |
/// credential, so a harness config passed as `OPENCODE_CONFIG` is the only
|
|
575
|
+ |
/// route one has to a delegated child. Asserted against the environment of a
|
|
576
|
+ |
/// real process, not against the parsed flag.
|
|
577
|
+ |
#[tokio::test]
|
|
578
|
+ |
async fn child_config_reaches_a_real_child_process() {
|
|
579
|
+ |
let harness = reporting_stand_in("config-reporting-claude");
|
|
580
|
+ |
let config = std::env::temp_dir().join("oa-child-harness-config.json");
|
|
581
|
+ |
std::fs::write(&config, "{}").unwrap();
|
|
582
|
+ |
|
|
583
|
+ |
let said = one_child_reports(&[
|
|
584
|
+ |
"oa",
|
|
585
|
+ |
"coder",
|
|
586
|
+ |
"--delegate",
|
|
587
|
+ |
"--child-command",
|
|
588
|
+ |
harness.to_str().unwrap(),
|
|
589
|
+ |
"--child-config",
|
|
590
|
+ |
config.to_str().unwrap(),
|
|
591
|
+ |
"do the thing",
|
|
592
|
+ |
])
|
|
593
|
+ |
.await;
|
|
594
|
+ |
|
|
595
|
+ |
assert!(
|
|
596
|
+ |
said.contains(&format!("config={}", config.display())),
|
|
597
|
+ |
"OPENCODE_CONFIG did not reach the child: {said}"
|
|
598
|
+ |
);
|
|
599
|
+ |
}
|
|
600
|
+ |
|
|
601
|
+ |
/// `--child-model` reaches the child's argument list, and `--child-ask`
|
|
602
|
+ |
/// changes the mode it is started in.
|
|
603
|
+ |
#[tokio::test]
|
|
604
|
+ |
async fn child_model_and_child_ask_reach_a_real_child_process() {
|
|
605
|
+ |
let harness = reporting_stand_in("model-reporting-claude");
|
|
606
|
+ |
let command = harness.to_str().unwrap();
|
|
607
|
+ |
|
|
608
|
+ |
let said = one_child_reports(&[
|
|
609
|
+ |
"oa",
|
|
610
|
+ |
"coder",
|
|
611
|
+ |
"--delegate",
|
|
612
|
+ |
"--child-command",
|
|
613
|
+ |
command,
|
|
614
|
+ |
"--child-model",
|
|
615
|
+ |
"claude-sonnet-4-5",
|
|
616
|
+ |
"go",
|
|
617
|
+ |
])
|
|
618
|
+ |
.await;
|
|
619
|
+ |
assert!(
|
|
620
|
+ |
said.contains("--model claude-sonnet-4-5"),
|
|
621
|
+ |
"--child-model did not reach the child's argv: {said}"
|
|
622
|
+ |
);
|
|
623
|
+ |
// Without --child-ask a delegated child has nobody to ask, so it accepts
|
|
624
|
+ |
// its own edits.
|
|
625
|
+ |
assert!(
|
|
626
|
+ |
said.contains("--permission-mode acceptEdits"),
|
|
627
|
+ |
"{said}"
|
|
628
|
+ |
);
|
|
629
|
+ |
|
|
630
|
+ |
let asking = one_child_reports(&[
|
|
631
|
+ |
"oa",
|
|
632
|
+ |
"coder",
|
|
633
|
+ |
"--delegate",
|
|
634
|
+ |
"--child-command",
|
|
635
|
+ |
command,
|
|
636
|
+ |
"--child-ask",
|
|
637
|
+ |
"go",
|
|
638
|
+ |
])
|
|
639
|
+ |
.await;
|
|
640
|
+ |
assert!(
|
|
641
|
+ |
asking.contains("--permission-mode default"),
|
|
642
|
+ |
"--child-ask did not change the mode the child was started in: {asking}"
|
|
643
|
+ |
);
|
|
644
|
+ |
assert!(
|
|
645
|
+ |
!asking.contains("acceptEdits"),
|
|
646
|
+ |
"--child-ask left the child accepting its own edits: {asking}"
|
|
647
|
+ |
);
|
|
648
|
+ |
}
|
|
649
|
+ |
|
|
650
|
+ |
/// `--concurrency` on `oa coder` is the cap, under the name the TypeScript CLI
|
|
651
|
+ |
/// and `oa delegate` both use for it.
|
|
652
|
+ |
#[test]
|
|
653
|
+ |
fn concurrency_is_the_cap_on_the_coder_command_too() {
|
|
654
|
+ |
use clap::Parser;
|
|
655
|
+ |
for flag in ["--concurrency", "--max-parallel"] {
|
|
656
|
+ |
let parsed =
|
|
657
|
+ |
openagents_cli::cli::Cli::parse_from(["oa", "coder", "--delegate", flag, "3", "go"]);
|
|
658
|
+ |
let Some(openagents_cli::cli::Commands::Coder(coder)) = parsed.command else {
|
|
659
|
+ |
panic!("{flag} did not parse as `oa coder`");
|
|
660
|
+ |
};
|
|
661
|
+ |
assert_eq!(
|
|
662
|
+ |
openagents_cli::delegate::DelegationRequest::from_coder(coder).max_parallel,
|
|
663
|
+ |
Some(3),
|
|
664
|
+ |
"{flag} did not reach the cap"
|
|
665
|
+ |
);
|
|
666
|
+ |
}
|
|
667
|
+ |
}
|
|
668
|
+ |
|
|
669
|
+ |
/// The `delegate` tool a session runs starts children on the session's own
|
|
670
|
+ |
/// `--child-*` flags.
|
|
671
|
+ |
///
|
|
672
|
+ |
/// `oa coder --child-config f` with no `--delegate` opens a session that can
|
|
673
|
+ |
/// still fan out — through `/delegate` or the model calling the tool — and that
|
|
674
|
+ |
/// path built its supervisor with no child options at all. The flag parsed,
|
|
675
|
+ |
/// said nothing, and the child never saw the file.
|
|
676
|
+ |
#[tokio::test]
|
|
677
|
+ |
async fn the_delegate_tool_carries_the_sessions_child_options() {
|
|
678
|
+ |
let _exclusive = exclusive();
|
|
679
|
+ |
let harness = reporting_stand_in("tool-reporting-claude");
|
|
680
|
+ |
let config = std::env::temp_dir().join("oa-tool-harness-config.json");
|
|
681
|
+ |
std::fs::write(&config, "{}").unwrap();
|
|
682
|
+ |
|
|
683
|
+ |
let options = child_options_from(&[
|
|
684
|
+ |
"oa",
|
|
685
|
+ |
"coder",
|
|
686
|
+ |
"--child-command",
|
|
687
|
+ |
harness.to_str().unwrap(),
|
|
688
|
+ |
"--child-config",
|
|
689
|
+ |
config.to_str().unwrap(),
|
|
690
|
+ |
"--child-model",
|
|
691
|
+ |
"claude-sonnet-4-5",
|
|
692
|
+ |
]);
|
|
693
|
+ |
|
|
694
|
+ |
// A directory of its own rather than this checkout: the tool's children
|
|
695
|
+ |
// work where the session works, and a temporary directory keeps this test
|
|
696
|
+ |
// from making a git worktree of the whole repository.
|
|
697
|
+ |
let cwd = tempfile::tempdir().unwrap();
|
|
698
|
+ |
let report = openagents_cli::delegate::fanout_for_tool(
|
|
699
|
+ |
"do the thing",
|
|
700
|
+ |
1,
|
|
701
|
+ |
"claude",
|
|
702
|
+ |
None,
|
|
703
|
+ |
options,
|
|
704
|
+ |
Some(cwd.path().to_path_buf()),
|
|
705
|
+ |
)
|
|
706
|
+ |
.await;
|
|
707
|
+ |
|
|
708
|
+ |
assert!(
|
|
709
|
+ |
report.contains("MARK"),
|
|
710
|
+ |
"--child-command never reached the tool's child: {report}"
|
|
711
|
+ |
);
|
|
712
|
+ |
assert!(
|
|
713
|
+ |
report.contains(&format!("config={}", config.display())),
|
|
714
|
+ |
"OPENCODE_CONFIG never reached the tool's child: {report}"
|
|
715
|
+ |
);
|
|
716
|
+ |
assert!(
|
|
717
|
+ |
report.contains("--model claude-sonnet-4-5"),
|
|
718
|
+ |
"--child-model never reached the tool's child: {report}"
|
|
719
|
+ |
);
|
|
720
|
+ |
}
|
|
721
|
+ |
|
|
722
|
+ |
/// A `--child-*` flag the session's lane cannot honour is said, not dropped.
|
|
723
|
+ |
#[tokio::test]
|
|
724
|
+ |
async fn the_delegate_tool_refuses_a_flag_its_lane_cannot_honour() {
|
|
725
|
+ |
let options = child_options_from(&["oa", "coder", "--child-model", "gpt-5"]);
|
|
726
|
+ |
// ox-alpha children run on the grant the server issues, which pins the
|
|
727
|
+ |
// model. There is no honouring `--child-model` there.
|
|
728
|
+ |
let report =
|
|
729
|
+ |
openagents_cli::delegate::fanout_for_tool("go", 1, "ox-alpha", None, options, None).await;
|
|
730
|
+ |
assert!(
|
|
731
|
+ |
report.starts_with("No children were started:"),
|
|
732
|
+ |
"the tool ran a fan-out without the model it was given: {report}"
|
|
733
|
+ |
);
|
|
734
|
+ |
assert!(report.contains("--child-model"), "{report}");
|
|
735
|
+ |
}
|