Wire oa coder's missing flags and make --scope visible

d6cd8d59d180 · AtlantisPleb · · parent 7b7453bd1006

Wire oa coder's missing flags and make --scope visible

`oa coder` declared five flags fewer than the TypeScript CLI and had no way
to continue a thread. Each of these is now read, and each is asserted by
running the binary twice — with the flag and without — against a server the
test owns.

`--resume`, `--resume <id>`, `--resume --last`, `--all`. New `resume` module:
`GET /api/v1/threads` is the picker's list, `GET /api/v1/threads/{id}/events`
is the transcript read through the `after` cursor, and
`POST /api/v1/threads/{id}/grants` re-mints the thread's authority. The
re-mint is what makes this a continuation rather than a new thread that has
read an old one — the server revokes the thread's active grants and bumps its
generation there. Proven live: resuming took thread cf987921 from generation
1 to 2 and answered on it.

The replayed transcript is the wire transcript, not the interface's: user
turns as sent, tool exchanges as the paired call/result messages the proxy
accepts, assistant text whole, and reasoning nowhere, because the live loop
never puts a thought on the wire. The session's own system prompt is
prepended, since the record does not hold one and a resumed turn without it
reaches the model with the conversation but none of its instructions.

The id is the flag's own value rather than the positional argument.
`openagents coder` reads the positional as the id under `--resume`, which
costs it the ability to resume and say something in the same breath.

`--offline`. The stand-in is now a mode someone asks for. It was previously
reachable only on failure and by no flag at all; the live path in `runtime.rs`
now fails loudly and there is no branch from there to here, so the two halves
are asserted together — `--offline` answers with nothing listening, and the
live path in the same conditions exits 2 without printing a word of it.

`--local` and `--model ollama:<model>`. `Lane::Local` already worked through
`--lane`; these are the names the TypeScript CLI uses. Proven live against a
local Ollama.

`--model <id>`. Settled against the live catalog before a thread opens, so an
id this deployment does not serve is refused by name rather than replaced with
the default: `--model claude-3-7-sonnet` names what it asked for and what the
deployment actually serves.

`--reasoning <minimal|low|medium|high|max>`. Carried on the thread as
`reasoning`, which is where the server takes it. Proven live: a run with
`--reasoning minimal` produced a thread reporting `reasoning_effort: minimal`
against the deployment's default of `high`.

Threads now also record the repository they were opened from, which is what
`--resume`'s picker filters on. Without it every thread is unattributable and
the filter can only ever be empty.

Flags that name the same setting differently end the command rather than one
of them silently winning: `--lane pro --model ox-alpha` is refused by name,
while `--lane pro --model gpt-5.6-luna` is one lane said twice and is not.

`oa auth login --scope` (#74) reached the wire and changed nothing a reader
could see, which is indistinguishable from not being read. The authorization
now carries the server's settled scope and the login reports it: the default
run says `chat:account forge:write` and `--scope forge:write` says
`forge:write`.

Refs #93, #74.

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/auth.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/delegate.rs
  • modified crates/openagents-cli/src/interactive.rs
  • modified crates/openagents-cli/src/lib.rs
  • added crates/openagents-cli/src/resume.rs
  • modified crates/openagents-cli/src/runtime.rs
  • modified crates/openagents-cli/tests/flags.rs

Diff

8 files changed, +2204 -8

crates/openagents-cli/src/auth.rs modified +11

@@ -847,6 +847,17 @@ pub struct DeviceAuthorization {

847 847
    pub verification_uri_complete: String,
848 848
    pub expires_in: i64,
849 849
    pub interval: u64,
850
    /// The scopes this authorization was opened for, as the server settled
851
    /// them.
852
    ///
853
    /// The server's answer, not the request: `--scope` asks, and the
854
    /// deployment decides — an unknown scope is refused outright, and asking
855
    /// for nothing takes the server's own default set. Carried here and
856
    /// reported so `--scope` has a visible effect. Without it the flag reached
857
    /// the wire and nothing a reader could see ever changed, which is
858
    /// indistinguishable from the flag not being read at all.
859
    #[serde(default)]
860
    pub scope: Option<String>,
850 861
}
851 862
852 863
/// One poll of the token endpoint.
crates/openagents-cli/src/cli.rs modified +317 -4

@@ -576,6 +576,70 @@ pub struct CoderArgs {

576 576
    #[arg(long, help = "Target harness lane (e.g. ox-alpha, gemini, devin, claude, codex)")]
577 577
    pub lane: Option<String>,
578 578
579
    /// Pick the model a turn runs on by its catalog id.
580
    ///
581
    /// `--lane` deals in tiers; this deals in ids, settled against `GET
582
    /// /api/v1/models` before a thread is opened, so an id this deployment
583
    /// does not serve is refused by name rather than quietly replaced with the
584
    /// default. `ollama:<model>` names a model on this machine.
585
    #[arg(
586
        long,
587
        help = "Model id to answer on, or ollama:<model> for one on this machine"
588
    )]
589
    pub model: Option<String>,
590
591
    /// Answer from an Ollama server on this machine.
592
    ///
593
    /// The same lane `--lane local` selects. Nothing in the conversation
594
    /// leaves the machine and nothing is metered.
595
    #[arg(long, help = "Answer from a model running on this machine through Ollama")]
596
    pub local: bool,
597
598
    /// Answer from the built-in stand-in instead of reaching a model.
599
    ///
600
    /// A deliberate mode, not a failure path. The live path fails loudly when
601
    /// it cannot reach a model — this is the only way to get the stand-in, and
602
    /// it opens no thread, spends nothing, and says on every reply that it is
603
    /// not a model.
604
    #[arg(long, help = "Answer from the built-in stand-in instead of reaching a model")]
605
    pub offline: bool,
606
607
    /// The effort recorded on the thread as its admitted execution shape.
608
    #[arg(
609
        long,
610
        value_parser = ["minimal", "low", "medium", "high", "max"],
611
        help = "Reasoning effort recorded on the thread as its admitted execution shape"
612
    )]
613
    pub reasoning: Option<String>,
614
615
    /// Continue a thread of the account's instead of opening a new one.
616
    ///
617
    /// Bare `--resume` shows a picker over this repository's recent threads;
618
    /// `--resume <id>` names one; `--resume --last` continues the most recent
619
    /// without asking.
620
    ///
621
    /// The id is the flag's own value rather than the positional argument.
622
    /// `openagents coder` reads the positional as the id under `--resume`,
623
    /// which costs it the ability to resume and say something in the same
624
    /// breath; here `oa coder --resume <id> "what did we conclude?"` is both.
625
    #[arg(
626
        long,
627
        num_args = 0..=1,
628
        default_missing_value = "",
629
        value_name = "ID",
630
        help = "Continue a thread instead of opening one. Bare: a picker over this repository's threads"
631
    )]
632
    pub resume: Option<String>,
633
634
    #[arg(long, help = "With --resume, continue the most recent thread without asking")]
635
    pub last: bool,
636
637
    #[arg(
638
        long,
639
        help = "With --resume, list every thread on the account rather than this repository's"
640
    )]
641
    pub all: bool,
642
579 643
    #[arg(long, help = "Run in non-interactive headless mode")]
580 644
    pub headless: bool,
581 645

@@ -609,6 +673,116 @@ pub struct CoderArgs {

609 673
    pub dev_port: u16,
610 674
}
611 675
676
/// The lane name `oa coder` runs on when nothing names another.
677
const DEFAULT_LANE: &str = "ox-alpha";
678
679
impl CoderArgs {
680
    /// The lane this invocation asked for, as a name [`crate::runtime::Lane`]
681
    /// understands.
682
    ///
683
    /// Three flags reach the same setting — `--lane` names a tier, `--model`
684
    /// names a catalog id, `--local` names this machine — so two of them
685
    /// naming different things is a refusal rather than a silent precedence
686
    /// order. A reader who wrote both meant one of them and cannot tell which
687
    /// one won.
688
    pub fn lane_name(&self) -> Result<String, String> {
689
        let mut asked: Vec<(&str, String)> = Vec::new();
690
        if let Some(lane) = self.lane.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
691
            asked.push(("--lane", lane.to_string()));
692
        }
693
        if let Some(model) = self.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
694
            asked.push(("--model", model.to_string()));
695
        }
696
        if self.local {
697
            asked.push(("--local", "local".to_string()));
698
        }
699
700
        match asked.len() {
701
            0 => Ok(DEFAULT_LANE.to_string()),
702
            1 => Ok(asked.remove(0).1),
703
            _ => {
704
                let resolved: Vec<crate::runtime::Lane> = asked
705
                    .iter()
706
                    .map(|(_, value)| crate::runtime::Lane::from_str(value))
707
                    .collect();
708
                // `--local --model ollama:x` is one intent written twice, and
709
                // is the one combination that is not a contradiction: `--local`
710
                // names the lane and the other names the model on it, so the
711
                // more specific one is what was meant. Bare `--local` alone
712
                // means "whatever is installed", which contradicts nothing.
713
                if resolved.iter().all(crate::runtime::Lane::is_local) {
714
                    let named: Vec<&str> = resolved
715
                        .iter()
716
                        .filter_map(|lane| match lane {
717
                            crate::runtime::Lane::Local(model) if !model.is_empty() => {
718
                                Some(model.as_str())
719
                            }
720
                            _ => None,
721
                        })
722
                        .collect();
723
                    match named.as_slice() {
724
                        [] => return Ok("local".to_string()),
725
                        [only] => return Ok(format!("ollama:{only}")),
726
                        _ if named.windows(2).all(|pair| pair[0] == pair[1]) => {
727
                            return Ok(format!("ollama:{}", named[0]))
728
                        }
729
                        _ => {}
730
                    }
731
                }
732
                // Two names for one hosted lane is agreement, not a conflict:
733
                // `--lane pro` and `--model gpt-5.6-luna` are the same thing
734
                // said twice.
735
                if resolved.windows(2).all(|pair| pair[0] == pair[1]) {
736
                    return Ok(asked.remove(0).1);
737
                }
738
                let names: Vec<String> = asked
739
                    .iter()
740
                    .map(|(flag, value)| format!("{flag} {value}"))
741
                    .collect();
742
                Err(format!(
743
                    "{} name different lanes. Give one of them.",
744
                    names.join(" and ")
745
                ))
746
            }
747
        }
748
    }
749
750
    /// Whether any of `--lane`, `--model` or `--local` was written.
751
    ///
752
    /// A resumed thread already holds the model its grant pins, so naming one
753
    /// on the same command line is a flag with nothing to do.
754
    pub fn named_a_lane(&self) -> bool {
755
        self.lane.is_some() || self.model.is_some() || self.local
756
    }
757
}
758
759
// ---------------------------------------------------------------------------
760
// coder: the offline stand-in
761
// ---------------------------------------------------------------------------
762
763
/// What `--offline` answers with.
764
///
765
/// A deliberate mode. This text is reachable only when someone asks for it:
766
/// the live path fails loudly when it cannot reach a model, and there is no
767
/// branch that falls back here. The reply names itself as a stand-in on every
768
/// turn, because a reply that reads like a model's and is not one is the
769
/// failure this whole flag exists to keep visible.
770
pub fn standin_reply(prompt: &str) -> String {
771
    let asked = prompt.trim();
772
    format!(
773
        "[stand-in] No model answered this. `--offline` asked for the built-in \
774
         stand-in, so nothing was sent anywhere, no thread was opened, and \
775
         nothing was spent.\n\n\
776
         You asked: {asked}\n\n\
777
         What works without a model: the composer, the transcript, `--plain`, \
778
         `--export`, and the tool registry. Drop `--offline` to reach a model, \
779
         or use `--local` for one on this machine."
780
    )
781
}
782
783
/// The label a stand-in session reports where a model id would go.
784
pub const STANDIN_MODEL: &str = "stand-in (no model attached)";
785
612 786
// ---------------------------------------------------------------------------
613 787
// delegate
614 788
// ---------------------------------------------------------------------------

@@ -1107,16 +1281,89 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1107 1281
            } else {
1108 1282
                api_base.clone()
1109 1283
            };
1284
            // Flags that name the same setting differently are refused before
1285
            // anything runs. Every one of these is a combination where one
1286
            // flag would have to be ignored, and a flag that is ignored is a
1287
            // flag that lied.
1288
            if let Err(reason) = coder.lane_name() {
1289
                fail(&reason);
1290
            }
1291
            let resuming = coder.resume.is_some();
1292
            if coder.offline && resuming {
1293
                fail("--resume reads the thread from the server; it cannot combine with --offline");
1294
            }
1295
            if coder.offline && coder.named_a_lane() {
1296
                fail(
1297
                    "--offline answers from the built-in stand-in and reaches no model, \
1298
                     so it cannot combine with --lane, --model or --local",
1299
                );
1300
            }
1301
            if coder.offline && coder.reasoning.is_some() {
1302
                fail(
1303
                    "--offline opens no thread, and --reasoning is recorded on a thread. \
1304
                     Drop one of them.",
1305
                );
1306
            }
1307
            if (coder.last || coder.all) && !resuming {
1308
                fail("--last and --all say which thread to continue, so they need --resume");
1309
            }
1310
            if resuming && coder.named_a_lane() {
1311
                fail(
1312
                    "a resumed thread answers on the model its own grant pins, \
1313
                     so --resume cannot combine with --lane, --model or --local",
1314
                );
1315
            }
1316
            if resuming && coder.reasoning.is_some() {
1317
                fail(
1318
                    "a resumed thread already carries the effort it was opened with, \
1319
                     so --resume cannot combine with --reasoning",
1320
                );
1321
            }
1322
1110 1323
            if coder.delegate {
1111 1324
                crate::delegate::run_delegation(
1112 1325
                    crate::delegate::DelegationRequest::from_coder(coder),
1113 1326
                    token,
1114 1327
                )
1115 1328
                .await?;
1116
            } else if coder.headless {
1117
                run_headless_coder(coder, &session_base, token).await?;
1329
            } else if coder.offline {
1330
                run_offline_coder(coder);
1118 1331
            } else {
1119
                crate::interactive::run_tui(coder, session_base, token).await?;
1332
                // What a thread is recorded against, and what `--resume`
1333
                // filters the picker to. A directory that is not an OpenAgents
1334
                // checkout has none, which is not an error: the thread is
1335
                // simply not attributable to a repository.
1336
                let repository = crate::repo::infer_repository(&endpoint.origin, None).ok();
1337
                // `--resume` is settled before anything draws: the picker
1338
                // prints to the normal screen, and a refusal has to be
1339
                // readable rather than painted over by the full-screen
1340
                // session and wiped on exit.
1341
                let resumed = if let Some(named) = coder.resume.as_deref() {
1342
                    let interactive =
1343
                        !coder.plain && !cli.json && std::io::IsTerminal::is_terminal(&std::io::stdin());
1344
                    let request = crate::resume::ResumeRequest {
1345
                        thread_id: Some(named).filter(|id| !id.is_empty()),
1346
                        last: coder.last,
1347
                        all: coder.all,
1348
                        repository: repository.clone(),
1349
                        interactive,
1350
                    };
1351
                    match crate::resume::resolve(&session_base, token.as_deref(), request).await {
1352
                        Ok(Some(resumption)) => Some(resumption),
1353
                        // An empty answer at the picker cancels, and cancelling
1354
                        // is not a failure.
1355
                        Ok(None) => return Ok(()),
1356
                        Err(reason) => fail(&reason),
1357
                    }
1358
                } else {
1359
                    None
1360
                };
1361
                if coder.headless {
1362
                    run_headless_coder(coder, &session_base, token, repository, resumed).await?;
1363
                } else {
1364
                    crate::interactive::run_tui(coder, session_base, token, repository, resumed)
1365
                        .await?;
1366
                }
1120 1367
            }
1121 1368
        }
1122 1369
        Commands::Delegate(args) => {

@@ -1410,6 +1657,10 @@ async fn run_auth_login(

1410 1657
            verification_uri_complete: pending.verification_uri_complete.clone(),
1411 1658
            expires_in: remaining,
1412 1659
            interval: pending.interval,
1660
            // The pending record predates the scope field and holds no scope.
1661
            // The authorization the server already opened decides it, and this
1662
            // path only waits for that one to be approved.
1663
            scope: None,
1413 1664
        };
1414 1665
        let token = or_fail(devices.wait(&authorization).await);
1415 1666
        or_fail(store.store(&token));

@@ -1444,12 +1695,18 @@ async fn run_auth_login(

1444 1695
                "verification_url": authorization.verification_uri_complete,
1445 1696
                "user_code": authorization.user_code,
1446 1697
                "expires_in": authorization.expires_in,
1698
                "scope": authorization.scope,
1447 1699
                "resume_command": resume_command,
1448 1700
            }));
1449 1701
        } else {
1450 1702
            println!("OpenAgents authorization is ready.");
1451 1703
            println!("Open this URL: {}", authorization.verification_uri_complete);
1452 1704
            println!("Authorization code: {}", authorization.user_code);
1705
            // What the approval page will ask the reader to grant. The server
1706
            // settles this: `--scope` asks and the deployment decides.
1707
            if let Some(scope) = authorization.scope.as_deref().filter(|s| !s.is_empty()) {
1708
                println!("Scope requested: {scope}");
1709
            }
1453 1710
            println!("After you approve the request, run: {resume_command}");
1454 1711
        }
1455 1712
        return;

@@ -1460,6 +1717,9 @@ async fn run_auth_login(

1460 1717
        authorization.verification_uri_complete
1461 1718
    );
1462 1719
    eprintln!("OpenAgents authorization code: {}", authorization.user_code);
1720
    if let Some(scope) = authorization.scope.as_deref().filter(|s| !s.is_empty()) {
1721
        eprintln!("Scope requested: {scope}");
1722
    }
1463 1723
    if !crate::auth::open_browser(&authorization.verification_uri_complete) {
1464 1724
        eprintln!("The browser did not open. Open the authorization URL above.");
1465 1725
    }

@@ -3873,6 +4133,43 @@ fn run_provider(action: ProviderAction, json: bool) {

3873 4133
    }
3874 4134
}
3875 4135
4136
// ---------------------------------------------------------------------------
4137
// coder: the offline stand-in
4138
// ---------------------------------------------------------------------------
4139
4140
/// `oa coder --offline`.
4141
///
4142
/// One turn, line-oriented, from [`standin_reply`]. It opens no socket at all,
4143
/// so it answers with the network down — which is the whole point, and the
4144
/// only reason the stand-in exists.
4145
///
4146
/// This is a mode someone asked for, not a fallback someone landed in. The
4147
/// live path in [`crate::runtime`] fails loudly when it cannot reach a model
4148
/// and there is no branch from there to here. The inversion this replaces was
4149
/// the reverse: a rejected request answered with the sentence `Completed
4150
/// autonomous reasoning turn (offline fallback).` and exit 0, with no flag
4151
/// that could ask for it.
4152
fn run_offline_coder(coder: CoderArgs) {
4153
    let Some(prompt) = coder.prompt.as_deref().map(str::trim).filter(|p| !p.is_empty()) else {
4154
        fail(
4155
            "--offline answers one prompt from the built-in stand-in. Give it one: \
4156
             `oa coder --offline \"<prompt>\"`",
4157
        );
4158
    };
4159
    let answer = standin_reply(prompt);
4160
    println!("{answer}");
4161
    println!("\nModel: {STANDIN_MODEL}");
4162
    if let Some(path) = coder.export.as_deref() {
4163
        let transcript = crate::interactive::transcript_of(prompt, &answer);
4164
        if let Err(error) = std::fs::write(path, &transcript) {
4165
            fail(&format!(
4166
                "could not write the transcript to {path}: {error}"
4167
            ));
4168
        }
4169
        println!("Transcript written to {path}");
4170
    }
4171
}
4172
3876 4173
// ---------------------------------------------------------------------------
3877 4174
// coder: headless
3878 4175
// ---------------------------------------------------------------------------

