Compare commits

...

6 Commits

Author SHA1 Message Date
xli-oai
4ec43402bd Poll workspace messages on startup 2026-05-18 05:21:06 -07:00
xli-oai
1f1a7fa1df Update config schema for workspace messages 2026-05-05 23:12:41 -07:00
xli-oai
f8892a6362 Remove workspace message TUI prewarm 2026-05-05 23:12:41 -07:00
xli-oai
4ecb4497b2 Remove workspace message created_at field 2026-05-05 23:12:40 -07:00
xli-oai
f9cd5bd631 Gate workspace headline polling 2026-05-05 23:12:40 -07:00
xli-oai
926c68e4f4 Add workspace headline polling client 2026-05-05 23:12:40 -07:00
9 changed files with 188 additions and 0 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -3645,6 +3645,7 @@ dependencies = [
"codex-app-server-client",
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
"codex-cli",
"codex-cloud-requirements",

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

@@ -13,6 +13,79 @@ 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,
}
#[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)
}
}
#[cfg(test)]
mod workspace_message_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn workspace_messages_response_deserializes_headlines_and_announcements() {
let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({
"messages": [
{
"message_id": "headline-id",
"message_type": "headline",
"message_body": "Headline body"
},
{
"message_id": "announcement-id",
"message_type": "announcement",
"message_body": "Announcement body"
}
]
}))
.expect("workspace messages response should deserialize");
let headlines = response
.headlines()
.map(|message| message.message_id.as_str())
.collect::<Vec<_>>();
let announcements = response
.announcements()
.map(|message| message.message_id.as_str())
.collect::<Vec<_>>();
assert_eq!(headlines, vec!["headline-id"]);
assert_eq!(announcements, vec!["announcement-id"]);
}
}
/// 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

@@ -598,6 +598,9 @@
"workspace_dependencies": {
"type": "boolean"
},
"workspace_messages": {
"type": "boolean"
},
"workspace_owner_usage_nudge": {
"type": "boolean"
}
@@ -4147,6 +4150,9 @@
"workspace_dependencies": {
"type": "boolean"
},
"workspace_messages": {
"type": "boolean"
},
"workspace_owner_usage_nudge": {
"type": "boolean"
}

View File

@@ -223,6 +223,8 @@ pub enum Feature {
PreventIdleSleep,
/// Enable workspace-specific owner nudge copy and prompts in the TUI.
WorkspaceOwnerUsageNudge,
/// Enable workspace headline and announcement message polling.
WorkspaceMessages,
/// Legacy rollout flag for Responses API WebSocket transport experiments.
ResponsesWebsockets,
/// Legacy rollout flag for Responses API WebSocket transport v2 experiments.
@@ -1111,6 +1113,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::WorkspaceMessages,
key: "workspace_messages",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ResponsesWebsockets,
key: "responses_websockets",

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

@@ -42,6 +42,7 @@ use codex_config::format_config_error_with_source;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
use codex_features::Feature;
use codex_login::AuthConfig;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::enforce_login_restrictions;
@@ -190,6 +191,7 @@ mod version;
mod voice;
mod width;
mod workspace_command;
mod workspace_messages;
#[cfg(target_os = "linux")]
#[allow(dead_code)]
mod voice {
@@ -1106,6 +1108,9 @@ async fn run_ratatui_app(
color_eyre::install()?;
tooltips::announcement::prewarm();
if initial_config.features.enabled(Feature::WorkspaceMessages) {
workspace_messages::prewarm_workspace_messages(&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,42 @@
use crate::legacy_core::config::Config;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::CodexWorkspaceMessagesResponse;
use codex_login::AuthManager;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::time::timeout;
const WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration = Duration::from_millis(1000);
static WORKSPACE_MESSAGES: OnceLock<Option<CodexWorkspaceMessagesResponse>> = OnceLock::new();
static WORKSPACE_MESSAGES_FETCH_STARTED: OnceLock<()> = OnceLock::new();
pub(crate) fn prewarm_workspace_messages(config: &Config) {
if WORKSPACE_MESSAGES_FETCH_STARTED.set(()).is_err() {
return;
}
let config = config.clone();
tokio::spawn(async move {
let messages = timeout(
WORKSPACE_MESSAGES_FETCH_TIMEOUT,
fetch_workspace_messages(config),
)
.await
.ok()
.flatten();
let _ = WORKSPACE_MESSAGES.set(messages);
});
}
async fn fetch_workspace_messages(config: Config) -> Option<CodexWorkspaceMessagesResponse> {
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()?;
client.list_workspace_messages().await.ok()
}