Add workspace headline polling client

This commit is contained in:
xli-oai
2026-05-05 03:20:46 -07:00
parent 9e0c191c13
commit 894ee74002
8 changed files with 137 additions and 0 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2104,6 +2104,7 @@ name = "codex-backend-client"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"codex-api",
"codex-backend-openapi-models",
"codex-client",
@@ -3634,6 +3635,7 @@ dependencies = [
"codex-app-server-client",
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
"codex-cli",
"codex-cloud-requirements",

View File

@@ -13,6 +13,7 @@ workspace = true
[dependencies]
anyhow = "1"
chrono = { workspace = true, features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -1,4 +1,5 @@
use crate::types::CodeTaskDetailsResponse;
use crate::types::CodexWorkspaceMessagesResponse;
use crate::types::ConfigFileResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
@@ -408,6 +409,16 @@ impl Client {
.map_err(RequestError::from)
}
pub async fn list_workspace_messages(
&self,
) -> std::result::Result<CodexWorkspaceMessagesResponse, RequestError> {
let url = self.workspace_messages_url();
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
self.decode_json::<CodexWorkspaceMessagesResponse>(&url, &ct, &body)
.map_err(RequestError::from)
}
/// Create a new task (user turn) by POSTing to the appropriate backend path
/// based on `path_style`. Returns the created task id.
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
@@ -539,6 +550,13 @@ impl Client {
}
}
fn workspace_messages_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url),
}
}
fn map_rate_limit_window(
window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
) -> Option<RateLimitWindow> {
@@ -862,4 +880,35 @@ mod tests {
serde_json::json!({ "credit_type": "usage_limit" })
);
}
#[test]
fn workspace_messages_uses_expected_paths() {
let codex_client = Client {
base_url: "https://example.test".to_string(),
http: reqwest::Client::new(),
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style: PathStyle::CodexApi,
};
assert_eq!(
codex_client.workspace_messages_url(),
"https://example.test/api/codex/workspace-messages"
);
let chatgpt_client = Client {
base_url: "https://chatgpt.com/backend-api".to_string(),
http: reqwest::Client::new(),
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style: PathStyle::ChatGptApi,
};
assert_eq!(
chatgpt_client.workspace_messages_url(),
"https://chatgpt.com/backend-api/wham/workspace-messages"
);
}
}

View File

@@ -6,6 +6,9 @@ pub use client::Client;
pub use client::RequestError;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::CodexWorkspaceMessage;
pub use types::CodexWorkspaceMessageType;
pub use types::CodexWorkspaceMessagesResponse;
pub use types::ConfigFileResponse;
pub use types::PaginatedListTaskListItem;
pub use types::TaskListItem;

View File

@@ -8,11 +8,51 @@ pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::TaskListItem;
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CodexWorkspaceMessagesResponse {
#[serde(default)]
pub messages: Vec<CodexWorkspaceMessage>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CodexWorkspaceMessage {
pub message_id: String,
pub message_type: CodexWorkspaceMessageType,
pub message_body: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodexWorkspaceMessageType {
Headline,
Announcement,
#[serde(other)]
Unknown,
}
impl CodexWorkspaceMessagesResponse {
pub fn headlines(&self) -> impl Iterator<Item = &CodexWorkspaceMessage> {
self.messages
.iter()
.filter(|message| message.message_type == CodexWorkspaceMessageType::Headline)
}
pub fn announcements(&self) -> impl Iterator<Item = &CodexWorkspaceMessage> {
self.messages
.iter()
.filter(|message| message.message_type == CodexWorkspaceMessageType::Announcement)
}
}
/// Hand-rolled models for the Cloud Tasks task-details response.
/// The generated OpenAPI models are pretty bad. This is a half-step
/// towards hand-rolling them.

View File

@@ -29,6 +29,7 @@ codex-ansi-escape = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-backend-client = { workspace = true }
codex-install-context = { workspace = true }
codex-chatgpt = { workspace = true }
codex-cloud-requirements = { workspace = true }

View File

@@ -190,6 +190,7 @@ mod version;
mod voice;
mod width;
mod workspace_command;
mod workspace_messages;
#[cfg(target_os = "linux")]
#[allow(dead_code)]
mod voice {
@@ -1104,6 +1105,7 @@ async fn run_ratatui_app(
color_eyre::install()?;
tooltips::announcement::prewarm();
workspace_messages::prewarm_headline(&initial_config);
// Forward panic reports through tracing so they appear in the UI status
// line, but do not swallow the default/color-eyre panic handler.

View File

@@ -0,0 +1,39 @@
use crate::legacy_core::config::Config;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::CodexWorkspaceMessage;
use codex_login::AuthManager;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::time::timeout;
const HEADLINE_FETCH_TIMEOUT: Duration = Duration::from_millis(1000);
static WORKSPACE_HEADLINE: OnceLock<Option<CodexWorkspaceMessage>> = OnceLock::new();
pub(crate) fn prewarm_headline(config: &Config) {
if WORKSPACE_HEADLINE.get().is_some() {
return;
}
let config = config.clone();
tokio::spawn(async move {
let headline = timeout(HEADLINE_FETCH_TIMEOUT, fetch_headline(config))
.await
.ok()
.flatten();
let _ = WORKSPACE_HEADLINE.set(headline);
});
}
async fn fetch_headline(config: Config) -> Option<CodexWorkspaceMessage> {
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
let auth = auth_manager.auth().await?;
if !auth.uses_codex_backend() {
return None;
}
let client = BackendClient::from_auth(config.chatgpt_base_url, &auth).ok()?;
let messages = client.list_workspace_messages().await.ok()?;
messages.headlines().next().cloned()
}