@@ -3886,13 +4183,15 @@ async fn run_headless_coder(

3886 4183
    coder: CoderArgs,
3887 4184
    api_base: &str,
3888 4185
    token: Option<String>,
4186
    repository: Option<String>,
4187
    resumed: Option<crate::resume::Resumption>,
3889 4188
) -> Result<(), Box<dyn std::error::Error>> {
3890 4189
    let prompt = coder
3891 4190
        .prompt
3892 4191
        .clone()
3893 4192
        .unwrap_or_else(|| "Analyze workspace and run tests".to_string());
3894 4193
    println!("Executing coder prompt headlessly: {}", prompt);
3895
    let lane_name = coder.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
4194
    let lane_name = coder.lane_name().unwrap_or_else(|reason| fail(&reason));
3896 4195
    // A headless session may start children. They run on the same lane and the
3897 4196
    // same credential, and they do not get the tool themselves.
3898 4197
    let tools = crate::tools::HarnessToolRegistry::with_delegation(

@@ -3910,6 +4209,20 @@ async fn run_headless_coder(

3910 4209
        token,
3911 4210
        tools,
3912 4211
    );
4212
    runtime.reasoning = coder.reasoning.clone();
4213
    runtime.repository = repository;
4214
    if let Some(resumption) = &resumed {
4215
        if let Err(reason) = crate::resume::apply(&mut runtime, resumption).await {
4216
            fail(&reason);
4217
        }
4218
        println!(
4219
            "{}",
4220
            crate::resume::resumed_line(
4221
                resumption,
4222
                runtime.last_model.as_deref().unwrap_or("an unnamed model")
4223
            )
4224
        );
4225
    }
3913 4226
    let result = runtime
3914 4227
        .execute_turn(&prompt, |chunk| {
3915 4228
            print!("{}", chunk);
crates/openagents-cli/src/delegate.rs modified +7

@@ -1626,6 +1626,13 @@ mod child_option_tests {

1626 1626
            isolation: None,
1627 1627
            keep_workspaces: false,
1628 1628
            lane: None,
1629
            model: None,
1630
            local: false,
1631
            offline: false,
1632
            reasoning: None,
1633
            resume: None,
1634
            last: false,
1635
            all: false,
1629 1636
            headless: false,
1630 1637
            export: None,
1631 1638
            plain: false,
crates/openagents-cli/src/interactive.rs modified +34 -4

@@ -420,18 +420,34 @@ pub async fn run_tui(

420 420
    args: CoderArgs,
421 421
    api_base: String,
422 422
    token: Option<String>,
423
    repository: Option<String>,
424
    resumed: Option<crate::resume::Resumption>,
423 425
) -> Result<(), Box<dyn std::error::Error>> {
424
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
426
    let lane_name = args.lane_name()?;
425 427
    let lane = Lane::from_str(&lane_name);
426 428
427 429
    // `--plain` asks for the line-oriented path even on a terminal. Without a
428 430
    // terminal there is no full-screen session to run either way.
429 431
    if args.plain || !is_terminal() {
430
        return run_without_a_terminal(args, api_base, token, lane).await;
432
        return run_without_a_terminal(args, api_base, token, repository, lane, resumed).await;
431 433
    }
432 434
433 435
    let tools = session_tools(&lane_name, &token);
434
    let session = CoderRuntimeSession::new(lane.clone(), Some(api_base), token, tools);
436
    let mut session = CoderRuntimeSession::new(lane.clone(), Some(api_base), token, tools);
437
    session.reasoning = args.reasoning.clone();
438
    session.repository = repository;
439
    // A resumed thread is adopted before the screen is entered, so its refusal
440
    // is readable rather than painted over and wiped on exit.
441
    if let Some(resumption) = &resumed {
442
        crate::resume::apply(&mut session, resumption).await?;
443
        println!(
444
            "{}",
445
            crate::resume::resumed_line(
446
                resumption,
447
                session.last_model.as_deref().unwrap_or("an unnamed model")
448
            )
449
        );
450
    }
435 451
436 452
    let (control_tx, control_rx) = unbounded_channel::<Control>();
437 453
    let (event_tx, mut event_rx) = unbounded_channel::<TurnEvent>();

@@ -496,9 +512,11 @@ async fn run_without_a_terminal(

496 512
    args: CoderArgs,
497 513
    api_base: String,
498 514
    token: Option<String>,
515
    repository: Option<String>,
499 516
    lane: Lane,
517
    resumed: Option<crate::resume::Resumption>,
500 518
) -> Result<(), Box<dyn std::error::Error>> {
501
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
519
    let lane_name = args.lane_name()?;
502 520
    let Some(prompt) = args.prompt.clone() else {
503 521
        eprintln!(
504 522
            "`oa coder` needs a terminal for an interactive session. \

@@ -509,6 +527,18 @@ async fn run_without_a_terminal(

509 527
510 528
    let tools = session_tools(&lane_name, &token);
511 529
    let mut session = CoderRuntimeSession::new(lane, Some(api_base), token, tools);
530
    session.reasoning = args.reasoning.clone();
531
    session.repository = repository;
532
    if let Some(resumption) = &resumed {
533
        crate::resume::apply(&mut session, resumption).await?;
534
        println!(
535
            "{}",
536
            crate::resume::resumed_line(
537
                resumption,
538
                session.last_model.as_deref().unwrap_or("an unnamed model")
539
            )
540
        );
541
    }
512 542
    // The reply is printed as it streams. `execute_turn` also returns the last
513 543
    // step's text, which is the same text — so it is printed only when nothing
514 544
    // streamed, which is how the offline paths still say something.
crates/openagents-cli/src/lib.rs modified +1

@@ -28,6 +28,7 @@ pub mod memory_client;

28 28
pub mod plugins;
29 29
pub mod provider;
30 30
pub mod repo;
31
pub mod resume;
31 32
pub mod runtime;
32 33
pub mod signals;
33 34
pub mod tools;
crates/openagents-cli/src/resume.rs added +669

@@ -0,0 +1,669 @@

1
//! `oa coder --resume`: back into a thread the account already holds.
2
//!
3
//! The shape is the one `openagents coder` settled on and this CLI copies so
4
//! the two agree: bare `--resume` shows a picker over this repository's recent
5
//! threads, `--resume <id>` names one directly (the positional argument is the
6
//! id, not a prompt), `--resume --last` continues the most recent without
7
//! asking, and `--all` drops the repository filter.
8
//!
9
//! Three server reads and one write:
10
//!
11
//! - `GET /api/v1/threads?limit=50` is the picker's list.
12
//! - `GET /api/v1/threads/{id}` is `--resume <id>`.
13
//! - `GET /api/v1/threads/{id}/events?limit=50&after=<id>` is the transcript,
14
//!   paged because the listing caps at fifty and a working session passes
15
//!   fifty events inside an hour.
16
//! - `POST /api/v1/threads/{id}/grants` re-mints the thread's authority, which
17
//!   is what makes this a continuation rather than a new thread that has read
18
//!   an old one. The server's own fence: it revokes every active grant on the
19
//!   thread and bumps its generation, so a resumed session cannot race a
20
//!   zombie of its former self.
21
//!
22
//! ## The replay is read-only
23
//!
24
//! [`replay_wire`] rebuilds the messages the live turn loop would have
25
//! accumulated and nothing else. Nothing here posts an event: the server
26
//! already holds these, and writing them back would double the record.
27
//! Reasoning is deliberately absent, because the live loop never puts a
28
//! thought on the wire.
29
//!
30
//! ## What a Rust-opened thread replays to
31
//!
32
//! [`crate::runtime`] records `thread.opened` and no turn events, so a thread
33
//! this CLI opened replays to an empty conversation while one `openagents
34
//! coder` opened replays to its transcript. That is the record, not a parse
35
//! failure, and the caller says how many messages came back rather than
36
//! implying a transcript that is not there.
37
38
use crate::runtime::ChatMessage;
39
use serde::Deserialize;
40
use std::time::Duration;
41
42
/// The server's listing cap. Pages are read at exactly this size.
43
const PAGE_LIMIT: usize = 50;
44
45
/// One thread as `GET /api/v1/threads` reports it.
46
#[derive(Debug, Clone, PartialEq, Eq)]
47
pub struct ThreadSummary {
48
    pub id: String,
49
    pub status: String,
50
    pub objective: String,
51
    pub event_count: u64,
52
    pub started_at: Option<String>,
53
    /// The thread's own `repository` field when the server reports one, else
54
    /// parsed back out of the objective sentence this CLI composes.
55
    pub repository: Option<String>,
56
}
57
58
impl ThreadSummary {
59
    fn from_view(value: &serde_json::Value) -> Option<Self> {
60
        let id = value.get("id")?.as_str()?.to_string();
61
        let objective = value
62
            .get("objective")
63
            .and_then(|v| v.as_str())
64
            .unwrap_or_default()
65
            .to_string();
66
        let repository = value
67
            .get("repository")
68
            .and_then(|v| v.as_str())
69
            .filter(|s| !s.is_empty())
70
            .map(str::to_string)
71
            .or_else(|| repository_of(&objective));
72
        Some(Self {
73
            id,
74
            status: value
75
                .get("status")
76
                .and_then(|v| v.as_str())
77
                .unwrap_or("unknown")
78
                .to_string(),
79
            objective,
80
            event_count: value
81
                .get("event_count")
82
                .and_then(|v| v.as_u64())
83
                .unwrap_or(0),
84
            started_at: value
85
                .get("started_at")
86
                .and_then(|v| v.as_str())
87
                .map(str::to_string),
88
            repository,
89
        })
90
    }
91
92
    /// One line in the picker.
93
    pub fn line(&self) -> String {
94
        let when = self.started_at.as_deref().unwrap_or("—");
95
        let where_ = self.repository.as_deref().unwrap_or("no repository");
96
        format!(
97
            "{}  {}  {}  {} events  {}",
98
            &self.id[..self.id.len().min(8)],
99
            self.status,
100
            when,
101
            self.event_count,
102
            where_
103
        )
104
    }
105
}
106
107
/// The repository a thread's objective names, when this CLI named it.
108
///
109
/// Deterministic parsing of a bounded field this same program wrote — the
110
/// session opener composes `openagents coder in <repo> on <branch>` — not a
111
/// guess at free text. Anything else parses to nothing and is simply a thread
112
/// without a repository.
113
pub fn repository_of(objective: &str) -> Option<String> {
114
    let rest = objective.strip_prefix("openagents coder in ")?;
115
    let (repository, _branch) = rest.rsplit_once(" on ")?;
116
    if repository.is_empty() {
117
        return None;
118
    }
119
    Some(repository.to_string())
120
}
121
122
/// The threads the picker offers, newest first as the server ordered them.
123
///
124
/// Filtered to the named repository unless `all`, because a reader resuming
125
/// work is almost always resuming it where they are standing. Terminal threads
126
/// stay in the list: this CLI revokes its thread on a clean exit, so an
127
/// open-only list would usually be empty, and picking a terminal one gets the
128
/// refusal that teaches why rather than a listing that hides it.
129
pub fn resumable_threads(
130
    threads: &[ThreadSummary],
131
    repository: Option<&str>,
132
    all: bool,
133
) -> Vec<ThreadSummary> {
134
    if all {
135
        return threads.to_vec();
136
    }
137
    let Some(repository) = repository else {
138
        return Vec::new();
139
    };
140
    threads
141
        .iter()
142
        .filter(|t| t.repository.as_deref() == Some(repository))
143
        .cloned()
144
        .collect()
145
}
146
147
/// Refuse a thread that cannot be continued.
148
///
149
/// A terminal thread holds no authority and its transcript is closed — the
150
/// server refuses both a re-mint and a new event — so resuming one could only
151
/// ever show history. The refusal names the status, because `cancelled` after
152
/// a clean exit and `failed` after an error call for different next steps.
153
pub fn assert_resumable(thread: &ThreadSummary) -> Result<(), String> {
154
    if thread.status == "open" {
155
        return Ok(());
156
    }
157
    Err(format!(
158
        "thread {} is {}: its transcript is closed and it holds no authority to re-grant. \
159
         Start a new session with `oa coder` instead.",
160
        thread.id, thread.status
161
    ))
162
}
163
164
/// One event as `GET /api/v1/threads/{id}/events` reports it.
165
#[derive(Debug, Clone, Deserialize)]
166
pub struct ThreadEvent {
167
    /// The cursor: a client continues from the last id it read.
168
    pub id: i64,
169
    pub event_type: String,
170
    #[serde(default)]
171
    pub payload: serde_json::Value,
172
}
173
174
/// The model-facing transcript, rebuilt in the shape the live loop feeds it.
175
///
176
/// `turn.user` is a user message as sent. `tool.ran` becomes the standard chat
177
/// exchange: an assistant message carrying the call in `tool_calls` with the
178
/// arguments as the raw JSON string the record kept, then a `tool` message
179
/// named by `tool_call_id`. `turn.assistant` is the turn's whole answer, and
180
/// an empty one is dropped — a turn that only ran tools is recorded with no
181
/// answer, and a blank assistant message on the wire is a message the provider
182
/// has to be asked to ignore. `turn.reasoning` is absent on purpose.
183
///
184
/// Event types outside this vocabulary are skipped rather than refused: the
185
/// transcript is append-only and a future writer may know words this reader
186
/// does not.
187
pub fn replay_wire(events: &[ThreadEvent]) -> Vec<ChatMessage> {
188
    let mut messages = Vec::new();
189
    for event in events {
190
        let payload = &event.payload;
191
        match event.event_type.as_str() {
192
            "turn.user" => messages.push(ChatMessage {
193
                role: "user".to_string(),
194
                content: Some(text(payload, "text")),
195
                tool_calls: None,
196
                tool_call_id: None,
197
            }),
198
            "tool.ran" => {
199
                let call_id = text(payload, "call_id");
200
                let name = {
201
                    let named = text(payload, "tool");
202
                    if named.is_empty() {
203
                        "tool".to_string()
204
                    } else {
205
                        named
206
                    }
207
                };
208
                let outcome = payload
209
                    .get("output")
210
                    .and_then(|v| v.as_str())
211
                    .or_else(|| payload.get("error").and_then(|v| v.as_str()))
212
                    .unwrap_or("")
213
                    .to_string();
214
                messages.push(ChatMessage {
215
                    role: "assistant".to_string(),
216
                    content: None,
217
                    tool_calls: Some(vec![serde_json::json!({
218
                        "id": call_id,
219
                        "type": "function",
220
                        "function": {
221
                            "name": name,
222
                            "arguments": text(payload, "arguments"),
223
                        }
224
                    })]),
225
                    tool_call_id: None,
226
                });
227
                messages.push(ChatMessage {
228
                    role: "tool".to_string(),
229
                    content: Some(outcome),
230
                    tool_calls: None,
231
                    tool_call_id: Some(call_id),
232
                });
233
            }
234
            "turn.assistant" => {
235
                let said = text(payload, "text");
236
                if said.trim().is_empty() {
237
                    continue;
238
                }
239
                messages.push(ChatMessage {
240
                    role: "assistant".to_string(),
241
                    content: Some(said),
242
                    tool_calls: None,
243
                    tool_call_id: None,
244
                });
245
            }
246
            _ => {}
247
        }
248
    }
249
    messages
250
}
251
252
fn text(payload: &serde_json::Value, key: &str) -> String {
253
    payload
254
        .get(key)
255
        .and_then(|v| v.as_str())
256
        .unwrap_or("")
257
        .to_string()
258
}
259
260
// ---------------------------------------------------------------------------
261
// the server reads
262
// ---------------------------------------------------------------------------
263
264
/// The reads `--resume` makes, against one origin with one account token.
265
pub struct ResumeApi {
266
    api_base: String,
267
    token: String,
268
    http: reqwest::Client,
269
}
270
271
impl ResumeApi {
272
    pub fn new(api_base: &str, token: &str) -> Self {
273
        Self {
274
            api_base: api_base.trim_end_matches('/').to_string(),
275
            token: token.to_string(),
276
            http: reqwest::Client::builder()
277
                .timeout(Duration::from_secs(30))
278
                .build()
279
                .unwrap_or_default(),
280
        }
281
    }
282
283
    async fn get(&self, url: &str, what: &str) -> Result<serde_json::Value, String> {
284
        let response = self
285
            .http
286
            .get(url)
287
            .bearer_auth(&self.token)
288
            .send()
289
            .await
290
            .map_err(|error| format!("{what}: {url} could not be reached: {error}"))?;
291
        let status = response.status();
292
        let body = response.text().await.unwrap_or_default();
293
        if !status.is_success() {
294
            return Err(format!("{what}: {url} refused the read: {status} {body}"));
295
        }
296
        serde_json::from_str(&body).map_err(|error| {
297
            format!("{what}: {url} answered with something that is not JSON: {error}")
298
        })
299
    }
300
301
    /// The account's threads, newest first, as the server reports them.
302
    pub async fn list_threads(&self) -> Result<Vec<ThreadSummary>, String> {
303
        let url = format!("{}/threads?limit={PAGE_LIMIT}", self.api_base);
304
        let body = self
305
            .get(&url, "the account's threads could not be listed")
306
            .await?;
307
        Ok(body
308
            .get("threads")
309
            .and_then(|v| v.as_array())
310
            .map(|list| list.iter().filter_map(ThreadSummary::from_view).collect())
311
            .unwrap_or_default())
312
    }
313
314
    /// One thread by id, for `--resume <id>`.
315
    pub async fn fetch_thread(&self, thread_id: &str) -> Result<ThreadSummary, String> {
316
        let url = format!("{}/threads/{thread_id}", self.api_base);
317
        let body = self
318
            .get(&url, &format!("thread {thread_id} could not be read"))
319
            .await?;
320
        let view = body.get("thread").unwrap_or(&body);
321
        ThreadSummary::from_view(view).ok_or_else(|| {
322
            format!("{url} answered without a thread id, so there is nothing to resume")
323
        })
324
    }
325
326
    /// The whole transcript, oldest first, through the cursor.
327
    ///
328
    /// Pages of [`PAGE_LIMIT`], each continuing from the last event id read,
329
    /// until a page comes back short. The cap is the server's; a session's
330
    /// history is exactly the thing that outgrows it.
331
    pub async fn fetch_all_events(&self, thread_id: &str) -> Result<Vec<ThreadEvent>, String> {
332
        let mut collected: Vec<ThreadEvent> = Vec::new();
333
        let mut after: Option<i64> = None;
334
        loop {
335
            let cursor = match after {
336
                Some(id) => format!("&after={id}"),
337
                None => String::new(),
338
            };
339
            let url = format!(
340
                "{}/threads/{thread_id}/events?limit={PAGE_LIMIT}{cursor}",
341
                self.api_base
342
            );
343
            let body = self
344
                .get(
345
                    &url,
346
                    &format!("the transcript of thread {thread_id} could not be read"),
347
                )
348
                .await?;
349
            let page: Vec<ThreadEvent> = body
350
                .get("events")
351
                .and_then(|v| v.as_array())
352
                .map(|list| {
353
                    list.iter()
354
                        .filter_map(|raw| serde_json::from_value(raw.clone()).ok())
355
                        .collect()
356
                })
357
                .unwrap_or_default();
358
            let short = page.len() < PAGE_LIMIT;
359
            let last = page.last().map(|e| e.id);
360
            collected.extend(page);
361
            match last {
362
                Some(id) if !short => after = Some(id),
363
                _ => return Ok(collected),
364
            }
365
        }
366
    }
367
}
368
369
// ---------------------------------------------------------------------------
370
// the picker
371
// ---------------------------------------------------------------------------
372
373
/// Ask which thread to continue, over the terminal.
374
///
375
/// Returns `Ok(None)` when the reader cancels with an empty line, which is not
376
/// a failure. The caller checks for a terminal before calling this: the
377
/// non-interactive forms are `--resume <id>` and `--resume --last`.
378
pub fn pick_thread(candidates: &[ThreadSummary]) -> Result<Option<ThreadSummary>, String> {
379
    use std::io::Write;
380
    println!("Threads you can continue:");
381
    for (index, thread) in candidates.iter().enumerate() {
382
        println!("  {:>2}. {}", index + 1, thread.line());
383
    }
384
    print!(
385
        "Continue which? (1-{}, or Enter to cancel) ",
386
        candidates.len()
387
    );
388
    let _ = std::io::stdout().flush();
389
    let mut answer = String::new();
390
    std::io::stdin()
391
        .read_line(&mut answer)
392
        .map_err(|error| format!("the picker could not read your answer: {error}"))?;
393
    let answer = answer.trim();
394
    if answer.is_empty() {
395
        return Ok(None);
396
    }
397
    let chosen: usize = answer
398
        .parse()
399
        .map_err(|_| format!("{answer:?} is not one of the numbers listed"))?;
400
    candidates
401
        .get(chosen.wrapping_sub(1))
402
        .cloned()
403
        .map(Some)
404
        .ok_or_else(|| format!("{chosen} is not one of the {} listed", candidates.len()))
405
}
406
407
// ---------------------------------------------------------------------------
408
// resolving `--resume` into a thread and a transcript
409
// ---------------------------------------------------------------------------
410
411
/// The thread a resumed session continues, and the transcript it continues it
412
/// with.
413
#[derive(Debug, Clone)]
414
pub struct Resumption {
415
    pub thread: ThreadSummary,
416
    /// The wire transcript rebuilt from the thread's recorded events. Empty
417
    /// where the thread recorded no turns, which is what a thread this CLI
418
    /// opened looks like today.
419
    pub messages: Vec<ChatMessage>,
420
    /// How many events were read, so the caller can say what came back rather
421
    /// than implying a transcript that is not there.
422
    pub events_read: usize,
423
}
424
425
/// How `--resume` was written on the command line.
426
pub struct ResumeRequest<'a> {
427
    /// The positional argument, which `--resume` reads as a thread id.
428
    pub thread_id: Option<&'a str>,
429
    /// `--last`.
430
    pub last: bool,
431
    /// `--all`.
432
    pub all: bool,
433
    /// The repository the picker filters to, when this checkout names one.
434
    pub repository: Option<String>,
435
    /// Whether a picker can be shown. `false` on a pipe, under `--plain`, and
436
    /// under `--json`.
437
    pub interactive: bool,
438
}
439
440
/// Pick the thread, read its transcript, and re-mint its authority.
441
///
442
/// `Ok(None)` means the reader cancelled the picker, which ends the command
443
/// without being a failure. Everything else is either a resumption or a
444
/// refusal naming what could not be reached.
445
pub async fn resolve(
446
    api_base: &str,
447
    token: Option<&str>,
448
    request: ResumeRequest<'_>,
449
) -> Result<Option<Resumption>, String> {
450
    let Some(token) = token else {
451
        return Err(
452
            "resuming reads the account's threads, and this API has no stored token. \
453
             Run `oa auth login` first."
454
                .to_string(),
455
        );
456
    };
457
    let api = ResumeApi::new(api_base, token);
458
459
    let thread = match request.thread_id {
460
        Some(id) => api.fetch_thread(id).await?,
461
        None => {
462
            let candidates = resumable_threads(
463
                &api.list_threads().await?,
464
                request.repository.as_deref(),
465
                request.all,
466
            );
467
            if candidates.is_empty() {
468
                return Err(if request.all {
469
                    "this account holds no threads to resume".to_string()
470
                } else {
471
                    match request.repository.as_deref() {
472
                        Some(repository) => format!(
473
                            "no threads were opened from {repository}. \
474
                             Use --all to list every thread on the account."
475
                        ),
476
                        None => "this directory is not a checkout of a repository the API knows, \
477
                                 so there is nothing to filter on. Use --all to list every thread \
478
                                 on the account, or --resume <id>."
479
                            .to_string(),
480
                    }
481
                });
482
            }
483
            if request.last {
484
                candidates[0].clone()
485
            } else if request.interactive {
486
                match pick_thread(&candidates)? {
487
                    Some(chosen) => chosen,
488
                    None => return Ok(None),
489
                }
490
            } else {
491
                return Err(
492
                    "the picker needs a terminal. Use `--resume <id>` or `--resume --last`."
493
                        .to_string(),
494
                );
495
            }
496
        }
497
    };
498
499
    assert_resumable(&thread)?;
500
    let events = api.fetch_all_events(&thread.id).await?;
501
    let messages = replay_wire(&events);
502
    Ok(Some(Resumption {
503
        thread,
504
        messages,
505
        events_read: events.len(),
506
    }))
507
}
508
509
/// Put a resumption onto a session: its transcript, then its authority.
510
///
511
/// The replayed messages carry no system prompt — the record does not hold one
512
/// — so the session's own is prepended. Without it a resumed turn would reach
513
/// the model with the conversation but none of the instructions that make its
514
/// tools usable, which reads as a model that has forgotten how to work.
515
pub async fn apply(
516
    session: &mut crate::runtime::CoderRuntimeSession,
517
    resumption: &Resumption,
518
) -> Result<(), String> {
519
    if !resumption.messages.is_empty() {
520
        let tool_defs = session.tools.list_tools();
521
        let mut messages = vec![ChatMessage {
522
            role: "system".to_string(),
523
            content: Some(session.build_system_prompt(&tool_defs)),
524
            tool_calls: None,
525
            tool_call_id: None,
526
        }];
527
        messages.extend(resumption.messages.iter().cloned());
528
        session.messages = messages;
529
    }
530
    session
531
        .adopt_thread(&resumption.thread.id)
532
        .await
533
        .map_err(|error| error.to_string())?;
534
    Ok(())
535
}
536
537
/// What a resumed session says before its first turn.
538
pub fn resumed_line(resumption: &Resumption, model: &str) -> String {
539
    format!(
540
        "Resumed thread {} on {model}: {} events read, {} messages replayed.",
541
        resumption.thread.id,
542
        resumption.events_read,
543
        resumption.messages.len()
544
    )
545
}
546
547
#[cfg(test)]
548
mod tests {
549
    use super::*;
550
551
    #[test]
552
    fn a_thread_this_cli_opened_reports_its_own_repository() {
553
        let view = serde_json::json!({
554
            "id": "t1", "status": "open", "event_count": 3,
555
            "repository": "OpenAgentsInc/openagents",
556
            "objective": "Coding assistant session",
557
            "started_at": "2026-08-26T07:00:00Z",
558
        });
559
        let summary = ThreadSummary::from_view(&view).expect("a summary");
560
        assert_eq!(
561
            summary.repository.as_deref(),
562
            Some("OpenAgentsInc/openagents")
563
        );
564
    }
565
566
    /// Threads opened before the server carried a `repository` field still
567
    /// name one in the objective this CLI composed.
568
    #[test]
569
    fn an_older_thread_falls_back_to_the_objective_sentence() {
570
        let view = serde_json::json!({
571
            "id": "t2", "status": "open", "event_count": 1,
572
            "repository": serde_json::Value::Null,
573
            "objective": "openagents coder in OpenAgentsInc/openagents on main",
574
        });
575
        let summary = ThreadSummary::from_view(&view).expect("a summary");
576
        assert_eq!(
577
            summary.repository.as_deref(),
578
            Some("OpenAgentsInc/openagents")
579
        );
580
    }
581
582
    #[test]
583
    fn a_thread_with_neither_has_no_repository_and_appears_only_under_all() {
584
        let view = serde_json::json!({
585
            "id": "t3", "status": "open", "event_count": 1,
586
            "objective": "Coding assistant session",
587
        });
588
        let summary = ThreadSummary::from_view(&view).expect("a summary");
589
        assert_eq!(summary.repository, None);
590
        let all = resumable_threads(std::slice::from_ref(&summary), Some("a/b"), true);
591
        assert_eq!(all.len(), 1);
592
        let filtered = resumable_threads(&[summary], Some("a/b"), false);
593
        assert!(filtered.is_empty());
594
    }
595
596
    #[test]
597
    fn a_terminal_thread_is_refused_by_status() {
598
        let thread = ThreadSummary {
599
            id: "t4".into(),
600
            status: "cancelled".into(),
601
            objective: String::new(),
602
            event_count: 0,
603
            started_at: None,
604
            repository: None,
605
        };
606
        let error = assert_resumable(&thread).unwrap_err();
607
        assert!(error.contains("cancelled"), "{error}");
608
    }
609
610
    /// The replay is the wire transcript, not the interface's: a recorded
611
    /// thought never becomes a message.
612
    #[test]
613
    fn the_replay_rebuilds_the_wire_and_leaves_reasoning_out() {
614
        let events = vec![
615
            ThreadEvent {
616
                id: 1,
617
                event_type: "thread.opened".into(),
618
                payload: serde_json::json!({}),
619
            },
620
            ThreadEvent {
621
                id: 2,
622
                event_type: "turn.user".into(),
623
                payload: serde_json::json!({"text": "count the crates"}),
624
            },
625
            ThreadEvent {
626
                id: 3,
627
                event_type: "turn.reasoning".into(),
628
                payload: serde_json::json!({"text": "I should look"}),
629
            },
630
            ThreadEvent {
631
                id: 4,
632
                event_type: "tool.ran".into(),
633
                payload: serde_json::json!({
634
                    "call_id": "c1", "tool": "repo_grep",
635
                    "arguments": "{\"pattern\":\"x\"}", "output": "none",
636
                }),
637
            },
638
            ThreadEvent {
639
                id: 5,
640
                event_type: "turn.assistant".into(),
641
                payload: serde_json::json!({"text": "There are 12."}),
642
            },
643
        ];
644
        let wire = replay_wire(&events);
645
        let roles: Vec<&str> = wire.iter().map(|m| m.role.as_str()).collect();
646
        assert_eq!(roles, vec!["user", "assistant", "tool", "assistant"]);
647
        assert_eq!(wire[2].tool_call_id.as_deref(), Some("c1"));
648
        assert!(
649
            !wire.iter().any(|m| m
650
                .content
651
                .as_deref()
652
                .unwrap_or_default()
653
                .contains("I should look")),
654
            "a recorded thought reached the wire"
655
        );
656
    }
657
658
    /// A turn that only ran tools is recorded with an empty answer, and an
659
    /// empty assistant message is not a message.
660
    #[test]
661
    fn an_empty_assistant_turn_is_not_replayed() {
662
        let events = vec![ThreadEvent {
663
            id: 1,
664
            event_type: "turn.assistant".into(),
665
            payload: serde_json::json!({"text": ""}),
666
        }];
667
        assert!(replay_wire(&events).is_empty());
668
    }
669
}
crates/openagents-cli/src/runtime.rs modified +90

@@ -267,6 +267,19 @@ pub struct CoderRuntimeSession {

267 267
    /// answer. It is parsed, summed and kept here so a caller that wants to
268 268
    /// show it can, and the transcript stays the answer.
269 269
    pub last_reasoning: String,
270
    /// The effort recorded on the thread as its admitted execution shape.
271
    ///
272
    /// `--reasoning`. Sent as the thread's `reasoning` at open, which is the
273
    /// only place the server takes it: `GET /api/v1/threads` reports it back
274
    /// as `reasoning_effort`, and the proxy reads it from the thread rather
275
    /// than from each request. `None` leaves the deployment's own default.
276
    pub reasoning: Option<String>,
277
    /// The repository this session was opened from, as `owner/name`.
278
    ///
279
    /// Recorded on the thread so `--resume` has something to filter on. A
280
    /// thread with no repository is not attributable to a checkout and shows
281
    /// up only under `--resume --all`.
282
    pub repository: Option<String>,
270 283
    pub api_base: String,
271 284
    pub user_token: Option<String>,
272 285
    pub ollama_host: String,

@@ -290,6 +303,8 @@ impl CoderRuntimeSession {

290 303
            last_model: None,
291 304
            last_usage: TurnUsage::default(),
292 305
            last_reasoning: String::new(),
306
            reasoning: None,
307
            repository: None,
293 308
            // `OPENAGENTS_API_BASE` points the session at another host. A test
294 309
            // that has to prove the streaming path end to end needs somewhere
295 310
            // to point it that is not production, and an operator on staging

@@ -483,6 +498,17 @@ impl CoderRuntimeSession {

483 498
        if let Some(model) = self.lane.model_id() {
484 499
            body["model"] = serde_json::json!(model);
485 500
        }
501
        // `--reasoning`. The thread carries the effort; the proxy reads it from
502
        // there. Omitted, the deployment's own default stands, which is a
503
        // different answer from naming one and worth keeping separate.
504
        if let Some(effort) = &self.reasoning {
505
            body["reasoning"] = serde_json::json!(effort);
506
        }
507
        // What `--resume` filters on. A thread with no repository is not
508
        // attributable to a checkout.
509
        if let Some(repository) = &self.repository {
510
            body["repository"] = serde_json::json!(repository);
511
        }
486 512
487 513
        let resp = self
488 514
            .http

@@ -550,6 +576,70 @@ impl CoderRuntimeSession {

550 576
        })
551 577
    }
552 578
579
    /// Continue an existing thread by asking the server to re-mint its
580
    /// authority.
581
    ///
582
    /// `POST /api/v1/threads/{id}/grants` is the server's resume fence: it
583
    /// revokes every active grant naming the thread, bumps the thread's
584
    /// generation, and mints fresh authority against the same thread. That is
585
    /// the only honest way to spend a thread that already exists — the grant's
586
    /// plaintext token exists exactly once, at minting, so `GET
587
    /// /api/v1/threads/{id}` reports the grant's status and never its token.
588
    ///
589
    /// After this the session holds the resumed thread the way it would hold
590
    /// one it opened: [`Self::close`] revokes it and turns spend against it.
591
    pub async fn adopt_thread(&mut self, thread_id: &str) -> Result<InferenceGrant, Failure> {
592
        let url = format!("{}/threads/{thread_id}/grants", self.api_base);
593
        let mut request = self.http.post(&url).json(&serde_json::json!({}));
594
        if let Some(token) = &self.user_token {
595
            request = request.bearer_auth(token);
596
        }
597
        let resp = request
598
            .send()
599
            .await
600
            .map_err(|error| -> Failure { format!("{url} could not be reached: {error}").into() })?;
601
        if !resp.status().is_success() {
602
            let status = resp.status();
603
            let text = resp.text().await.unwrap_or_default();
604
            return Err(format!(
605
                "{url} refused to re-mint the thread's grant: {status} {}",
606
                snippet(&text)
607
            )
608
            .into());
609
        }
610
        let body: serde_json::Value = resp.json().await?;
611
        let grant = body.get("grant").cloned().unwrap_or(serde_json::json!({}));
612
        let field = |name: &str| -> Result<String, Failure> {
613
            grant
614
                .get(name)
615
                .and_then(|v| v.as_str())
616
                .filter(|s| !s.is_empty())
617
                .map(str::to_string)
618
                .ok_or_else(|| -> Failure {
619
                    format!(
620
                        "{url} re-minted thread {thread_id} but the grant names no {name}, \
621
                         so there is no authority to spend it with"
622
                    )
623
                    .into()
624
                })
625
        };
626
        let grant = InferenceGrant {
627
            thread_id: body
628
                .get("thread")
629
                .and_then(|t| t.get("id"))
630
                .and_then(|v| v.as_str())
631
                .unwrap_or(thread_id)
632
                .to_string(),
633
            token: field("token")?,
634
            proxy_url: field("url")?,
635
            model: field("model")?,
636
        };
637
        self.thread_id = Some(grant.thread_id.clone());
638
        self.last_model = Some(grant.model.clone());
639
        self.last_grant = Some(grant.clone());
640
        Ok(grant)
641
    }
642
553 643
    /// Revoke this session's thread.
554 644
    ///
555 645
    /// A thread left open holds its grant's remaining budget. `DELETE
crates/openagents-cli/tests/flags.rs modified +1075

@@ -682,3 +682,1078 @@ fn deploy_promote_generates_an_idempotency_key_and_does_not_print_it() {

682 682
        run.stdout
683 683
    );
684 684
}
685
686
// ---------------------------------------------------------------------------
687
// `oa coder`'s lane, effort, offline and resume flags
688
// ---------------------------------------------------------------------------
689
//
690
// The same rule as everything above: each of these runs the binary twice, once
691
// with the flag and once without, and asserts the difference the flag
692
// promises. Several of them assert on the *request that arrived*, because that
693
// is where a flag like `--reasoning` either exists or does not: a run that
694
// accepts the flag and sends the same body is exactly the defect these tests
695
// are here to catch.
696
697
/// One request the routing server was asked for.
698
#[derive(Debug, Clone)]
699
struct Hit {
700
    method: String,
701
    path: String,
702
    body: String,
703
}
704
705
/// A server that answers different bodies on different routes and hands back
706
/// the requests it was asked for, bodies included.
707
///
708
/// [`StubServer`] answers one body on every path, which is enough for a
709
/// listing but not for a coder turn: opening a thread reads `GET
710
/// /api/v1/models`, posts `POST /api/v1/threads`, and then streams from
711
/// whatever proxy url the grant names. Each of those needs a different answer,
712
/// and the assertion for `--model` and `--reasoning` is about what was *sent*,
713
/// not what came back.
714
struct RouteServer {
715
    port: u16,
716
    hits: mpsc::Receiver<Hit>,
717
}
718
719
/// `("POST /api/v1/threads", status, body, content_type)`.
720
type Route = (String, u16, String, &'static str);
721
722
impl RouteServer {
723
    /// `routes` is built from the port, because a grant has to name a proxy
724
    /// url on this same server and the port is not known until it binds.
725
    fn start(routes: impl FnOnce(u16) -> Vec<Route>) -> Self {
726
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
727
        let port = listener.local_addr().expect("read the port").port();
728
        let routes = routes(port);
729
        let (tx, hits) = mpsc::channel();
730
        thread::spawn(move || {
731
            for stream in listener.incoming() {
732
                let Ok(stream) = stream else { break };
733
                let tx = tx.clone();
734
                let routes = routes.clone();
735
                thread::spawn(move || serve_routed(stream, &routes, tx));
736
            }
737
        });
738
        Self { port, hits }
739
    }
740
741
    fn origin(&self) -> String {
742
        format!("http://127.0.0.1:{}", self.port)
743
    }
744
745
    fn hits(&self) -> Vec<Hit> {
746
        self.hits.try_iter().collect()
747
    }
748
}
749
750
fn serve_routed(mut stream: TcpStream, routes: &[Route], hits: mpsc::Sender<Hit>) {
751
    let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
752
    let mut request_line = String::new();
753
    if reader.read_line(&mut request_line).is_err() {
754
        return;
755
    }
756
    let mut parts = request_line.split_whitespace();
757
    let method = parts.next().unwrap_or("").to_string();
758
    let full_path = parts.next().unwrap_or("").to_string();
759
    let path = full_path
760
        .split('?')
761
        .next()
762
        .unwrap_or(&full_path)
763
        .to_string();
764
765
    let mut length = 0usize;
766
    loop {
767
        let mut header = String::new();
768
        if reader.read_line(&mut header).unwrap_or(0) == 0 {
769
            break;
770
        }
771
        if header.trim().is_empty() {
772
            break;
773
        }
774
        if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
775
            length = value.trim().parse().unwrap_or(0);
776
        }
777
    }
778
    let mut body = String::new();
779
    if length > 0 {
780
        let mut buffer = vec![0u8; length];
781
        if reader.read_exact(&mut buffer).is_ok() {
782
            body = String::from_utf8_lossy(&buffer).into_owned();
783
        }
784
    }
785
786
    let key = format!("{method} {path}");
787
    let _ = hits.send(Hit {
788
        method,
789
        path,
790
        body,
791
    });
792
793
    let answer = routes.iter().find(|(route, ..)| *route == key);
794
    let (code, reply, content_type) = match answer {
795
        Some((_, code, reply, content_type)) => (*code, reply.clone(), *content_type),
796
        None => (
797
            404,
798
            format!("{{\"code\":\"no_route\",\"message\":{key:?}}}"),
799
            "application/json",
800
        ),
801
    };
802
    let response = format!(
803
        "HTTP/1.1 {code} X\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
804
        reply.len(),
805
        reply
806
    );
807
    let _ = stream.write_all(response.as_bytes());
808
    let _ = stream.flush();
809
}
810
811
/// `oa`, with environment of the caller's choosing.
812
///
813
/// The coder paths read `OPENAGENTS_TOKEN` for the account credential and
814
/// `OPENAGENTS_OLLAMA_HOST` for the local lane, and a test that does not set
815
/// them is testing whatever the developer's machine happens to hold.
816
fn oa_env(args: &[&str], env: &[(&str, &str)]) -> Output {
817
    let mut command = Command::new(env!("CARGO_BIN_EXE_oa"));
818
    command.args(args).env("NO_COLOR", "");
819
    for (key, value) in env {
820
        command.env(key, value);
821
    }
822
    let result = command.output().expect("run oa");
823
    Output {
824
        stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
825
        stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
826
        status: result.status.code(),
827
    }
828
}
829
830
/// The catalog `GET /api/v1/models` serves in these tests.
831
const CATALOG: &str = r#"{"default":"gemini-3.7-flash","models":[
832
    {"id":"gemini-3.7-flash","availability":"available","default":true},
833
    {"id":"ox-alpha","availability":"available","default":false},
834
    {"id":"gpt-5.6-luna","availability":"available","default":false}]}"#;
835
836
/// A thread open, a grant that points the proxy back at this same server, and
837
/// a stream that answers one word.
838
fn coder_routes(port: u16) -> Vec<Route> {
839
    let proxy = format!("http://127.0.0.1:{port}/api/inference/proxy");
840
    vec![
841
        (
842
            "GET /api/v1/models".to_string(),
843
            200,
844
            CATALOG.to_string(),
845
            "application/json",
846
        ),
847
        (
848
            "POST /api/v1/threads".to_string(),
849
            201,
850
            format!(
851
                r#"{{"thread":{{"id":"t-1","status":"open"}},
852
                     "grant":{{"token":"g-1","url":{proxy:?},"model":"ox-alpha"}}}}"#
853
            ),
854
            "application/json",
855
        ),
856
        (
857
            "POST /api/inference/proxy".to_string(),
858
            200,
859
            "data: {\"choices\":[{\"delta\":{\"content\":\"PONG\"},\"index\":0}]}\n\n\
860
             data: [DONE]\n\n"
861
                .to_string(),
862
            "text/event-stream",
863
        ),
864
        (
865
            "DELETE /api/v1/threads/t-1".to_string(),
866
            200,
867
            r#"{"thread":{"id":"t-1","status":"cancelled"}}"#.to_string(),
868
            "application/json",
869
        ),
870
    ]
871
}
872
873
/// The body of the first request that matches a method and path.
874
fn body_of(hits: &[Hit], method: &str, path: &str) -> Option<String> {
875
    hits.iter()
876
        .find(|hit| hit.method == method && hit.path == path)
877
        .map(|hit| hit.body.clone())
878
}
879
880
// --------------------------------------------------------------- `--offline`
881
882
/// `--offline` answers, and the live path in the same conditions fails.
883
///
884
/// This is the inversion #83 named, asserted from both sides at once. The
885
/// stand-in text used to be reachable *only* on failure and by no flag; now it
886
/// is reachable only by the flag and never on failure. A binary with the old
887
/// behaviour fails the second half of this test: its live run would print the
888
/// stand-in and exit 0.
889
#[test]
890
fn offline_answers_from_the_stand_in_and_the_live_path_refuses_instead() {
891
    // A port nothing listens on. Both runs are offline in the network sense;
892
    // only one of them was asked to be.
893
    let dead = "http://127.0.0.1:1";
894
    let asked = oa_env(
895
        &["--api-url", dead, "coder", "--offline", "count the crates"],
896
        &[("OPENAGENTS_TOKEN", "t")],
897
    );
898
    let live = oa_env(
899
        &[
900
            "--api-url",
901
            dead,
902
            "coder",
903
            "--headless",
904
            "count the crates",
905
        ],
906
        &[("OPENAGENTS_TOKEN", "t")],
907
    );
908
909
    assert_eq!(asked.status, Some(0), "stderr: {}", asked.stderr);
910
    assert!(
911
        asked.stdout.contains("[stand-in]"),
912
        "--offline did not answer from the stand-in: {}",
913
        asked.stdout
914
    );
915
    assert!(
916
        asked.stdout.contains("count the crates"),
917
        "the stand-in did not carry the prompt: {}",
918
        asked.stdout
919
    );
920
921
    assert_eq!(
922
        live.status,
923
        Some(2),
924
        "the live path succeeded with nothing to reach: {} {}",
925
        live.stdout,
926
        live.stderr
927
    );
928
    assert!(
929
        !live.stdout.contains("stand-in") && !live.stdout.contains("offline fallback"),
930
        "a failed turn reached the stand-in: {}",
931
        live.stdout
932
    );
933
}
934
935
/// `--offline` opens no socket, and the same command without it opens several.
936
#[test]
937
fn offline_reaches_no_server_and_the_live_path_reaches_one() {
938
    let server = RouteServer::start(coder_routes);
939
    let origin = server.origin();
940
941
    let offline = oa_env(
942
        &["--api-url", &origin, "coder", "--offline", "hello"],
943
        &[("OPENAGENTS_TOKEN", "t")],
944
    );
945
    assert_eq!(offline.status, Some(0), "stderr: {}", offline.stderr);
946
    assert!(
947
        server.hits().is_empty(),
948
        "--offline sent requests to the server"
949
    );
950
951
    let live = oa_env(
952
        &["--api-url", &origin, "coder", "--headless", "hello"],
953
        &[("OPENAGENTS_TOKEN", "t")],
954
    );
955
    assert_eq!(live.status, Some(0), "stderr: {}", live.stderr);
956
    let paths: Vec<String> = server.hits().into_iter().map(|hit| hit.path).collect();
957
    assert!(
958
        paths.iter().any(|p| p == "/api/v1/threads"),
959
        "the live run did not open a thread; it asked for {paths:?}"
960
    );
961
}
962
963
// ----------------------------------------------------------------- `--model`
964
965
/// `--model` decides the id sent at thread open; without it the default lane's
966
/// id is sent.
967
#[test]
968
fn model_names_the_id_the_thread_opens_on() {
969
    let named = RouteServer::start(coder_routes);
970
    let origin = named.origin();
971
    let run = oa_env(
972
        &[
973
            "--api-url",
974
            &origin,
975
            "coder",
976
            "--headless",
977
            "--model",
978
            "gpt-5.6-luna",
979
            "hello",
980
        ],
981
        &[("OPENAGENTS_TOKEN", "t")],
982
    );
983
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
984
    let with = body_of(&named.hits(), "POST", "/api/v1/threads")
985
        .expect("the run did not open a thread");
986
987
    let plain = RouteServer::start(coder_routes);
988
    let origin = plain.origin();
989
    let run = oa_env(
990
        &["--api-url", &origin, "coder", "--headless", "hello"],
991
        &[("OPENAGENTS_TOKEN", "t")],
992
    );
993
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
994
    let without = body_of(&plain.hits(), "POST", "/api/v1/threads")
995
        .expect("the run did not open a thread");
996
997
    let with: serde_json::Value = serde_json::from_str(&with).expect("the open body is JSON");
998
    let without: serde_json::Value = serde_json::from_str(&without).expect("the open body is JSON");
999
    assert_eq!(with["model"], "gpt-5.6-luna");
1000
    assert_eq!(without["model"], "ox-alpha");
1001
    assert_ne!(
1002
        with["model"], without["model"],
1003
        "--model changed nothing about the thread that was opened"
1004
    );
1005
}
1006
1007
/// An id this deployment does not serve is refused by name, and no thread is
1008
/// opened. It used to fall through to the default lane, so a reader who asked
1009
/// for one model got another and was told nothing.
1010
#[test]
1011
fn a_model_the_catalog_does_not_serve_is_refused_rather_than_substituted() {
1012
    let server = RouteServer::start(coder_routes);
1013
    let origin = server.origin();
1014
    let run = oa_env(
1015
        &[
1016
            "--api-url",
1017
            &origin,
1018
            "coder",
1019
            "--headless",
1020
            "--model",
1021
            "claude-3-7-sonnet",
1022
            "hello",
1023
        ],
1024
        &[("OPENAGENTS_TOKEN", "t")],
1025
    );
1026
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1027
    assert!(
1028
        run.stderr.contains("claude-3-7-sonnet"),
1029
        "the refusal did not name the model asked for: {}",
1030
        run.stderr
1031
    );
1032
    assert!(
1033
        run.stderr.contains("gpt-5.6-luna"),
1034
        "the refusal did not name what this deployment serves: {}",
1035
        run.stderr
1036
    );
1037
    let paths: Vec<String> = server.hits().into_iter().map(|hit| hit.path).collect();
1038
    assert!(
1039
        !paths.iter().any(|p| p == "/api/v1/threads"),
1040
        "a refused model still opened a thread: {paths:?}"
1041
    );
1042
}
1043
1044
// ----------------------------------------------------------------- `--local`
1045
1046
/// `--local` answers from this machine, so it never reaches the server; the
1047
/// same command without it does.
1048
#[test]
1049
fn local_answers_from_this_machine_and_never_reaches_the_server() {
1050
    let server = RouteServer::start(coder_routes);
1051
    let origin = server.origin();
1052
    // A port nothing listens on, so the local lane fails at Ollama rather than
1053
    // silently finding whatever this developer has running.
1054
    let no_ollama = [
1055
        ("OPENAGENTS_TOKEN", "t"),
1056
        ("OPENAGENTS_OLLAMA_HOST", "http://127.0.0.1:1"),
1057
    ];
1058
1059
    let local = oa_env(
1060
        &["--api-url", &origin, "coder", "--headless", "--local", "hello"],
1061
        &no_ollama,
1062
    );
1063
    assert_eq!(local.status, Some(2), "stdout: {}", local.stdout);
1064
    assert!(
1065
        local.stderr.contains("Ollama"),
1066
        "the local lane did not name Ollama: {}",
1067
        local.stderr
1068
    );
1069
    assert!(
1070
        server.hits().is_empty(),
1071
        "--local sent the turn to the server"
1072
    );
1073
1074
    let hosted = oa_env(
1075
        &["--api-url", &origin, "coder", "--headless", "hello"],
1076
        &no_ollama,
1077
    );
1078
    assert_eq!(hosted.status, Some(0), "stderr: {}", hosted.stderr);
1079
    let paths: Vec<String> = server.hits().into_iter().map(|hit| hit.path).collect();
1080
    assert!(
1081
        paths.iter().any(|p| p == "/api/v1/threads"),
1082
        "the hosted run did not open a thread: {paths:?}"
1083
    );
1084
}
1085
1086
/// `--model ollama:<model>` is the same lane, and names the model to Ollama
1087
/// rather than to the server.
1088
#[test]
1089
fn model_ollama_selects_the_local_lane() {
1090
    let server = RouteServer::start(coder_routes);
1091
    let origin = server.origin();
1092
    let run = oa_env(
1093
        &[
1094
            "--api-url",
1095
            &origin,
1096
            "coder",
1097
            "--headless",
1098
            "--model",
1099
            "ollama:llama3",
1100
            "hello",
1101
        ],
1102
        &[
1103
            ("OPENAGENTS_TOKEN", "t"),
1104
            ("OPENAGENTS_OLLAMA_HOST", "http://127.0.0.1:1"),
1105
        ],
1106
    );
1107
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1108
    assert!(
1109
        run.stderr.contains("Ollama"),
1110
        "the turn did not go to Ollama: {}",
1111
        run.stderr
1112
    );
1113
    assert!(
1114
        server.hits().is_empty(),
1115
        "an ollama: model still reached the server"
1116
    );
1117
}
1118
1119
// ------------------------------------------------------------- `--reasoning`
1120
1121
/// `--reasoning` is carried on the thread; without it the request carries no
1122
/// effort at all and the deployment's default stands.
1123
///
1124
/// The thread is where the server takes it — `GET /api/v1/threads` reports it
1125
/// back as `reasoning_effort` — so this asserts on the open body. A binary
1126
/// that parsed the flag and sent the same open body twice fails here, which is
1127
/// the whole point.
1128
#[test]
1129
fn reasoning_is_carried_on_the_thread_and_absent_without_the_flag() {
1130
    let asked = RouteServer::start(coder_routes);
1131
    let origin = asked.origin();
1132
    let run = oa_env(
1133
        &[
1134
            "--api-url",
1135
            &origin,
1136
            "coder",
1137
            "--headless",
1138
            "--reasoning",
1139
            "high",
1140
            "hello",
1141
        ],
1142
        &[("OPENAGENTS_TOKEN", "t")],
1143
    );
1144
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1145
    let with: serde_json::Value = serde_json::from_str(
1146
        &body_of(&asked.hits(), "POST", "/api/v1/threads").expect("no thread was opened"),
1147
    )
1148
    .expect("the open body is JSON");
1149
1150
    let quiet = RouteServer::start(coder_routes);
1151
    let origin = quiet.origin();
1152
    let run = oa_env(
1153
        &["--api-url", &origin, "coder", "--headless", "hello"],
1154
        &[("OPENAGENTS_TOKEN", "t")],
1155
    );
1156
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1157
    let without: serde_json::Value = serde_json::from_str(
1158
        &body_of(&quiet.hits(), "POST", "/api/v1/threads").expect("no thread was opened"),
1159
    )
1160
    .expect("the open body is JSON");
1161
1162
    assert_eq!(with["reasoning"], "high");
1163
    assert!(
1164
        without.get("reasoning").is_none(),
1165
        "a run without --reasoning still named an effort: {without}"
1166
    );
1167
}
1168
1169
/// An effort outside the admitted set is a usage error, not a thread opened at
1170
/// something the server will refuse.
1171
#[test]
1172
fn an_unadmitted_reasoning_effort_is_refused() {
1173
    let server = RouteServer::start(coder_routes);
1174
    let origin = server.origin();
1175
    let run = oa_env(
1176
        &[
1177
            "--api-url",
1178
            &origin,
1179
            "coder",
1180
            "--headless",
1181
            "--reasoning",
1182
            "extreme",
1183
            "hello",
1184
        ],
1185
        &[("OPENAGENTS_TOKEN", "t")],
1186
    );
1187
    assert_eq!(run.status, Some(2));
1188
    assert!(server.hits().is_empty(), "a refused effort still sent a request");
1189
}
1190
1191
// ---------------------------------------------------------------- `--resume`
1192
1193
/// The routes a resume reads: one thread, its events, and the re-mint.
1194
fn resume_routes(port: u16) -> Vec<Route> {
1195
    let proxy = format!("http://127.0.0.1:{port}/api/inference/proxy");
1196
    let mut routes = coder_routes(port);
1197
    routes.push((
1198
        "GET /api/v1/threads".to_string(),
1199
        200,
1200
        r#"{"threads":[
1201
            {"id":"t-far","status":"open","objective":"Coding assistant session",
1202
             "repository":"Elsewhere/other","event_count":2,"started_at":"2026-08-26T09:00:00Z"},
1203
            {"id":"t-near","status":"open","objective":"Coding assistant session",
1204
             "repository":"OpenAgentsInc/openagents","event_count":2,
1205
             "started_at":"2026-08-26T08:00:00Z"}]}"#
1206
            .to_string(),
1207
        "application/json",
1208
    ));
1209
    routes.push((
1210
        "GET /api/v1/threads/t-near".to_string(),
1211
        200,
1212
        r#"{"thread":{"id":"t-near","status":"open","objective":"Coding assistant session",
1213
             "repository":"OpenAgentsInc/openagents","event_count":2}}"#
1214
            .to_string(),
1215
        "application/json",
1216
    ));
1217
    routes.push((
1218
        "GET /api/v1/threads/t-far".to_string(),
1219
        200,
1220
        r#"{"thread":{"id":"t-far","status":"open","objective":"Coding assistant session",
1221
             "repository":"Elsewhere/other","event_count":2}}"#
1222
            .to_string(),
1223
        "application/json",
1224
    ));
