Preserve originating budgets for code mode notifications (#44867)

## Why

A yielded code mode cell can emit a notification after the session switches models. Its output should retain the originating model's truncation budget instead of using the receiving model's budget.

## What changed

Carry the cell's output token limit, including the serialization allowance, through notification dispatch and attach it as `history_truncation_token_limit` metadata. Allow `inject_if_running` to accept response item envelopes so queued notifications preserve this metadata in history and rollouts.

## Testing

Add regression coverage for delayed notifications across a model switch, verifying per-cell truncation budgets, full notification text in raw events, and persisted rollout metadata. Add a task-completion test that checks notification budgets are preserved alongside queued user input.

GitOrigin-RevId: 28380a039a6d41ea324401fcf1f611d81a279477
This commit is contained in:
Abhinav authored and copyberry committed 2026-09-11 17:03:21 +00:00
1 parent 2fc4bda3ca
commit 122d55cba8
6 files changed
+301 -12

No files matched your search

+4 -4
View File
@@ -13,10 +13,10 @@ impl Session {
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn inject_if_running(
pub(crate) async fn inject_if_running<T: Into<ResponseItemEnvelope>>(
&self,
input: Vec<ResponseItem>,
) -> Result<(), Vec<ResponseItem>> {
input: Vec<T>,
) -> Result<(), Vec<T>> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(active_turn) => {
@@ -25,7 +25,7 @@ impl Session {
active_turn.turn_state.as_ref(),
input
.into_iter()
.map(ResponseItemEnvelope::new)
.map(Into::into)
.map(PendingTurnInput::ResponseItem)
.collect(),
)
@@ -0,0 +1,66 @@
//! Task completion preserves a queued notification's budget alongside real user input.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_finish_preserves_notification_budget_and_queued_user_input() {
let (session, turn, _rx) = make_session_and_context_with_rx().await;
session
.spawn_task(
Arc::clone(&turn),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let text = "notification diagnostic line\n".repeat(500);
let notification = |text| ResponseItem::CustomToolCallOutput {
id: None,
call_id: "call-a".to_string(),
name: Some("exec".to_string()),
output: FunctionCallOutputPayload::from_text(text),
internal_chat_message_metadata_passthrough: None,
};
session
.inject_if_running(vec![ResponseItemEnvelope {
item: notification(text.clone()),
metadata: Some(CodexHarnessMetadata {
history_truncation_token_limit: Some(120),
..Default::default()
}),
}])
.await
.unwrap();
let user_input = vec![UserInput::Text {
text: "keep this queued user input".to_string(),
text_elements: Vec::new(),
}];
assert!(matches!(
submit_steer_only(&session, user_input.clone(), &turn.sub_id).await,
TurnInputSubmission::Steered { .. }
));
let mut current = turn.initial_settings.as_ref().clone();
Arc::make_mut(&mut current.model_info).truncation_policy =
codex_protocol::openai_models::TruncationPolicyConfig::tokens(/*limit*/ 400);
turn.current_settings.store(Arc::new(current));
session
.on_task_finished(Arc::clone(&turn), /*task_result*/ Ok(None))
.await;
let expected = vec![
notification(codex_utils_output_truncation::truncate_text(
&text,
codex_utils_output_truncation::TruncationPolicy::Tokens(120),
)),
session.response_item_from_user_input(user_input),
];
let history = session.clone_history().await;
assert_eq!(
strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(&history))),
strip_response_item_ids(&strip_metadata_from_items(&expected)),
);
}
+3
View File
@@ -1,3 +1,6 @@
#[path = "notification_tests.rs"]
mod notification_tests;
use super::mcp_refresh::McpRefresh;
use super::step_settings::ResolvedStepSettings;
use super::step_settings::StepSettings;