mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
9c3b10e5d4
commit
21d36296f1
@@ -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": {
|
||||
|
||||
+88
@@ -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",
|
||||
|
||||
+88
@@ -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",
|
||||
|
||||
+67
@@ -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
+14
@@ -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/")]
|
||||
|
||||
@@ -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(×tamp)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+20
@@ -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;
|
||||
|
||||
@@ -200,6 +200,7 @@ mod width;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod windows_sandbox;
|
||||
mod workspace_command;
|
||||
mod workspace_messages;
|
||||
|
||||
mod wrapping;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user