1225
    routes.push((
1226
        "GET /api/v1/threads/t-shut".to_string(),
1227
        200,
1228
        r#"{"thread":{"id":"t-shut","status":"cancelled","objective":"Coding assistant session",
1229
             "repository":"OpenAgentsInc/openagents","event_count":1}}"#
1230
            .to_string(),
1231
        "application/json",
1232
    ));
1233
    for id in ["t-near", "t-far"] {
1234
        routes.push((
1235
            format!("GET /api/v1/threads/{id}/events"),
1236
            200,
1237
            r#"{"events":[
1238
                {"id":1,"event_type":"turn.user","payload":{"text":"how many crates"}},
1239
                {"id":2,"event_type":"turn.assistant","payload":{"text":"Twelve."}}]}"#
1240
                .to_string(),
1241
            "application/json",
1242
        ));
1243
        routes.push((
1244
            format!("POST /api/v1/threads/{id}/grants"),
1245
            201,
1246
            format!(
1247
                r#"{{"thread":{{"id":{id:?},"status":"open"}},
1248
                     "grant":{{"token":"g-2","url":{proxy:?},"model":"ox-alpha"}}}}"#
1249
            ),
1250
            "application/json",
1251
        ));
1252
        routes.push((
1253
            format!("DELETE /api/v1/threads/{id}"),
1254
            200,
1255
            r#"{"thread":{"status":"cancelled"}}"#.to_string(),
1256
            "application/json",
1257
        ));
