Add workspace messages app-server API (#29001)

## Summary

- Add backend-client types and fetch support for active workspace
messages.
- Add the app-server v2 `account/workspaceMessages/read` method,
generated schemas, and README documentation.
- Delegate workspace-message eligibility to the Codex backend feature
gate; map a backend 404 to `featureEnabled: false`.

## Testing

- `just write-app-server-schema`
- `just test -p codex-backend-client`
- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server workspace_messages`
- `just fix -p codex-backend-client -p codex-app-server-protocol -p
codex-app-server`
- `just fmt`

## Stack

- Base PR for #28232, which adds the TUI status-line integration.
This commit is contained in:
xli-oai
2026-06-22 04:25:07 -07:00
committed by GitHub
Unverified
parent 9c3b10e5d4
commit 21d36296f1
34 changed files with 1163 additions and 3 deletions
@@ -6462,6 +6462,29 @@
"title": "Account/usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/workspaceMessages/read"
],
"title": "Account/workspaceMessages/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/workspaceMessages/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1904,6 +1904,29 @@
"title": "Account/usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"account/workspaceMessages/read"
],
"title": "Account/workspaceMessages/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/workspaceMessages/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -10354,6 +10377,28 @@
"title": "GetAccountTokenUsageResponse",
"type": "object"
},
"GetWorkspaceMessagesResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"featureEnabled": {
"description": "Whether the workspace-message backend route is available for this client.",
"type": "boolean"
},
"messages": {
"description": "Active workspace messages returned by the backend.",
"items": {
"$ref": "#/definitions/v2/WorkspaceMessage"
},
"type": "array"
}
},
"required": [
"featureEnabled",
"messages"
],
"title": "GetWorkspaceMessagesResponse",
"type": "object"
},
"GitInfo": {
"properties": {
"branch": {
@@ -20564,6 +20609,49 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorkspaceMessage": {
"properties": {
"archivedAt": {
"description": "Unix timestamp (in seconds) when the message was archived.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"createdAt": {
"description": "Unix timestamp (in seconds) when the message was created.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"messageBody": {
"type": "string"
},
"messageId": {
"type": "string"
},
"messageType": {
"$ref": "#/definitions/v2/WorkspaceMessageType"
}
},
"required": [
"messageBody",
"messageId",
"messageType"
],
"type": "object"
},
"WorkspaceMessageType": {
"enum": [
"headline",
"announcement",
"unknown"
],
"type": "string"
},
"WriteStatus": {
"enum": [
"ok",
@@ -2913,6 +2913,29 @@
"title": "Account/usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/workspaceMessages/read"
],
"title": "Account/workspaceMessages/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/workspaceMessages/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -6705,6 +6728,28 @@
"title": "GetAccountTokenUsageResponse",
"type": "object"
},
"GetWorkspaceMessagesResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"featureEnabled": {
"description": "Whether the workspace-message backend route is available for this client.",
"type": "boolean"
},
"messages": {
"description": "Active workspace messages returned by the backend.",
"items": {
"$ref": "#/definitions/WorkspaceMessage"
},
"type": "array"
}
},
"required": [
"featureEnabled",
"messages"
],
"title": "GetWorkspaceMessagesResponse",
"type": "object"
},
"GitInfo": {
"properties": {
"branch": {
@@ -18343,6 +18388,49 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorkspaceMessage": {
"properties": {
"archivedAt": {
"description": "Unix timestamp (in seconds) when the message was archived.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"createdAt": {
"description": "Unix timestamp (in seconds) when the message was created.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"messageBody": {
"type": "string"
},
"messageId": {
"type": "string"
},
"messageType": {
"$ref": "#/definitions/WorkspaceMessageType"
}
},
"required": [
"messageBody",
"messageId",
"messageType"
],
"type": "object"
},
"WorkspaceMessageType": {
"enum": [
"headline",
"announcement",
"unknown"
],
"type": "string"
},
"WriteStatus": {
"enum": [
"ok",
@@ -0,0 +1,67 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WorkspaceMessage": {
"properties": {
"archivedAt": {
"description": "Unix timestamp (in seconds) when the message was archived.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"createdAt": {
"description": "Unix timestamp (in seconds) when the message was created.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"messageBody": {
"type": "string"
},
"messageId": {
"type": "string"
},
"messageType": {
"$ref": "#/definitions/WorkspaceMessageType"
}
},
"required": [
"messageBody",
"messageId",
"messageType"
],
"type": "object"
},
"WorkspaceMessageType": {
"enum": [
"headline",
"announcement",
"unknown"
],
"type": "string"
}
},
"properties": {
"featureEnabled": {
"description": "Whether the workspace-message backend route is available for this client.",
"type": "boolean"
},
"messages": {
"description": "Active workspace messages returned by the backend.",
"items": {
"$ref": "#/definitions/WorkspaceMessage"
},
"type": "array"
}
},
"required": [
"featureEnabled",
"messages"
],
"title": "GetWorkspaceMessagesResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorkspaceMessage } from "./WorkspaceMessage";
export type GetWorkspaceMessagesResponse = {
/**
* Whether the workspace-message backend route is available for this client.
*/
featureEnabled: boolean,
/**
* Active workspace messages returned by the backend.
*/
messages: Array<WorkspaceMessage>, };
@@ -0,0 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WorkspaceMessageType } from "./WorkspaceMessageType";
export type WorkspaceMessage = { messageId: string, messageType: WorkspaceMessageType, messageBody: string,
/**
* Unix timestamp (in seconds) when the message was created.
*/
createdAt: number | null,
/**
* Unix timestamp (in seconds) when the message was archived.
*/
archivedAt: number | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorkspaceMessageType = "headline" | "announcement" | "unknown";
@@ -157,6 +157,7 @@ export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse";
export type { GitInfo } from "./GitInfo";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GuardianApprovalReview } from "./GuardianApprovalReview";
@@ -494,4 +495,6 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams";
export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse";
export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification";
export type { WorkspaceMessage } from "./WorkspaceMessage";
export type { WorkspaceMessageType } from "./WorkspaceMessageType";
export type { WriteStatus } from "./WriteStatus";
@@ -1022,6 +1022,12 @@ client_request_definitions! {
response: v2::GetAccountTokenUsageResponse,
},
GetWorkspaceMessages => "account/workspaceMessages/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: None,
response: v2::GetWorkspaceMessagesResponse,
},
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
params: v2::SendAddCreditsNudgeEmailParams,
serialization: global("account-auth"),
@@ -2541,6 +2547,24 @@ mod tests {
Ok(())
}
#[test]
fn serialize_get_workspace_messages() -> Result<()> {
let request = ClientRequest::GetWorkspaceMessages {
request_id: RequestId::Integer(1),
params: None,
};
assert_eq!(request.id(), &RequestId::Integer(1));
assert_eq!(request.method(), "account/workspaceMessages/read");
assert_eq!(
json!({
"method": "account/workspaceMessages/read",
"id": 1,
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_client_response() -> Result<()> {
let cwd = absolute_path("/tmp");
@@ -314,6 +314,41 @@ pub struct GetAccountTokenUsageResponse {
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetWorkspaceMessagesResponse {
/// Whether the workspace-message backend route is available for this client.
pub feature_enabled: bool,
/// Active workspace messages returned by the backend.
pub messages: Vec<WorkspaceMessage>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorkspaceMessage {
pub message_id: String,
pub message_type: WorkspaceMessageType,
pub message_body: String,
/// Unix timestamp (in seconds) when the message was created.
#[ts(type = "number | null")]
pub created_at: Option<i64>,
/// Unix timestamp (in seconds) when the message was archived.
#[ts(type = "number | null")]
pub archived_at: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/", rename_all = "snake_case")]
pub enum WorkspaceMessageType {
Headline,
Announcement,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+13 -1
View File
@@ -1920,6 +1920,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
@@ -2046,7 +2047,18 @@ Field notes:
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
### 9) Notify a workspace owner about a limit
### 9) Workspace messages (ChatGPT)
```json
{ "method": "account/workspaceMessages/read", "id": 9 }
{ "id": 9, "result": { "featureEnabled": true, "messages": [
{ "messageId": "msg_123", "messageType": "headline", "messageBody": "Workspace maintenance starts at 5pm.", "createdAt": 1781395200, "archivedAt": null }
] } }
```
When the upstream workspace-message feature is disabled, `featureEnabled` is `false` and `messages` is empty.
### 10) Notify a workspace owner about a limit
```json
{ "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } }
@@ -1427,6 +1427,9 @@ impl MessageProcessor {
ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await
}
ClientRequest::GetWorkspaceMessages { .. } => {
self.account_processor.get_workspace_messages().await
}
ClientRequest::SendAddCreditsNudgeEmail { params, .. } => {
self.account_processor
.send_add_credits_nudge_email(params)
@@ -76,6 +76,7 @@ use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GetWorkspaceMessagesResponse;
use codex_app_server_protocol::GitDiffToRemoteParams;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
@@ -282,10 +283,16 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::WorkspaceMessage;
use codex_app_server_protocol::WorkspaceMessageType;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
@@ -1,10 +1,13 @@
use super::*;
use chrono::DateTime;
mod rate_limit_resets;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// The override is intentionally available only in debug builds, matching the login path below.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
@@ -142,6 +145,14 @@ impl AccountRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn get_workspace_messages(
&self,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_workspace_messages_response()
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn send_add_credits_nudge_email(
&self,
params: SendAddCreditsNudgeEmailParams,
@@ -943,6 +954,48 @@ impl AccountRequestProcessor {
Ok(Self::account_token_usage_response(profile))
}
async fn get_workspace_messages_response(
&self,
) -> Result<GetWorkspaceMessagesResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read workspace messages",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read workspace messages",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let messages = tokio::time::timeout(
ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT,
client.list_workspace_messages(),
)
.await
.map_err(|_| internal_error("workspace messages fetch timed out"))?;
match messages {
Ok(messages) => {
Self::workspace_messages_response(messages, /*feature_enabled*/ true)
}
Err(err) if workspace_messages_feature_disabled(&err) => {
Self::workspace_messages_response(
BackendWorkspaceMessagesResponse {
messages: Vec::new(),
},
/*feature_enabled*/ false,
)
}
Err(err) => Err(internal_error(format!(
"failed to fetch workspace messages: {err}"
))),
}
}
fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse {
let stats = profile.stats;
GetAccountTokenUsageResponse {
@@ -965,6 +1018,20 @@ impl AccountRequestProcessor {
}
}
fn workspace_messages_response(
messages: BackendWorkspaceMessagesResponse,
feature_enabled: bool,
) -> Result<GetWorkspaceMessagesResponse, JSONRPCErrorError> {
Ok(GetWorkspaceMessagesResponse {
feature_enabled,
messages: messages
.messages
.into_iter()
.map(workspace_message_from_backend)
.collect::<Result<Vec<_>, _>>()?,
})
}
async fn send_add_credits_nudge_email_response(
&self,
params: SendAddCreditsNudgeEmailParams,
@@ -1015,6 +1082,48 @@ impl AccountRequestProcessor {
}
}
fn workspace_message_from_backend(
message: BackendWorkspaceMessage,
) -> Result<WorkspaceMessage, JSONRPCErrorError> {
Ok(WorkspaceMessage {
message_id: message.message_id,
message_type: workspace_message_type_from_backend(message.message_type),
message_body: message.message_body,
created_at: workspace_message_timestamp_from_backend(message.created_at)?,
archived_at: workspace_message_timestamp_from_backend(message.archived_at)?,
})
}
fn workspace_message_timestamp_from_backend(
timestamp: Option<String>,
) -> Result<Option<i64>, JSONRPCErrorError> {
timestamp
.map(|timestamp| {
DateTime::parse_from_rfc3339(&timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| {
internal_error(format!(
"failed to parse workspace message timestamp `{timestamp}`: {err}"
))
})
})
.transpose()
}
fn workspace_message_type_from_backend(
message_type: BackendWorkspaceMessageType,
) -> WorkspaceMessageType {
match message_type {
BackendWorkspaceMessageType::Headline => WorkspaceMessageType::Headline,
BackendWorkspaceMessageType::Announcement => WorkspaceMessageType::Announcement,
BackendWorkspaceMessageType::Unknown => WorkspaceMessageType::Unknown,
}
}
fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool {
err.status().is_some_and(|status| status.as_u16() == 404)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1055,4 +1164,55 @@ mod tests {
}
);
}
#[test]
fn workspace_messages_response_maps_backend_messages() {
let response = AccountRequestProcessor::workspace_messages_response(
BackendWorkspaceMessagesResponse {
messages: vec![BackendWorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: BackendWorkspaceMessageType::Headline,
message_body: "Headline body".to_string(),
created_at: Some("2026-06-14T00:00:00Z".to_string()),
archived_at: Some("2026-06-15T00:00:00Z".to_string()),
}],
},
/*feature_enabled*/ true,
)
.expect("workspace message timestamps should parse");
assert_eq!(
response,
GetWorkspaceMessagesResponse {
feature_enabled: true,
messages: vec![WorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: WorkspaceMessageType::Headline,
message_body: "Headline body".to_string(),
created_at: Some(1_781_395_200),
archived_at: Some(1_781_481_600),
}],
}
);
}
#[test]
fn workspace_messages_feature_disabled_only_for_not_found() {
let cases = [
(reqwest::StatusCode::NOT_FOUND, true),
(reqwest::StatusCode::UNAUTHORIZED, false),
(reqwest::StatusCode::FORBIDDEN, false),
];
for (status, expected) in cases {
let err = BackendRequestError::UnexpectedStatus {
method: "GET".to_string(),
url: "https://example.test/api/codex/workspace-messages".to_string(),
status,
content_type: "application/json".to_string(),
body: "{}".to_string(),
};
assert_eq!(workspace_messages_feature_disabled(&err), expected);
}
}
}
+38
View File
@@ -1,5 +1,6 @@
use crate::types::AccountsCheckResponse;
use crate::types::CodeTaskDetailsResponse;
use crate::types::CodexWorkspaceMessagesResponse;
use crate::types::ConfigBundleResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
@@ -19,6 +20,7 @@ use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot;
use reqwest::StatusCode;
use reqwest::header::CACHE_CONTROL;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
@@ -430,6 +432,20 @@ 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())
.header(CACHE_CONTROL, HeaderValue::from_static("no-store"));
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> {
@@ -570,6 +586,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> {
@@ -936,6 +959,21 @@ mod tests {
);
}
#[test]
fn workspace_messages_uses_expected_paths() {
let codex_client = test_client("https://example.test", PathStyle::CodexApi);
assert_eq!(
codex_client.workspace_messages_url(),
"https://example.test/api/codex/workspace-messages"
);
let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi);
assert_eq!(
chatgpt_client.workspace_messages_url(),
"https://chatgpt.com/backend-api/wham/workspace-messages"
);
}
fn test_client(base_url: &str, path_style: PathStyle) -> Client {
Client {
base_url: base_url.to_string(),
+3
View File
@@ -8,6 +8,9 @@ pub use types::AccountEntry;
pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::CodexWorkspaceMessage;
pub use types::CodexWorkspaceMessageType;
pub use types::CodexWorkspaceMessagesResponse;
pub use types::ConfigBundleResponse;
pub use types::ConsumeRateLimitResetCreditCode;
pub use types::ConsumeRateLimitResetCreditResponse;
+83
View File
@@ -36,6 +36,23 @@ pub(crate) struct RateLimitStatusWithResetCredits {
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
}
#[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,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub archived_at: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsumeRateLimitResetCreditCode {
@@ -52,6 +69,15 @@ pub struct ConsumeRateLimitResetCreditResponse {
pub windows_reset: i64,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodexWorkspaceMessageType {
Headline,
Announcement,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug)]
pub struct AccountsCheckResponse {
pub accounts: Vec<AccountEntry>,
@@ -520,4 +546,61 @@ Second line"
.expect("error should be present");
assert_eq!(msg, "APPLY_FAILED: Patch could not be applied");
}
#[test]
fn workspace_messages_response_deserializes_messages() {
let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({
"messages": [
{
"message_id": "headline-id",
"message_type": "headline",
"message_body": "Headline body",
"created_at": "2026-06-14T00:00:00Z",
"archived_at": null
},
{
"message_id": "announcement-id",
"message_type": "announcement",
"message_body": "Announcement body",
"created_at": "2026-06-14T01:00:00Z",
"archived_at": null
},
{
"message_id": "unknown-id",
"message_type": "unknown",
"message_body": "Unknown body"
}
]
}))
.expect("workspace messages response should deserialize");
assert_eq!(
response,
CodexWorkspaceMessagesResponse {
messages: vec![
CodexWorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: CodexWorkspaceMessageType::Headline,
message_body: "Headline body".to_string(),
created_at: Some("2026-06-14T00:00:00Z".to_string()),
archived_at: None,
},
CodexWorkspaceMessage {
message_id: "announcement-id".to_string(),
message_type: CodexWorkspaceMessageType::Announcement,
message_body: "Announcement body".to_string(),
created_at: Some("2026-06-14T01:00:00Z".to_string()),
archived_at: None,
},
CodexWorkspaceMessage {
message_id: "unknown-id".to_string(),
message_type: CodexWorkspaceMessageType::Unknown,
message_body: "Unknown body".to_string(),
created_at: None,
archived_at: None,
},
],
}
);
}
}
@@ -31,6 +31,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_millis(/*millis*/ 2000);
impl App {
pub(super) fn fetch_mcp_inventory(
@@ -158,6 +160,29 @@ impl App {
});
}
pub(super) fn refresh_status_line_workspace_headline(
&mut self,
app_server: &AppServerSession,
request_id: u64,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
WORKSPACE_HEADLINE_FETCH_TIMEOUT,
fetch_workspace_messages(request_handle),
)
.await
.map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string())
.and_then(|result| {
result
.map(crate::workspace_messages::workspace_headline_from_response)
.map_err(|err| err.to_string())
});
app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result });
});
}
pub(super) fn send_add_credits_nudge_email(
&mut self,
app_server: &AppServerSession,
@@ -796,6 +821,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request(
.wrap_err("account/rateLimitResetCredit/consume failed in TUI")
}
pub(super) async fn fetch_workspace_messages(
request_handle: AppServerRequestHandle,
) -> Result<codex_app_server_protocol::GetWorkspaceMessagesResponse> {
let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::GetWorkspaceMessages {
request_id,
params: None,
})
.await
.wrap_err("account/workspaceMessages/read failed in TUI")
}
pub(super) async fn send_add_credits_nudge_email(
request_handle: AppServerRequestHandle,
credit_type: AddCreditsNudgeCreditType,
+11
View File
@@ -716,6 +716,9 @@ impl App {
AppEvent::RefreshTokenActivity { request_id } => {
self.refresh_token_activity(app_server, request_id);
}
AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => {
self.refresh_status_line_workspace_headline(app_server, request_id);
}
AppEvent::OpenThreadGoalMenu { thread_id } => {
self.open_thread_goal_menu(app_server, thread_id).await;
}
@@ -2020,6 +2023,14 @@ impl App {
self.chat_widget.set_status_line_git_summary(cwd, summary);
self.refresh_status_line();
}
AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => {
if self
.chat_widget
.set_status_line_workspace_headline(request_id, result)
{
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetupCancelled => {
self.chat_widget.cancel_status_line_setup();
}
+10
View File
@@ -341,6 +341,11 @@ pub(crate) enum AppEvent {
result: Result<GetAccountTokenUsageResponse, String>,
},
/// Fetch workspace messages for the status-line headline item.
RefreshStatusLineWorkspaceHeadline {
request_id: u64,
},
/// Commit settled asynchronous usage output after active-output barriers clear.
CommitPendingUsageOutput,
@@ -982,6 +987,11 @@ pub(crate) enum AppEvent {
cwd: PathBuf,
summary: crate::chatwidget::StatusLineGitSummary,
},
/// Async update of the workspace notification headline for status line rendering.
StatusLineWorkspaceHeadlineUpdated {
request_id: u64,
result: Result<crate::workspace_messages::WorkspaceHeadlineFetchResult, String>,
},
/// Apply a user-confirmed status-line item ordering/selection.
StatusLineSetup {
items: Vec<StatusLineItem>,
@@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem {
/// Current thread title (if set by user).
ThreadTitle,
/// Current workspace notification headline.
WorkspaceHeadline,
/// Latest checklist task progress from `update_plan` (if available).
TaskProgress,
}
@@ -185,6 +188,9 @@ impl StatusLineItem {
StatusLineItem::ThreadTitle => {
"Current thread title, or thread identifier when unnamed"
}
StatusLineItem::WorkspaceHeadline => {
"Workspace notification headline (Enterprise workspaces only; omitted when unavailable)"
}
StatusLineItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)"
}
@@ -217,6 +223,7 @@ impl StatusLineItem {
StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode,
StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput,
StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle,
StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline,
StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress,
}
}
@@ -49,7 +49,7 @@ impl StatusLineAccent {
StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode,
StatusLineItem::Permissions => Self::Mode,
StatusLineItem::ApprovalMode => Self::Mode,
StatusLineItem::ThreadTitle => Self::Thread,
StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread,
StatusLineItem::TaskProgress => Self::Progress,
}
}
@@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem {
SessionId,
FastMode,
RawOutput,
WorkspaceHeadline,
Model,
ModelWithReasoning,
Reasoning,
@@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem {
StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4",
StatusSurfacePreviewItem::FastMode => "Fast on",
StatusSurfacePreviewItem::RawOutput => "raw output",
StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline",
StatusSurfacePreviewItem::Model => "gpt-5.2-codex",
StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium",
StatusSurfacePreviewItem::Reasoning => "medium",
@@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem {
Self::SessionId,
Self::FastMode,
Self::RawOutput,
Self::WorkspaceHeadline,
Self::Model,
Self::ModelWithReasoning,
Self::Reasoning,
+11
View File
@@ -727,6 +727,16 @@ pub(crate) struct ChatWidget {
status_line_git_summary_pending: bool,
// True once we've attempted a Git summary lookup for the current CWD.
status_line_git_summary_lookup_complete: bool,
// Cached workspace notification headline for the status line.
status_line_workspace_headline: Option<String>,
// Request ID for the async workspace headline fetch currently in flight.
status_line_workspace_headline_pending_request_id: Option<u64>,
// Request ID to assign to the next workspace headline fetch.
next_status_line_workspace_headline_request_id: u64,
// Last time a workspace headline fetch was requested.
status_line_workspace_headline_last_requested_at: Option<Instant>,
// Set after the backend reports the workspace-message feature gate is disabled.
status_line_workspace_messages_disabled: bool,
// Current thread-goal status shown in the status line when plan mode is inactive.
current_goal_status_indicator: Option<GoalStatusIndicator>,
current_goal_status: Option<GoalStatusState>,
@@ -1188,6 +1198,7 @@ impl ChatWidget {
{
self.refresh_terminal_title();
}
self.refresh_status_line_if_workspace_headline_due();
}
fn flush_active_cell(&mut self) {
@@ -229,6 +229,11 @@ impl ChatWidget {
status_line_git_summary_cwd: None,
status_line_git_summary_pending: false,
status_line_git_summary_lookup_complete: false,
status_line_workspace_headline: None,
status_line_workspace_headline_pending_request_id: None,
next_status_line_workspace_headline_request_id: 0,
status_line_workspace_headline_last_requested_at: None,
status_line_workspace_messages_disabled: false,
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
+5
View File
@@ -224,6 +224,10 @@ impl ChatWidget {
self.clear_pending_token_activity_refreshes();
self.clear_pending_rate_limit_reset_requests();
}
self.status_line_workspace_headline = None;
self.status_line_workspace_headline_pending_request_id = None;
self.status_line_workspace_headline_last_requested_at = None;
self.status_line_workspace_messages_disabled = false;
self.status_account_display = status_account_display;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
@@ -232,6 +236,7 @@ impl ChatWidget {
.set_connectors_enabled(self.connectors_enabled());
self.bottom_pane
.set_token_activity_command_enabled(has_codex_backend_auth);
self.refresh_status_line();
}
/// Set the syntax theme override in the widget's config copy.
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: status_line_popup_snapshot(&mut chat)
---
Configure Status Line
Select which items to display in the status line.
Type to search
>
[x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted …
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] current-dir Current working directory
[ ] project-name Project name (omitted when unavailable)
Workspace maintenance starts at 5pm
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
@@ -65,6 +65,11 @@ impl StatusSurfaceSelections {
.status_line_items
.contains(&StatusLineItem::BranchChanges)
}
fn uses_workspace_headline(&self) -> bool {
self.status_line_items
.contains(&StatusLineItem::WorkspaceHeadline)
}
}
/// Cached project-root display name keyed by the cwd used for the last lookup.
@@ -157,6 +162,15 @@ impl ChatWidget {
self.request_status_line_git_summary(cwd);
}
}
if !selections.uses_workspace_headline() {
self.status_line_workspace_headline = None;
self.status_line_workspace_headline_pending_request_id = None;
self.status_line_workspace_headline_last_requested_at = None;
self.status_line_workspace_messages_disabled = false;
} else {
self.request_status_line_workspace_headline_if_due(Instant::now());
}
}
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
@@ -553,6 +567,85 @@ impl ChatWidget {
});
}
fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) {
if !self.status_line_workspace_headline_should_fetch(now) {
return;
}
let request_id = self.next_status_line_workspace_headline_request_id;
self.next_status_line_workspace_headline_request_id = self
.next_status_line_workspace_headline_request_id
.wrapping_add(/*rhs*/ 1);
self.status_line_workspace_headline_pending_request_id = Some(request_id);
self.status_line_workspace_headline_last_requested_at = Some(now);
self.app_event_tx
.send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id });
}
fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool {
if self
.status_line_workspace_headline_pending_request_id
.is_some()
|| self.status_line_workspace_messages_disabled
|| !self.has_codex_backend_auth
{
return false;
}
self.status_line_workspace_headline_last_requested_at
.is_none_or(|last_requested_at| {
now.saturating_duration_since(last_requested_at)
>= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL
})
}
pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) {
let now = Instant::now();
if self.status_line_workspace_headline_should_fetch(now)
&& self
.status_line_items_with_invalids()
.0
.contains(&StatusLineItem::WorkspaceHeadline)
{
self.refresh_status_line();
}
}
pub(crate) fn set_status_line_workspace_headline(
&mut self,
request_id: u64,
result: Result<crate::workspace_messages::WorkspaceHeadlineFetchResult, String>,
) -> bool {
if self.status_line_workspace_headline_pending_request_id != Some(request_id) {
return false;
}
self.status_line_workspace_headline_pending_request_id = None;
match result {
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => {
self.status_line_workspace_messages_disabled = false;
self.status_line_workspace_headline = headline;
}
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => {
self.status_line_workspace_messages_disabled = true;
self.status_line_workspace_headline = None;
}
Err(err) => {
tracing::debug!(error = %err, "failed to fetch workspace headline");
}
}
if !self.status_line_workspace_messages_disabled
&& self
.status_line_items_with_invalids()
.0
.contains(&StatusLineItem::WorkspaceHeadline)
{
self.frame_requester
.schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL);
}
self.refresh_status_line();
true
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
@@ -653,6 +746,7 @@ impl ChatWidget {
}
},
),
StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(),
StatusLineItem::TaskProgress => self.terminal_title_task_progress(),
}
}
@@ -693,6 +787,7 @@ impl ChatWidget {
StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId,
StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode,
StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput,
StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline,
StatusSurfacePreviewItem::Model => StatusLineItem::ModelName,
StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning,
StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning,
@@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) {
chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
}
fn take_workspace_headline_request_id(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> u64 {
match rx.try_recv() {
Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id,
event => panic!("expected workspace headline refresh, got {event:?}"),
}
}
/// Receiving a token usage update without usage clears the context indicator.
#[tokio::test]
async fn token_count_none_resets_context_indicator() {
@@ -2141,6 +2150,192 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() {
);
}
#[tokio::test]
async fn status_line_workspace_headline_renders_cached_value() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string());
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some("Workspace maintenance starts at 5pm".to_string())
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"workspace-headline should be a valid status line item"
);
}
#[tokio::test]
async fn status_line_workspace_headline_omits_when_unavailable() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec![
"workspace-headline".to_string(),
"run-state".to_string(),
]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Ready".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"workspace-headline should be omitted without warning when no headline is cached"
);
}
#[tokio::test]
async fn workspace_headline_update_applies_feature_disabled_result() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Old headline".to_string());
let request_id = 3;
chat.status_line_workspace_headline_pending_request_id = Some(request_id);
assert!(chat.set_status_line_workspace_headline(
request_id,
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled),
));
assert_eq!(status_line_text(&chat), None);
assert!(chat.status_line_workspace_messages_disabled);
}
#[tokio::test]
async fn workspace_headline_update_applies_available_headline() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
let request_id = 4;
chat.status_line_workspace_headline_pending_request_id = Some(request_id);
assert!(chat.set_status_line_workspace_headline(
request_id,
Ok(
crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"Fresh workspace headline".to_string(),
))
),
));
assert_eq!(
status_line_text(&chat),
Some("Fresh workspace headline".to_string())
);
assert!(!chat.status_line_workspace_messages_disabled);
}
#[tokio::test]
async fn account_update_clears_workspace_headline_state() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Old workspace headline".to_string());
chat.status_line_workspace_headline_pending_request_id = Some(5);
chat.status_line_workspace_headline_last_requested_at = Some(Instant::now());
chat.status_line_workspace_messages_disabled = true;
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
);
assert_eq!(
(
status_line_text(&chat),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_headline_last_requested_at,
chat.status_line_workspace_messages_disabled,
),
(None, None, None, false)
);
}
#[tokio::test]
async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
);
let request_id = take_workspace_headline_request_id(&mut rx);
assert_eq!(
chat.status_line_workspace_headline_pending_request_id,
Some(request_id)
);
}
#[tokio::test]
async fn account_update_discards_stale_workspace_headline_results() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.update_account_state(
Some(StatusAccountDisplay::ChatGpt {
email: Some("first@example.com".to_string()),
plan: None,
}),
/*plan_type*/ None,
/*has_chatgpt_account*/ true,
/*has_codex_backend_auth*/ true,
);
let stale_request_id = take_workspace_headline_request_id(&mut rx);
chat.update_account_state(
Some(StatusAccountDisplay::ChatGpt {
email: Some("second@example.com".to_string()),
plan: None,
}),
/*plan_type*/ None,
/*has_chatgpt_account*/ true,
/*has_codex_backend_auth*/ true,
);
let current_request_id = take_workspace_headline_request_id(&mut rx);
assert_ne!(stale_request_id, current_request_id);
assert!(!chat.set_status_line_workspace_headline(
stale_request_id,
Ok(
crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"First account headline".to_string(),
))
),
));
assert_eq!(
(
chat.status_line_workspace_headline.clone(),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_messages_disabled,
),
(None, Some(current_request_id), false)
);
assert!(chat.set_status_line_workspace_headline(
current_request_id,
Ok(
crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"Second account headline".to_string(),
))
),
));
assert!(!chat.set_status_line_workspace_headline(
stale_request_id,
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled),
));
assert_eq!(
(
status_line_text(&chat),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_messages_disabled,
),
(Some("Second account headline".to_string()), None, false,)
);
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() {
);
}
#[tokio::test]
async fn status_line_setup_popup_workspace_headline_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string());
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_workspace_headline",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+1
View File
@@ -200,6 +200,7 @@ mod width;
#[cfg(any(target_os = "windows", test))]
mod windows_sandbox;
mod workspace_command;
mod workspace_messages;
mod wrapping;
+29
View File
@@ -0,0 +1,29 @@
use codex_app_server_protocol::GetWorkspaceMessagesResponse;
use codex_app_server_protocol::WorkspaceMessageType;
use std::time::Duration;
pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum WorkspaceHeadlineFetchResult {
Available(Option<String>),
FeatureDisabled,
}
pub(crate) fn workspace_headline_from_response(
response: GetWorkspaceMessagesResponse,
) -> WorkspaceHeadlineFetchResult {
if !response.feature_enabled {
return WorkspaceHeadlineFetchResult::FeatureDisabled;
}
WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| {
(message.message_type == WorkspaceMessageType::Headline)
.then(|| message.message_body.trim().to_string())
.filter(|headline| !headline.is_empty())
}))
}
#[cfg(test)]
#[path = "workspace_messages_tests.rs"]
mod tests;
@@ -0,0 +1,51 @@
use super::*;
use codex_app_server_protocol::WorkspaceMessage;
use pretty_assertions::assert_eq;
#[test]
fn workspace_headline_from_response_uses_first_non_empty_headline() {
let response = GetWorkspaceMessagesResponse {
feature_enabled: true,
messages: vec![
WorkspaceMessage {
message_id: "announcement-id".to_string(),
message_type: WorkspaceMessageType::Announcement,
message_body: "Announcement body".to_string(),
created_at: None,
archived_at: None,
},
WorkspaceMessage {
message_id: "empty-headline-id".to_string(),
message_type: WorkspaceMessageType::Headline,
message_body: " ".to_string(),
created_at: None,
archived_at: None,
},
WorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: WorkspaceMessageType::Headline,
message_body: " Workspace headline ".to_string(),
created_at: None,
archived_at: None,
},
],
};
assert_eq!(
workspace_headline_from_response(response),
WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string()))
);
}
#[test]
fn workspace_headline_from_response_reports_feature_disabled() {
let response = GetWorkspaceMessagesResponse {
feature_enabled: false,
messages: Vec::new(),
};
assert_eq!(
workspace_headline_from_response(response),
WorkspaceHeadlineFetchResult::FeatureDisabled
);
}