Plumb compaction counts to TUI clients

This commit is contained in:
canvrno-oai
2026-07-08 14:54:19 -07:00
parent 358575465c
commit d5e7342080
20 changed files with 170 additions and 6 deletions
@@ -5115,6 +5115,13 @@
},
"turnId": {
"type": "string"
},
"windowNumber": {
"default": 0,
"description": "Current context-window generation. This starts at zero and advances after compaction.",
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
@@ -19821,6 +19821,13 @@
},
"turnId": {
"type": "string"
},
"windowNumber": {
"default": 0,
"description": "Current context-window generation. This starts at zero and advances after compaction.",
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
@@ -17600,6 +17600,13 @@
},
"turnId": {
"type": "string"
},
"windowNumber": {
"default": 0,
"description": "Current context-window generation. This starts at zero and advances after compaction.",
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
@@ -65,6 +65,13 @@
},
"turnId": {
"type": "string"
},
"windowNumber": {
"default": 0,
"description": "Current context-window generation. This starts at zero and advances after compaction.",
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
@@ -3,4 +3,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadTokenUsage } from "./ThreadTokenUsage";
export type ThreadTokenUsageUpdatedNotification = { threadId: string, turnId: string, tokenUsage: ThreadTokenUsage, };
export type ThreadTokenUsageUpdatedNotification = { threadId: string, turnId: string, tokenUsage: ThreadTokenUsage,
/**
* Current context-window generation. This starts at zero and advances after compaction.
*/
windowNumber: number, };
@@ -1373,6 +1373,10 @@ pub struct ThreadTokenUsageUpdatedNotification {
pub thread_id: String,
pub turn_id: String,
pub token_usage: ThreadTokenUsage,
/// Current context-window generation. This starts at zero and advances after compaction.
#[serde(default)]
#[ts(type = "number")]
pub window_number: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+1 -1
View File
@@ -313,7 +313,7 @@ Start a fresh thread when you need a new Codex conversation.
Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. The notification's `windowNumber` is the current zero-based context-window generation, so it also gives the number of compactions in the surviving thread history. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.
@@ -901,8 +901,15 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::TokenCount(token_count_event) => {
handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing)
.await;
let window_number = conversation.context_window_number().await;
handle_token_count_event(
conversation_id,
event_turn_id,
token_count_event,
window_number,
&outgoing,
)
.await;
}
EventMsg::Error(ev) => {
thread_watch_manager
@@ -1610,6 +1617,7 @@ async fn handle_token_count_event(
conversation_id: ThreadId,
turn_id: String,
token_count_event: TokenCountEvent,
window_number: u64,
outgoing: &ThreadScopedOutgoingMessageSender,
) {
let TokenCountEvent { info, rate_limits } = token_count_event;
@@ -1618,6 +1626,7 @@ async fn handle_token_count_event(
thread_id: conversation_id.to_string(),
turn_id,
token_usage,
window_number,
};
outgoing
.send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification))
@@ -3829,6 +3838,7 @@ mod tests {
info: Some(info),
rate_limits: Some(rate_limits),
},
/*window_number*/ 7,
&outgoing,
)
.await;
@@ -3840,6 +3850,7 @@ mod tests {
) => {
assert_eq!(payload.thread_id, conversation_id.to_string());
assert_eq!(payload.turn_id, turn_id);
assert_eq!(payload.window_number, 7);
let usage = payload.token_usage;
assert_eq!(usage.total.total_tokens, 200);
assert_eq!(usage.total.cached_input_tokens, 25);
@@ -3886,6 +3897,7 @@ mod tests {
info: None,
rate_limits: None,
},
/*window_number*/ 0,
&outgoing,
)
.await;
@@ -44,10 +44,12 @@ pub(super) async fn send_thread_token_usage_update_to_connection(
let Some(info) = conversation.token_usage_info().await else {
return;
};
let window_number = conversation.context_window_number().await;
let notification = ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: token_usage_turn_id.unwrap_or_else(|| latest_token_usage_turn_id(thread)),
token_usage: ThreadTokenUsage::from(info),
window_number,
};
outgoing
.send_server_notification_to_connections(
+5
View File
@@ -450,6 +450,11 @@ impl CodexThread {
self.codex.session.token_usage_info().await
}
/// Returns the current context-window generation for this thread.
pub async fn context_window_number(&self) -> u64 {
self.codex.session.context_window_number().await
}
/// Records a user-role session-prefix message without creating a new user turn boundary.
pub(crate) async fn inject_user_message_without_turn(&self, message: String) {
let item = ResponseItem::Message {
+7 -2
View File
@@ -1273,6 +1273,12 @@ impl Session {
state.token_info()
}
/// Returns the current context-window generation for this session.
pub(crate) async fn context_window_number(&self) -> u64 {
let state = self.state.lock().await;
state.auto_compact_window_number()
}
pub(crate) async fn get_estimated_token_count(
&self,
turn_context: &TurnContext,
@@ -3487,9 +3493,8 @@ impl Session {
}
pub(crate) async fn current_window_id(&self) -> String {
let state = self.state.lock().await;
let thread_id = self.thread_id;
let window_number = state.auto_compact_window_number();
let window_number = self.context_window_number().await;
format!("{thread_id}:{window_number}")
}
@@ -1239,6 +1239,7 @@ fn token_usage_update_is_emitted_on_turn_completion() {
},
model_context_window: Some(128_000),
},
window_number: 0,
},
));
assert_eq!(
+3
View File
@@ -1135,6 +1135,7 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window()
ThreadId::new(),
"turn-1",
Some(950_000),
/*window_number*/ 0,
)));
assert_eq!(
@@ -4651,10 +4652,12 @@ fn token_usage_notification(
thread_id: ThreadId,
turn_id: &str,
model_context_window: Option<i64>,
window_number: u64,
) -> ServerNotification {
ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
window_number,
token_usage: ThreadTokenUsage {
total: TokenUsageBreakdown {
total_tokens: 10,
+71
View File
@@ -45,6 +45,7 @@ pub(super) struct ThreadEventStore {
pub(super) pending_interactive_replay: PendingInteractiveReplayState,
pub(super) active_turn_id: Option<String>,
pub(super) input_state: Option<ThreadInputState>,
latest_token_usage: Option<codex_app_server_protocol::ThreadTokenUsageUpdatedNotification>,
pub(super) capacity: usize,
pub(super) active: bool,
}
@@ -69,6 +70,7 @@ impl ThreadEventStore {
pending_interactive_replay: PendingInteractiveReplayState::default(),
active_turn_id: None,
input_state: None,
latest_token_usage: None,
capacity,
active: false,
}
@@ -107,6 +109,9 @@ impl ThreadEventStore {
pub(super) fn push_notification(&mut self, notification: ServerNotification) {
self.pending_interactive_replay
.note_server_notification(&notification);
if let ServerNotification::ThreadTokenUsageUpdated(notification) = &notification {
self.latest_token_usage = Some(notification.clone());
}
match &notification {
ServerNotification::TurnStarted(turn) => {
self.active_turn_id = Some(turn.turn.id.clone());
@@ -206,6 +211,7 @@ impl ThreadEventStore {
}
pub(super) fn snapshot(&self) -> ThreadEventSnapshot {
let latest_token_usage = self.latest_token_usage.as_ref();
ThreadEventSnapshot {
session: self.session.clone(),
turns: self.turns.clone(),
@@ -218,11 +224,19 @@ impl ThreadEventStore {
ThreadBufferedEvent::Request(request) => self
.pending_interactive_replay
.should_replay_snapshot_request(request),
ThreadBufferedEvent::Notification(
ServerNotification::ThreadTokenUsageUpdated(_),
) => latest_token_usage.is_none(),
ThreadBufferedEvent::Notification(_)
| ThreadBufferedEvent::HistoryEntryResponse(_)
| ThreadBufferedEvent::FeedbackSubmission(_) => true,
})
.cloned()
.chain(latest_token_usage.cloned().map(|notification| {
ThreadBufferedEvent::Notification(ServerNotification::ThreadTokenUsageUpdated(
notification,
))
}))
.collect(),
input_state: self.input_state.clone(),
}
@@ -349,6 +363,9 @@ mod tests {
use codex_app_server_protocol::HookScope as AppServerHookScope;
use codex_app_server_protocol::HookStartedNotification;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartedNotification;
use codex_config::types::ApprovalsReviewer;
@@ -419,6 +436,31 @@ mod tests {
})
}
fn token_usage_notification(thread_id: ThreadId, window_number: u64) -> ServerNotification {
ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
window_number,
token_usage: ThreadTokenUsage {
total: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
output_tokens: 5,
reasoning_output_tokens: 0,
},
last: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
output_tokens: 5,
reasoning_output_tokens: 0,
},
model_context_window: Some(100),
},
})
}
fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
ServerNotification::HookStarted(HookStartedNotification {
thread_id: thread_id.to_string(),
@@ -555,6 +597,35 @@ mod tests {
assert_eq!(store.active_turn_id(), None);
}
#[test]
fn thread_event_store_snapshot_preserves_latest_token_usage() {
let thread_id = ThreadId::new();
let mut store = ThreadEventStore::new(/*capacity*/ 1);
store.push_notification(token_usage_notification(
thread_id, /*window_number*/ 3,
));
store.push_notification(ServerNotification::ConfigWarning(
codex_app_server_protocol::ConfigWarningNotification {
summary: "unrelated warning".to_string(),
details: None,
path: None,
range: None,
},
));
store.rebase_buffer_after_session_refresh();
let snapshot = store.snapshot();
let [
ThreadBufferedEvent::Notification(ServerNotification::ThreadTokenUsageUpdated(
notification,
)),
] = snapshot.events.as_slice()
else {
panic!("expected the latest token usage notification");
};
assert_eq!(notification.window_number, 3);
}
#[test]
fn thread_event_store_rebase_preserves_resolved_request_state() {
let thread_id = ThreadId::new();
+2
View File
@@ -551,6 +551,8 @@ pub(crate) struct ChatWidget {
runtime_model_provider_base_url: Option<String>,
pub(crate) remote_connection: Option<RemoteConnectionStatus>,
token_info: Option<TokenUsageInfo>,
#[cfg_attr(not(test), allow(dead_code))]
window_number: u64,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
@@ -123,6 +123,7 @@ impl ChatWidget {
runtime_model_provider_base_url,
remote_connection: None,
token_info: None,
window_number: 0,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
+1
View File
@@ -30,6 +30,7 @@ impl ChatWidget {
}
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.window_number = notification.window_number;
self.set_token_info(Some(token_usage_info_from_app_server(
notification.token_usage,
)));
@@ -27,6 +27,7 @@ impl ChatWidget {
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
self.window_number = 0;
self.clear_safety_buffering();
self.thread_name = session.thread_name.clone();
self.current_goal_status_indicator = None;
@@ -382,6 +382,14 @@ fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenU
}
pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option<TokenUsageInfo>) {
handle_token_count_with_window_number(chat, info, /*window_number*/ 0);
}
pub(super) fn handle_token_count_with_window_number(
chat: &mut ChatWidget,
info: Option<TokenUsageInfo>,
window_number: u64,
) {
match info {
Some(info) => {
chat.handle_server_notification(
@@ -393,6 +401,7 @@ pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option<TokenUsageI
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
window_number,
token_usage: codex_app_server_protocol::ThreadTokenUsage {
total: token_usage_breakdown(info.total_token_usage),
last: token_usage_breakdown(info.last_token_usage),
@@ -41,6 +41,21 @@ async fn token_count_none_resets_context_indicator() {
assert_eq!(chat.bottom_pane.context_window_percent(), None);
}
#[tokio::test]
async fn token_count_updates_context_window_generation() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_token_count_with_window_number(
&mut chat,
Some(make_token_info(
/*total_tokens*/ 1, /*context_window*/ 100,
)),
/*window_number*/ 3,
);
assert_eq!(chat.window_number, 3);
}
#[tokio::test]
async fn app_server_cyber_policy_error_renders_dedicated_notice() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;