1258
    }
1259
    routes
1260
}
1261
1262
/// `--resume <id>` continues that thread: it re-mints the thread's own grant
1263
/// and opens no new thread. Without it the same command opens one.
1264
///
1265
/// The re-mint is what makes this a continuation rather than a new thread that
1266
/// has read an old one — the server revokes the thread's active grants and
1267
/// bumps its generation there, so a resumed session cannot race a zombie of
1268
/// its former self.
1269
#[test]
1270
fn resume_by_id_re_mints_the_thread_instead_of_opening_one() {
1271
    let resumed = RouteServer::start(resume_routes);
1272
    let origin = resumed.origin();
1273
    let run = oa_env(
1274
        &[
1275
            "--api-url",
1276
            &origin,
1277
            "coder",
1278
            "--headless",
1279
            "--resume",
1280
            "t-near",
1281
            "and now",
1282
        ],
1283
        &[("OPENAGENTS_TOKEN", "t")],
1284
    );
1285
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1286
    let paths: Vec<String> = resumed.hits().into_iter().map(|hit| hit.path).collect();
1287
    assert!(
1288
        paths.iter().any(|p| p == "/api/v1/threads/t-near/grants"),
1289
        "the resume did not re-mint the thread's grant: {paths:?}"
1290
    );
1291
    assert!(
1292
        !paths.iter().any(|p| p == "/api/v1/threads"),
1293
        "the resume opened a new thread as well: {paths:?}"
1294
    );
1295
    assert!(
1296
        run.stdout.contains("Resumed thread t-near"),
1297
        "the run did not say which thread it continued: {}",
1298
        run.stdout
1299
    );
1300
1301
    let fresh = RouteServer::start(resume_routes);
1302
    let origin = fresh.origin();
1303
    let run = oa_env(
1304
        &["--api-url", &origin, "coder", "--headless", "and now"],
1305
        &[("OPENAGENTS_TOKEN", "t")],
1306
    );
1307
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1308
    let paths: Vec<String> = fresh.hits().into_iter().map(|hit| hit.path).collect();
1309
    assert!(
1310
        paths.iter().any(|p| p == "/api/v1/threads"),
1311
        "the run without --resume did not open a thread: {paths:?}"
1312
    );
1313
    assert!(
1314
        !paths.iter().any(|p| p.ends_with("/grants")),
1315
        "a run without --resume re-minted something: {paths:?}"
1316
    );
1317
}
1318
1319
/// The replayed transcript reaches the model. The resumed turn's request
1320
/// carries the thread's recorded conversation; a fresh one carries only the
1321
/// system prompt and this turn.
1322
#[test]
1323
fn a_resumed_turn_carries_the_threads_recorded_conversation() {
1324
    let resumed = RouteServer::start(resume_routes);
1325
    let origin = resumed.origin();
1326
    let run = oa_env(
1327
        &[
1328
            "--api-url",
1329
            &origin,
1330
            "coder",
1331
            "--headless",
1332
            "--resume",
1333
            "t-near",
1334
            "and now",
1335
        ],
1336
        &[("OPENAGENTS_TOKEN", "t")],
1337
    );
1338
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1339
    let sent = body_of(&resumed.hits(), "POST", "/api/inference/proxy")
1340
        .expect("the resumed turn sent nothing to the proxy");
1341
    assert!(
1342
        sent.contains("how many crates") && sent.contains("Twelve."),
1343
        "the replayed conversation did not reach the model: {sent}"
1344
    );
1345
1346
    let fresh = RouteServer::start(resume_routes);
1347
    let origin = fresh.origin();
1348
    let run = oa_env(
1349
        &["--api-url", &origin, "coder", "--headless", "and now"],
1350
        &[("OPENAGENTS_TOKEN", "t")],
1351
    );
1352
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1353
    let sent = body_of(&fresh.hits(), "POST", "/api/inference/proxy")
1354
        .expect("the fresh turn sent nothing to the proxy");
1355
    assert!(
1356
        !sent.contains("how many crates"),
1357
        "a fresh turn carried a thread it never resumed: {sent}"
1358
    );
1359
}
1360
1361
/// `--all` drops the repository filter, and the two runs pick different
1362
/// threads because of it.
1363
///
1364
/// Run from a temporary directory, which is no checkout at all: the filtered
1365
/// list is empty and says so, and `--all` reaches the newest thread on the
1366
/// account.
1367
#[test]
1368
fn all_drops_the_repository_filter_the_picker_applies() {
1369
    let server = RouteServer::start(resume_routes);
1370
    let origin = server.origin();
1371
    let elsewhere = std::env::temp_dir();
1372
1373
    let filtered = Command::new(env!("CARGO_BIN_EXE_oa"))
1374
        .args([
1375
            "--api-url",
1376
            &origin,
1377
            "coder",
1378
            "--headless",
1379
            "--resume",
1380
            "--last",
1381
            "and now",
1382
        ])
1383
        .current_dir(&elsewhere)
1384
        .env("NO_COLOR", "")
1385
        .env("OPENAGENTS_TOKEN", "t")
1386
        .output()
1387
        .expect("run oa");
1388
    assert_eq!(filtered.status.code(), Some(2));
1389
    let stderr = String::from_utf8_lossy(&filtered.stderr);
1390
    assert!(
1391
        stderr.contains("--all"),
1392
        "the refusal did not say what to do next: {stderr}"
1393
    );
1394
1395
    let everything = Command::new(env!("CARGO_BIN_EXE_oa"))
1396
        .args([
1397
            "--api-url",
1398
            &origin,
1399
            "coder",
1400
            "--headless",
1401
            "--resume",
1402
            "--last",
1403
            "--all",
1404
            "and now",
1405
        ])
1406
        .current_dir(&elsewhere)
1407
        .env("NO_COLOR", "")
1408
        .env("OPENAGENTS_TOKEN", "t")
1409
        .output()
1410
        .expect("run oa");
1411
    assert_eq!(
1412
        everything.status.code(),
1413
        Some(0),
1414
        "stderr: {}",
1415
        String::from_utf8_lossy(&everything.stderr)
1416
    );
1417
    // Newest first as the server ordered them, so `--all` reaches the thread
1418
    // the repository filter had excluded.
1419
    assert!(
1420
        String::from_utf8_lossy(&everything.stdout).contains("Resumed thread t-far"),
1421
        "--all did not reach the thread outside this repository: {}",
1422
        String::from_utf8_lossy(&everything.stdout)
1423
    );
1424
}
1425
1426
/// A terminal thread is refused by its status rather than resumed into a
1427
/// session that could only ever show history.
1428
#[test]
1429
fn a_terminal_thread_is_refused_with_its_status() {
1430
    let server = RouteServer::start(resume_routes);
1431
    let origin = server.origin();
1432
    let run = oa_env(
1433
        &[
1434
            "--api-url",
1435
            &origin,
1436
            "coder",
1437
            "--headless",
1438
            "--resume",
1439
            "t-shut",
1440
            "and now",
1441
        ],
1442
        &[("OPENAGENTS_TOKEN", "t")],
1443
    );
1444
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1445
    assert!(
1446
        run.stderr.contains("cancelled"),
1447
        "the refusal did not name the status: {}",
1448
        run.stderr
1449
    );
1450
    let paths: Vec<String> = server.hits().into_iter().map(|hit| hit.path).collect();
1451
    assert!(
1452
        !paths.iter().any(|p| p.ends_with("/grants")),
1453
        "a terminal thread was still re-minted: {paths:?}"
1454
    );
1455
}
1456
1457
/// Bare `--resume` needs a terminal to show a picker in, and says which two
1458
/// forms work without one.
1459
#[test]
1460
fn the_bare_picker_needs_a_terminal() {
1461
    let server = RouteServer::start(resume_routes);
1462
    let origin = server.origin();
1463
    // `--all` so the candidate list is not empty: an empty list refuses for a
1464
    // different and earlier reason, and this test is about the picker.
1465
    let run = oa_env(
1466
        &["--api-url", &origin, "coder", "--headless", "--resume", "--all"],
1467
        &[("OPENAGENTS_TOKEN", "t")],
1468
    );
1469
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1470
    assert!(
1471
        run.stderr.contains("--resume <id>") && run.stderr.contains("--last"),
1472
        "the refusal did not name a form that works: {}",
1473
        run.stderr
1474
    );
1475
}
1476
1477
/// Resuming reads the account's threads, so a run with no credential says so
1478
/// instead of listing nothing.
1479
#[test]
1480
fn resume_without_a_credential_says_to_sign_in() {
1481
    let server = RouteServer::start(resume_routes);
1482
    let origin = server.origin();
1483
    let run = oa_env(
1484
        &[
1485
            "--api-url",
1486
            &origin,
1487
            "coder",
1488
            "--headless",
1489
            "--resume",
1490
            "t-near",
1491
            "and now",
1492
        ],
1493
        &[("OPENAGENTS_TOKEN", "")],
1494
    );
1495
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1496
    assert!(
1497
        run.stderr.contains("auth login"),
1498
        "the refusal did not say how to fix it: {}",
1499
        run.stderr
1500
    );
1501
}
1502
1503
// ------------------------------------------------- flags that cannot combine
1504
1505
/// Two flags that name the same setting differently end the command, because
1506
/// honouring one means ignoring the other and a flag that is ignored is a flag
1507
/// that lied.
1508
#[test]
1509
fn flags_that_name_different_lanes_are_refused_by_name() {
1510
    let both = oa_env(
1511
        &[
1512
            "--api-url",
1513
            "http://127.0.0.1:1",
1514
            "coder",
1515
            "--headless",
1516
            "--model",
1517
            "ox-alpha",
1518
            "--lane",
1519
            "pro",
1520
            "hello",
1521
        ],
1522
        &[("OPENAGENTS_TOKEN", "t")],
1523
    );
1524
    assert_eq!(both.status, Some(2), "stdout: {}", both.stdout);
1525
    assert!(
1526
        both.stderr.contains("--lane pro") && both.stderr.contains("--model ox-alpha"),
1527
        "the refusal did not name both flags: {}",
1528
        both.stderr
1529
    );
1530
1531
    // Two names for the *same* lane is agreement, not a conflict, and is not
1532
    // refused: `--lane pro` and `--model gpt-5.6-luna` name one thing.
1533
    let agreeing = oa_env(
1534
        &[
1535
            "--api-url",
1536
            "http://127.0.0.1:1",
1537
            "coder",
1538
            "--headless",
1539
            "--model",
1540
            "gpt-5.6-luna",
1541
            "--lane",
1542
            "pro",
1543
            "hello",
1544
        ],
1545
        &[("OPENAGENTS_TOKEN", "t")],
1546
    );
1547
    assert!(
1548
        !agreeing.stderr.contains("name different lanes"),
1549
        "one lane named twice was refused as two: {}",
1550
        agreeing.stderr
1551
    );
1552
1553
    // The same command with one of them is not that refusal. It fails for
1554
    // want of a reachable server, which is a different sentence.
1555
    let one = oa_env(
1556
        &["--api-url", "http://127.0.0.1:1", "coder", "--headless", "--lane", "pro", "hello"],
1557
        &[("OPENAGENTS_TOKEN", "t")],
1558
    );
1559
    assert!(
1560
        !one.stderr.contains("name different lanes"),
1561
        "one lane flag was refused as two: {}",
1562
        one.stderr
1563
    );
1564
}
1565
1566
/// `--local` and `--model ollama:<model>` are the same intent written twice,
1567
/// which is the one combination that is not a contradiction.
1568
#[test]
1569
fn local_and_an_ollama_model_agree_rather_than_conflict() {
1570
    let run = oa_env(
1571
        &[
1572
            "--api-url",
1573
            "http://127.0.0.1:1",
1574
            "coder",
1575
            "--headless",
1576
            "--local",
1577
            "--model",
1578
            "ollama:llama3",
1579
            "hello",
1580
        ],
1581
        &[
1582
            ("OPENAGENTS_TOKEN", "t"),
1583
            ("OPENAGENTS_OLLAMA_HOST", "http://127.0.0.1:1"),
1584
        ],
1585
    );
1586
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1587
    assert!(
1588
        run.stderr.contains("Ollama") && !run.stderr.contains("name different lanes"),
1589
        "the same lane written twice was refused as two: {}",
1590
        run.stderr
1591
    );
1592
}
1593
1594
/// `--offline` and `--resume` cannot combine: one reaches no server and the
1595
/// other is a read from one.
1596
#[test]
1597
fn offline_and_resume_are_refused_together() {
1598
    let run = oa_env(
1599
        &[
1600
            "--api-url",
1601
            "http://127.0.0.1:1",
1602
            "coder",
1603
            "--offline",
1604
            "--resume",
1605
            "t-near",
1606
        ],
1607
        &[("OPENAGENTS_TOKEN", "t")],
1608
    );
1609
    assert_eq!(run.status, Some(2), "stdout: {}", run.stdout);
1610
    assert!(
1611
        run.stderr.contains("--offline") && run.stderr.contains("--resume"),
1612
        "the refusal did not name both: {}",
1613
        run.stderr
1614
    );
1615
}
1616
1617
/// `--last` and `--all` say which thread to continue, so without `--resume`
1618
/// they have nothing to say.
1619
#[test]
1620
fn last_and_all_are_refused_without_resume() {
1621
    for flag in ["--last", "--all"] {
1622
        let run = oa_env(
1623
            &["--api-url", "http://127.0.0.1:1", "coder", flag, "hello"],
1624
            &[("OPENAGENTS_TOKEN", "t")],
1625
        );
1626
        assert_eq!(run.status, Some(2), "{flag}: {}", run.stdout);
1627
        assert!(
1628
            run.stderr.contains("--resume"),
1629
            "{flag}: the refusal did not name --resume: {}",
1630
            run.stderr
1631
        );
1632
    }
1633
}
1634
1635
// --------------------------------------------------- `oa auth login --scope`
1636
1637
/// `--scope` reaches the server and its answer reaches the reader.
1638
///
1639
/// Two halves, because either one alone would pass against a binary that lies.
1640
/// The request half proves the flag is sent: the body carries the scopes
1641
/// asked for, and a run without the flag sends no scope at all so the
1642
/// deployment's own default stands. The output half proves the answer is read:
1643
/// the approval a reader is about to click is named on screen, and the two
1644
/// runs name different things.
1645
///
1646
/// The server settles this, not the client — an unknown scope is refused
1647
/// outright — so the stub answers with what it decided rather than echoing.
1648
#[test]
1649
fn scope_is_asked_for_and_the_servers_answer_is_reported() {
1650
    fn authorization_routes(_port: u16) -> Vec<Route> {
1651
        vec![
1652
            (
1653
                "POST /api/v1/device/authorizations".to_string(),
1654
                201,
1655
                r#"{"device_code":"d-1","user_code":"AAAA-BBBB",
1656
                    "verification_uri":"https://example.test/device",
1657
                    "verification_uri_complete":"https://example.test/device?user_code=AAAA-BBBB",
1658
                    "expires_in":600,"interval":5,"scope":"forge:write"}"#
1659
                    .to_string(),
1660
                "application/json",
1661
            ),
1662
        ]
1663
    }
