Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/tui/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}),
))
}
Expand Down
61 changes: 60 additions & 1 deletion crates/tui/src/runtime_api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion docs/RUNTIME_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading