From 21d36296f137c0954df24ea86abe9619318915e6 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Mon, 22 Jun 2026 04:25:07 -0700 Subject: [PATCH] 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. --- .../schema/json/ClientRequest.json | 23 +++ .../codex_app_server_protocol.schemas.json | 88 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 88 ++++++++ .../json/v2/GetWorkspaceMessagesResponse.json | 67 ++++++ .../schema/typescript/ClientRequest.ts | 2 +- .../v2/GetWorkspaceMessagesResponse.ts | 14 ++ .../schema/typescript/v2/WorkspaceMessage.ts | 14 ++ .../typescript/v2/WorkspaceMessageType.ts | 5 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 +++ .../src/protocol/v2/account.rs | 35 ++++ codex-rs/app-server/README.md | 14 +- codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 7 + .../request_processors/account_processor.rs | 160 ++++++++++++++ codex-rs/backend-client/src/client.rs | 38 ++++ codex-rs/backend-client/src/lib.rs | 3 + codex-rs/backend-client/src/types.rs | 83 ++++++++ codex-rs/tui/src/app/background_requests.rs | 38 ++++ codex-rs/tui/src/app/event_dispatch.rs | 11 + codex-rs/tui/src/app_event.rs | 10 + .../tui/src/bottom_pane/status_line_setup.rs | 7 + .../tui/src/bottom_pane/status_line_style.rs | 2 +- .../src/bottom_pane/status_surface_preview.rs | 3 + codex-rs/tui/src/chatwidget.rs | 11 + codex-rs/tui/src/chatwidget/constructor.rs | 5 + codex-rs/tui/src/chatwidget/settings.rs | 5 + ...s_line_setup_popup_workspace_headline.snap | 20 ++ .../tui/src/chatwidget/status_surfaces.rs | 95 +++++++++ .../src/chatwidget/tests/status_and_layout.rs | 195 ++++++++++++++++++ .../tests/status_surface_previews.rs | 12 ++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/workspace_messages.rs | 29 +++ codex-rs/tui/src/workspace_messages_tests.rs | 51 +++++ 34 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap create mode 100644 codex-rs/tui/src/workspace_messages.rs create mode 100644 codex-rs/tui/src/workspace_messages_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 18c147ba3..7ea246f0e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ec0321d9b..9c2abf525 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index cd0f46bd4..c8b172c7d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json new file mode 100644 index 000000000..4d1246a1b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e014cc98b..6af04ef40 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -88,4 +88,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts new file mode 100644 index 000000000..949ad433a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts new file mode 100644 index 000000000..b024ce116 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts new file mode 100644 index 000000000..9d9438d96 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 419cf8c3c..3e769d3fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 0b1e11133..9bcc6a86d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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"); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 2a71a092c..348d6810f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -314,6 +314,41 @@ pub struct GetAccountTokenUsageResponse { pub daily_usage_buckets: Option>, } +#[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, +} + +#[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, + /// Unix timestamp (in seconds) when the message was archived. + #[ts(type = "number | null")] + pub archived_at: Option, +} + +#[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/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e823eb100..e70db8bba 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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" } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ad29e0501..33cf142fe 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8c202a806..c8bd48c47 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 5d234f700..4c8538e2b 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -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, 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 { + 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 { + Ok(GetWorkspaceMessagesResponse { + feature_enabled, + messages: messages + .messages + .into_iter() + .map(workspace_message_from_backend) + .collect::, _>>()?, + }) + } + 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 { + 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, +) -> Result, 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); + } + } } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e8e4f5fb5..be444b624 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -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 { + 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::(&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 { @@ -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 { @@ -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(), diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 9731bc82b..6cbeaf30b 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -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; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d46971389..18ab32398 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -36,6 +36,23 @@ pub(crate) struct RateLimitStatusWithResetCredits { pub rate_limit_reset_credits: Option, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessagesResponse { + #[serde(default)] + pub messages: Vec, +} + +#[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, + #[serde(default)] + pub archived_at: Option, +} + #[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, @@ -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, + }, + ], + } + ); + } } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index bb05e1a3f..08e7f9b07 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -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 { + 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, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0ab569b0e..181e60d96 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fc018aad8..643cd9e2d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -341,6 +341,11 @@ pub(crate) enum AppEvent { result: Result, }, + /// 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, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4dd62a7d2..63ce4237d 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -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, } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 170c4641d..4198943a4 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -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, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index bd0a94a4d..b8b7a6fbd 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd3c140e3..20bef58a1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, + // Request ID for the async workspace headline fetch currently in flight. + status_line_workspace_headline_pending_request_id: Option, + // 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, + // 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, current_goal_status: Option, @@ -1188,6 +1198,7 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 77fdd74e5..17f2c8c3b 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f8d1ad4..75c4cc66e 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap new file mode 100644 index 000000000..32ad18ec6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 592dd0438..5f02dd8d6 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -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, + ) -> 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, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 583377bac..9e528fb6c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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, +) -> 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; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d2..63005cb95 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -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; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 82d9a72bd..9a4a88c86 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -200,6 +200,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 000000000..4f5695f80 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -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), + 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; diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs new file mode 100644 index 000000000..962d2ceba --- /dev/null +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -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 + ); +}