1664
    fn default_routes(_port: u16) -> Vec<Route> {
1665
        vec![(
1666
            "POST /api/v1/device/authorizations".to_string(),
1667
            201,
1668
            r#"{"device_code":"d-2","user_code":"CCCC-DDDD",
1669
                "verification_uri":"https://example.test/device",
1670
                "verification_uri_complete":"https://example.test/device?user_code=CCCC-DDDD",
1671
                "expires_in":600,"interval":5,"scope":"chat:account forge:write"}"#
1672
                .to_string(),
1673
            "application/json",
1674
        )]
1675
    }
1676
1677
    let asked = RouteServer::start(authorization_routes);
1678
    let origin = asked.origin();
1679
    let named = oa(&[
1680
        "--api-url",
1681
        &origin,
1682
        "auth",
1683
        "login",
1684
        "--headless",
1685
        "--scope",
1686
        "forge:write",
1687
    ]);
1688
    assert_eq!(named.status, Some(0), "stderr: {}", named.stderr);
1689
    let sent = body_of(&asked.hits(), "POST", "/api/v1/device/authorizations")
1690
        .expect("no authorization was started");
1691
    let sent: serde_json::Value = serde_json::from_str(&sent).expect("the start body is JSON");
1692
    assert_eq!(
1693
        sent["scope"], "forge:write",
1694
        "--scope did not reach the request: {sent}"
1695
    );
