diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 83a132d293..bf662585b2 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -9181,6 +9181,11 @@ fn map_compat_stream_event(event: &crate::runtime_threads::RuntimeEventRecord) - "remember": payload.get("remember"), "auto": payload.get("auto"), "timeout": payload.get("timeout"), + // Set when the decision was forced by a turn interrupt + // or turn teardown: no user selection was made, + // so clients must clear the pending prompt instead of + // reporting a refusal. + "cancelled": payload.get("cancelled"), }), )) } diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 4ed7e1aae2..07e421d0de 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -5694,13 +5694,72 @@ async fn stream_compat_mapping_handles_expected_runtime_events() -> Result<()> { assert!(text.contains("\"decision\":\"allow\"")); assert!(!text.contains("approval-decision-secret")); - let unknown = RuntimeEventRecord { + // A resolution forced by a turn interrupt or turn teardown must + // surface the `cancelled` flag so clients clear the pending approval + // UI instead of reporting a refusal. + let approval_cancelled = RuntimeEventRecord { schema_version: 1, seq: 9, timestamp: chrono::Utc::now(), thread_id: "thr_test".to_string(), turn_id: Some("turn_test".to_string()), item_id: None, + event: "approval.decided".to_string(), + payload: json!({ + "approval_id": "approval_test", + "decision": "deny", + "cancelled": true, + }), + }; + let mapped = map_compat_stream_event(&approval_cancelled) + .context("missing cancelled approval.decided event")?; + let stream = async_stream::stream! { + yield Ok::<_, Infallible>(mapped); + }; + let body = + axum::body::to_bytes(Sse::new(stream).into_response().into_body(), usize::MAX).await?; + let text = String::from_utf8_lossy(&body); + assert!(text.contains("event: approval.decided")); + assert!( + text.contains("\"cancelled\":true"), + "cancelled resolutions must project the flag through the compat stream: {text}" + ); + + // The compat stream must also keep surfacing `approval.timeout` (the + // decision-budget resolution, and replays of journals written by older + // builds that only recorded the legacy event). + let legacy_timeout = RuntimeEventRecord { + schema_version: 1, + seq: 10, + timestamp: chrono::Utc::now(), + thread_id: "thr_test".to_string(), + turn_id: Some("turn_test".to_string()), + item_id: None, + event: "approval.timeout".to_string(), + payload: json!({ + "approval_id": "approval_legacy", + "timeout_secs": 300, + }), + }; + let mapped = + map_compat_stream_event(&legacy_timeout).context("missing approval.timeout event")?; + let stream = async_stream::stream! { + yield Ok::<_, Infallible>(mapped); + }; + let body = + axum::body::to_bytes(Sse::new(stream).into_response().into_body(), usize::MAX).await?; + let text = String::from_utf8_lossy(&body); + assert!(text.contains("event: approval.timeout")); + assert!(text.contains("\"approval_id\":\"approval_legacy\"")); + assert!(text.contains("\"timeout_secs\":300")); + + let unknown = RuntimeEventRecord { + schema_version: 1, + seq: 11, + timestamp: chrono::Utc::now(), + thread_id: "thr_test".to_string(), + turn_id: Some("turn_test".to_string()), + item_id: None, event: "item.delta".to_string(), payload: json!({ "kind": "context_compaction", diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index b202b2e437..0b606ff13a 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -2482,7 +2482,12 @@ approval capability or assume it is unique across threads. The thread event stream forwards these payloads intact. The compatibility turn stream carries `approval_id`, its `id` alias and `tool_call_id`; the pending snapshot carries the same capability and correlator so reconnecting clients can -attach an approval prompt to its tool row. +attach an approval prompt to its tool row. Resolutions carry their resolution +flags through that projection too: `timeout: true` marks the deny produced when +the configured decision budget expires, and `cancelled: true` marks a deny +forced by a turn interrupt or turn teardown where no user selection +was made — clients should clear the pending prompt rather than report a +refusal. ## Security boundary