diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 571b715..2f558dd 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -444,6 +444,33 @@ pub fn carry(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, spawne Ok(outcome) } +/// The most of a result's text that reaches the journal. +const RESULT_TEXT_LIMIT: usize = 4096; + +/// What `claude` concluded: a result event's text, with its subtype and error +/// flag when it has them, written to the unit's stdout so it reaches the journal +/// before the transcript goes with the runtime directory. Bounded, marked when cut. +fn write_result(line: &str, out: &mut dyn Write) -> std::io::Result<()> { + let Ok(Value::Object(event)) = serde_json::from_str::(line) else { return Ok(()) }; + if event.get("type").and_then(Value::as_str) != Some("result") { + return Ok(()); + } + let mut head = String::from("result"); + if let Some(subtype) = event.get("subtype").and_then(Value::as_str) { + head.push_str(&format!(" subtype={subtype}")); + } + if let Some(is_error) = event.get("is_error").and_then(Value::as_bool) { + head.push_str(&format!(" is_error={is_error}")); + } + let text = event.get("result").and_then(Value::as_str).unwrap_or(""); + let mut end = text.len().min(RESULT_TEXT_LIMIT); + while !text.is_char_boundary(end) { + end -= 1; + } + let marker = if end < text.len() { "\n[truncated]" } else { "" }; + writeln!(out, "{head}: {}{marker}", &text[..end]) +} + /// The loop: facts in, one clock read per fact, actions out. fn drive(mind: &dyn MindPort, clock: &dyn Clock, mut child: Box, run: &PipelineRef, spent: &mut Spent, out: &mut dyn Write) -> Result { let user_line = |text: &str| json!({"type": "user", "message": {"role": "user", "content": text}}).to_string(); @@ -456,6 +483,7 @@ fn drive(mind: &dyn MindPort, clock: &dyn Clock, mut child: Box, run: let fact = match child.next(wait) { Read::Line(line) => { spent.hear(&line); + write_result(&line, out)?; match read_usage(&line) { Usage::Request { id, prefix } => Event::Request { id, prefix }, Usage::Result => Event::Result, diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs index f265d44..2f5db35 100644 --- a/crates/mind-body/src/self_run/tests.rs +++ b/crates/mind-body/src/self_run/tests.rs @@ -622,6 +622,24 @@ mod seed_links { } } +#[test] +fn a_result_events_text_reaches_the_journal_with_its_subtype_and_error_flag() { + let line = json!({"type": "result", "subtype": "success", "is_error": false, "result": "all done"}).to_string(); + let ran = go(vec![Out(line)]); + assert!(ran.out.contains("result subtype=success is_error=false: all done\n"), "{}", ran.out); +} + +#[test] +fn a_long_result_is_cut_at_the_limit_on_a_character_boundary_and_marked() { + // One ASCII byte first, so the limit falls in the middle of a two-byte character. + let text = format!("a{}", "é".repeat(RESULT_TEXT_LIMIT)); + let line = json!({"type": "result", "result": text}).to_string(); + let ran = go(vec![Out(line)]); + let written = ran.out.lines().find(|l| l.starts_with("result")).unwrap(); + assert_eq!(written, format!("result: a{}", "é".repeat(RESULT_TEXT_LIMIT / 2 - 1))); + assert!(ran.out.contains("\n[truncated]\n"), "{}", ran.out); +} + // ---- what is read ----------------------------------------------------------- #[test] @@ -805,7 +823,7 @@ fn a_jump_past_the_ceiling_plus_the_headroom_writes_the_flush_and_the_running_tu // The headroom is measured from the prefix the flush was asked at (400k) and counts the requests after the // first result only: 459_999 does not interrupt; 460_000 does, once, and m6 is never read. assert_eq!(jump.count(&Event::Stopped(Stop::Interrupt)), 1, "{kinds:?}"); - assert_eq!(jump.out, "request 1 prefix 290000\nrequest 2 prefix 400000\nrequest 3 prefix 470000\nrequest 4 prefix 459999\nrequest 5 prefix 460000\nsettled: billed 7 micro-USD\n"); + assert_eq!(jump.out, "request 1 prefix 290000\nrequest 2 prefix 400000\nrequest 3 prefix 470000\nresult subtype=success is_error=true: Failed to authenticate: OAuth session expired and could not be refreshed\nrequest 4 prefix 459999\nrequest 5 prefix 460000\nsettled: billed 7 micro-USD\n"); assert_eq!(jump.ended().0, Cause::Signalled(Stop::Interrupt)); assert_eq!(flushes(&jump), 1); } @@ -1014,7 +1032,7 @@ fn unreadable_and_foreign_lines_are_skipped_and_never_printed() { Out(at("m1", 10)), Out(result_line()), ]); - assert_eq!(ran.out, "request 1 prefix 10\nsettled: billed 7 micro-USD\n"); + assert_eq!(ran.out, "request 1 prefix 10\nresult subtype=success is_error=true: Failed to authenticate: OAuth session expired and could not be refreshed\nsettled: billed 7 micro-USD\n"); let outcome = ran.outcome.as_ref().unwrap().to_string(); assert!(!ran.out.contains(canary) && !outcome.contains(canary)); assert!(!ran.written().iter().any(|text| text.contains(canary)), "nothing from the stream is written back"); @@ -1214,7 +1232,7 @@ mod process { let mut out = Vec::new(); let outcome = carry(&Seen::of(&mind), &SystemClock, &FakeLender::new(Answer::Grant), &Spawn(program), TEST_CAP_S, &mut out).unwrap(); assert_eq!(outcome, SelfRunOutcome::Ended { cause: Cause::FlushRequested, code: Some(0), requests: 2, prefix: 300_000 }); - assert_eq!(String::from_utf8(out).unwrap(), "request 1 prefix 10\nrequest 2 prefix 300000\nsettled: billed 7 micro-USD\n"); + assert_eq!(String::from_utf8(out).unwrap(), "request 1 prefix 10\nrequest 2 prefix 300000\nresult subtype=success is_error=true: Failed to authenticate: OAuth session expired and could not be refreshed\nsettled: billed 7 micro-USD\n"); } #[test] diff --git a/crates/mind-body/src/self_run/tests/lease.rs b/crates/mind-body/src/self_run/tests/lease.rs index 112ab7f..68fa60f 100644 --- a/crates/mind-body/src/self_run/tests/lease.rs +++ b/crates/mind-body/src/self_run/tests/lease.rs @@ -218,9 +218,9 @@ fn the_journal_says_what_was_billed_and_that_a_failed_settle_leaves_the_reservat (outcome, String::from_utf8(out).unwrap()) }; let (outcome, out) = run(Answer::Grant); - assert_eq!(out, "request 1 prefix 10\nsettled: billed 7 micro-USD\n"); + assert_eq!(out, "request 1 prefix 10\nresult subtype=success is_error=true: Failed to authenticate: OAuth session expired and could not be refreshed\nsettled: billed 7 micro-USD\n"); let (failed, out) = run(Answer::GrantSettleFails); - assert_eq!(out, "request 1 prefix 10\nsettle failed: the connector bills the reservation\n", "the connector's words are not echoed"); + assert_eq!(out, "request 1 prefix 10\nresult subtype=success is_error=true: Failed to authenticate: OAuth session expired and could not be refreshed\nsettle failed: the connector bills the reservation\n", "the connector's words are not echoed"); assert_eq!(failed, outcome, "the run's own outcome stands"); }