From 7ecd799513e3a1cc1ba17b016310b378a34f2af0 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 09:25:35 -0700 Subject: [PATCH 1/9] Add workspace owner usage nudge --- codex-rs/Cargo.lock | 1 + .../schema/json/ClientRequest.json | 42 +++++ .../schema/json/ServerNotification.json | 20 ++ .../codex_app_server_protocol.schemas.json | 84 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 84 +++++++++ .../AccountRateLimitsUpdatedNotification.json | 20 ++ .../json/v2/GetAccountRateLimitsResponse.json | 20 ++ .../v2/SendAddCreditsNudgeEmailParams.json | 22 +++ .../v2/SendAddCreditsNudgeEmailResponse.json | 22 +++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/AddCreditsNudgeCreditType.ts | 5 + .../v2/AddCreditsNudgeEmailStatus.ts | 5 + .../typescript/v2/RateLimitReachedType.ts | 5 + .../schema/typescript/v2/RateLimitSnapshot.ts | 3 +- .../v2/SendAddCreditsNudgeEmailParams.ts | 6 + .../v2/SendAddCreditsNudgeEmailResponse.ts | 6 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 5 + .../app-server-protocol/src/protocol/v2.rs | 86 +++++++++ codex-rs/app-server/README.md | 13 +- .../app-server/src/bespoke_event_handling.rs | 1 + .../app-server/src/codex_message_processor.rs | 73 ++++++++ codex-rs/app-server/src/outgoing_message.rs | 4 +- .../app-server/tests/common/mcp_process.rs | 11 ++ .../app-server/tests/suite/v2/rate_limits.rs | 60 ++++++ codex-rs/backend-client/src/client.rs | 177 ++++++++++++++++++ codex-rs/backend-client/src/types.rs | 1 + codex-rs/codex-api/src/rate_limits.rs | 2 + .../src/models/mod.rs | 2 + .../src/models/rate_limit_status_payload.rs | 33 ++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 62 ++++++ codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/state/session_tests.rs | 6 + codex-rs/core/tests/suite/client.rs | 6 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/error_tests.rs | 1 + codex-rs/protocol/src/protocol.rs | 60 ++++++ codex-rs/rollout/src/policy.rs | 1 + codex-rs/tui/src/app.rs | 62 ++++++ codex-rs/tui/src/app_event.rs | 12 ++ codex-rs/tui/src/app_server_session.rs | 25 +++ .../src/bottom_pane/list_selection_view.rs | 10 + codex-rs/tui/src/chatwidget.rs | 110 ++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 3 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 3 + .../src/chatwidget/tests/status_and_layout.rs | 145 ++++++++++++++ codex-rs/tui/src/status/tests.rs | 13 ++ 48 files changed, 1340 insertions(+), 8 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ea79776bdc8..6694490a24f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1906,6 +1906,7 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-async-utils", + "codex-backend-client", "codex-code-mode", "codex-config", "codex-connectors", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 113d82c94cb..d76b7a990cb 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,13 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -2536,6 +2543,17 @@ } ] }, + "SendAddCreditsNudgeEmailParams": { + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "type": "object" + }, "ServiceTier": { "enum": [ "fast", @@ -4890,6 +4908,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a613590e3f3..2649f506dba 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2021,6 +2021,16 @@ ], "type": "string" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -2065,6 +2075,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { 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 d0ce368b78d..1adb5adfcb6 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 @@ -1416,6 +1416,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { @@ -5039,6 +5063,20 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10621,6 +10659,16 @@ }, "type": "object" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -10665,6 +10713,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { @@ -12063,6 +12121,32 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/v2/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, "ServerRequestResolvedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 aca9b238800..fc1f5cd7782 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 @@ -114,6 +114,20 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -1998,6 +2012,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { @@ -7393,6 +7431,16 @@ }, "type": "object" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -7437,6 +7485,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { @@ -8835,6 +8893,32 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, "ServerNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Notification sent from the server to the client.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 67a5f055b36..96fefb228b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -39,6 +39,16 @@ ], "type": "string" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -83,6 +93,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 2d34ee47d12..8f7db24e7fe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -39,6 +39,16 @@ ], "type": "string" }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -83,6 +93,16 @@ } ] }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, "secondary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json new file mode 100644 index 00000000000..c3c63edef09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + } + }, + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json new file mode 100644 index 00000000000..bfeba322b2b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "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 9d9a8234086..2d2299a3cd2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -43,6 +43,7 @@ import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; +import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; @@ -68,4 +69,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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "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/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": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "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": "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": "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": "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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "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/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": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "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": "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/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": "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/AddCreditsNudgeCreditType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts new file mode 100644 index 00000000000..70498d6a67a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.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 AddCreditsNudgeCreditType = "credits" | "usage_limit"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts new file mode 100644 index 00000000000..2b62da68eaf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.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 AddCreditsNudgeEmailStatus = "sent" | "cooldown_active"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.ts new file mode 100644 index 00000000000..78f106c905d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitReachedType.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 RateLimitReachedType = "rate_limit_reached" | "workspace_owner_credits_depleted" | "workspace_member_credits_depleted" | "workspace_owner_usage_limit_reached" | "workspace_member_usage_limit_reached"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index 0c2ebe1893f..dc8417a3040 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PlanType } from "../PlanType"; import type { CreditsSnapshot } from "./CreditsSnapshot"; +import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts new file mode 100644 index 00000000000..383ad4aab3d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts @@ -0,0 +1,6 @@ +// 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 { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; + +export type SendAddCreditsNudgeEmailParams = { creditType: AddCreditsNudgeCreditType, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts new file mode 100644 index 00000000000..71dcb190a63 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts @@ -0,0 +1,6 @@ +// 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 { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; + +export type SendAddCreditsNudgeEmailResponse = { status: AddCreditsNudgeEmailStatus, }; 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 d432530b42b..cf78cbe13cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,6 +4,8 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; +export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; +export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; @@ -239,6 +241,7 @@ export type { PluginUninstallParams } from "./PluginUninstallParams"; export type { PluginUninstallResponse } from "./PluginUninstallResponse"; export type { PluginsMigration } from "./PluginsMigration"; export type { ProfileV2 } from "./ProfileV2"; +export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; @@ -256,6 +259,8 @@ export type { ReviewTarget } from "./ReviewTarget"; export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; +export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams"; +export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse"; export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification"; export type { SessionSource } from "./SessionSource"; export type { SkillDependencies } from "./SkillDependencies"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9efc6571f29..a000b7bb358 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -515,6 +515,11 @@ client_request_definitions! { response: v2::GetAccountRateLimitsResponse, }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { + params: v2::SendAddCreditsNudgeEmailParams, + response: v2::SendAddCreditsNudgeEmailResponse, + }, + FeedbackUpload => "feedback/upload" { params: v2::FeedbackUploadParams, response: v2::FeedbackUploadResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0843241f32b..74971a1fd57 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -48,6 +48,8 @@ use codex_protocol::openai_models::default_input_modalities; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::protocol::AddCreditsNudgeCreditType as CoreAddCreditsNudgeCreditType; +use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -69,6 +71,7 @@ use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; @@ -1762,6 +1765,54 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits_by_limit_id: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[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 AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +impl From for CoreAddCreditsNudgeCreditType { + fn from(value: AddCreditsNudgeCreditType) -> Self { + match value { + AddCreditsNudgeCreditType::Credits => Self::Credits, + AddCreditsNudgeCreditType::UsageLimit => Self::UsageLimit, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[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 AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + +impl From for AddCreditsNudgeEmailStatus { + fn from(value: CoreAddCreditsNudgeEmailStatus) -> Self { + match value { + CoreAddCreditsNudgeEmailStatus::Sent => Self::Sent, + CoreAddCreditsNudgeEmailStatus::CooldownActive => Self::CooldownActive, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6448,6 +6499,7 @@ pub struct RateLimitSnapshot { pub secondary: Option, pub credits: Option, pub plan_type: Option, + pub rate_limit_reached_type: Option, } impl From for RateLimitSnapshot { @@ -6459,6 +6511,40 @@ impl From for RateLimitSnapshot { secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), plan_type: value.plan_type, + rate_limit_reached_type: value + .rate_limit_reached_type + .map(RateLimitReachedType::from), + } + } +} + +#[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 RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +impl From for RateLimitReachedType { + fn from(value: CoreRateLimitReachedType) -> Self { + match value { + CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, + CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } } } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e00bbefc910..61730909a08 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1412,6 +1412,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `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? }`. - `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`. @@ -1504,7 +1505,7 @@ Field notes: ```json { "method": "account/rateLimits/read", "id": 7 } -{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } } { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` @@ -1513,6 +1514,16 @@ Field notes: - `usedPercent` is current usage within the OpenAI quota window. - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. +- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. + +### 8) Notify a workspace owner about a limit + +```json +{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } } +{ "id": 8, "result": { "status": "sent" } } +``` + +Use `creditType: "credits"` when workspace credits are depleted, or `creditType: "usage_limit"` when the workspace usage limit has been reached. If the owner was already notified recently, the response status is `cooldown_active`. ## Experimental API Opt-in diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fddee4a112..5f5ff0633b7 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -4136,6 +4136,7 @@ mod tests { balance: Some("5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; handle_token_count_event( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f31aafeac52..1b4e8e372fc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::SkillSummary; @@ -288,6 +290,7 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; @@ -1164,6 +1167,10 @@ impl CodexMessageProcessor { self.get_account_rate_limits(to_connection_request_id(request_id)) .await; } + ClientRequest::SendAddCreditsNudgeEmail { request_id, params } => { + self.send_add_credits_nudge_email(to_connection_request_id(request_id), params) + .await; + } ClientRequest::FeedbackUpload { request_id, params } => { self.upload_feedback(to_connection_request_id(request_id), params) .await; @@ -1875,6 +1882,72 @@ impl CodexMessageProcessor { } } + async fn send_add_credits_nudge_email( + &self, + request_id: ConnectionRequestId, + params: SendAddCreditsNudgeEmailParams, + ) { + match self.send_add_credits_nudge_email_inner(params).await { + Ok(status) => { + self.outgoing + .send_response( + request_id, + SendAddCreditsNudgeEmailResponse { + status: status.into(), + }, + ) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn send_add_credits_nudge_email_inner( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to notify workspace owner" + .to_string(), + data: None, + }); + }; + + if !auth.is_chatgpt_auth() { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to notify workspace owner".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + match client + .send_add_credits_nudge_email(params.credit_type.into()) + .await + { + Ok(()) => Ok(CoreAddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { + Ok(CoreAddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to notify workspace owner: {err}"), + data: None, + }), + } + } + async fn fetch_account_rate_limits( &self, ) -> Result< diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde0639..1f02fca3262 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -736,6 +736,7 @@ mod tests { secondary: None, credits: None, plan_type: Some(PlanType::Plus), + rate_limit_reached_type: None, }, }); @@ -754,7 +755,8 @@ mod tests { }, "secondary": null, "credits": null, - "planType": "plus" + "planType": "plus", + "rateLimitReachedType": null } }, }), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 22225c7c92d..935b8bef41a 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -58,6 +58,7 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; @@ -304,6 +305,16 @@ impl McpProcess { .await } + /// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request. + pub async fn send_add_credits_nudge_email_request( + &mut self, + params: SendAddCreditsNudgeEmailParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/sendAddCreditsNudgeEmail", params) + .await + } + /// Send an `account/read` JSON-RPC request. pub async fn send_get_account_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 203d664940a..9cc8e9bc46a 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -3,13 +3,18 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RateLimitReachedType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; @@ -118,6 +123,9 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { "reset_at": secondary_reset_timestamp, } }, + "rate_limit_reached_type": { + "type": "workspace_member_usage_limit_reached", + }, "additional_rate_limits": [ { "limit_name": "codex_other", @@ -173,6 +181,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), }, rate_limits_by_limit_id: Some( [ @@ -193,6 +202,9 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: Some( + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + ), }, ), ( @@ -208,6 +220,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { secondary: None, credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, }, ), ] @@ -220,6 +233,53 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { Ok(()) } +#[tokio::test] +async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(wiremock::matchers::body_json(json!({ + "credit_type": "usage_limit", + }))) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent,); + + Ok(()) +} + async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 20baa1cfead..6052a7c3bbc 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -8,7 +8,9 @@ use codex_client::build_reqwest_client_with_custom_ca; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::protocol::AddCreditsNudgeCreditType; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use reqwest::StatusCode; @@ -18,6 +20,7 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; +use serde::Serialize; use serde::de::DeserializeOwned; use std::fmt; @@ -79,6 +82,11 @@ impl From for RequestError { } } +#[derive(Serialize)] +struct SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { /// /api/codex/… @@ -265,6 +273,21 @@ impl Client { Ok(Self::rate_limit_snapshots_from_payload(payload)) } + pub async fn send_add_credits_nudge_email( + &self, + credit_type: AddCreditsNudgeCreditType, + ) -> std::result::Result<(), RequestError> { + let url = self.send_add_credits_nudge_email_url(); + let req = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&SendAddCreditsNudgeEmailRequest { credit_type }); + self.exec_request_detailed(req, "POST", &url).await?; + Ok(()) + } + pub async fn list_tasks( &self, limit: Option, @@ -397,12 +420,17 @@ impl Client { payload: RateLimitStatusPayload, ) -> Vec { let plan_type = Some(Self::map_plan_type(payload.plan_type)); + let rate_limit_reached_type = payload + .rate_limit_reached_type + .flatten() + .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); let mut snapshots = vec![Self::make_rate_limit_snapshot( Some("codex".to_string()), /*limit_name*/ None, payload.rate_limit.flatten().map(|details| *details), payload.credits.flatten().map(|details| *details), plan_type, + rate_limit_reached_type, )]; if let Some(additional) = payload.additional_rate_limits.flatten() { snapshots.extend(additional.into_iter().map(|details| { @@ -412,6 +440,7 @@ impl Client { details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, plan_type, + /*rate_limit_reached_type*/ None, ) })); } @@ -424,6 +453,7 @@ impl Client { rate_limit: Option, credits: Option, plan_type: Option, + rate_limit_reached_type: Option, ) -> RateLimitSnapshot { let (primary, secondary) = match rate_limit { Some(details) => ( @@ -439,6 +469,42 @@ impl Client { secondary, credits: Self::map_credits(credits), plan_type, + rate_limit_reached_type, + } + } + + fn map_rate_limit_reached_type( + kind: crate::types::RateLimitReachedKind, + ) -> Option { + match kind { + crate::types::RateLimitReachedKind::RateLimitReached => { + Some(RateLimitReachedType::RateLimitReached) + } + crate::types::RateLimitReachedKind::WorkspaceOwnerCreditsDepleted => { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) + } + crate::types::RateLimitReachedKind::WorkspaceMemberCreditsDepleted => { + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + } + crate::types::RateLimitReachedKind::WorkspaceOwnerUsageLimitReached => { + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) + } + crate::types::RateLimitReachedKind::WorkspaceMemberUsageLimitReached => { + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + } + crate::types::RateLimitReachedKind::Unknown => None, + } + } + + fn send_add_credits_nudge_email_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!( + "{}/api/codex/accounts/send_add_credits_nudge_email", + self.base_url + ), + PathStyle::ChatGptApi => { + format!("{}/accounts/send_add_credits_nudge_email", self.base_url) + } } } @@ -506,6 +572,8 @@ impl Client { mod tests { use super::*; use codex_backend_openapi_models::models::AdditionalRateLimitDetails; + use codex_backend_openapi_models::models::RateLimitReachedKind; + use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType; use pretty_assertions::assert_eq; #[test] @@ -559,6 +627,9 @@ mod tests { balance: Some(Some("9.99".to_string())), ..Default::default() }))), + rate_limit_reached_type: Some(Some(Box::new(BackendRateLimitReachedType { + kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, + }))), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -583,6 +654,10 @@ mod tests { }) ); assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); + assert_eq!( + snapshots[0].rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + ); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); @@ -592,6 +667,7 @@ mod tests { ); assert_eq!(snapshots[1].credits, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); + assert_eq!(snapshots[1].rate_limit_reached_type, None); } #[test] @@ -605,6 +681,7 @@ mod tests { rate_limit: None, }])), credits: None, + rate_limit_reached_type: None, }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -630,6 +707,7 @@ mod tests { secondary: None, credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, }, RateLimitSnapshot { limit_id: Some("codex".to_string()), @@ -642,6 +720,7 @@ mod tests { secondary: None, credits: None, plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, }, ]; @@ -652,4 +731,102 @@ mod tests { .unwrap_or_else(|| snapshots[0].clone()); assert_eq!(preferred.limit_id.as_deref(), Some("codex")); } + + #[test] + fn usage_payload_maps_every_rate_limit_reached_type() { + let cases = [ + ( + RateLimitReachedKind::RateLimitReached, + Some(RateLimitReachedType::RateLimitReached), + ), + ( + RateLimitReachedKind::WorkspaceOwnerCreditsDepleted, + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted), + ), + ( + RateLimitReachedKind::WorkspaceMemberCreditsDepleted, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted), + ), + ( + RateLimitReachedKind::WorkspaceOwnerUsageLimitReached, + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached), + ), + ( + RateLimitReachedKind::WorkspaceMemberUsageLimitReached, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + ), + (RateLimitReachedKind::Unknown, None), + ]; + + for (kind, expected) in cases { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: Some(Some(Box::new(BackendRateLimitReachedType { kind }))), + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots[0].rate_limit_reached_type, expected); + } + } + + #[test] + fn usage_payload_preserves_absent_rate_limit_reached_type() { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: None, + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots[0].rate_limit_reached_type, None); + } + + #[test] + fn add_credits_nudge_email_uses_expected_paths_and_bodies() { + let codex_client = Client { + base_url: "https://example.test".to_string(), + http: reqwest::Client::new(), + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + path_style: PathStyle::CodexApi, + }; + assert_eq!( + codex_client.send_add_credits_nudge_email_url(), + "https://example.test/api/codex/accounts/send_add_credits_nudge_email" + ); + + let chatgpt_client = Client { + base_url: "https://chatgpt.com/backend-api".to_string(), + http: reqwest::Client::new(), + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + path_style: PathStyle::ChatGptApi, + }; + assert_eq!( + chatgpt_client.send_add_credits_nudge_email_url(), + "https://chatgpt.com/backend-api/accounts/send_add_credits_nudge_email" + ); + + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .unwrap(), + serde_json::json!({ "credit_type": "credits" }) + ); + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .unwrap(), + serde_json::json!({ "credit_type": "usage_limit" }) + ); + } } diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 22e0984cda1..d8d24ab9fce 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -2,6 +2,7 @@ pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; pub use codex_backend_openapi_models::models::PlanType; +pub use codex_backend_openapi_models::models::RateLimitReachedKind; pub use codex_backend_openapi_models::models::RateLimitStatusDetails; pub use codex_backend_openapi_models::models::RateLimitStatusPayload; pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 1c71dc75025..979500cdabc 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -93,6 +93,7 @@ pub fn parse_rate_limit_for_limit( secondary, credits, plan_type: None, + rate_limit_reached_type: None, }) } @@ -156,6 +157,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { secondary, credits, plan_type: event.plan_type, + rate_limit_reached_type: None, }) } diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index 2140c83f912..c881822b375 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -32,6 +32,8 @@ pub use self::additional_rate_limit_details::AdditionalRateLimitDetails; pub(crate) mod rate_limit_status_payload; pub use self::rate_limit_status_payload::PlanType; +pub use self::rate_limit_status_payload::RateLimitReachedKind; +pub use self::rate_limit_status_payload::RateLimitReachedType; pub use self::rate_limit_status_payload::RateLimitStatusPayload; pub(crate) mod rate_limit_status_details; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 4d9ec1e364e..38c8730d912 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -37,6 +37,13 @@ pub struct RateLimitStatusPayload { skip_serializing_if = "Option::is_none" )] pub additional_rate_limits: Option>>, + #[serde( + rename = "rate_limit_reached_type", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit_reached_type: Option>>, } impl RateLimitStatusPayload { @@ -46,10 +53,36 @@ impl RateLimitStatusPayload { rate_limit: None, credits: None, additional_rate_limits: None, + rate_limit_reached_type: None, } } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitReachedType { + #[serde(rename = "type")] + pub kind: RateLimitReachedKind, +} + +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, +)] +pub enum RateLimitReachedKind { + #[serde(rename = "rate_limit_reached")] + RateLimitReached, + #[serde(rename = "workspace_owner_credits_depleted")] + WorkspaceOwnerCreditsDepleted, + #[serde(rename = "workspace_member_credits_depleted")] + WorkspaceMemberCreditsDepleted, + #[serde(rename = "workspace_owner_usage_limit_reached")] + WorkspaceOwnerUsageLimitReached, + #[serde(rename = "workspace_member_usage_limit_reached")] + WorkspaceMemberUsageLimitReached, + #[serde(rename = "unknown", other)] + #[default] + Unknown, +} + #[derive( Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, )] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c91f07f705a..7d48c532bc4 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -30,6 +30,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-backend-client = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 95e932b1dc1..3052d7b662c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4702,6 +4702,16 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::realtime_conversation_list_voices(&sess, sub.id.clone()).await; false } + Op::SendAddCreditsNudgeEmail { credit_type } => { + handlers::send_add_credits_nudge_email( + &sess, + &config, + sub.id.clone(), + credit_type, + ) + .await; + false + } Op::OverrideTurnContext { cwd, approval_policy, @@ -4931,8 +4941,13 @@ mod handlers { use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; + use codex_backend_client::Client as BackendClient; use codex_mcp::collect_mcp_snapshot_from_manager; use codex_mcp::compute_auth_statuses; + use codex_protocol::protocol::AddCreditsNudgeCreditType; + use codex_protocol::protocol::AddCreditsNudgeEmailResponseEvent; + use codex_protocol::protocol::AddCreditsNudgeEmailResult; + use codex_protocol::protocol::AddCreditsNudgeEmailStatus; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -4966,6 +4981,7 @@ mod handlers { use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; + use reqwest::StatusCode; use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; @@ -4993,6 +5009,51 @@ mod handlers { .await; } + pub async fn send_add_credits_nudge_email( + sess: &Arc, + config: &Arc, + sub_id: String, + credit_type: AddCreditsNudgeCreditType, + ) { + let result = match send_add_credits_nudge_email_inner(sess, config, credit_type).await { + Ok(status) => status.into(), + Err(message) => AddCreditsNudgeEmailResult::Failed { message }, + }; + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::AddCreditsNudgeEmailResponse(AddCreditsNudgeEmailResponseEvent { + result, + }), + }) + .await; + } + + async fn send_add_credits_nudge_email_inner( + sess: &Arc, + config: &Arc, + credit_type: AddCreditsNudgeCreditType, + ) -> Result { + let Some(auth) = sess.services.auth_manager.auth().await else { + return Err( + "codex account authentication required to notify workspace owner".to_string(), + ); + }; + + if !auth.is_chatgpt_auth() { + return Err("chatgpt authentication required to notify workspace owner".to_string()); + } + + let client = BackendClient::from_auth(config.chatgpt_base_url.clone(), &auth) + .map_err(|err| format!("failed to construct backend client: {err}"))?; + match client.send_add_credits_nudge_email(credit_type).await { + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status() == Some(StatusCode::TOO_MANY_REQUESTS) => { + Ok(AddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(format!("failed to notify workspace owner: {err}")), + } + } + pub async fn override_turn_context( sess: &Session, sub_id: String, @@ -7470,6 +7531,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::McpListToolsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) + | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6de8909e6d3..8a0b7cb232a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1948,6 +1948,7 @@ async fn set_rate_limits_retains_previous_credits() { balance: Some("10.00".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }; state.set_rate_limits(initial.clone()); @@ -1966,6 +1967,7 @@ async fn set_rate_limits_retains_previous_credits() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; state.set_rate_limits(update.clone()); @@ -1978,6 +1980,7 @@ async fn set_rate_limits_retains_previous_credits() { secondary: update.secondary, credits: initial.credits, plan_type: initial.plan_type, + rate_limit_reached_type: None, }) ); } @@ -2054,6 +2057,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { balance: Some("15.00".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }; state.set_rate_limits(initial.clone()); @@ -2068,6 +2072,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { secondary: None, credits: None, plan_type: Some(codex_protocol::account::PlanType::Pro), + rate_limit_reached_type: None, }; state.set_rate_limits(update.clone()); @@ -2080,6 +2085,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { secondary: update.secondary, credits: initial.credits, plan_type: update.plan_type, + rate_limit_reached_type: None, }) ); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index 1af7ccc8f60..8664daf4fd0 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -49,6 +49,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); assert_eq!( @@ -76,6 +77,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); state.set_rate_limits(RateLimitSnapshot { limit_id: None, @@ -88,6 +90,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); assert_eq!( @@ -119,6 +122,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other balance: Some("50".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }); state.set_rate_limits(RateLimitSnapshot { @@ -132,6 +136,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }); assert_eq!( @@ -151,6 +156,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other balance: Some("50".to_string()), }), plan_type: Some(codex_protocol::account::PlanType::Plus), + rate_limit_reached_type: None, }) ); } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7811654a2e2..984a0af1c92 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2375,7 +2375,8 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null } }) ); @@ -2426,7 +2427,8 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null } }) ); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index eee2347202b..ef20b992d11 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -393,6 +393,7 @@ async fn run_codex_tool_session_inner( | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) + | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 0b1d1897277..aef7478607c 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -36,6 +36,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { }), credits: None, plan_type: None, + rate_limit_reached_type: None, } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 244f748c820..f62cb7f27c1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -400,6 +400,12 @@ pub enum Op { /// Request the list of voices supported by realtime conversation streams. RealtimeConversationListVoices, + /// Ask the workspace owner to add credits or raise the workspace usage + /// limit after the user confirms an actionable workspace member limit. + SendAddCreditsNudgeEmail { + credit_type: AddCreditsNudgeCreditType, + }, + /// Legacy user input. /// /// Prefer [`Op::UserTurn`] so the caller provides full turn context @@ -764,6 +770,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", + Self::SendAddCreditsNudgeEmail { .. } => "send_add_credits_nudge_email", Self::UserInput { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", @@ -1552,6 +1559,8 @@ pub enum EventMsg { /// Notification that skill data may have been updated and clients may want to reload. SkillsUpdateAvailable, + AddCreditsNudgeEmailResponse(AddCreditsNudgeEmailResponseEvent), + PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -2166,6 +2175,57 @@ pub struct RateLimitSnapshot { pub secondary: Option, pub credits: Option, pub plan_type: Option, + pub rate_limit_reached_type: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "status", rename_all = "snake_case")] +#[ts(tag = "status")] +pub enum AddCreditsNudgeEmailResult { + Sent, + CooldownActive, + Failed { message: String }, +} + +impl From for AddCreditsNudgeEmailResult { + fn from(value: AddCreditsNudgeEmailStatus) -> Self { + match value { + AddCreditsNudgeEmailStatus::Sent => Self::Sent, + AddCreditsNudgeEmailStatus::CooldownActive => Self::CooldownActive, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] +pub struct AddCreditsNudgeEmailResponseEvent { + pub result: AddCreditsNudgeEmailResult, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 4b50781e76e..4223c8ad5c1 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -161,6 +161,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::UndoStarted(_) | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) + | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::ListSkillsResponse(_) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b46ace36273..4ea485fb9e6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -91,6 +91,7 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; @@ -118,6 +119,9 @@ use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::AddCreditsNudgeCreditType; +use codex_protocol::protocol::AddCreditsNudgeEmailResult; +use codex_protocol::protocol::AddCreditsNudgeEmailStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FinalOutput; use codex_protocol::protocol::GetHistoryEntryResponseEvent; @@ -1942,6 +1946,24 @@ impl App { }); } + fn send_add_credits_nudge_email( + &mut self, + app_server: &AppServerSession, + credit_type: AddCreditsNudgeCreditType, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = send_add_credits_nudge_email(request_handle, credit_type) + .await + .map(AddCreditsNudgeEmailResult::from) + .unwrap_or_else(|err| AddCreditsNudgeEmailResult::Failed { + message: err.to_string(), + }); + app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { result }); + }); + } + fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -4577,6 +4599,14 @@ impl App { AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } + AppEvent::SendAddCreditsNudgeEmail { credit_type } => { + self.chat_widget.start_add_credits_nudge_email_request(); + self.send_add_credits_nudge_email(app_server, credit_type); + } + AppEvent::AddCreditsNudgeEmailFinished { result } => { + self.chat_widget + .finish_add_credits_nudge_email_request(result); + } AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(snapshots) => { for snapshot in snapshots { @@ -6229,6 +6259,38 @@ async fn fetch_account_rate_limits( Ok(app_server_rate_limit_snapshots_to_core(response)) } +async fn send_add_credits_nudge_email( + request_handle: AppServerRequestHandle, + credit_type: AddCreditsNudgeCreditType, +) -> Result { + let request_id = RequestId::String(format!("add-credits-nudge-{}", Uuid::new_v4())); + let response: codex_app_server_protocol::SendAddCreditsNudgeEmailResponse = request_handle + .request_typed(ClientRequest::SendAddCreditsNudgeEmail { + request_id, + params: SendAddCreditsNudgeEmailParams { + credit_type: match credit_type { + AddCreditsNudgeCreditType::Credits => { + codex_app_server_protocol::AddCreditsNudgeCreditType::Credits + } + AddCreditsNudgeCreditType::UsageLimit => { + codex_app_server_protocol::AddCreditsNudgeCreditType::UsageLimit + } + }, + }, + }) + .await + .wrap_err("account/sendAddCreditsNudgeEmail failed in TUI")?; + + Ok(match response.status { + codex_app_server_protocol::AddCreditsNudgeEmailStatus::Sent => { + AddCreditsNudgeEmailStatus::Sent + } + codex_app_server_protocol::AddCreditsNudgeEmailStatus::CooldownActive => { + AddCreditsNudgeEmailStatus::CooldownActive + } + }) +} + async fn fetch_plugins_list( request_handle: AppServerRequestHandle, cwd: PathBuf, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 91f00b99b08..4ec4cebec48 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,6 +20,8 @@ use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::protocol::AddCreditsNudgeCreditType; +use codex_protocol::protocol::AddCreditsNudgeEmailResult; use codex_protocol::protocol::GetHistoryEntryResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot; @@ -171,6 +173,16 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Send a user-confirmed request to notify the workspace owner. + SendAddCreditsNudgeEmail { + credit_type: AddCreditsNudgeCreditType, + }, + + /// Result of notifying the workspace owner. + AddCreditsNudgeEmailFinished { + result: AddCreditsNudgeEmailResult, + }, + /// Result of prefetching connectors. ConnectorsLoaded { result: Result, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9c4546c4fa8..afe83649f50 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1133,6 +1133,31 @@ pub(crate) fn app_server_rate_limit_snapshot_to_core( secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core), credits: snapshot.credits.map(app_server_credits_snapshot_to_core), plan_type: snapshot.plan_type, + rate_limit_reached_type: snapshot + .rate_limit_reached_type + .map(app_server_rate_limit_reached_type_to_core), + } +} + +fn app_server_rate_limit_reached_type_to_core( + rate_limit_reached_type: codex_app_server_protocol::RateLimitReachedType, +) -> codex_protocol::protocol::RateLimitReachedType { + match rate_limit_reached_type { + codex_app_server_protocol::RateLimitReachedType::RateLimitReached => { + codex_protocol::protocol::RateLimitReachedType::RateLimitReached + } + codex_app_server_protocol::RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + codex_protocol::protocol::RateLimitReachedType::WorkspaceOwnerCreditsDepleted + } + codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + codex_protocol::protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted + } + codex_app_server_protocol::RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + codex_protocol::protocol::RateLimitReachedType::WorkspaceOwnerUsageLimitReached + } + codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + codex_protocol::protocol::RateLimitReachedType::WorkspaceMemberUsageLimitReached + } } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index e3eecf2ed00..30d55bbb12c 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -648,6 +648,16 @@ impl BottomPaneView for ListSelectionView { && !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { + if let Some(idx) = self.items.iter().position(|item| { + item.display_shortcut + .is_some_and(|shortcut| shortcut.is_press(key_event)) + && item.disabled_reason.is_none() + && !item.is_disabled + }) { + self.state.selected_idx = Some(idx); + self.accept(); + return; + } if let Some(idx) = c .to_digit(10) .map(|d| d as usize) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1334b6e2f23..cd3da0218f4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -134,6 +134,8 @@ use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; +use codex_protocol::protocol::AddCreditsNudgeCreditType; +use codex_protocol::protocol::AddCreditsNudgeEmailResult; #[cfg(test)] use codex_protocol::protocol::AgentMessageDeltaEvent; #[cfg(test)] @@ -189,6 +191,7 @@ use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; @@ -770,8 +773,10 @@ pub(crate) struct ChatWidget { refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, plan_type: Option, + codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, + add_credits_nudge_email_in_flight: bool, adaptive_chunking: AdaptiveChunkingPolicy, // Stream lifecycle controller stream_controller: Option, @@ -2681,6 +2686,9 @@ impl ChatWidget { self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); + if is_codex_limit { + self.codex_rate_limit_reached_type = snapshot.rate_limit_reached_type; + } let warnings = if is_codex_limit { self.rate_limit_warnings.take_warnings( snapshot @@ -2744,6 +2752,7 @@ impl ChatWidget { } } else { self.rate_limit_snapshots_by_limit_id.clear(); + self.codex_rate_limit_reached_type = None; } self.refresh_status_line(); } @@ -2796,6 +2805,13 @@ impl ChatWidget { self.maybe_send_next_queued_input(); } + fn on_rate_limit_error(&mut self, message: String) { + self.on_error(message); + if let Some(credit_type) = self.workspace_owner_nudge_credit_type() { + self.open_workspace_owner_nudge_prompt(credit_type); + } + } + fn handle_non_retry_error( &mut self, message: String, @@ -2812,7 +2828,7 @@ impl ChatWidget { match info { RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.on_rate_limit_error(message) } } } else { @@ -4803,8 +4819,10 @@ impl ChatWidget { refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: initial_plan_type, + codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + add_credits_nudge_email_in_flight: false, adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, @@ -6638,6 +6656,9 @@ impl ChatWidget { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } + EventMsg::AddCreditsNudgeEmailResponse(ev) => { + self.finish_add_credits_nudge_email_request(ev.result); + } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} @@ -6658,7 +6679,7 @@ impl ChatWidget { self.on_server_overloaded_error(message) } RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.on_rate_limit_error(message) } } } else { @@ -7458,6 +7479,91 @@ impl ChatWidget { }); } + fn workspace_owner_nudge_credit_type(&self) -> Option { + match self.codex_rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { + Some(AddCreditsNudgeCreditType::Credits) + } + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { + Some(AddCreditsNudgeCreditType::UsageLimit) + } + Some( + RateLimitReachedType::RateLimitReached + | RateLimitReachedType::WorkspaceOwnerCreditsDepleted + | RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + ) + | None => None, + } + } + + fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { + if self.add_credits_nudge_email_in_flight { + return; + } + + let prompt = match credit_type { + AddCreditsNudgeCreditType::Credits => { + "Your workspace is out of credits. Notify your workspace owner?" + } + AddCreditsNudgeCreditType::UsageLimit => { + "Your workspace usage limit has been reached. Request an increase from your workspace owner?" + } + }; + let send_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); + })]; + let items = vec![ + SelectionItem { + name: "Yes".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), + actions: send_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "No".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), + is_default: true, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Workspace limit reached".to_string()), + subtitle: Some(prompt.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx: Some(1), + ..Default::default() + }); + } + + pub(crate) fn start_add_credits_nudge_email_request(&mut self) { + self.add_credits_nudge_email_in_flight = true; + } + + pub(crate) fn finish_add_credits_nudge_email_request( + &mut self, + result: AddCreditsNudgeEmailResult, + ) { + self.add_credits_nudge_email_in_flight = false; + let message = match result { + AddCreditsNudgeEmailResult::Sent => "Workspace owner notified.", + AddCreditsNudgeEmailResult::CooldownActive => { + "Workspace owner was already notified recently." + } + AddCreditsNudgeEmailResult::Failed { .. } => { + "Could not notify your workspace owner. Please try again." + } + }; + self.add_to_history(history_cell::new_info_event( + message.to_string(), + /*hint*/ None, + )); + self.request_redraw(); + } + /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e92a809e802..b57f1fccefa 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -129,6 +129,7 @@ pub(super) use codex_protocol::parse_command::ParsedCommand; pub(super) use codex_protocol::plan_tool::PlanItemArg; pub(super) use codex_protocol::plan_tool::StepStatus; pub(super) use codex_protocol::plan_tool::UpdatePlanArgs; +pub(super) use codex_protocol::protocol::AddCreditsNudgeEmailResult; pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent; pub(super) use codex_protocol::protocol::AgentMessageEvent; pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent; @@ -167,6 +168,8 @@ pub(super) use codex_protocol::protocol::Op; pub(super) use codex_protocol::protocol::PatchApplyBeginEvent; pub(super) use codex_protocol::protocol::PatchApplyEndEvent; pub(super) use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +pub(super) use codex_protocol::protocol::RateLimitReachedType; +pub(super) use codex_protocol::protocol::RateLimitSnapshot; pub(super) use codex_protocol::protocol::RateLimitWindow; pub(super) use codex_protocol::protocol::ReadOnlyAccess; pub(super) use codex_protocol::protocol::RealtimeConversationClosedEvent; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0ae51c4ff8a..d356eb8bf29 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -105,6 +105,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, } } @@ -199,8 +200,10 @@ pub(super) async fn make_chatwidget_manual( refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: None, + codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + add_credits_nudge_email_in_flight: false, adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, 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 e59928ff1c2..091c8bede41 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -235,6 +235,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { balance: Some("17.5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, })); let initial_balance = chat .rate_limit_snapshots_by_limit_id @@ -254,6 +255,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, })); let display = chat @@ -292,6 +294,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { }), credits: None, plan_type: Some(PlanType::Plus), + rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Plus)); @@ -310,6 +313,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { }), credits: None, plan_type: Some(PlanType::Pro), + rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); @@ -328,6 +332,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); } @@ -351,6 +356,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { balance: Some("5.00".to_string()), }), plan_type: Some(PlanType::Pro), + rate_limit_reached_type: None, })); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { @@ -364,6 +370,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { secondary: None, credits: None, plan_type: Some(PlanType::Pro), + rate_limit_reached_type: None, })); let codex = chat @@ -416,6 +423,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, })); assert!(matches!( @@ -493,6 +501,143 @@ async fn rate_limit_switch_prompt_popup_snapshot() { assert_chatwidget_snapshot!("rate_limit_switch_prompt_popup", popup); } +#[tokio::test] +async fn workspace_member_credits_depleted_prompts_and_sends_credits() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + let popup = render_bottom_popup(&chat, /*width*/ 90); + assert!(popup.contains("Your workspace is out of credits. Notify your workspace owner?")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!( + event, + codex_protocol::protocol::AddCreditsNudgeCreditType::Credits + ); +} + +#[tokio::test] +async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!(popup.contains( + "Your workspace usage limit has been reached. Request an increase from your workspace owner?" + )); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!( + event, + codex_protocol::protocol::AddCreditsNudgeCreditType::UsageLimit + ); +} + +#[tokio::test] +async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() { + for limit_type in [ + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + RateLimitReachedType::RateLimitReached, + ] { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(limit_type); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + let popup = render_bottom_popup(&chat, /*width*/ 90); + assert!(!popup.contains("workspace owner")); + } +} + +#[tokio::test] +async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 100.0))); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + let popup = render_bottom_popup(&chat, /*width*/ 90); + assert!(!popup.contains("workspace owner")); + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); +} + +#[tokio::test] +async fn workspace_owner_nudge_default_no_dismisses_without_sending() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); +} + +#[tokio::test] +async fn workspace_owner_nudge_completion_renders_feedback() { + let cases = [ + ( + AddCreditsNudgeEmailResult::Sent, + "Workspace owner notified.", + ), + ( + AddCreditsNudgeEmailResult::CooldownActive, + "Workspace owner was already notified recently.", + ), + ( + AddCreditsNudgeEmailResult::Failed { + message: "request failed".to_string(), + }, + "Could not notify your workspace owner. Please try again.", + ), + ]; + + for (result, expected) in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.finish_add_credits_nudge_email_request(result); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!(rendered.contains(expected), "rendered: {rendered}"); + } +} + +fn next_send_add_credits_nudge_email_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> codex_protocol::protocol::AddCreditsNudgeCreditType { + while let Ok(event) = rx.try_recv() { + if let AppEvent::SendAddCreditsNudgeEmail { credit_type } = event { + return credit_type; + } + } + panic!("expected SendAddCreditsNudgeEmail app event"); +} + +fn assert_no_owner_nudge_or_rate_limit_refresh( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) { + while let Ok(event) = rx.try_recv() { + assert!( + !matches!( + event, + AppEvent::SendAddCreditsNudgeEmail { .. } | AppEvent::RefreshRateLimits { .. } + ), + "unexpected event: {event:?}" + ); + } +} + #[tokio::test] async fn streaming_final_answer_keeps_task_running_state() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 12b23a38f65..a537a398485 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -138,6 +138,7 @@ async fn status_snapshot_includes_reasoning_details() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -321,6 +322,7 @@ async fn status_snapshot_includes_monthly_limit() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -372,6 +374,7 @@ async fn status_snapshot_shows_unlimited_credits() { balance: None, }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); @@ -421,6 +424,7 @@ async fn status_snapshot_shows_positive_credits() { balance: Some("12.5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); @@ -470,6 +474,7 @@ async fn status_snapshot_hides_zero_credits() { balance: Some("0".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); @@ -517,6 +522,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { balance: None, }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); @@ -622,6 +628,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -735,6 +742,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -805,6 +813,7 @@ async fn status_snapshot_includes_credits_and_limits() { balance: Some("37.5".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -858,6 +867,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) @@ -914,6 +924,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { secondary: None, credits: None, plan_type: None, + rate_limit_reached_type: None, }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) @@ -984,6 +995,7 @@ async fn status_snapshot_shows_stale_limits_message() { }), credits: None, plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); @@ -1054,6 +1066,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { balance: Some("80".to_string()), }), plan_type: None, + rate_limit_reached_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); From 8ad0619f11f1aaaa932502182f76ac3f4684fc03 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 12:15:45 -0700 Subject: [PATCH 2/9] Tighten workspace owner nudge handling --- .../app-server-protocol/src/protocol/v2.rs | 38 +++++++++++++++++++ .../app-server/tests/suite/v2/rate_limits.rs | 2 +- codex-rs/backend-client/src/client.rs | 15 ++++---- codex-rs/tui/src/app.rs | 21 ++-------- codex-rs/tui/src/app_server_session.rs | 26 +------------ codex-rs/tui/src/chatwidget.rs | 32 +++++++--------- ...kspace_member_credits_depleted_prompt.snap | 11 ++++++ ...__workspace_member_usage_limit_prompt.snap | 11 ++++++ ...space_owner_nudge_completion_feedback.snap | 11 ++++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 25 ++++++------ 11 files changed, 111 insertions(+), 82 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 74971a1fd57..566c1d4cc6d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1789,6 +1789,15 @@ impl From for CoreAddCreditsNudgeCreditType { } } +impl From for AddCreditsNudgeCreditType { + fn from(value: CoreAddCreditsNudgeCreditType) -> Self { + match value { + CoreAddCreditsNudgeCreditType::Credits => Self::Credits, + CoreAddCreditsNudgeCreditType::UsageLimit => Self::UsageLimit, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1813,6 +1822,15 @@ impl From for AddCreditsNudgeEmailStatus { } } +impl From for CoreAddCreditsNudgeEmailStatus { + fn from(value: AddCreditsNudgeEmailStatus) -> Self { + match value { + AddCreditsNudgeEmailStatus::Sent => Self::Sent, + AddCreditsNudgeEmailStatus::CooldownActive => Self::CooldownActive, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6549,6 +6567,26 @@ impl From for RateLimitReachedType { } } +impl From for CoreRateLimitReachedType { + fn from(value: RateLimitReachedType) -> Self { + match value { + RateLimitReachedType::RateLimitReached => Self::RateLimitReached, + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 9cc8e9bc46a..2104d4ca81f 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -275,7 +275,7 @@ async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { .await??; let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; - assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent,); + assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent); Ok(()) } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6052a7c3bbc..d7d6b7ffe2f 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,6 +1,7 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; +use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; @@ -474,25 +475,25 @@ impl Client { } fn map_rate_limit_reached_type( - kind: crate::types::RateLimitReachedKind, + kind: BackendRateLimitReachedKind, ) -> Option { match kind { - crate::types::RateLimitReachedKind::RateLimitReached => { + BackendRateLimitReachedKind::RateLimitReached => { Some(RateLimitReachedType::RateLimitReached) } - crate::types::RateLimitReachedKind::WorkspaceOwnerCreditsDepleted => { + BackendRateLimitReachedKind::WorkspaceOwnerCreditsDepleted => { Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) } - crate::types::RateLimitReachedKind::WorkspaceMemberCreditsDepleted => { + BackendRateLimitReachedKind::WorkspaceMemberCreditsDepleted => { Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) } - crate::types::RateLimitReachedKind::WorkspaceOwnerUsageLimitReached => { + BackendRateLimitReachedKind::WorkspaceOwnerUsageLimitReached => { Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) } - crate::types::RateLimitReachedKind::WorkspaceMemberUsageLimitReached => { + BackendRateLimitReachedKind::WorkspaceMemberUsageLimitReached => { Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) } - crate::types::RateLimitReachedKind::Unknown => None, + BackendRateLimitReachedKind::Unknown => None, } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4ea485fb9e6..578d797428c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1956,6 +1956,7 @@ impl App { tokio::spawn(async move { let result = send_add_credits_nudge_email(request_handle, credit_type) .await + .map(AddCreditsNudgeEmailStatus::from) .map(AddCreditsNudgeEmailResult::from) .unwrap_or_else(|err| AddCreditsNudgeEmailResult::Failed { message: err.to_string(), @@ -6262,33 +6263,19 @@ async fn fetch_account_rate_limits( async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, -) -> Result { +) -> Result { let request_id = RequestId::String(format!("add-credits-nudge-{}", Uuid::new_v4())); let response: codex_app_server_protocol::SendAddCreditsNudgeEmailResponse = request_handle .request_typed(ClientRequest::SendAddCreditsNudgeEmail { request_id, params: SendAddCreditsNudgeEmailParams { - credit_type: match credit_type { - AddCreditsNudgeCreditType::Credits => { - codex_app_server_protocol::AddCreditsNudgeCreditType::Credits - } - AddCreditsNudgeCreditType::UsageLimit => { - codex_app_server_protocol::AddCreditsNudgeCreditType::UsageLimit - } - }, + credit_type: credit_type.into(), }, }) .await .wrap_err("account/sendAddCreditsNudgeEmail failed in TUI")?; - Ok(match response.status { - codex_app_server_protocol::AddCreditsNudgeEmailStatus::Sent => { - AddCreditsNudgeEmailStatus::Sent - } - codex_app_server_protocol::AddCreditsNudgeEmailStatus::CooldownActive => { - AddCreditsNudgeEmailStatus::CooldownActive - } - }) + Ok(response.status) } async fn fetch_plugins_list( diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index afe83649f50..8e4f540153f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1133,31 +1133,7 @@ pub(crate) fn app_server_rate_limit_snapshot_to_core( secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core), credits: snapshot.credits.map(app_server_credits_snapshot_to_core), plan_type: snapshot.plan_type, - rate_limit_reached_type: snapshot - .rate_limit_reached_type - .map(app_server_rate_limit_reached_type_to_core), - } -} - -fn app_server_rate_limit_reached_type_to_core( - rate_limit_reached_type: codex_app_server_protocol::RateLimitReachedType, -) -> codex_protocol::protocol::RateLimitReachedType { - match rate_limit_reached_type { - codex_app_server_protocol::RateLimitReachedType::RateLimitReached => { - codex_protocol::protocol::RateLimitReachedType::RateLimitReached - } - codex_app_server_protocol::RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - codex_protocol::protocol::RateLimitReachedType::WorkspaceOwnerCreditsDepleted - } - codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - codex_protocol::protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted - } - codex_app_server_protocol::RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - codex_protocol::protocol::RateLimitReachedType::WorkspaceOwnerUsageLimitReached - } - codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberUsageLimitReached => { - codex_protocol::protocol::RateLimitReachedType::WorkspaceMemberUsageLimitReached - } + rate_limit_reached_type: snapshot.rate_limit_reached_type.map(Into::into), } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cd3da0218f4..6f9abc7d360 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2807,8 +2807,19 @@ impl ChatWidget { fn on_rate_limit_error(&mut self, message: String) { self.on_error(message); - if let Some(credit_type) = self.workspace_owner_nudge_credit_type() { - self.open_workspace_owner_nudge_prompt(credit_type); + match self.codex_rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); + } + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); + } + Some( + RateLimitReachedType::RateLimitReached + | RateLimitReachedType::WorkspaceOwnerCreditsDepleted + | RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + ) + | None => {} } } @@ -7479,23 +7490,6 @@ impl ChatWidget { }); } - fn workspace_owner_nudge_credit_type(&self) -> Option { - match self.codex_rate_limit_reached_type { - Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { - Some(AddCreditsNudgeCreditType::Credits) - } - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { - Some(AddCreditsNudgeCreditType::UsageLimit) - } - Some( - RateLimitReachedType::RateLimitReached - | RateLimitReachedType::WorkspaceOwnerCreditsDepleted - | RateLimitReachedType::WorkspaceOwnerUsageLimitReached, - ) - | None => None, - } - } - fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { if self.add_credits_nudge_email_in_flight { return; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap new file mode 100644 index 00000000000..8997121fdbc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: popup +--- + Workspace limit reached + Your workspace is out of credits. Notify your workspace owner? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap new file mode 100644 index 00000000000..cf2044db30e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: popup +--- + Workspace limit reached + Your workspace usage limit has been reached. Request an increase from your workspace owner? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap new file mode 100644 index 00000000000..e2b0885ed77 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- +• Workspace owner notified. + +--- +• Workspace owner was already notified recently. + +--- +• Could not notify your workspace owner. Please try again. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b57f1fccefa..5f8a7e3185b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -129,6 +129,7 @@ pub(super) use codex_protocol::parse_command::ParsedCommand; pub(super) use codex_protocol::plan_tool::PlanItemArg; pub(super) use codex_protocol::plan_tool::StepStatus; pub(super) use codex_protocol::plan_tool::UpdatePlanArgs; +pub(super) use codex_protocol::protocol::AddCreditsNudgeCreditType; pub(super) use codex_protocol::protocol::AddCreditsNudgeEmailResult; pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent; pub(super) use codex_protocol::protocol::AgentMessageEvent; 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 091c8bede41..dccff1609f2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -510,14 +510,11 @@ async fn workspace_member_credits_depleted_prompts_and_sends_credits() { chat.on_rate_limit_error("Usage limit reached.".to_string()); let popup = render_bottom_popup(&chat, /*width*/ 90); - assert!(popup.contains("Your workspace is out of credits. Notify your workspace owner?")); + assert_chatwidget_snapshot!("workspace_member_credits_depleted_prompt", popup); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let event = next_send_add_credits_nudge_email_event(&mut rx); - assert_eq!( - event, - codex_protocol::protocol::AddCreditsNudgeCreditType::Credits - ); + assert_eq!(event, AddCreditsNudgeCreditType::Credits); } #[tokio::test] @@ -529,16 +526,11 @@ async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { chat.on_rate_limit_error("Usage limit reached.".to_string()); let popup = render_bottom_popup(&chat, /*width*/ 100); - assert!(popup.contains( - "Your workspace usage limit has been reached. Request an increase from your workspace owner?" - )); + assert_chatwidget_snapshot!("workspace_member_usage_limit_prompt", popup); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let event = next_send_add_credits_nudge_email_event(&mut rx); - assert_eq!( - event, - codex_protocol::protocol::AddCreditsNudgeCreditType::UsageLimit - ); + assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); } #[tokio::test] @@ -602,6 +594,7 @@ async fn workspace_owner_nudge_completion_renders_feedback() { ), ]; + let mut rendered_cases = Vec::new(); for (result, expected) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.finish_add_credits_nudge_email_request(result); @@ -610,12 +603,18 @@ async fn workspace_owner_nudge_completion_renders_feedback() { .map(|lines| lines_to_single_string(&lines)) .collect::(); assert!(rendered.contains(expected), "rendered: {rendered}"); + rendered_cases.push(rendered); } + + assert_chatwidget_snapshot!( + "workspace_owner_nudge_completion_feedback", + rendered_cases.join("\n---\n") + ); } fn next_send_add_credits_nudge_email_event( rx: &mut tokio::sync::mpsc::UnboundedReceiver, -) -> codex_protocol::protocol::AddCreditsNudgeCreditType { +) -> AddCreditsNudgeCreditType { while let Ok(event) = rx.try_recv() { if let AppEvent::SendAddCreditsNudgeEmail { credit_type } = event { return credit_type; From 9ef7712d06486f75a71fa1038529bdaca7470536 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 13:54:55 -0700 Subject: [PATCH 3/9] Match usage-limit TUI copy --- codex-rs/tui/src/app.rs | 3 +- codex-rs/tui/src/chatwidget.rs | 79 ++++++++++++------ ...kspace_member_credits_depleted_prompt.snap | 4 +- ...__workspace_member_usage_limit_prompt.snap | 4 +- ...er_credits_nudge_completion_feedback.snap} | 0 ..._workspace_owner_limit_state_messages.snap | 8 ++ ...usage_limit_nudge_completion_feedback.snap | 11 +++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 +- .../src/chatwidget/tests/status_and_layout.rs | 81 ++++++++++++++++++- 9 files changed, 158 insertions(+), 34 deletions(-) rename codex-rs/tui/src/chatwidget/snapshots/{codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap => codex_tui__chatwidget__tests__workspace_owner_credits_nudge_completion_feedback.snap} (100%) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 578d797428c..cfe288d4ee7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4601,7 +4601,8 @@ impl App { self.refresh_rate_limits(app_server, origin); } AppEvent::SendAddCreditsNudgeEmail { credit_type } => { - self.chat_widget.start_add_credits_nudge_email_request(); + self.chat_widget + .start_add_credits_nudge_email_request(credit_type); self.send_add_credits_nudge_email(app_server, credit_type); } AppEvent::AddCreditsNudgeEmailFinished { result } => { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6f9abc7d360..4f4cf60d8b2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -776,7 +776,7 @@ pub(crate) struct ChatWidget { codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, - add_credits_nudge_email_in_flight: bool, + add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, // Stream lifecycle controller stream_controller: Option, @@ -2806,20 +2806,30 @@ impl ChatWidget { } fn on_rate_limit_error(&mut self, message: String) { - self.on_error(message); match self.codex_rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { + self.on_error( + "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { + self.on_error( + "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." + .to_string(), + ); + } Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { + self.on_error(message); self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); } Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { + self.on_error(message); self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); } - Some( - RateLimitReachedType::RateLimitReached - | RateLimitReachedType::WorkspaceOwnerCreditsDepleted - | RateLimitReachedType::WorkspaceOwnerUsageLimitReached, - ) - | None => {} + Some(RateLimitReachedType::RateLimitReached) | None => { + self.on_error(message); + } } } @@ -4833,7 +4843,7 @@ impl ChatWidget { codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - add_credits_nudge_email_in_flight: false, + add_credits_nudge_email_in_flight: None, adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, @@ -7491,17 +7501,19 @@ impl ChatWidget { } fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { - if self.add_credits_nudge_email_in_flight { + if self.add_credits_nudge_email_in_flight.is_some() { return; } - let prompt = match credit_type { - AddCreditsNudgeCreditType::Credits => { - "Your workspace is out of credits. Notify your workspace owner?" - } - AddCreditsNudgeCreditType::UsageLimit => { - "Your workspace usage limit has been reached. Request an increase from your workspace owner?" - } + let (title, prompt) = match credit_type { + AddCreditsNudgeCreditType::Credits => ( + "You've reached your workspace credit limit", + "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", + ), + AddCreditsNudgeCreditType::UsageLimit => ( + "Usage limit reached", + "Request a limit increase from your owner to continue using codex. Request increase?", + ), }; let send_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); @@ -7524,7 +7536,7 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Workspace limit reached".to_string()), + title: Some(title.to_string()), subtitle: Some(prompt.to_string()), footer_hint: Some(standard_popup_hint_line()), items, @@ -7533,23 +7545,40 @@ impl ChatWidget { }); } - pub(crate) fn start_add_credits_nudge_email_request(&mut self) { - self.add_credits_nudge_email_in_flight = true; + pub(crate) fn start_add_credits_nudge_email_request( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) { + self.add_credits_nudge_email_in_flight = Some(credit_type); } pub(crate) fn finish_add_credits_nudge_email_request( &mut self, result: AddCreditsNudgeEmailResult, ) { - self.add_credits_nudge_email_in_flight = false; - let message = match result { - AddCreditsNudgeEmailResult::Sent => "Workspace owner notified.", - AddCreditsNudgeEmailResult::CooldownActive => { + let credit_type = self + .add_credits_nudge_email_in_flight + .take() + .unwrap_or(AddCreditsNudgeCreditType::Credits); + let message = match (credit_type, result) { + (AddCreditsNudgeCreditType::Credits, AddCreditsNudgeEmailResult::Sent) => { + "Workspace owner notified." + } + (AddCreditsNudgeCreditType::Credits, AddCreditsNudgeEmailResult::CooldownActive) => { "Workspace owner was already notified recently." } - AddCreditsNudgeEmailResult::Failed { .. } => { + (AddCreditsNudgeCreditType::Credits, AddCreditsNudgeEmailResult::Failed { .. }) => { "Could not notify your workspace owner. Please try again." } + (AddCreditsNudgeCreditType::UsageLimit, AddCreditsNudgeEmailResult::Sent) => { + "Limit increase requested." + } + (AddCreditsNudgeCreditType::UsageLimit, AddCreditsNudgeEmailResult::CooldownActive) => { + "A limit increase was already requested recently." + } + (AddCreditsNudgeCreditType::UsageLimit, AddCreditsNudgeEmailResult::Failed { .. }) => { + "Could not request a limit increase. Please try again." + } }; self.add_to_history(history_cell::new_info_event( message.to_string(), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap index 8997121fdbc..283dcd5f0f0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap @@ -2,8 +2,8 @@ source: tui/src/chatwidget/tests/status_and_layout.rs expression: popup --- - Workspace limit reached - Your workspace is out of credits. Notify your workspace owner? + You've reached your workspace credit limit + Your workspace is out of credits. Ask your workspace owner to add more. Notify owner? 1. Yes (y) › 2. No (default) (n) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap index cf2044db30e..ee8322ab7fd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap @@ -2,8 +2,8 @@ source: tui/src/chatwidget/tests/status_and_layout.rs expression: popup --- - Workspace limit reached - Your workspace usage limit has been reached. Request an increase from your workspace owner? + Usage limit reached + Request a limit increase from your owner to continue using codex. Request increase? 1. Yes (y) › 2. No (default) (n) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_credits_nudge_completion_feedback.snap similarity index 100% rename from codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_nudge_completion_feedback.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_credits_nudge_completion_feedback.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap new file mode 100644 index 00000000000..b3c1022a076 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- +■ You're out of credits. Your workspace is out of credits. Add credits to continue using Codex. + +--- +■ Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap new file mode 100644 index 00000000000..8091deebf45 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- +• Limit increase requested. + +--- +• A limit increase was already requested recently. + +--- +• Could not request a limit increase. Please try again. diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d356eb8bf29..c504d39d5a2 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -203,7 +203,7 @@ pub(super) async fn make_chatwidget_manual( codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - add_credits_nudge_email_in_flight: false, + add_credits_nudge_email_in_flight: None, adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, 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 dccff1609f2..30b20a5a747 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -540,7 +540,7 @@ async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() { RateLimitReachedType::WorkspaceOwnerUsageLimitReached, RateLimitReachedType::RateLimitReached, ] { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(limit_type); chat.on_rate_limit_snapshot(Some(limits)); @@ -548,9 +548,45 @@ async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() { chat.on_rate_limit_error("Usage limit reached.".to_string()); let popup = render_bottom_popup(&chat, /*width*/ 90); assert!(!popup.contains("workspace owner")); + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); } } +#[tokio::test] +async fn workspace_owner_limit_states_render_state_specific_messages() { + let cases = [ + ( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex.", + ), + ( + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex.", + ), + ]; + + let mut rendered_cases = Vec::new(); + for (limit_type, expected) in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(limit_type); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!(rendered.contains(expected), "rendered: {rendered}"); + rendered_cases.push(rendered); + } + + assert_chatwidget_snapshot!( + "workspace_owner_limit_state_messages", + rendered_cases.join("\n---\n") + ); +} + #[tokio::test] async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -576,7 +612,7 @@ async fn workspace_owner_nudge_default_no_dismisses_without_sending() { } #[tokio::test] -async fn workspace_owner_nudge_completion_renders_feedback() { +async fn workspace_owner_credits_nudge_completion_renders_feedback() { let cases = [ ( AddCreditsNudgeEmailResult::Sent, @@ -597,6 +633,45 @@ async fn workspace_owner_nudge_completion_renders_feedback() { let mut rendered_cases = Vec::new(); for (result, expected) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits); + chat.finish_add_credits_nudge_email_request(result); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!(rendered.contains(expected), "rendered: {rendered}"); + rendered_cases.push(rendered); + } + + assert_chatwidget_snapshot!( + "workspace_owner_credits_nudge_completion_feedback", + rendered_cases.join("\n---\n") + ); +} + +#[tokio::test] +async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() { + let cases = [ + ( + AddCreditsNudgeEmailResult::Sent, + "Limit increase requested.", + ), + ( + AddCreditsNudgeEmailResult::CooldownActive, + "A limit increase was already requested recently.", + ), + ( + AddCreditsNudgeEmailResult::Failed { + message: "request failed".to_string(), + }, + "Could not request a limit increase. Please try again.", + ), + ]; + + let mut rendered_cases = Vec::new(); + for (result, expected) in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit); chat.finish_add_credits_nudge_email_request(result); let rendered = drain_insert_history(&mut rx) .into_iter() @@ -607,7 +682,7 @@ async fn workspace_owner_nudge_completion_renders_feedback() { } assert_chatwidget_snapshot!( - "workspace_owner_nudge_completion_feedback", + "workspace_owner_usage_limit_nudge_completion_feedback", rendered_cases.join("\n---\n") ); } From c079914656ba459b49bb843a67c1053ecea669ae Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 14:32:49 -0700 Subject: [PATCH 4/9] Fix rate limit event test expectations --- codex-rs/core/tests/suite/client.rs | 3 ++- codex-rs/core/tests/suite/client_websockets.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 984a0af1c92..fef26d4e3a2 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2502,7 +2502,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "resets_at": null }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null }); let submission_id = codex diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index eaa8a9610dd..07f9f5821f2 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1033,7 +1033,8 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "resets_at": null }, "credits": null, - "plan_type": null + "plan_type": null, + "rate_limit_reached_type": null } }) ); From 3dc09e8193b285b63817a414e27c0806ca0ddce3 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 15:00:11 -0700 Subject: [PATCH 5/9] Keep owner nudge flow out of core --- codex-rs/Cargo.lock | 1 - .../app-server-protocol/src/protocol/v2.rs | 38 ------------ .../app-server/src/codex_message_processor.rs | 19 ++++-- .../app-server/tests/suite/v2/rate_limits.rs | 42 +++++++++++++ codex-rs/backend-client/src/client.rs | 8 ++- codex-rs/backend-client/src/lib.rs | 1 + codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex.rs | 62 ------------------- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/protocol.rs | 48 -------------- codex-rs/rollout/src/policy.rs | 1 - codex-rs/tui/src/app.rs | 15 ++--- codex-rs/tui/src/app_event.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 31 +++++----- codex-rs/tui/src/chatwidget/tests.rs | 4 +- .../src/chatwidget/tests/status_and_layout.rs | 16 ++--- 16 files changed, 94 insertions(+), 200 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6694490a24f..ea79776bdc8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1906,7 +1906,6 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-async-utils", - "codex-backend-client", "codex-code-mode", "codex-config", "codex-connectors", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 566c1d4cc6d..13c49dc0303 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -48,8 +48,6 @@ use codex_protocol::openai_models::default_input_modalities; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; -use codex_protocol::protocol::AddCreditsNudgeCreditType as CoreAddCreditsNudgeCreditType; -use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -1780,24 +1778,6 @@ pub enum AddCreditsNudgeCreditType { UsageLimit, } -impl From for CoreAddCreditsNudgeCreditType { - fn from(value: AddCreditsNudgeCreditType) -> Self { - match value { - AddCreditsNudgeCreditType::Credits => Self::Credits, - AddCreditsNudgeCreditType::UsageLimit => Self::UsageLimit, - } - } -} - -impl From for AddCreditsNudgeCreditType { - fn from(value: CoreAddCreditsNudgeCreditType) -> Self { - match value { - CoreAddCreditsNudgeCreditType::Credits => Self::Credits, - CoreAddCreditsNudgeCreditType::UsageLimit => Self::UsageLimit, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1813,24 +1793,6 @@ pub enum AddCreditsNudgeEmailStatus { CooldownActive, } -impl From for AddCreditsNudgeEmailStatus { - fn from(value: CoreAddCreditsNudgeEmailStatus) -> Self { - match value { - CoreAddCreditsNudgeEmailStatus::Sent => Self::Sent, - CoreAddCreditsNudgeEmailStatus::CooldownActive => Self::CooldownActive, - } - } -} - -impl From for CoreAddCreditsNudgeEmailStatus { - fn from(value: AddCreditsNudgeEmailStatus) -> Self { - match value { - AddCreditsNudgeEmailStatus::Sent => Self::Sent, - AddCreditsNudgeEmailStatus::CooldownActive => Self::CooldownActive, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1b4e8e372fc..626ea95adde 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -28,6 +28,8 @@ use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; @@ -199,6 +201,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_arg0::Arg0DispatchPaths; +use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; @@ -290,7 +293,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; @@ -1907,7 +1909,7 @@ impl CodexMessageProcessor { async fn send_add_credits_nudge_email_inner( &self, params: SendAddCreditsNudgeEmailParams, - ) -> Result { + ) -> Result { let Some(auth) = self.auth_manager.auth().await else { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -1933,12 +1935,12 @@ impl CodexMessageProcessor { })?; match client - .send_add_credits_nudge_email(params.credit_type.into()) + .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) .await { - Ok(()) => Ok(CoreAddCreditsNudgeEmailStatus::Sent), + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { - Ok(CoreAddCreditsNudgeEmailStatus::CooldownActive) + Ok(AddCreditsNudgeEmailStatus::CooldownActive) } Err(err) => Err(JSONRPCErrorError { code: INTERNAL_ERROR_CODE, @@ -1948,6 +1950,13 @@ impl CodexMessageProcessor { } } + fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { + match value { + AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, + AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, + } + } + async fn fetch_account_rate_limits( &self, ) -> Result< diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 2104d4ca81f..b1a108dc7b1 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -280,6 +280,48 @@ async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { Ok(()) } +#[tokio::test] +async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::CooldownActive); + + Ok(()) +} + async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index d7d6b7ffe2f..e024974a0e0 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -9,7 +9,6 @@ use codex_client::build_reqwest_client_with_custom_ca; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; -use codex_protocol::protocol::AddCreditsNudgeCreditType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; @@ -83,6 +82,13 @@ impl From for RequestError { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + #[derive(Serialize)] struct SendAddCreditsNudgeEmailRequest { credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 397c2a2cd41..300da815682 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -1,6 +1,7 @@ mod client; pub(crate) mod types; +pub use client::AddCreditsNudgeCreditType; pub use client::Client; pub use client::RequestError; pub use types::CodeTaskDetailsResponse; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 7d48c532bc4..c91f07f705a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -30,7 +30,6 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } -codex-backend-client = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3052d7b662c..95e932b1dc1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4702,16 +4702,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::realtime_conversation_list_voices(&sess, sub.id.clone()).await; false } - Op::SendAddCreditsNudgeEmail { credit_type } => { - handlers::send_add_credits_nudge_email( - &sess, - &config, - sub.id.clone(), - credit_type, - ) - .await; - false - } Op::OverrideTurnContext { cwd, approval_policy, @@ -4941,13 +4931,8 @@ mod handlers { use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; - use codex_backend_client::Client as BackendClient; use codex_mcp::collect_mcp_snapshot_from_manager; use codex_mcp::compute_auth_statuses; - use codex_protocol::protocol::AddCreditsNudgeCreditType; - use codex_protocol::protocol::AddCreditsNudgeEmailResponseEvent; - use codex_protocol::protocol::AddCreditsNudgeEmailResult; - use codex_protocol::protocol::AddCreditsNudgeEmailStatus; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -4981,7 +4966,6 @@ mod handlers { use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; - use reqwest::StatusCode; use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; @@ -5009,51 +4993,6 @@ mod handlers { .await; } - pub async fn send_add_credits_nudge_email( - sess: &Arc, - config: &Arc, - sub_id: String, - credit_type: AddCreditsNudgeCreditType, - ) { - let result = match send_add_credits_nudge_email_inner(sess, config, credit_type).await { - Ok(status) => status.into(), - Err(message) => AddCreditsNudgeEmailResult::Failed { message }, - }; - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::AddCreditsNudgeEmailResponse(AddCreditsNudgeEmailResponseEvent { - result, - }), - }) - .await; - } - - async fn send_add_credits_nudge_email_inner( - sess: &Arc, - config: &Arc, - credit_type: AddCreditsNudgeCreditType, - ) -> Result { - let Some(auth) = sess.services.auth_manager.auth().await else { - return Err( - "codex account authentication required to notify workspace owner".to_string(), - ); - }; - - if !auth.is_chatgpt_auth() { - return Err("chatgpt authentication required to notify workspace owner".to_string()); - } - - let client = BackendClient::from_auth(config.chatgpt_base_url.clone(), &auth) - .map_err(|err| format!("failed to construct backend client: {err}"))?; - match client.send_add_credits_nudge_email(credit_type).await { - Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), - Err(err) if err.status() == Some(StatusCode::TOO_MANY_REQUESTS) => { - Ok(AddCreditsNudgeEmailStatus::CooldownActive) - } - Err(err) => Err(format!("failed to notify workspace owner: {err}")), - } - } - pub async fn override_turn_context( sess: &Session, sub_id: String, @@ -7531,7 +7470,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::McpListToolsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) - | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index ef20b992d11..eee2347202b 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -393,7 +393,6 @@ async fn run_codex_tool_session_inner( | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) - | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f62cb7f27c1..6091213852b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -400,12 +400,6 @@ pub enum Op { /// Request the list of voices supported by realtime conversation streams. RealtimeConversationListVoices, - /// Ask the workspace owner to add credits or raise the workspace usage - /// limit after the user confirms an actionable workspace member limit. - SendAddCreditsNudgeEmail { - credit_type: AddCreditsNudgeCreditType, - }, - /// Legacy user input. /// /// Prefer [`Op::UserTurn`] so the caller provides full turn context @@ -770,7 +764,6 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", - Self::SendAddCreditsNudgeEmail { .. } => "send_add_credits_nudge_email", Self::UserInput { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", @@ -1559,8 +1552,6 @@ pub enum EventMsg { /// Notification that skill data may have been updated and clients may want to reload. SkillsUpdateAvailable, - AddCreditsNudgeEmailResponse(AddCreditsNudgeEmailResponseEvent), - PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -2189,45 +2180,6 @@ pub enum RateLimitReachedType { WorkspaceMemberUsageLimitReached, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -pub enum AddCreditsNudgeCreditType { - Credits, - UsageLimit, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -pub enum AddCreditsNudgeEmailStatus { - Sent, - CooldownActive, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(tag = "status", rename_all = "snake_case")] -#[ts(tag = "status")] -pub enum AddCreditsNudgeEmailResult { - Sent, - CooldownActive, - Failed { message: String }, -} - -impl From for AddCreditsNudgeEmailResult { - fn from(value: AddCreditsNudgeEmailStatus) -> Self { - match value { - AddCreditsNudgeEmailStatus::Sent => Self::Sent, - AddCreditsNudgeEmailStatus::CooldownActive => Self::CooldownActive, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] -pub struct AddCreditsNudgeEmailResponseEvent { - pub result: AddCreditsNudgeEmailResult, -} - #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 4223c8ad5c1..4b50781e76e 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -161,7 +161,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::UndoStarted(_) | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) - | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::ListSkillsResponse(_) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cfe288d4ee7..6ffd64aeaeb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -72,6 +72,8 @@ use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; @@ -119,9 +121,6 @@ use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::AddCreditsNudgeCreditType; -use codex_protocol::protocol::AddCreditsNudgeEmailResult; -use codex_protocol::protocol::AddCreditsNudgeEmailStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FinalOutput; use codex_protocol::protocol::GetHistoryEntryResponseEvent; @@ -1956,11 +1955,7 @@ impl App { tokio::spawn(async move { let result = send_add_credits_nudge_email(request_handle, credit_type) .await - .map(AddCreditsNudgeEmailStatus::from) - .map(AddCreditsNudgeEmailResult::from) - .unwrap_or_else(|err| AddCreditsNudgeEmailResult::Failed { - message: err.to_string(), - }); + .map_err(|err| err.to_string()); app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { result }); }); } @@ -6269,9 +6264,7 @@ async fn send_add_credits_nudge_email( let response: codex_app_server_protocol::SendAddCreditsNudgeEmailResponse = request_handle .request_typed(ClientRequest::SendAddCreditsNudgeEmail { request_id, - params: SendAddCreditsNudgeEmailParams { - credit_type: credit_type.into(), - }, + params: SendAddCreditsNudgeEmailParams { credit_type }, }) .await .wrap_err("account/sendAddCreditsNudgeEmail failed in TUI")?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4ec4cebec48..fe9f8008984 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; @@ -20,8 +22,6 @@ use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::protocol::AddCreditsNudgeCreditType; -use codex_protocol::protocol::AddCreditsNudgeEmailResult; use codex_protocol::protocol::GetHistoryEntryResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot; @@ -180,7 +180,7 @@ pub(crate) enum AppEvent { /// Result of notifying the workspace owner. AddCreditsNudgeEmailFinished { - result: AddCreditsNudgeEmailResult, + result: Result, }, /// Result of prefetching connectors. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4f4cf60d8b2..cc1f1f06eac 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -78,6 +78,8 @@ use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; @@ -134,8 +136,6 @@ use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; -use codex_protocol::protocol::AddCreditsNudgeCreditType; -use codex_protocol::protocol::AddCreditsNudgeEmailResult; #[cfg(test)] use codex_protocol::protocol::AgentMessageDeltaEvent; #[cfg(test)] @@ -6677,9 +6677,6 @@ impl ChatWidget { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } - EventMsg::AddCreditsNudgeEmailResponse(ev) => { - self.finish_add_credits_nudge_email_request(ev.result); - } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} @@ -7554,29 +7551,31 @@ impl ChatWidget { pub(crate) fn finish_add_credits_nudge_email_request( &mut self, - result: AddCreditsNudgeEmailResult, + result: Result, ) { let credit_type = self .add_credits_nudge_email_in_flight .take() .unwrap_or(AddCreditsNudgeCreditType::Credits); let message = match (credit_type, result) { - (AddCreditsNudgeCreditType::Credits, AddCreditsNudgeEmailResult::Sent) => { + (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { "Workspace owner notified." } - (AddCreditsNudgeCreditType::Credits, AddCreditsNudgeEmailResult::CooldownActive) => { - "Workspace owner was already notified recently." - } - (AddCreditsNudgeCreditType::Credits, AddCreditsNudgeEmailResult::Failed { .. }) => { + ( + AddCreditsNudgeCreditType::Credits, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "Workspace owner was already notified recently.", + (AddCreditsNudgeCreditType::Credits, Err(_)) => { "Could not notify your workspace owner. Please try again." } - (AddCreditsNudgeCreditType::UsageLimit, AddCreditsNudgeEmailResult::Sent) => { + (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { "Limit increase requested." } - (AddCreditsNudgeCreditType::UsageLimit, AddCreditsNudgeEmailResult::CooldownActive) => { - "A limit increase was already requested recently." - } - (AddCreditsNudgeCreditType::UsageLimit, AddCreditsNudgeEmailResult::Failed { .. }) => { + ( + AddCreditsNudgeCreditType::UsageLimit, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "A limit increase was already requested recently.", + (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { "Could not request a limit increase. Please try again." } }; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5f8a7e3185b..8adcfe6e8fc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -33,6 +33,8 @@ pub(super) use crate::test_support::test_path_buf; pub(super) use crate::test_support::test_path_display; pub(super) use crate::tui::FrameRequester; pub(super) use assert_matches::assert_matches; +pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType; +pub(super) use codex_app_server_protocol::AddCreditsNudgeEmailStatus; pub(super) use codex_app_server_protocol::AdditionalFileSystemPermissions as AppServerAdditionalFileSystemPermissions; pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppServerAdditionalNetworkPermissions; pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile; @@ -129,8 +131,6 @@ pub(super) use codex_protocol::parse_command::ParsedCommand; pub(super) use codex_protocol::plan_tool::PlanItemArg; pub(super) use codex_protocol::plan_tool::StepStatus; pub(super) use codex_protocol::plan_tool::UpdatePlanArgs; -pub(super) use codex_protocol::protocol::AddCreditsNudgeCreditType; -pub(super) use codex_protocol::protocol::AddCreditsNudgeEmailResult; pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent; pub(super) use codex_protocol::protocol::AgentMessageEvent; pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent; 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 30b20a5a747..d43abd5cf51 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -615,17 +615,15 @@ async fn workspace_owner_nudge_default_no_dismisses_without_sending() { async fn workspace_owner_credits_nudge_completion_renders_feedback() { let cases = [ ( - AddCreditsNudgeEmailResult::Sent, + Ok(AddCreditsNudgeEmailStatus::Sent), "Workspace owner notified.", ), ( - AddCreditsNudgeEmailResult::CooldownActive, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), "Workspace owner was already notified recently.", ), ( - AddCreditsNudgeEmailResult::Failed { - message: "request failed".to_string(), - }, + Err("request failed".to_string()), "Could not notify your workspace owner. Please try again.", ), ]; @@ -653,17 +651,15 @@ async fn workspace_owner_credits_nudge_completion_renders_feedback() { async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() { let cases = [ ( - AddCreditsNudgeEmailResult::Sent, + Ok(AddCreditsNudgeEmailStatus::Sent), "Limit increase requested.", ), ( - AddCreditsNudgeEmailResult::CooldownActive, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), "A limit increase was already requested recently.", ), ( - AddCreditsNudgeEmailResult::Failed { - message: "request failed".to_string(), - }, + Err("request failed".to_string()), "Could not request a limit increase. Please try again.", ), ]; From 832ab818fcdba7c5165e9756ebb20b0185a2c9da Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 15:40:31 -0700 Subject: [PATCH 6/9] Preserve usage limit type for owner nudge --- codex-rs/tui/src/app.rs | 1 - codex-rs/tui/src/chatwidget.rs | 6 +++-- .../src/chatwidget/tests/status_and_layout.rs | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6ffd64aeaeb..feb8a757754 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -73,7 +73,6 @@ use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::AddCreditsNudgeCreditType; -use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cc1f1f06eac..9ed0d5d39e9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2686,8 +2686,10 @@ impl ChatWidget { self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); - if is_codex_limit { - self.codex_rate_limit_reached_type = snapshot.rate_limit_reached_type; + if is_codex_limit + && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type + { + self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); } let warnings = if is_codex_limit { self.rate_limit_warnings.take_warnings( 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 d43abd5cf51..3047a6ccacd 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -533,6 +533,32 @@ async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); } +#[tokio::test] +async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut usage_limits = snapshot(/*percent*/ 100.0); + usage_limits.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(usage_limits)); + + // Turn-failure snapshots are derived from response headers and do not carry + // the backend-classified reached type. They arrive before the Error event. + let mut header_limits = snapshot(/*percent*/ 100.0); + header_limits.rate_limit_reached_type = None; + chat.on_rate_limit_snapshot(Some(header_limits)); + + chat.on_rate_limit_error("Usage limit reached.".to_string()); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Request a limit increase from your owner"), + "popup: {popup}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); +} + #[tokio::test] async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() { for limit_type in [ From 96a874b9fb61648e3d75cd8c45c644ddf5dd1952 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 16:15:01 -0700 Subject: [PATCH 7/9] Consume rate limit reached type once --- codex-rs/tui/src/chatwidget.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9ed0d5d39e9..25f43a5e7be 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2808,7 +2808,7 @@ impl ChatWidget { } fn on_rate_limit_error(&mut self, message: String) { - match self.codex_rate_limit_reached_type { + match self.codex_rate_limit_reached_type.take() { Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { self.on_error( "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." From 62ea8b2ebb7662dca3f97120f328a1f999eabb4c Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 16:28:56 -0700 Subject: [PATCH 8/9] codex: fix CI failure on PR #17956 --- codex-rs/app-server/src/codex_message_processor.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 626ea95adde..b89f38b958f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1892,12 +1892,7 @@ impl CodexMessageProcessor { match self.send_add_credits_nudge_email_inner(params).await { Ok(status) => { self.outgoing - .send_response( - request_id, - SendAddCreditsNudgeEmailResponse { - status: status.into(), - }, - ) + .send_response(request_id, SendAddCreditsNudgeEmailResponse { status }) .await; } Err(error) => { From 04a4ab0ec141029d8e1e0688c880d2eb4ea20635 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Wed, 15 Apr 2026 17:21:44 -0700 Subject: [PATCH 9/9] Skip Windows nudge email integration tests --- codex-rs/app-server/tests/suite/v2/rate_limits.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index b1a108dc7b1..bbfac163c01 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -233,6 +233,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { Ok(()) } +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] #[tokio::test] async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { let codex_home = TempDir::new()?; @@ -280,6 +281,7 @@ async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { Ok(()) } +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] #[tokio::test] async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { let codex_home = TempDir::new()?;