1696
    assert!(
1697
        named.stdout.contains("Scope requested: forge:write"),
1698
        "the run did not name the scope the approval will grant: {}",
1699
        named.stdout
1700
    );
1701
1702
    let quiet = RouteServer::start(default_routes);
1703
    let origin = quiet.origin();
1704
    let plain = oa(&["--api-url", &origin, "auth", "login", "--headless"]);
1705
    assert_eq!(plain.status, Some(0), "stderr: {}", plain.stderr);
1706
    let sent = body_of(&quiet.hits(), "POST", "/api/v1/device/authorizations")
1707
        .expect("no authorization was started");
1708
    let sent: serde_json::Value = serde_json::from_str(&sent).expect("the start body is JSON");
1709
    assert!(
1710
        sent.get("scope").is_none(),
1711
        "a run without --scope still named one, so the server's default cannot apply: {sent}"
1712
    );
1713
    assert!(
1714
        plain.stdout.contains("Scope requested: chat:account forge:write"),
1715
        "the default run did not name the server's own scopes: {}",
1716
        plain.stdout
1717
    );
1718
1719
    assert_ne!(
1720
        named.stdout, plain.stdout,
1721
        "--scope produced the same output as a run without it"
1722
    );
1723
}
1724
1725
/// `--scope` repeats, and the repeats reach the server as one space-separated
1726
/// set rather than the last one winning.
1727
#[test]
1728
fn repeated_scopes_are_all_sent() {
1729
    fn routes(_port: u16) -> Vec<Route> {
1730
        vec![(
1731
            "POST /api/v1/device/authorizations".to_string(),
1732
            201,
1733
            r#"{"device_code":"d-3","user_code":"EEEE-FFFF",
1734
                "verification_uri":"https://example.test/device",
1735
                "verification_uri_complete":"https://example.test/device?user_code=EEEE-FFFF",
1736
                "expires_in":600,"interval":5,"scope":"chat:account forge:write"}"#
1737
                .to_string(),
1738
            "application/json",
1739
        )]
1740
    }
1741
    let server = RouteServer::start(routes);
1742
    let origin = server.origin();
1743
    let run = oa(&[
1744
        "--api-url",
1745
        &origin,
1746
        "auth",
1747
        "login",
1748
        "--headless",
1749
        "--scope",
1750
        "chat:account",
1751
        "--scope",
1752
        "forge:write",
1753
    ]);
1754
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
1755
    let sent = body_of(&server.hits(), "POST", "/api/v1/device/authorizations")
1756
        .expect("no authorization was started");
1757
    let sent: serde_json::Value = serde_json::from_str(&sent).expect("the start body is JSON");
1758
    assert_eq!(sent["scope"], "chat:account forge:write");
1759
}

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