Let a coder session say how it ended, instead of cancelling itself

a8d1aa5e892b · AtlantisPleb · · parent f203128d5e68

Let a coder session say how it ended, instead of cancelling itself

The CLI ended every thread with `DELETE /api/v1/threads/{id}`, which writes
`error_code: cancelled` and "The thread was cancelled before it reported." So a
run that answered correctly and exited 0 left a permanent record saying it had
been cancelled — 31 of one account's 50 most recent threads (#106). The server
half landed in 8c53d09; this is the client that has to use it.

Every exit now goes through `CoderRuntimeSession::finish`, which sends
`POST /api/v1/threads/{id}/report` and revokes in the same call: headless
`oa coder`, both interactive paths, delegated children, coder-lite's frame, and
the `Drop` backstop. `DELETE` stays for what it describes — throwing a thread
away — and is now reached only when the report is refused, which keeps a
deployment older than the route from leaking an open thread holding its grant's
budget. That refusal lands in `record_failures`, which every caller prints.

The mirror of the bug would be worse, so the outcome is not a caller's to
assert. `ThreadOutcome` has no constructor pairing `succeeded` with an error
code or a failure without one, its codes are `&'static str` from one list, and
`execute_turn` settles the outcome in one place from the turn's own `Result`: a
refused proxy reports `provider_failed`, a torn stream `stream_broken`, an
exhausted budget `max_steps`, an interruption `cancelled`/`interrupted`, and a
thread no turn ran on `no_turn`. While a turn is in flight the standing outcome
is an interruption, so a session dropped mid-turn cannot file the previous
turn's answer as this session's ending.

That also unblocks `--resume` across processes: `POST /threads/{id}/grants`
reopens a thread that reported and refuses one that was cancelled, so closing
normally no longer makes a thread unresumable.

Proved against real sockets. Three reversions were used to check the tests
rather than the other way round: filing a failed turn as `succeeded` fails
three, filing an interruption as `succeeded` fails one, letting an in-flight
turn inherit the last success fails one, and going back to `DELETE` on the
interactive exit fails one.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/runtime.rs
  • modified crates/coder-lite/tests/turn.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/runtime.rs
  • modified crates/openagents-cli/tests/delegate_test.rs
  • modified crates/openagents-cli/tests/runtime_test.rs

Diff

9 files changed, +1105 -125

crates/coder-lite/src/interactive.rs modified +8 -7

@@ -188,21 +188,22 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

188 188
    disable_raw_mode()?;
189 189
    std::io::stdout().execute(LeaveAlternateScreen)?;
190 190
191
    // The screen is gone, so these land on the normal one. Revoking the thread
192
    // is the point: one left open holds its grant's remaining budget, and the
193
    // `Drop` backstop can only spawn a `DELETE` this process may exit before
194
    // polling.
191
    // The screen is gone, so these land on the normal one. Ending the thread is
192
    // the point: one left open holds its grant's remaining budget, and the
193
    // `Drop` backstop can only spawn an ending this process may exit before
194
    // polling. It ends by reporting what the session did, so leaving is not
195
    // recorded as a cancellation and the thread can be resumed later.
195 196
    match tokio::time::timeout(REVOCATION_GRACE, async {
196
        session.lock().await.close().await
197
        session.lock().await.finish().await
197 198
    })
198 199
    .await
199 200
    {
200 201
        Ok(Ok(Some(line))) => println!("{line}"),
201 202
        Ok(Ok(None)) => {}
202
        Ok(Err(error)) => eprintln!("coder-lite: the thread was not revoked: {error}"),
203
        Ok(Err(error)) => eprintln!("coder-lite: the thread was not ended: {error}"),
203 204
        Err(_) => eprintln!(
204 205
            "coder-lite: the session was still working after {}s, so its thread was left to \
205
             the best-effort revocation.",
206
             the best-effort ending.",
206 207
            REVOCATION_GRACE.as_secs()
207 208
        ),
208 209
    }
crates/coder-lite/src/runtime.rs modified +10 -4

@@ -347,13 +347,19 @@ impl Session {

347 347
        send(&sink, Control::Done);
348 348
    }
349 349
350
    /// Revoke this session's thread and say what the server billed.
350
    /// End this session's thread by saying what it did, and say what the
351
    /// server billed.
352
    ///
353
    /// A report rather than a `DELETE`: a session that answered and left is not
354
    /// a cancellation, and filing it as one is what made every thread in the
355
    /// account's history read as cancelled (issue #106). It also leaves the
356
    /// thread resumable, which `DELETE` does not.
351 357
    ///
352 358
    /// Awaited by the caller rather than left to `Drop`: a thread left open
353 359
    /// holds its grant's remaining budget, and the `Drop` backstop can only
354
    /// spawn a `DELETE` this process may exit before polling.
355
    pub async fn close(&mut self) -> Result<Option<String>, Failure> {
356
        let spent = self.inner.close().await?;
360
    /// spawn an ending this process may exit before polling.
361
    pub async fn finish(&mut self) -> Result<Option<String>, Failure> {
362
        let spent = self.inner.finish().await?;
357 363
        Ok(self.inner.spend_line(spent))
358 364
    }
359 365
}
crates/coder-lite/tests/turn.rs modified +37 -11

@@ -36,6 +36,11 @@ struct Stub {

36 36
}
37 37
38 38
impl Stub {
39
    /// Every request this stub has taken, headers and body, most recent last.
40
    fn requests(&self) -> Vec<String> {
41
        self.requests.lock().unwrap().clone()
42
    }
43
39 44
    fn request_lines(&self) -> Vec<String> {
40 45
        self.requests
41 46
            .lock()

@@ -424,19 +429,25 @@ async fn a_refused_proxy_fails_the_turn() {

424 429
    assert_eq!(reply_text(&seen), "", "a reply was invented");
425 430
}
426 431
427
/// Leaving revokes the thread and reports what the server billed. A thread
428
/// left open holds its grant's remaining budget.
432
/// Leaving reports what the session did and ends the thread, and reports what
433
/// the server billed. A thread left open holds its grant's remaining budget.
434
///
435
/// The `DELETE` this replaces recorded a session that had answered as
436
/// `cancelled` (issue #106) and left it unresumable, so the assertion is on
437
/// both halves: the report says `succeeded`, and no revocation is sent.
429 438
#[tokio::test]
430
async fn closing_revokes_the_thread_and_reports_the_server_figure() {
439
async fn leaving_reports_what_the_session_did_rather_than_cancelling_it() {
431 440
    let stub = start(|request, origin| {
432 441
        if request.contains("POST /api/v1/threads ") {
433 442
            return Reply::Body(200, "application/json", grant(origin));
434 443
        }
435
        if request.contains("DELETE /api/v1/threads/th_test") {
444
        if request.contains("POST /api/v1/threads/th_test/report") {
436 445
            return Reply::Body(
437 446
                200,
438 447
                "application/json",
439
                r#"{"grant":{"spent":{"total_tokens":99}}}"#.to_string(),
448
                r#"{"thread":{"id":"th_test","status":"succeeded"},
449
                    "grant":{"spent":{"total_tokens":99}}}"#
450
                    .to_string(),
440 451
            );
441 452
        }
442 453
        if request.contains("/api/inference/proxy") {

@@ -450,18 +461,33 @@ async fn closing_revokes_the_thread_and_reports_the_server_figure() {

450 461
    session.execute_turn("hello", tx).await;
451 462
    let _ = drain(&rx);
452 463
453
    let line = tokio::time::timeout(Duration::from_secs(10), session.close())
464
    let line = tokio::time::timeout(Duration::from_secs(10), session.finish())
454 465
        .await
455
        .expect("close hung")
456
        .expect("close failed")
466
        .expect("the ending hung")
467
        .expect("the ending failed")
457 468
        .expect("the server reported no spend");
458 469
    assert!(line.contains("99"), "{line}");
459 470
471
    let reported = stub
472
        .requests()
473
        .into_iter()
474
        .find(|r| r.contains("POST /api/v1/threads/th_test/report"))
475
        .expect("the session never said what it did");
476
    let body: serde_json::Value =
477
        serde_json::from_str(reported.split("\r\n\r\n").nth(1).unwrap_or("{}")).unwrap();
478
    assert_eq!(body["status"], "succeeded");
479
    assert_eq!(
480
        body.get("error_code"),
481
        None,
482
        "a session that answered named an error code: {body}"
483
    );
484
460 485
    assert!(
461
        stub.request_lines()
486
        !stub
487
            .request_lines()
462 488
            .iter()
463
            .any(|l| l.starts_with("DELETE /api/v1/threads/th_test")),
464
        "the thread was never revoked: {:?}",
489
            .any(|l| l.starts_with("DELETE /api/v1/threads/")),
490
        "leaving cancelled the thread instead of reporting: {:?}",
465 491
        stub.request_lines()
466 492
    );
467 493
}
crates/openagents-cli/src/cli.rs modified +6 -4

@@ -4610,9 +4610,11 @@ async fn run_headless_coder(

4610 4610
        })
4611 4611
        .await
4612 4612
        .map_err(|e| e.to_string());
4613
    // The thread is revoked whether the turn worked or not: a failed turn still
4614
    // opened one, and one left open holds its grant's remaining budget.
4615
    let revoked = runtime.close().await;
4613
    // The thread ends whether the turn worked or not — a failed turn still
4614
    // opened one, and one left open holds its grant's remaining budget — and it
4615
    // ends by saying which of the two happened. `close()` here instead would
4616
    // file this run as a cancellation however it went (issue #106).
4617
    let revoked = runtime.finish().await;
4616 4618
    // A turn that could not reach a model is a failure, and says so in the
4617 4619
    // shape every other refusal here uses.
4618 4620
    let result = match result {

@@ -4634,7 +4636,7 @@ async fn run_headless_coder(

4634 4636
                println!("{line}");
4635 4637
            }
4636 4638
        }
4637
        Err(error) => eprintln!("oa: the thread was not revoked: {error}"),
4639
        Err(error) => eprintln!("oa: the thread was not ended: {error}"),
4638 4640
    }
4639 4641
    for failure in &runtime.record_failures {
4640 4642
        eprintln!("oa: {failure}");
crates/openagents-cli/src/delegate.rs modified +8 -6

@@ -586,14 +586,16 @@ async fn run_proxy_child(

586 586
        runtime.note_interruption(why).await;
587 587
    }
588 588
    // Awaited, on every path. A child used to leave its thread to the `Drop`
589
    // impl, which spawns a best-effort DELETE the process may never poll — and
590
    // a thread left open holds its grant's remaining budget. The failure is
591
    // reported to the parent's event stream rather than to a screen this child
592
    // does not own.
593
    if let Err(error) = runtime.close().await {
589
    // impl, which spawns a best-effort ending the process may never poll — and
590
    // a thread left open holds its grant's remaining budget. It ends by
591
    // reporting what it did: a child that answered is not a cancellation, and a
592
    // child that was stopped reports `cancelled` with `interrupted` because
593
    // `note_interruption` above settled that. The failure is reported to the
594
    // parent's event stream rather than to a screen this child does not own.
595
    if let Err(error) = runtime.finish().await {
594 596
        let _ = events.send(ChildEvent::Activity {
595 597
            id,
596
            text: format!("the thread was not revoked: {error}"),
598
            text: format!("the thread was not ended: {error}"),
597 599
        });
598 600
    }
599 601
    for failure in &runtime.record_failures {
crates/openagents-cli/src/interactive.rs modified +8 -4

@@ -864,19 +864,23 @@ where

864 864
    }
865 865
}
866 866
867
/// Revoke a session's thread and say what it cost, and what went unrecorded.
867
/// End a session's thread, and say what it cost and what went unrecorded.
868
///
869
/// The ending is a report — what the session's last turn actually did — and
870
/// not a `DELETE`, which would file every session here as a cancellation
871
/// whatever it answered (issue #106).
868 872
///
869 873
/// The lines land on stdout rather than in a screen because both callers reach
870 874
/// here after their screen is gone. Silence when the session held no thread:
871
/// the local lane has nothing to revoke and nothing was billed.
875
/// the local lane has nothing to end and nothing was billed.
872 876
pub async fn close_and_report(session: &mut CoderRuntimeSession) {
873
    match session.close().await {
877
    match session.finish().await {
874 878
        Ok(spent) => {
875 879
            if let Some(line) = session.spend_line(spent) {
876 880
                println!("{line}");
877 881
            }
878 882
        }
879
        Err(error) => eprintln!("oa: the thread was not revoked: {error}"),
883
        Err(error) => eprintln!("oa: the thread was not ended: {error}"),
880 884
    }
881 885
    for failure in &session.record_failures {
882 886
        eprintln!("oa: {failure}");
crates/openagents-cli/src/runtime.rs modified +469 -41

@@ -5,7 +5,8 @@

5 5
//!
6 6
//! - The **thread lane** opens `POST /api/v1/threads`, takes the grant that
7 7
//!   comes back, and streams `POST /api/inference/proxy` with the grant's
8
//!   bearer token. The thread is revoked with `DELETE /api/v1/threads/{id}`.
8
//!   bearer token. The thread ends by saying what it did, with
9
//!   `POST /api/v1/threads/{id}/report`.
9 10
//! - The **local lane** talks to an Ollama server on this machine and never
10 11
//!   touches openagents.com at all, so it answers with the proxy unreachable.
11 12
//!

@@ -39,6 +40,32 @@

39 40
//! that could not be written is kept in [`CoderRuntimeSession::record_failures`]
40 41
//! and reported, rather than swallowed or allowed to throw away an answer.
41 42
//!
43
//! ## A session says how it ended, and `DELETE` is a disposal
44
//!
45
//! `DELETE /api/v1/threads/{id}` writes `error_code: cancelled` and the
46
//! sentence *The thread was cancelled before it reported.* That is the only
47
//! thing this file used to send, so a run that answered correctly and exited 0
48
//! left a permanent record saying it had been cancelled — 31 of one account's
49
//! 50 most recent threads (issue #106).
50
//!
51
//! Every exit now goes through [`CoderRuntimeSession::finish`], which sends
52
//! `POST /api/v1/threads/{id}/report` with the outcome the session actually
53
//! reached and revokes in the same call. `DELETE` stays for the one case it
54
//! describes: throwing a thread away.
55
//!
56
//! The mirror of that bug would be worse, so the outcome is not a caller's to
57
//! assert. [`CoderRuntimeSession::execute_turn`] settles it from the turn's own
58
//! `Result` in one place, and [`ThreadOutcome`] has no constructor that can
59
//! pair `succeeded` with an error code or a failure without one — the server
60
//! refuses an incoherent pair twice over, and this file does not try to send
61
//! one. A turn still in flight leaves the session's standing outcome
62
//! [`ThreadOutcome::interrupted`], so a session dropped mid-turn reports as
63
//! interrupted rather than inheriting the last turn's success.
64
//!
65
//! Reporting rather than cancelling is also what makes `--resume` work across
66
//! processes: `POST /threads/{id}/grants` reopens a thread that reported and
67
//! refuses one that was cancelled.
68
//!
42 69
//! ## Model ids are the server's, not this file's
43 70
//!
44 71
//! The deployment publishes its catalog at `GET /api/v1/models` and refuses

@@ -371,6 +398,159 @@ impl ThreadRecord {

371 398
    }
372 399
}
373 400
401
/// The three ways a thread may end, as the server spells them.
402
pub const SUCCEEDED: &str = "succeeded";
403
pub const FAILED: &str = "failed";
404
pub const CANCELLED: &str = "cancelled";
405
406
/// The error codes this CLI files, one per way a turn can fail to answer.
407
///
408
/// `&'static str` throughout, so a code is one of these and not a sentence
409
/// somebody assembled at a call site: `GET /api/v1/threads` groups on this
410
/// field, and a code built from an error message would make every failure its
411
/// own category.
412
pub mod error_code {
413
    /// The proxy refused the turn or could not be reached.
414
    pub const PROVIDER_FAILED: &str = "provider_failed";
415
    /// The reply stopped part way through, so there is no whole answer.
416
    pub const STREAM_BROKEN: &str = "stream_broken";
417
    /// The turn spent its whole tool-step budget without answering.
418
    pub const MAX_STEPS: &str = "max_steps";
419
    /// The session was stopped before its turn finished. Ctrl-C is this.
420
    pub const INTERRUPTED: &str = "interrupted";
421
    /// A turn failed some other way, named in the report rather than the code.
422
    pub const TURN_FAILED: &str = "turn_failed";
423
    /// The session held a thread but never ran a turn on it.
424
    pub const NO_TURN: &str = "no_turn";
425
}
426
427
/// The largest report the server takes, `OpenAgents.Threads.Thread`'s
428
/// `@objective_bytes`. A longer one is refused whole, so it is cut here.
429
const MAX_REPORT_BYTES: usize = 32_768;
430
431
/// How a session ended, in the vocabulary `POST /threads/{id}/report` takes.
432
///
433
/// The status and the error code are one decision, not two, and the
434
/// constructors are the only way to make either: `succeeded` carries no code
435
/// and every other end has to name one. That is not politeness towards the
436
/// server's validation — it is the same rule as [`ThreadRecord::failed`],
437
/// pointed at the thread's permanent report instead of its transcript. A run
438
/// that failed, was interrupted, or exhausted its steps and is filed as a
439
/// success would be a worse record than the wall of cancellations this
440
/// replaces, because a reader can tell a cancellation is uninformative and
441
/// cannot tell a false success from a true one.
442
#[derive(Debug, Clone, PartialEq, Eq)]
443
pub struct ThreadOutcome {
444
    status: &'static str,
445
    error_code: Option<&'static str>,
446
    report: String,
447
}
448
449
impl ThreadOutcome {
450
    /// The session answered. No error code, because there was no error.
451
    pub fn succeeded(report: &str) -> Self {
452
        Self {
453
            status: SUCCEEDED,
454
            error_code: None,
455
            report: bounded_report(report, "The session answered."),
456
        }
457
    }
458
459
    /// The session did not answer, and `code` says which way.
460
    pub fn failed(code: &'static str, report: &str) -> Self {
461
        Self {
462
            status: FAILED,
463
            error_code: Some(code),
464
            report: bounded_report(report, "The turn failed without saying why."),
465
        }
466
    }
467
468
    /// The session was stopped before the turn in flight could finish.
469
    ///
470
    /// `cancelled` rather than `failed`: nothing was wrong with the work, a
471
    /// reader ended it. The server treats a cancelled thread as disposed of and
472
    /// refuses to re-mint authority on it, which is the right answer for a
473
    /// thread somebody stopped on purpose.
474
    pub fn interrupted(report: &str) -> Self {
475
        Self {
476
            status: CANCELLED,
477
            error_code: Some(error_code::INTERRUPTED),
478
            report: bounded_report(report, "The session was stopped before the turn finished."),
479
        }
480
    }
481
482
    /// The session held a thread and never ran a turn on it.
483
    ///
484
    /// Not a success: nothing was answered. Not a cancellation either, because
485
    /// nobody asked for the thread to be over and a thread that reports stays
486
    /// resumable.
487
    pub fn no_turn() -> Self {
488
        Self::failed(
489
            error_code::NO_TURN,
490
            "The session ended without running a turn on this thread.",
491
        )
492
    }
493
494
    pub fn status(&self) -> &str {
495
        self.status
496
    }
497
498
    /// `None` exactly when the session succeeded.
499
    pub fn error_code(&self) -> Option<&str> {
500
        self.error_code
501
    }
502
503
    pub fn report(&self) -> &str {
504
        &self.report
505
    }
506
507
    /// The body `POST /threads/{id}/report` takes.
508
    ///
509
    /// `usage` is what this process counted, sent as the session's own figure
510
    /// and labelled as such: the account is charged against the grant's spend,
511
    /// which the server already holds and this call reads back.
512
    fn wire(&self, usage: TurnUsage) -> serde_json::Value {
513
        let mut body = serde_json::json!({
514
            "status": self.status,
515
            "report": self.report,
516
            "usage": {
517
                "prompt_tokens": usage.prompt_tokens,
518
                "completion_tokens": usage.completion_tokens,
519
                "total_tokens": usage.total_tokens,
520
                "counted_by": "client",
521
            },
522
        });
523
        if let Some(code) = self.error_code {
524
            body["error_code"] = serde_json::json!(code);
525
        }
526
        body
527
    }
528
}
529
530
/// A report the server will take: never blank, never over the bound.
531
///
532
/// A model that answered with nothing still ended a session, and a blank
533
/// report is refused — so the stand-in says what happened rather than letting
534
/// the whole report fail over an empty answer.
535
fn bounded_report(text: &str, if_blank: &str) -> String {
536
    let text = text.trim();
537
    if text.is_empty() {
538
        return if_blank.to_string();
539
    }
540
    if text.len() <= MAX_REPORT_BYTES {
541
        return text.to_string();
542
    }
543
    // On a character boundary, and with the cut named: a report that stops mid
544
    // sentence should say that it was cut rather than look like the whole of
545
    // what the session said.
546
    const NOTE: &str = "\n[report truncated]";
547
    let mut end = MAX_REPORT_BYTES - NOTE.len();
548
    while end > 0 && !text.is_char_boundary(end) {
549
        end -= 1;
550
    }
551
    format!("{}{NOTE}", &text[..end])
552
}
553
374 554
#[derive(Debug, Clone, Serialize, Deserialize)]
375 555
pub struct ChatMessage {
376 556
    pub role: String,

@@ -447,6 +627,20 @@ pub struct CoderRuntimeSession {

447 627
    pub tool_observer: Option<ToolObserver>,
448 628
    /// The thread to revoke when the session closes.
449 629
    thread_id: Option<String>,
630
    /// What this session would report if it ended now.
631
    ///
632
    /// Written in exactly one place — the end of [`Self::execute_turn`], from
633
    /// that turn's own `Result` — and by [`Self::note_interruption`] for a turn
634
    /// that never got to return. `None` means no turn has run, which is a
635
    /// different thing from a turn that ran and failed.
636
    outcome: Option<ThreadOutcome>,
637
    /// The failure the turn in flight already wrote down, with its code.
638
    ///
639
    /// [`Self::record_failure`] knows which way a turn failed; the `Err` that
640
    /// comes back up the stack is only a sentence. This carries the code from
641
    /// one to the other so the reported outcome is specific rather than
642
    /// `turn_failed` for everything. Cleared at the top of every turn.
643
    pending_failure: Option<ThreadOutcome>,
450 644
}
451 645
452 646
impl CoderRuntimeSession {

@@ -491,6 +685,8 @@ impl CoderRuntimeSession {

491 685
            messages: Vec::new(),
492 686
            tool_observer: None,
493 687
            thread_id: None,
688
            outcome: None,
689
            pending_failure: None,
494 690
        }
495 691
    }
496 692

@@ -769,10 +965,9 @@ impl CoderRuntimeSession {

769 965
        if let Some(token) = &self.user_token {
770 966
            request = request.bearer_auth(token);
771 967
        }
772
        let resp = request
773
            .send()
774
            .await
775
            .map_err(|error| -> Failure { format!("{url} could not be reached: {error}").into() })?;
968
        let resp = request.send().await.map_err(|error| -> Failure {
969
            format!("{url} could not be reached: {error}").into()
970
        })?;
776 971
        if !resp.status().is_success() {
777 972
            let status = resp.status();
778 973
            let text = resp.text().await.unwrap_or_default();

@@ -815,11 +1010,99 @@ impl CoderRuntimeSession {

815 1010
        Ok(grant)
816 1011
    }
817 1012
818
    /// Revoke this session's thread.
1013
    /// What this session would report if it ended now.
1014
    ///
1015
    /// `None` until a turn has run. Read-only on purpose: the outcome is
1016
    /// settled from a turn's own result, not asserted by whoever is holding
1017
    /// the session.
1018
    pub fn outcome(&self) -> Option<&ThreadOutcome> {
1019
        self.outcome.as_ref()
1020
    }
1021
1022
    /// End this session's thread by saying what it did.
1023
    ///
1024
    /// This is the exit every path takes. `POST /api/v1/threads/{id}/report`
1025
    /// writes the outcome and revokes the grant in the same call, so the thread
1026
    /// does not stay open holding its remaining budget and the permanent record
1027
    /// says what happened. A session with no thread — the local lane, or one
1028
    /// already ended — writes nothing and answers `Ok(None)`.
819 1029
    ///
820
    /// A thread left open holds its grant's remaining budget. `DELETE
821
    /// /api/v1/threads/{id}` closes both and returns the grant's spend, which
822
    /// is why the reply is worth reading rather than discarding.
1030
    /// The outcome is [`Self::outcome`], which is the turn's own result and not
1031
    /// a claim made here. A session that held a thread and ran no turn on it
1032
    /// reports [`ThreadOutcome::no_turn`] rather than a success it never had.
1033
    ///
1034
    /// If the report is refused — a deployment older than the route, most
1035
    /// plainly — the thread is still revoked with [`Self::close`] and the
1036
    /// refusal is kept in [`Self::record_failures`], which every caller
1037
    /// already prints. Leaving the thread open because the honest ending was
1038
    /// unavailable would trade one bug for a worse one.
1039
    pub async fn finish(&mut self) -> Result<Option<TurnUsage>, Failure> {
1040
        if self.thread_id.is_none() {
1041
            return Ok(None);
1042
        }
1043
        let outcome = self.outcome.clone().unwrap_or_else(ThreadOutcome::no_turn);
1044
        match self.report(outcome).await {
1045
            Ok(spent) => Ok(spent),
1046
            Err(error) => {
1047
                self.record_failures.push(format!(
1048
                    "the thread could not report how it ended: {error}. \
1049
                     It was cancelled instead, so it does not stay open holding \
1050
                     its grant's remaining budget."
1051
                ));
1052
                self.close().await
1053
            }
1054
        }
1055
    }
1056
1057
    /// Say what the thread did, and end it.
1058
    ///
1059
    /// `POST /api/v1/threads/{id}/report`. The reply carries the revoked
1060
    /// grant's spend, exactly as a revocation's does, so a caller reads what
1061
    /// the session cost in the answer that ends it.
1062
    ///
1063
    /// The thread is released only once the server has taken the report: a
1064
    /// refused report leaves the session still holding its thread, so
1065
    /// [`Self::finish`] can still revoke it rather than leaking it.
1066
    pub async fn report(&mut self, outcome: ThreadOutcome) -> Result<Option<TurnUsage>, Failure> {
1067
        let Some(thread_id) = self.thread_id.clone() else {
1068
            return Ok(None);
1069
        };
1070
        let url = format!("{}/threads/{thread_id}/report", self.api_base);
1071
        let mut request = self
1072
            .http
1073
            .post(&url)
1074
            .timeout(Duration::from_secs(30))
1075
            .json(&outcome.wire(self.session_usage));
1076
        if let Some(token) = &self.user_token {
1077
            request = request.bearer_auth(token);
1078
        }
1079
        let resp = request.send().await.map_err(|error| -> Failure {
1080
            format!("{url} could not be reached: {error}").into()
1081
        })?;
1082
        if !resp.status().is_success() {
1083
            let status = resp.status();
1084
            let body = resp.text().await.unwrap_or_default();
1085
            return Err(format!("{url} refused the report: {status} {}", snippet(&body)).into());
1086
        }
1087
        // Only now: the thread has ended, and there is nothing left to revoke.
1088
        self.thread_id = None;
1089
        self.last_grant = None;
1090
        let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
1091
        Ok(grant_spend(&body))
1092
    }
1093
1094
    /// Throw this session's thread away.
1095
    ///
1096
    /// `DELETE /api/v1/threads/{id}` writes `cancelled` with the sentence *The
1097
    /// thread was cancelled before it reported.*, and the server will not
1098
    /// re-mint authority on a cancelled thread. So this is the disposal, for a
1099
    /// caller that means the thread to be over — not the way a session that did
1100
    /// its work ends. That is [`Self::finish`], and using this instead is how
1101
    /// every session in the account's history came to read as a cancellation
1102
    /// (issue #106).
1103
    ///
1104
    /// A thread left open holds its grant's remaining budget, and the reply
1105
    /// returns the grant's spend, which is why it is worth reading.
823 1106
    pub async fn close(&mut self) -> Result<Option<TurnUsage>, Failure> {
824 1107
        let Some(thread_id) = self.thread_id.take() else {
825 1108
            return Ok(None);

@@ -841,21 +1124,13 @@ impl CoderRuntimeSession {

841 1124
            );
842 1125
        }
843 1126
        let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
844
        let spent = body.get("grant").and_then(|g| g.get("spent"));
845
        Ok(spent.map(|spent| TurnUsage {
846
            prompt_tokens: 0,
847
            completion_tokens: 0,
848
            total_tokens: spent
849
                .get("total_tokens")
850
                .and_then(|v| v.as_u64())
851
                .unwrap_or(0),
852
        }))
1127
        Ok(grant_spend(&body))
853 1128
    }
854 1129
855 1130
    /// What the server billed this session against what this process counted.
856 1131
    ///
857
    /// `close` hands back the grant's own `spent`, and the caller used to drop
858
    /// it, so the CLI printed its client-side accumulation and nothing could
1132
    /// Ending a thread hands back the grant's own `spent`, and the caller used
1133
    /// to drop it, so the CLI printed its client-side accumulation and nothing could
859 1134
    /// ever notice the two disagreeing. This is the line that notices: the
860 1135
    /// server's figure, and a second sentence naming the gap when there is one.
861 1136
    ///

@@ -968,6 +1243,16 @@ impl CoderRuntimeSession {

968 1243
        self.last_usage = TurnUsage::default();
969 1244
        self.last_calls = 0;
970 1245
        self.last_reasoning.clear();
1246
        self.pending_failure = None;
1247
        // While the turn runs, the session's standing outcome is an
1248
        // interruption. A turn that returns replaces it below; a session
1249
        // dropped or quit mid-turn never gets that far, and this is what
1250
        // stops it reporting the *previous* turn's success as this session's
1251
        // ending.
1252
        self.outcome = Some(ThreadOutcome::interrupted(
1253
            "The session ended while a turn was still running, so the turn never \
1254
             reported an outcome.",
1255
        ));
971 1256
972 1257
        let answered = if self.lane.is_local() {
973 1258
            self.run_local_turn(&tool_defs, chunk_callback).await

@@ -976,6 +1261,17 @@ impl CoderRuntimeSession {

976 1261
                .await
977 1262
        };
978 1263
        self.session_usage.add(self.last_usage);
1264
1265
        // The one place the outcome is decided, and it is decided from the
1266
        // turn's own result rather than from anything a caller believes. An
1267
        // `Err` cannot land here as `succeeded` whatever it says, which is the
1268
        // rule that keeps this from becoming issue #106 pointed the other way.
1269
        self.outcome = Some(match &answered {
1270
            Ok(answer) => ThreadOutcome::succeeded(answer),
1271
            Err(error) => self.pending_failure.take().unwrap_or_else(|| {
1272
                ThreadOutcome::failed(error_code::TURN_FAILED, &error.to_string())
1273
            }),
1274
        });
979 1275
        answered
980 1276
    }
981 1277

@@ -1059,11 +1355,11 @@ impl CoderRuntimeSession {

1059 1355
                        grant.proxy_url,
1060 1356
                        snippet(&body)
1061 1357
                    );
1062
                    return Err(self.record_failure(why).await);
1358
                    return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1063 1359
                }
1064 1360
                Err(error) => {
1065 1361
                    let why = format!("{} could not be reached: {error}", grant.proxy_url);
1066
                    return Err(self.record_failure(why).await);
1362
                    return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1067 1363
                }
1068 1364
            };
1069 1365

@@ -1083,7 +1379,7 @@ impl CoderRuntimeSession {

1083 1379
                        // failed: an incomplete answer is not an answer.
1084 1380
                        self.last_usage.add(step.usage);
1085 1381
                        self.last_reasoning.push_str(&step.reasoning);
1086
                        return Err(self.record_failure(why).await);
1382
                        return Err(self.record_failure(error_code::STREAM_BROKEN, why).await);
1087 1383
                    }
1088 1384
                };
1089 1385
                if event.data == "[DONE]" {

@@ -1140,7 +1436,7 @@ impl CoderRuntimeSession {

1140 1436
                "the turn used all {MAX_TOOL_STEPS} tool steps without producing an answer; \
1141 1437
                 nothing was returned rather than an empty answer that reads as success"
1142 1438
            );
1143
            return Err(self.record_failure(why).await);
1439
            return Err(self.record_failure(error_code::MAX_STEPS, why).await);
1144 1440
        }
1145 1441
        Ok(final_answer)
1146 1442
    }

@@ -1149,10 +1445,15 @@ impl CoderRuntimeSession {

1149 1445
    ///
1150 1446
    /// Every exit from a turn that is not the model's own answer goes through
1151 1447
    /// here, so the record cannot say a session succeeded where it did not.
1152
    async fn record_failure(&mut self, why: String) -> Failure {
1448
    ///
1449
    /// `code` is which way it failed, kept for the thread's report: the `Err`
1450
    /// that travels back up the stack is a sentence, and a sentence is not a
1451
    /// category anything can group on.
1452
    async fn record_failure(&mut self, code: &'static str, why: String) -> Failure {
1153 1453
        let (usage, calls) = (self.last_usage, self.last_calls);
1154 1454
        self.note(vec![ThreadRecord::failed(&why, usage, calls)])
1155 1455
            .await;
1456
        self.pending_failure = Some(ThreadOutcome::failed(code, &why));
1156 1457
        why.into()
1157 1458
    }
1158 1459

@@ -1163,10 +1464,15 @@ impl CoderRuntimeSession {

1163 1464
    /// it says so here. Without this an interruption leaves a transcript that
1164 1465
    /// simply stops, which a later reader has no way to tell from a turn that
1165 1466
    /// finished quietly.
1467
    ///
1468
    /// It settles the session's outcome too, so the thread's report says the
1469
    /// session was stopped rather than carrying whatever the last turn that
1470
    /// did finish had said.
1166 1471
    pub async fn note_interruption(&mut self, why: &str) {
1167 1472
        let (usage, calls) = (self.last_usage, self.last_calls);
1168 1473
        self.note(vec![ThreadRecord::failed(why, usage, calls)])
1169 1474
            .await;
1475
        self.outcome = Some(ThreadOutcome::interrupted(why));
1170 1476
    }
1171 1477
1172 1478
    /// Record the assistant's tool calls, run them, and put the results back.

@@ -1361,11 +1667,13 @@ impl CoderRuntimeSession {

1361 1667
                Ok(r) => {
1362 1668
                    let status = r.status();
1363 1669
                    let body = r.text().await.unwrap_or_default();
1364
                    return Err(
1365
                        format!("{url} refused the turn: {status} {}", snippet(&body)).into(),
1366
                    );
1670
                    let why = format!("{url} refused the turn: {status} {}", snippet(&body));
1671
                    return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1672
                }
1673
                Err(error) => {
1674
                    let why = format!("{url} could not be reached: {error}");
1675
                    return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1367 1676
                }
1368
                Err(error) => return Err(format!("{url} could not be reached: {error}").into()),
1369 1677
            };
1370 1678
1371 1679
            // Ollama streams newline-delimited JSON rather than server-sent

@@ -1375,9 +1683,15 @@ impl CoderRuntimeSession {

1375 1683
            let mut step = StepAccumulator::default();
1376 1684
1377 1685
            while let Some(chunk) = bytes.next().await {
1378
                let chunk = chunk.map_err(|error| -> Failure {
1379
                    format!("the reply from {url} stopped mid-stream: {error}").into()
1380
                })?;
1686
                let chunk = match chunk {
1687
                    Ok(chunk) => chunk,
1688
                    Err(error) => {
1689
                        let why = format!("the reply from {url} stopped mid-stream: {error}");
1690
                        self.last_usage.add(step.usage);
1691
                        self.last_reasoning.push_str(&step.reasoning);
1692
                        return Err(self.record_failure(error_code::STREAM_BROKEN, why).await);
1693
                    }
1694
                };
1381 1695
                pending.push_str(&String::from_utf8_lossy(&chunk));
1382 1696
                while let Some(newline) = pending.find('\n') {
1383 1697
                    let line: String = pending.drain(..=newline).collect();

@@ -1430,21 +1744,26 @@ impl CoderRuntimeSession {

1430 1744
        }
1431 1745
1432 1746
        if !answered {
1433
            return Err(format!(
1747
            let why = format!(
1434 1748
                "the turn used all {MAX_TOOL_STEPS} tool steps without producing an answer; \
1435 1749
                 nothing was returned rather than an empty answer that reads as success"
1436
            )
1437
            .into());
1750
            );
1751
            return Err(self.record_failure(error_code::MAX_STEPS, why).await);
1438 1752
        }
1439 1753
        Ok(final_answer)
1440 1754
    }
1441 1755
}
1442 1756
1443 1757
/// A thread left open holds its grant's remaining budget, and the interactive
1444
/// session has no place to await a revocation on its way out. This is the
1445
/// backstop: best effort, on whatever runtime is still up. `close` is the path
1446
/// that can be awaited and proven, and it clears the id so this does not fire
1447
/// twice.
1758
/// session has no place to await an ending on its way out. This is the
1759
/// backstop: best effort, on whatever runtime is still up. [`CoderRuntimeSession::finish`]
1760
/// is the path that can be awaited and proven, and it clears the id so this
1761
/// does not fire twice.
1762
///
1763
/// It says what the session did where the session knows — a dropped session is
1764
/// not a reason for the record to claim a cancellation that did not happen —
1765
/// and falls back to the disposal only for a thread no turn ever ran on, which
1766
/// is the one case where there is genuinely nothing to report.
1448 1767
impl Drop for CoderRuntimeSession {
1449 1768
    fn drop(&mut self) {
1450 1769
        let Some(thread_id) = self.thread_id.take() else {

@@ -1453,11 +1772,24 @@ impl Drop for CoderRuntimeSession {

1453 1772
        let Ok(handle) = tokio::runtime::Handle::try_current() else {
1454 1773
            return;
1455 1774
        };
1456
        let url = format!("{}/threads/{thread_id}", self.api_base);
1457 1775
        let token = self.user_token.clone();
1458 1776
        let http = self.http.clone();
1777
        let ending = self
1778
            .outcome
1779
            .as_ref()
1780
            .map(|outcome| (outcome.wire(self.session_usage), true))
1781
            .unwrap_or((serde_json::json!({}), false));
1782
        let url = match ending.1 {
1783
            true => format!("{}/threads/{thread_id}/report", self.api_base),
1784
            false => format!("{}/threads/{thread_id}", self.api_base),
1785
        };
1459 1786
        handle.spawn(async move {
1460
            let mut request = http.delete(&url).timeout(Duration::from_secs(10));
1787
            let (body, reporting) = ending;
1788
            let mut request = match reporting {
1789
                true => http.post(&url).json(&body),
1790
                false => http.delete(&url),
1791
            }
1792
            .timeout(Duration::from_secs(10));
1461 1793
            if let Some(token) = token {
1462 1794
                request = request.bearer_auth(token);
1463 1795
            }

@@ -1580,6 +1912,20 @@ impl StepAccumulator {

1580 1912
    }
1581 1913
}
1582 1914
1915
/// What the grant in an ending's reply had spent, when it says.
1916
///
1917
/// Both endings answer with the same body, so both read it the same way.
1918
/// `None` when the server reported no spend, because there is nothing to
1919
/// reconcile against.
1920
fn grant_spend(body: &serde_json::Value) -> Option<TurnUsage> {
1921
    let spent = body.get("grant").and_then(|grant| grant.get("spent"))?;
1922
    Some(TurnUsage {
1923
        prompt_tokens: 0,
1924
        completion_tokens: 0,
1925
        total_tokens: field(spent, "total_tokens"),
1926
    })
1927
}
1928
1583 1929
fn field(value: &serde_json::Value, key: &str) -> u64 {
1584 1930
    value.get(key).and_then(|v| v.as_u64()).unwrap_or(0)
1585 1931
}

@@ -1843,6 +2189,88 @@ mod tests {

1843 2189
        );
1844 2190
    }
1845 2191
2192
    /// The status and the error code cannot disagree, whichever way round.
2193
    ///
2194
    /// The server refuses an incoherent pair in a changeset and again in a
2195
    /// database constraint, and the point of the constructors is that this
2196
    /// client never sends the server one to refuse. There is no way to build a
2197
    /// success carrying an error code or a failure carrying none, so this
2198
    /// walks every ending the CLI can file and asserts the pair.
2199
    #[test]
2200
    fn a_reported_outcome_and_its_error_code_always_agree() {
2201
        let endings = [
2202
            ThreadOutcome::succeeded("it answered"),
2203
            ThreadOutcome::failed(error_code::PROVIDER_FAILED, "the proxy refused it"),
2204
            ThreadOutcome::failed(error_code::STREAM_BROKEN, "the reply stopped"),
2205
            ThreadOutcome::failed(error_code::MAX_STEPS, "no answer in 30 steps"),
2206
            ThreadOutcome::failed(error_code::TURN_FAILED, "something else"),
2207
            ThreadOutcome::interrupted("ctrl-c"),
2208
            ThreadOutcome::no_turn(),
2209
        ];
2210
2211
        for ending in endings {
2212
            let body = ending.wire(TurnUsage::default());
2213
            let coded = body.get("error_code").and_then(|v| v.as_str());
2214
            match ending.status() {
2215
                SUCCEEDED => assert_eq!(
2216
                    coded, None,
2217
                    "a success named an error code, which the server refuses: {body}"
2218
                ),
2219
                other => {
2220
                    assert!(
2221
                        other == FAILED || other == CANCELLED,
2222
                        "'{other}' is not one of the server's terminal statuses"
2223
                    );
2224
                    assert!(
2225
                        coded.is_some_and(|code| !code.trim().is_empty()),
2226
                        "a thread that did not succeed named no error code: {body}"
2227
                    );
2228
                }
2229
            }
2230
            assert_eq!(coded, ending.error_code());
2231
            assert!(
2232
                !body["report"]
2233
                    .as_str()
2234
                    .unwrap_or_default()
2235
                    .trim()
2236
                    .is_empty(),
2237
                "a blank report is refused: {body}"
2238
            );
2239
        }
2240
    }
2241
2242
    /// An interruption is a cancellation, and it is never a success.
2243
    #[test]
2244
    fn an_interruption_is_recorded_as_the_cancellation_it_was() {
2245
        let stopped = ThreadOutcome::interrupted("stopped before finishing");
2246
        assert_eq!(stopped.status(), CANCELLED);
2247
        assert_eq!(stopped.error_code(), Some(error_code::INTERRUPTED));
2248
        assert_ne!(stopped.status(), SUCCEEDED);
2249
        assert_eq!(stopped.report(), "stopped before finishing");
2250
    }
2251
2252
    /// A report is never blank and never over the server's bound.
2253
    ///
2254
    /// A model that answered with nothing still ended a session; filing a
2255
    /// blank report is refused outright, so the whole ending would fail over
2256
    /// an empty answer and the thread would be left open.
2257
    #[test]
2258
    fn a_report_is_always_something_the_server_will_take() {
2259
        let empty = ThreadOutcome::succeeded("   ");
2260
        assert_eq!(empty.report(), "The session answered.");
2261
2262
        let long = ThreadOutcome::succeeded(&"é".repeat(MAX_REPORT_BYTES));
2263
        assert!(
2264
            long.report().len() <= MAX_REPORT_BYTES,
2265
            "a report of {} bytes is over the server's bound",
2266
            long.report().len()
2267
        );
2268
        assert!(long.report().ends_with("[report truncated]"));
2269
        // Cut on a character boundary: `é` is two bytes, so a naive cut splits
2270
        // one and the string is not valid UTF-8 to begin with.
2271
        assert!(long.report().starts_with('é'));
2272
    }
2273
1846 2274
    /// The local lane's system prompt must not promise a metered proxy, and the
1847 2275
    /// thread lane's must not promise that nothing leaves the machine.
1848 2276
    #[test]
crates/openagents-cli/tests/delegate_test.rs modified +45 -20

@@ -354,6 +354,11 @@ struct ProxyStub {

354 354
}
355 355
356 356
impl ProxyStub {
357
    /// Every request this stub has taken, headers and body, most recent last.
358
    fn requests(&self) -> Vec<String> {
359
        self.requests.lock().unwrap().clone()
360
    }
361
357 362
    fn request_lines(&self) -> Vec<String> {
358 363
        self.requests
359 364
            .lock()

@@ -364,7 +369,7 @@ impl ProxyStub {

364 369
    }
365 370
}
366 371
367
/// Answers a thread open, transcript appends, a revocation, and one turn.
372
/// Answers a thread open, transcript appends, the thread's report, and one turn.
368 373
async fn proxy_stub() -> ProxyStub {
369 374
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
370 375

@@ -417,23 +422,24 @@ async fn proxy_stub() -> ProxyStub {

417 422
                    "application/json",
418 423
                    r#"{"events":[{"id":1}]}"#.to_string(),
419 424
                )
420
            } else if line.starts_with("POST /api/v1/threads") {
425
            } else if line.starts_with("POST") && line.contains("/report") {
426
                // Held open, so an awaited ending is measurably slower than a
427
                // spawned one. See the test below.
428
                tokio::time::sleep(REVOCATION_HELD).await;
421 429
                (
422 430
                    200,
423 431
                    "application/json",
424
                    format!(
425
                        r#"{{"thread":{{"id":"th_child"}},"grant":{{"token":"tok","url":"{grant_url}","model":"ox-alpha"}}}}"#
426
                    ),
432
                    r#"{"thread":{"id":"th_child","status":"succeeded"},
433
                        "grant":{"status":"revoked","spent":{"calls":1,"total_tokens":12}}}"#
434
                        .to_string(),
427 435
                )
428
            } else if line.starts_with("DELETE /api/v1/threads/") {
429
                // Held open, so an awaited revocation is measurably slower
430
                // than a spawned one. See the test below.
431
                tokio::time::sleep(REVOCATION_HELD).await;
436
            } else if line.starts_with("POST /api/v1/threads") {
432 437
                (
433 438
                    200,
434 439
                    "application/json",
435
                    r#"{"grant":{"status":"revoked","spent":{"calls":1,"total_tokens":12}}}"#
436
                        .to_string(),
440
                    format!(
441
                        r#"{{"thread":{{"id":"th_child"}},"grant":{{"token":"tok","url":"{grant_url}","model":"ox-alpha"}}}}"#
442
                    ),
437 443
                )
438 444
            } else {
439 445
                let frame =

@@ -459,15 +465,18 @@ async fn proxy_stub() -> ProxyStub {

459 465
    ProxyStub { origin, requests }
460 466
}
461 467
462
/// A child on the proxy revokes its own thread, awaited, and writes its turn
463
/// down on the way.
468
/// A child on the proxy ends its own thread, awaited, saying what it did and
469
/// writing its turn down on the way.
464 470
///
465
/// A child used to leave the revocation to the `Drop` impl, which spawns a
466
/// `DELETE` onto whatever runtime is still up and may never be polled. Timed
467
/// rather than merely observed: the stub holds the revocation open, so a child
468
/// that finishes faster than that did not wait for it.
471
/// A child used to leave the ending to the `Drop` impl, which spawns a request
472
/// onto whatever runtime is still up and may never be polled. Timed rather
473
/// than merely observed: the stub holds the ending open, so a child that
474
/// finishes faster than that did not wait for it.
475
///
476
/// A child that answered reports `succeeded` rather than being cancelled
477
/// (issue #106), which is also what leaves its thread resumable.
469 478
#[tokio::test]
470
async fn a_delegated_child_revokes_its_own_thread() {
479
async fn a_delegated_child_ends_its_own_thread_by_saying_what_it_did() {
471 480
    let _guard = exclusive();
472 481
    let stub = proxy_stub().await;
473 482
    std::env::set_var("OPENAGENTS_API_BASE", format!("{}/api/v1", stub.origin));

@@ -490,9 +499,25 @@ async fn a_delegated_child_revokes_its_own_thread() {

490 499
    assert!(
491 500
        lines
492 501
            .iter()
493
            .any(|line| line.starts_with("DELETE /api/v1/threads/th_child")),
502
            .any(|line| line.starts_with("POST /api/v1/threads/th_child/report")),
494 503
        "the child left its thread open: {lines:?}"
495 504
    );
505
    assert!(
506
        !lines.iter().any(|line| line.starts_with("DELETE")),
507
        "the child cancelled a thread it had answered on: {lines:?}"
508
    );
509
    let reported: serde_json::Value = stub
510
        .requests()
511
        .into_iter()
512
        .find(|request| request.contains("/report"))
513
        .and_then(|request| {
514
            request
515
                .split_once("\r\n\r\n")
516
                .and_then(|(_, body)| serde_json::from_str(body).ok())
517
        })
518
        .expect("the child said nothing about how it ended");
519
    assert_eq!(reported["status"], "succeeded");
520
    assert_eq!(reported.get("error_code"), None);
496 521
    assert!(
497 522
        lines
498 523
            .iter()

@@ -501,7 +526,7 @@ async fn a_delegated_child_revokes_its_own_thread() {

501 526
    );
502 527
    assert!(
503 528
        results[0].duration_ms >= REVOCATION_HELD.as_millis(),
504
        "the child finished in {}ms with the revocation held open for {}ms, so it was \
529
        "the child finished in {}ms with the ending held open for {}ms, so it was \
505 530
         spawned and abandoned rather than awaited",
506 531
        results[0].duration_ms,
507 532
        REVOCATION_HELD.as_millis()
crates/openagents-cli/tests/runtime_test.rs modified +514 -28

@@ -35,6 +35,9 @@ enum Reply {

35 35
    /// A body sent after a pause. What tells a call that was awaited from one
36 36
    /// that was spawned and hoped for: only the first waits.
37 37
    Delayed(Duration, u16, &'static str, String),
38
    /// An event stream that is cut off: one frame, then the connection goes
39
    /// without closing the chunked body. A reply that stopped part way.
40
    Truncated(String),
38 41
}
39 42
40 43
/// A server that records what it was asked and answers from a script.

@@ -103,6 +106,24 @@ where

103 106
                        let _ = socket.write_all(head.as_bytes()).await;
104 107
                        let _ = socket.write_all(body.as_bytes()).await;
105 108
                    }
109
                    Reply::Truncated(frame) => {
110
                        // Chunked, so a body that simply stops is a torn
111
                        // stream rather than a complete one: with `connection:
112
                        // close` and no framing, EOF *is* the end and nothing
113
                        // downstream can tell it from a finished reply.
114
                        let _ = socket
115
                            .write_all(
116
                                b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
117
                                  transfer-encoding: chunked\r\n\r\n",
118
                            )
119
                            .await;
120
                        let body = format!("data: {frame}\n\n");
121
                        let _ = socket
122
                            .write_all(format!("{:x}\r\n{body}\r\n", body.len()).as_bytes())
123
                            .await;
124
                        let _ = socket.flush().await;
125
                        // No terminating chunk: the socket just goes.
126
                    }
106 127
                    Reply::Sse(frames, pause) => {
107 128
                        let _ = socket
108 129
                            .write_all(

@@ -993,6 +1014,37 @@ fn appended() -> Reply {

993 1014
    )
994 1015
}
995 1016
1017
/// The 200 the report route answers with: the ended thread and its grant's
1018
/// spend, the same shape a revocation answers with.
1019
fn filed(total_tokens: u64) -> Reply {
1020
    Reply::Body(
1021
        200,
1022
        "application/json",
1023
        format!(
1024
            r#"{{"grant":{{"status":"revoked","spent":{{"calls":1,"total_tokens":{total_tokens}}}}},
1025
                "thread":{{"id":"th_test","status":"succeeded"}}}}"#
1026
        ),
1027
    )
1028
}
1029
1030
/// The body of the report the session filed, or `None` if it filed none.
1031
fn filed_report(stub: &Stub) -> Option<serde_json::Value> {
1032
    stub.requests()
1033
        .iter()
1034
        .find(|request| {
1035
            request
1036
                .lines()
1037
                .next()
1038
                .is_some_and(|line| line.starts_with("POST") && line.contains("/report"))
1039
        })
1040
        .and_then(|request| {
1041
            request
1042
                .split_once("\r\n\r\n")
1043
                .map(|(_, body)| body.to_string())
1044
        })
1045
        .and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
1046
}
1047
996 1048
fn revoked(total_tokens: u64) -> Reply {
997 1049
    Reply::Body(
998 1050
        200,

@@ -1012,6 +1064,9 @@ fn recording_stub() -> Stub {

1012 1064
        if line.starts_with("POST") && line.contains("/events") {
1013 1065
            return appended();
1014 1066
        }
1067
        if line.starts_with("POST") && line.contains("/report") {
1068
            return filed(116);
1069
        }
1015 1070
        if line.starts_with("POST /api/v1/threads") {
1016 1071
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1017 1072
        }

@@ -1057,7 +1112,7 @@ async fn a_finished_turn_is_written_down_before_the_thread_is_revoked() {

1057 1112
        .await
1058 1113
        .expect("the turn failed");
1059 1114
    assert_eq!(answer, "It said hello.");
1060
    session.close().await.expect("the revocation failed");
1115
    session.finish().await.expect("the ending failed");
1061 1116
1062 1117
    let events = recorded(&stub);
1063 1118
    assert_eq!(

@@ -1093,20 +1148,20 @@ async fn a_finished_turn_is_written_down_before_the_thread_is_revoked() {

1093 1148
        "a turn that answered recorded a failure"
1094 1149
    );
1095 1150
1096
    // And every append landed before the revocation. A record written after
1097
    // the thread is terminal is refused by the server and is not a record.
1151
    // And every append landed before the ending. A record written after the
1152
    // thread is terminal is refused by the server and is not a record.
1098 1153
    let lines = stub.request_lines();
1099
    let revocation = lines
1154
    let ending = lines
1100 1155
        .iter()
1101
        .position(|line| line.starts_with("DELETE"))
1102
        .expect("no revocation was sent");
1156
        .position(|line| line.starts_with("POST") && line.contains("/report"))
1157
        .expect("no report was sent");
1103 1158
    let last_append = lines
1104 1159
        .iter()
1105 1160
        .rposition(|line| line.starts_with("POST") && line.contains("/events"))
1106 1161
        .expect("nothing was appended");
1107 1162
    assert!(
1108
        last_append < revocation,
1109
        "an append landed after the revocation: {lines:?}"
1163
        last_append < ending,
1164
        "an append landed after the thread had ended: {lines:?}"
1110 1165
    );
1111 1166
}
1112 1167

@@ -1124,7 +1179,7 @@ async fn a_recorded_turn_replays_into_the_conversation_it_came_from() {

1124 1179
        .execute_turn("what does echo hello print?", |_| {})
1125 1180
        .await
1126 1181
        .expect("the turn failed");
1127
    session.close().await.expect("the revocation failed");
1182
    session.finish().await.expect("the ending failed");
1128 1183
1129 1184
    // Exactly what `GET /api/v1/threads/{id}/events` would hand back.
1130 1185
    let transcript: Vec<openagents_cli::resume::ThreadEvent> = recorded(&stub)

@@ -1167,12 +1222,12 @@ async fn a_refused_turn_records_the_failure_and_never_an_answer() {

1167 1222
        if line.starts_with("POST") && line.contains("/events") {
1168 1223
            return appended();
1169 1224
        }
1225
        if line.starts_with("POST") && line.contains("/report") {
1226
            return filed(0);
1227
        }
1170 1228
        if line.starts_with("POST /api/v1/threads") {
1171 1229
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1172 1230
        }
1173
        if line.starts_with("DELETE /api/v1/threads/") {
1174
            return revoked(0);
1175
        }
1176 1231
        Reply::Body(
1177 1232
            402,
1178 1233
            "application/json",

@@ -1185,7 +1240,7 @@ async fn a_refused_turn_records_the_failure_and_never_an_answer() {

1185 1240
        .execute_turn("do something", |_| {})
1186 1241
        .await
1187 1242
        .expect_err("a refused proxy returned success");
1188
    session.close().await.expect("the revocation failed");
1243
    session.finish().await.expect("the ending failed");
1189 1244
1190 1245
    let events = recorded(&stub);
1191 1246
    assert_eq!(

@@ -1332,12 +1387,12 @@ async fn the_grant_spend_is_reported_and_a_divergence_is_named() {

1332 1387
        if line.starts_with("POST") && line.contains("/events") {
1333 1388
            return appended();
1334 1389
        }
1390
        if line.starts_with("POST") && line.contains("/report") {
1391
            return filed(500);
1392
        }
1335 1393
        if line.starts_with("POST /api/v1/threads") {
1336 1394
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1337 1395
        }
1338
        if line.starts_with("DELETE /api/v1/threads/") {
1339
            return revoked(500);
1340
        }
1341 1396
        Reply::Sse(
1342 1397
            vec![
1343 1398
                frame(serde_json::json!({"choices":[{"delta":{"content":"PONG"}}]})),

@@ -1354,7 +1409,7 @@ async fn the_grant_spend_is_reported_and_a_divergence_is_named() {

1354 1409
    session.execute_turn("say pong", |_| {}).await.unwrap();
1355 1410
    assert_eq!(session.session_usage.total_tokens, 116);
1356 1411
1357
    let spent = session.close().await.expect("the revocation failed");
1412
    let spent = session.finish().await.expect("the ending failed");
1358 1413
    let line = session
1359 1414
        .spend_line(spent)
1360 1415
        .expect("the server reported a spend and nothing said so");

@@ -1373,20 +1428,20 @@ async fn the_grant_spend_is_reported_and_a_divergence_is_named() {

1373 1428
    assert_eq!(agreed, "Billed by the server: 116 tokens");
1374 1429
}
1375 1430
1376
/// The interactive session revokes its thread when the screen goes, awaited.
1431
/// The interactive session ends its thread when the screen goes, awaited.
1377 1432
///
1378 1433
/// `run_tui` used to `abort()` this actor, which drops the session inside a
1379
/// dead task where the `Drop` impl can only spawn a `DELETE` the exiting
1434
/// dead task where the `Drop` impl can only spawn an ending the exiting
1380 1435
/// process may never poll (issue #107). Here the app's control channel is
1381 1436
/// dropped, exactly as leaving the screen drops it.
1382 1437
///
1383
/// Proved with a clock, because "the stub eventually saw a DELETE" is also
1438
/// Proved with a clock, because "the stub eventually saw the ending" is also
1384 1439
/// what the racing `Drop` does on a runtime that stays up. The stub holds the
1385
/// revocation open for `HELD`, so only an actor that *awaits* the close takes
1440
/// report open for `HELD`, so only an actor that *awaits* the ending takes
1386 1441
/// that long to return; a spawned best effort returns immediately and leaves
1387 1442
/// the request in flight.
1388 1443
#[tokio::test]
1389
async fn the_interactive_actor_awaits_its_revocation_when_the_app_goes() {
1444
async fn the_interactive_actor_awaits_its_ending_when_the_app_goes() {
1390 1445
    /// Long enough to separate an awaited call from a spawned one, short
1391 1446
    /// enough not to slow the suite.
1392 1447
    const HELD: Duration = Duration::from_millis(600);

@@ -1396,10 +1451,7 @@ async fn the_interactive_actor_awaits_its_revocation_when_the_app_goes() {

1396 1451
        if line.starts_with("POST") && line.contains("/events") {
1397 1452
            return appended();
1398 1453
        }
1399
        if line.starts_with("POST /api/v1/threads") {
1400
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1401
        }
1402
        if line.starts_with("DELETE /api/v1/threads/") {
1454
        if line.starts_with("POST") && line.contains("/report") {
1403 1455
            return Reply::Delayed(
1404 1456
                HELD,
1405 1457
                200,

@@ -1408,6 +1460,9 @@ async fn the_interactive_actor_awaits_its_revocation_when_the_app_goes() {

1408 1460
                    .to_string(),
1409 1461
            );
1410 1462
        }
1463
        if line.starts_with("POST /api/v1/threads") {
1464
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1465
        }
1411 1466
        Reply::Sse(
1412 1467
            vec![frame(
1413 1468
                serde_json::json!({"choices":[{"delta":{"content":"ok"}}]}),

@@ -1450,12 +1505,17 @@ async fn the_interactive_actor_awaits_its_revocation_when_the_app_goes() {

1450 1505
    assert!(
1451 1506
        lines
1452 1507
            .iter()
1453
            .any(|line| line.starts_with("DELETE /api/v1/threads/th_test")),
1508
            .any(|line| line.starts_with("POST /api/v1/threads/th_test/report")),
1454 1509
        "the interactive session left its thread open: {lines:?}"
1455 1510
    );
1511
    assert_eq!(
1512
        filed_report(&stub).map(|body| body["status"].clone()),
1513
        Some(serde_json::json!("succeeded")),
1514
        "the interactive session did not say what it did: {lines:?}"
1515
    );
1456 1516
    assert!(
1457 1517
        took >= HELD,
1458
        "the actor returned after {took:?} with the revocation still held open for {HELD:?}, \
1518
        "the actor returned after {took:?} with the ending still held open for {HELD:?}, \
1459 1519
         so it was spawned and abandoned rather than awaited"
1460 1520
    );
1461 1521
    // And it wrote the turn down on the way, like every other path.

@@ -1464,3 +1524,429 @@ async fn the_interactive_actor_awaits_its_revocation_when_the_app_goes() {

1464 1524
        "the interactive session recorded no answer"
1465 1525
    );
1466 1526
}
1527
1528
// ─────────────────────────────────────────────────────────────── the ending
1529
//
1530
// The other half of issue #106. A turn that wrote itself down still reached
1531
// `DELETE /api/v1/threads/{id}`, which hard-codes `error_code: cancelled` and
1532
// the sentence "The thread was cancelled before it reported." — so a session
1533
// that answered correctly and exited 0 left a permanent record saying it had
1534
// been cancelled, and a cancelled thread cannot be resumed.
1535
//
1536
// These prove the session says which of the three things happened, that it
1537
// says it before anything is revoked, and — the half that matters more — that
1538
// nothing which failed, was interrupted, or ran out of steps can say it
1539
// succeeded. Recording every session as a success would be worse than
1540
// recording every session as a cancellation: a reader can tell that a wall of
1541
// cancellations is uninformative, and cannot tell a false success from a
1542
// true one.
1543
1544
/// A session that answered reports `succeeded`, names no error code, and is
1545
/// never cancelled.
1546
#[tokio::test]
1547
async fn a_session_that_answered_reports_succeeded_and_is_not_cancelled() {
1548
    let stub = recording_stub();
1549
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1550
    let answer = session
1551
        .execute_turn("what does echo hello print?", |_| {})
1552
        .await
1553
        .expect("the turn failed");
1554
    assert_eq!(answer, "It said hello.");
1555
1556
    let spent = session.finish().await.expect("the ending failed");
1557
    assert_eq!(spent.map(|usage| usage.total_tokens), Some(116));
1558
1559
    let report = filed_report(&stub).expect("the session never said what it did");
1560
    assert_eq!(report["status"], "succeeded");
1561
    assert_eq!(
1562
        report.get("error_code"),
1563
        None,
1564
        "a session that answered named an error code, which the server refuses \
1565
         and this client should not be trying: {report}"
1566
    );
1567
    assert_eq!(
1568
        report["report"], "It said hello.",
1569
        "the report is not what the session answered: {report}"
1570
    );
1571
    // What the session counted, sent as the session's own figure. The account
1572
    // is charged against the grant's spend, which the reply carries back.
1573
    assert_eq!(report["usage"]["total_tokens"], 116);
1574
    assert_eq!(report["usage"]["counted_by"], "client");
1575
1576
    // Nothing was thrown away. A cancelled thread cannot be re-granted, so a
1577
    // `DELETE` here is what made `--resume` impossible across processes.
1578
    assert!(
1579
        !stub
1580
            .request_lines()
1581
            .iter()
1582
            .any(|line| line.starts_with("DELETE")),
1583
        "the session cancelled the thread it had just reported on: {:?}",
1584
        stub.request_lines()
1585
    );
1586
1587
    // The ending fires once.
1588
    assert!(session.finish().await.unwrap().is_none());
1589
    assert_eq!(
1590
        stub.request_lines()
1591
            .iter()
1592
            .filter(|line| line.contains("/report"))
1593
            .count(),
1594
        1
1595
    );
1596
}
1597
1598
/// The report carries the account's own credential, and is sent with a bearer
1599
/// token like every other owner-scoped call.
1600
#[tokio::test]
1601
async fn the_report_carries_the_accounts_credential() {
1602
    let stub = recording_stub();
1603
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1604
    session
1605
        .execute_turn("what does echo hello print?", |_| {})
1606
        .await
1607
        .unwrap();
1608
    session.finish().await.expect("the ending failed");
1609
1610
    let reported = stub
1611
        .requests()
1612
        .into_iter()
1613
        .find(|request| request.contains("/report"))
1614
        .expect("no report was sent");
1615
    assert!(reported.contains("Bearer oat_test"), "{reported}");
1616
}
1617
1618
/// A turn the proxy refused reports `failed` with a code, and never `succeeded`.
1619
#[tokio::test]
1620
async fn a_refused_turn_reports_failed_and_names_a_code() {
1621
    let stub = start(|request, origin| {
1622
        let line = request.lines().next().unwrap_or_default().to_string();
1623
        if line.starts_with("POST") && line.contains("/events") {
1624
            return appended();
1625
        }
1626
        if line.starts_with("POST") && line.contains("/report") {
1627
            return filed(0);
1628
        }
1629
        if line.starts_with("POST /api/v1/threads") {
1630
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1631
        }
1632
        Reply::Body(
1633
            402,
1634
            "application/json",
1635
            r#"{"code":"credit_exhausted","message":"nothing left"}"#.to_string(),
1636
        )
1637
    });
1638
1639
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1640
    session
1641
        .execute_turn("do something", |_| {})
1642
        .await
1643
        .expect_err("a refused proxy returned success");
1644
    session.finish().await.expect("the ending failed");
1645
1646
    let report = filed_report(&stub).expect("the session never said what it did");
1647
    assert_eq!(
1648
        report["status"], "failed",
1649
        "a turn the proxy refused was filed as something else: {report}"
1650
    );
1651
    assert_eq!(report["error_code"], "provider_failed");
1652
    let why = report["report"].as_str().unwrap_or_default();
1653
    assert!(
1654
        why.contains("402") && why.contains("credit_exhausted"),
1655
        "the report does not say what refused the turn: {why}"
1656
    );
1657
}
1658
1659
/// A turn that spent its whole step budget reports `max_steps`, not an answer
1660
/// it never produced.
1661
#[tokio::test]
1662
async fn a_turn_that_runs_out_of_steps_reports_max_steps() {
1663
    let stub = start(|request, origin| {
1664
        let line = request.lines().next().unwrap_or_default().to_string();
1665
        if line.starts_with("POST") && line.contains("/events") {
1666
            return appended();
1667
        }
1668
        if line.starts_with("POST") && line.contains("/report") {
1669
            return filed(0);
1670
        }
1671
        if line.starts_with("POST /api/v1/threads") {
1672
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1673
        }
1674
        // Never answers. Always asks for another tool.
1675
        Reply::Sse(
1676
            vec![frame(
1677
                serde_json::json!({"choices":[{"delta":{"tool_calls":[{
1678
                    "index": 0,
1679
                    "id": "call_loop",
1680
                    "function": {"name": "shell", "arguments": "{\"command\":\"true\"}"}
1681
                }]}}]}),
1682
            )],
1683
            None,
1684
        )
1685
    });
1686
1687
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1688
    session
1689
        .execute_turn("loop forever", |_| {})
1690
        .await
1691
        .expect_err("a turn with no answer returned one");
1692
    session.finish().await.expect("the ending failed");
1693
1694
    let report = filed_report(&stub).expect("the session never said what it did");
1695
    assert_eq!(report["status"], "failed");
1696
    assert_eq!(report["error_code"], "max_steps");
1697
    assert!(
1698
        report["report"]
1699
            .as_str()
1700
            .unwrap_or_default()
1701
            .contains("tool steps"),
1702
        "the report does not name the budget: {report}"
1703
    );
1704
}
1705
1706
/// A reply that broke mid-stream reports `stream_broken`. Half an answer is
1707
/// not an answer.
1708
#[tokio::test]
1709
async fn a_broken_stream_reports_that_the_reply_never_finished() {
1710
    let stub = start(|request, origin| {
1711
        let line = request.lines().next().unwrap_or_default().to_string();
1712
        if line.starts_with("POST") && line.contains("/events") {
1713
            return appended();
1714
        }
1715
        if line.starts_with("POST") && line.contains("/report") {
1716
            return filed(0);
1717
        }
1718
        if line.starts_with("POST /api/v1/threads") {
1719
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1720
        }
1721
        // A frame, then the socket goes without `[DONE]` and without the
1722
        // declared body ever finishing.
1723
        Reply::Truncated(frame(
1724
            serde_json::json!({"choices":[{"delta":{"content":"PO"}}]}),
1725
        ))
1726
    });
1727
1728
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1729
    let failure = session
1730
        .execute_turn("say pong", |_| {})
1731
        .await
1732
        .expect_err("half a reply was returned as an answer");
1733
    assert!(
1734
        failure.to_string().contains("mid-stream"),
1735
        "the caller was told something else: {failure}"
1736
    );
1737
    session.finish().await.expect("the ending failed");
1738
1739
    let report = filed_report(&stub).expect("the session never said what it did");
1740
    assert_eq!(
1741
        report["status"], "failed",
1742
        "a reply that never finished was filed as an answer: {report}"
1743
    );
1744
    assert_eq!(report["error_code"], "stream_broken");
1745
}
1746
1747
/// A session stopped mid-turn reports `cancelled` with `interrupted`, and
1748
/// cannot inherit the last finished turn's success.
1749
///
1750
/// This is the Ctrl-C shape: `oa delegate` cancels a child by dropping its
1751
/// turn future, which never reaches the turn's own failure path.
1752
#[tokio::test]
1753
async fn an_interrupted_session_reports_cancelled_and_says_it_was_interrupted() {
1754
    let stub = recording_stub();
1755
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1756
    // A turn that answered first, so a stale success is available to inherit.
1757
    session
1758
        .execute_turn("what does echo hello print?", |_| {})
1759
        .await
1760
        .expect("the turn failed");
1761
    assert_eq!(session.outcome().map(|o| o.status()), Some("succeeded"));
1762
1763
    session.note_interruption("stopped before finishing").await;
1764
    session.finish().await.expect("the ending failed");
1765
1766
    let report = filed_report(&stub).expect("the session never said what it did");
1767
    assert_eq!(
1768
        report["status"], "cancelled",
1769
        "an interrupted session was filed as something else: {report}"
1770
    );
1771
    assert_eq!(report["error_code"], "interrupted");
1772
    assert!(
1773
        report["report"]
1774
            .as_str()
1775
            .unwrap_or_default()
1776
            .contains("stopped before finishing"),
1777
        "{report}"
1778
    );
1779
}
1780
1781
/// A turn dropped while it was still running reports as interrupted, not as
1782
/// whatever the previous turn did.
1783
///
1784
/// The session's standing outcome is an interruption for as long as a turn is
1785
/// in flight, so a process that quits mid-turn cannot file the last finished
1786
/// turn's answer as this session's ending.
1787
#[tokio::test]
1788
async fn a_turn_dropped_while_it_ran_does_not_report_the_previous_turns_success() {
1789
    let stub = start(|request, origin| {
1790
        let line = request.lines().next().unwrap_or_default().to_string();
1791
        if line.starts_with("POST") && line.contains("/events") {
1792
            return appended();
1793
        }
1794
        if line.starts_with("POST") && line.contains("/report") {
1795
            return filed(0);
1796
        }
1797
        if line.starts_with("POST /api/v1/threads") {
1798
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1799
        }
1800
        // The first turn answers at once. The second is held open long enough
1801
        // to be dropped part way through.
1802
        if request.contains("\"content\":\"second\"") {
1803
            return Reply::Sse(
1804
                vec![
1805
                    frame(serde_json::json!({"choices":[{"delta":{"content":"…"}}]})),
1806
                    frame(serde_json::json!({"choices":[{"delta":{"content":"never"}}]})),
1807
                ],
1808
                Some((1, Duration::from_secs(30))),
1809
            );
1810
        }
1811
        Reply::Sse(
1812
            vec![frame(
1813
                serde_json::json!({"choices":[{"delta":{"content":"first"}}]}),
1814
            )],
1815
            None,
1816
        )
1817
    });
1818
1819
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1820
    let answer = session.execute_turn("one", |_| {}).await.expect("turn one");
1821
    assert_eq!(answer, "first");
1822
    assert_eq!(session.outcome().map(|o| o.status()), Some("succeeded"));
1823
1824
    // Drop the second turn part way through, exactly as quitting does.
1825
    let dropped = tokio::time::timeout(
1826
        Duration::from_millis(400),
1827
        session.execute_turn("second", |_| {}),
1828
    )
1829
    .await;
1830
    assert!(
1831
        dropped.is_err(),
1832
        "the held turn returned; the stub answered"
1833
    );
1834
1835
    assert_eq!(
1836
        session.outcome().map(|o| o.status()),
1837
        Some("cancelled"),
1838
        "a session dropped mid-turn kept the previous turn's outcome"
1839
    );
1840
    session.finish().await.expect("the ending failed");
1841
    let report = filed_report(&stub).expect("the session never said what it did");
1842
    assert_eq!(report["status"], "cancelled");
1843
    assert_eq!(report["error_code"], "interrupted");
1844
    assert_ne!(report["report"], "first");
1845
}
1846
1847
/// A session that held a thread and never ran a turn does not claim it
1848
/// answered.
1849
#[tokio::test]
1850
async fn a_thread_no_turn_ran_on_reports_that_no_turn_ran() {
1851
    let stub = start(|request, origin| {
1852
        let line = request.lines().next().unwrap_or_default().to_string();
1853
        if line.starts_with("POST") && line.contains("/report") {
1854
            return filed(0);
1855
        }
1856
        if line.starts_with("POST /api/v1/threads/") && line.contains("/grants") {
1857
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1858
        }
1859
        Reply::Body(200, "application/json", "{}".to_string())
1860
    });
1861
1862
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1863
    session
1864
        .adopt_thread("th_test")
1865
        .await
1866
        .expect("the thread was not adopted");
1867
    assert!(session.outcome().is_none());
1868
    session.finish().await.expect("the ending failed");
1869
1870
    let report = filed_report(&stub).expect("the session never said what it did");
1871
    assert_eq!(report["status"], "failed");
1872
    assert_eq!(report["error_code"], "no_turn");
1873
}
1874
1875
/// A deployment without the report route still has its thread ended, and says
1876
/// so rather than swallowing it.
1877
///
1878
/// A thread left open holds its grant's remaining budget (#107), so the
1879
/// refusal falls back to the disposal — and the report is sent first, which is
1880
/// the only place both requests appear in one run.
1881
#[tokio::test]
1882
async fn a_refused_report_still_ends_the_thread_and_is_reported_to_the_reader() {
1883
    let stub = start(|request, origin| {
1884
        let line = request.lines().next().unwrap_or_default().to_string();
1885
        if line.starts_with("POST") && line.contains("/events") {
1886
            return appended();
1887
        }
1888
        if line.starts_with("POST") && line.contains("/report") {
1889
            return Reply::Body(404, "text/plain", "Not Found".to_string());
1890
        }
1891
        if line.starts_with("POST /api/v1/threads") {
1892
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
1893
        }
1894
        if line.starts_with("DELETE /api/v1/threads/") {
1895
            return revoked(116);
1896
        }
1897
        Reply::Sse(
1898
            vec![frame(
1899
                serde_json::json!({"choices":[{"delta":{"content":"ok"}}]}),
1900
            )],
1901
            None,
1902
        )
1903
    });
1904
1905
    let mut session = session(Lane::OxAlpha, stub.base.clone());
1906
    session.execute_turn("say ok", |_| {}).await.unwrap();
1907
    let spent = session
1908
        .finish()
1909
        .await
1910
        .expect("a refused report left the thread open");
1911
    assert_eq!(spent.map(|usage| usage.total_tokens), Some(116));
1912
1913
    let lines = stub.request_lines();
1914
    let attempted = lines
1915
        .iter()
1916
        .position(|line| line.contains("/report"))
1917
        .expect("no report was attempted");
1918
    let revocation = lines
1919
        .iter()
1920
        .position(|line| line.starts_with("DELETE"))
1921
        .expect("the thread was left open");
1922
    assert!(
1923
        attempted < revocation,
1924
        "the thread was cancelled before it tried to report: {lines:?}"
1925
    );
1926
    assert!(
1927
        session
1928
            .record_failures
1929
            .iter()
1930
            .any(|failure| failure.contains("404") && failure.contains("report")),
1931
        "the refused report was swallowed: {:?}",
1932
        session.record_failures
1933
    );
1934
}
1935
1936
/// The local lane has no thread, so it reports nothing and fails at nothing.
1937
#[tokio::test]
1938
async fn a_session_with_no_thread_reports_nothing() {
1939
    let mut session = session(Lane::Local("qwen3".to_string()), DEAD.to_string());
1940
    assert!(session
1941
        .finish()
1942
        .await
1943
        .expect("no thread is not a failure")
1944
        .is_none());
1945
    assert!(session
1946
        .report(openagents_cli::runtime::ThreadOutcome::succeeded(
1947
            "anything"
1948
        ))
1949
        .await
1950
        .expect("no thread is not a failure")
1951
        .is_none());
1952
}

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