diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 113d82c94c..79e00cf574 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -965,6 +965,14 @@ ], "type": "object" }, + "GetAccountMetadataParams": { + "properties": { + "eligibilityOnly": { + "type": "boolean" + } + }, + "type": "object" + }, "GetAccountParams": { "properties": { "refreshToken": { @@ -4890,6 +4898,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/metadata/read" + ], + "title": "Account/metadata/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GetAccountMetadataParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/metadata/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d0ce368b78..5d942bbdfd 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/metadata/read" + ], + "title": "Account/metadata/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/GetAccountMetadataParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/metadata/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -8094,6 +8118,44 @@ } ] }, + "GetAccountMetadataParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "eligibilityOnly": { + "type": "boolean" + } + }, + "title": "GetAccountMetadataParams", + "type": "object" + }, + "GetAccountMetadataResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "accountDisplayName": { + "type": [ + "string", + "null" + ] + }, + "accountGroupNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "metadataFetchEligible": { + "type": "boolean" + } + }, + "required": [ + "metadataFetchEligible" + ], + "title": "GetAccountMetadataResponse", + "type": "object" + }, "GetAccountParams": { "$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 aca9b23880..d067b81686 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 @@ -1998,6 +1998,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/metadata/read" + ], + "title": "Account/metadata/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GetAccountMetadataParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/metadata/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -4822,6 +4846,44 @@ "title": "FuzzyFileSearchSessionUpdatedNotification", "type": "object" }, + "GetAccountMetadataParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "eligibilityOnly": { + "type": "boolean" + } + }, + "title": "GetAccountMetadataParams", + "type": "object" + }, + "GetAccountMetadataResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "accountDisplayName": { + "type": [ + "string", + "null" + ] + }, + "accountGroupNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "metadataFetchEligible": { + "type": "boolean" + } + }, + "required": [ + "metadataFetchEligible" + ], + "title": "GetAccountMetadataResponse", + "type": "object" + }, "GetAccountParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountMetadataParams.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountMetadataParams.json new file mode 100644 index 0000000000..a75b868df8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountMetadataParams.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "eligibilityOnly": { + "type": "boolean" + } + }, + "title": "GetAccountMetadataParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountMetadataResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountMetadataResponse.json new file mode 100644 index 0000000000..2794e9541e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountMetadataResponse.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "accountDisplayName": { + "type": [ + "string", + "null" + ] + }, + "accountGroupNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "metadataFetchEligible": { + "type": "boolean" + } + }, + "required": [ + "metadataFetchEligible" + ], + "title": "GetAccountMetadataResponse", + "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 9d9a823408..e583af3c1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -30,6 +30,7 @@ import type { FsRemoveParams } from "./v2/FsRemoveParams"; import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; +import type { GetAccountMetadataParams } from "./v2/GetAccountMetadataParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; @@ -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/metadata/read", id: RequestId, params: GetAccountMetadataParams, } | { "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/GetAccountMetadataParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountMetadataParams.ts new file mode 100644 index 0000000000..9c2be3bfce --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountMetadataParams.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 GetAccountMetadataParams = { eligibilityOnly?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountMetadataResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountMetadataResponse.ts new file mode 100644 index 0000000000..dd5d888dc8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountMetadataResponse.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 GetAccountMetadataResponse = { metadataFetchEligible: boolean, accountDisplayName: string | null, accountGroupNames: Array | null, }; 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 d432530b42..bf112e2faf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -118,6 +118,8 @@ export type { FsWatchParams } from "./FsWatchParams"; export type { FsWatchResponse } from "./FsWatchResponse"; export type { FsWriteFileParams } from "./FsWriteFileParams"; export type { FsWriteFileResponse } from "./FsWriteFileResponse"; +export type { GetAccountMetadataParams } from "./GetAccountMetadataParams"; +export type { GetAccountMetadataResponse } from "./GetAccountMetadataResponse"; export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9efc6571f2..dcd48fa533 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, }, + GetAccountMetadata => "account/metadata/read" { + params: v2::GetAccountMetadataParams, + response: v2::GetAccountMetadataResponse, + }, + 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 0843241f32..46455ca686 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1762,6 +1762,23 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits_by_limit_id: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountMetadataParams { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub eligibility_only: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountMetadataResponse { + pub metadata_fetch_eligible: bool, + pub account_display_name: Option, + pub account_group_names: Option>, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e00bbefc91..b614e96e15 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1437,6 +1437,21 @@ Field notes: - `refreshToken` (bool): set `true` to force a token refresh. - `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. +### 1a) Read ChatGPT account metadata + +```json +{ "method": "account/metadata/read", "params": { "eligibilityOnly": false }, "id": 2 } +``` + +Response example: + +```json +{ "id": 2, "result": { "metadataFetchEligible": true, "accountDisplayName": "Acme Corp", "accountGroupNames": ["Engineering"] } } +``` + +The metadata response returns nullable fields and may be empty for non-workspace accounts or providers outside the first-party OpenAI ChatGPT flow. +Set `eligibilityOnly` to `true` to check whether a live metadata fetch would run without making the live metadata requests. + ### 2) Log in with an API key 1. Send: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f31aafeac5..f4d58bf27c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -63,6 +63,8 @@ use codex_app_server_protocol::FuzzyFileSearchSessionStopParams; use codex_app_server_protocol::FuzzyFileSearchSessionStopResponse; use codex_app_server_protocol::FuzzyFileSearchSessionUpdateParams; use codex_app_server_protocol::FuzzyFileSearchSessionUpdateResponse; +use codex_app_server_protocol::GetAccountMetadataParams; +use codex_app_server_protocol::GetAccountMetadataResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; @@ -382,6 +384,19 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60); +const ACCOUNT_METADATA_FETCH_TIMEOUT: Duration = Duration::from_secs(2); + +fn account_metadata_response( + metadata_fetch_eligible: bool, + account_display_name: Option, + account_group_names: Option>, +) -> GetAccountMetadataResponse { + GetAccountMetadataResponse { + metadata_fetch_eligible, + account_display_name, + account_group_names, + } +} enum ActiveLogin { Browser { @@ -1164,6 +1179,10 @@ impl CodexMessageProcessor { self.get_account_rate_limits(to_connection_request_id(request_id)) .await; } + ClientRequest::GetAccountMetadata { request_id, params } => { + self.get_account_metadata(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 +1894,202 @@ impl CodexMessageProcessor { } } + async fn get_account_metadata( + &self, + request_id: ConnectionRequestId, + params: GetAccountMetadataParams, + ) { + match self.fetch_account_metadata(params).await { + Ok(response) => { + self.outgoing.send_response(request_id, response).await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn fetch_account_metadata( + &self, + params: GetAccountMetadataParams, + ) -> Result { + tracing::info!( + target: "codex_tui::status_metadata", + eligibility_only = params.eligibility_only, + "app-server account metadata request started" + ); + let Some((client, account_id, chatgpt_user_id)) = + self.account_metadata_fetch_context().await? + else { + tracing::info!( + target: "codex_tui::status_metadata", + "app-server account metadata request ineligible" + ); + return Ok(account_metadata_response( + /*metadata_fetch_eligible*/ false, /*account_display_name*/ None, + /*account_group_names*/ None, + )); + }; + if params.eligibility_only { + tracing::info!( + target: "codex_tui::status_metadata", + account_id_present = account_id.is_some(), + chatgpt_user_id_present = chatgpt_user_id.is_some(), + "app-server account metadata eligibility check passed" + ); + return Ok(account_metadata_response( + /*metadata_fetch_eligible*/ true, /*account_display_name*/ None, + /*account_group_names*/ None, + )); + } + + let account_display_name = match tokio::time::timeout( + ACCOUNT_METADATA_FETCH_TIMEOUT, + client.get_current_account_display_name(), + ) + .await + { + Ok(Ok(name)) => name, + Ok(Err(err)) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to fetch ChatGPT account display name: {err}"), + data: None, + }); + } + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("timed out fetching ChatGPT account display name: {err}"), + data: None, + }); + } + }; + tracing::info!( + target: "codex_tui::status_metadata", + display_name_present = account_display_name.is_some(), + "app-server account display name fetch completed" + ); + + let account_group_names = if account_display_name.is_some() { + match (account_id.as_deref(), chatgpt_user_id.as_deref()) { + (Some(account_id), Some(chatgpt_user_id)) => match tokio::time::timeout( + ACCOUNT_METADATA_FETCH_TIMEOUT, + client.get_current_account_group_names(account_id, chatgpt_user_id), + ) + .await + { + Ok(Ok(names)) => { + tracing::info!( + target: "codex_tui::status_metadata", + group_count = names.as_ref().map_or(0, std::vec::Vec::len), + "app-server account groups fetch completed" + ); + names + } + Ok(Err(err)) => { + tracing::warn!( + target: "codex_tui::status_metadata", + "failed to fetch ChatGPT account groups: {err}" + ); + None + } + Err(err) => { + tracing::warn!( + target: "codex_tui::status_metadata", + "timed out fetching ChatGPT account groups: {err}" + ); + None + } + }, + _ => { + tracing::info!( + target: "codex_tui::status_metadata", + account_id_present = account_id.is_some(), + chatgpt_user_id_present = chatgpt_user_id.is_some(), + "skipping account groups fetch: missing identifiers" + ); + None + } + } + } else { + tracing::info!( + target: "codex_tui::status_metadata", + "skipping account groups fetch: missing display name" + ); + None + }; + + tracing::info!( + target: "codex_tui::status_metadata", + display_name_present = account_display_name.is_some(), + group_count = account_group_names.as_ref().map_or(0, std::vec::Vec::len), + "app-server account metadata request completed" + ); + Ok(account_metadata_response( + /*metadata_fetch_eligible*/ true, + account_display_name, + account_group_names, + )) + } + + async fn account_metadata_fetch_context( + &self, + ) -> Result, Option)>, JSONRPCErrorError> { + if self.config.model_provider.name != "OpenAI" { + tracing::info!( + target: "codex_tui::status_metadata", + provider_name = %self.config.model_provider.name, + "account metadata ineligible: non-OpenAI provider" + ); + return Ok(None); + } + + let Some(auth) = self.auth_manager.auth().await else { + tracing::info!( + target: "codex_tui::status_metadata", + "account metadata ineligible: no auth" + ); + return Ok(None); + }; + + if !matches!(auth.auth_mode(), CoreAuthMode::Chatgpt) { + tracing::info!( + target: "codex_tui::status_metadata", + "account metadata ineligible: auth mode is not ChatGPT" + ); + return Ok(None); + } + let token_data = auth.get_token_data().ok(); + let is_workspace_account = token_data + .as_ref() + .is_some_and(|token_data| token_data.id_token.is_workspace_account()); + if !is_workspace_account { + tracing::info!( + target: "codex_tui::status_metadata", + token_data_present = token_data.is_some(), + "account metadata ineligible: not a workspace account" + ); + return Ok(None); + } + + let account_id = auth.get_account_id(); + let chatgpt_user_id = auth.get_chatgpt_user_id(); + tracing::info!( + target: "codex_tui::status_metadata", + account_id_present = account_id.is_some(), + chatgpt_user_id_present = chatgpt_user_id.is_some(), + "account metadata fetch context built" + ); + 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, + })?; + Ok(Some((client, account_id, chatgpt_user_id))) + } + async fn fetch_account_rate_limits( &self, ) -> Result< diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 20baa1cfea..ac5cfc301f 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -18,6 +18,7 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; +use serde::Deserialize; use serde::de::DeserializeOwned; use std::fmt; @@ -87,6 +88,26 @@ pub enum PathStyle { ChatGptApi, } +#[derive(Debug, Deserialize)] +struct CurrentAccountOptimizedResponse { + account: CurrentAccountOptimizedAccount, +} + +#[derive(Debug, Deserialize)] +struct CurrentAccountOptimizedAccount { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct AccountGroupsResponse { + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct AccountGroup { + name: String, +} + impl PathStyle { pub fn from_base_url(base_url: &str) -> Self { if base_url.contains("/backend-api") { @@ -265,6 +286,53 @@ impl Client { Ok(Self::rate_limit_snapshots_from_payload(payload)) } + pub async fn get_current_account_display_name(&self) -> Result> { + let url = match self.path_style { + PathStyle::CodexApi => { + anyhow::bail!("current account display name is only available from ChatGPT API") + } + PathStyle::ChatGptApi => format!("{}/accounts/optimized/check", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let payload: CurrentAccountOptimizedResponse = self.decode_json(&url, &ct, &body)?; + let display_name = payload + .account + .name + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()); + Ok(display_name) + } + + pub async fn get_current_account_group_names( + &self, + account_id: &str, + chatgpt_user_id: &str, + ) -> Result>> { + let url = match self.path_style { + PathStyle::CodexApi => { + anyhow::bail!("current account groups are only available from ChatGPT API") + } + PathStyle::ChatGptApi => { + let account_user_id = format!("{chatgpt_user_id}__{account_id}"); + format!( + "{}/accounts/{account_id}/users/{account_user_id}/groups", + self.base_url + ) + } + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let payload: AccountGroupsResponse = self.decode_json(&url, &ct, &body)?; + let group_names = payload + .items + .into_iter() + .map(|group| group.name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect::>(); + Ok((!group_names.is_empty()).then_some(group_names)) + } + pub async fn list_tasks( &self, limit: Option, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b46ace3627..cceed06399 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -59,6 +59,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; use crate::resume_picker::SessionTarget; +use crate::status::StatusAccountMetadata; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] @@ -77,6 +78,8 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; +use codex_app_server_protocol::GetAccountMetadataParams; +use codex_app_server_protocol::GetAccountMetadataResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -1942,6 +1945,83 @@ impl App { }); } + fn refresh_status_metadata(&mut self, app_server: &AppServerSession, request_id: u64) { + tracing::info!( + target: "codex_tui::status_metadata", + "checking status metadata fetch eligibility" + ); + let eligibility_request_handle = app_server.request_handle(); + let metadata_request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = async { + let metadata_fetch_eligible = + fetch_account_metadata_eligibility(eligibility_request_handle).await?; + tracing::info!( + target: "codex_tui::status_metadata", + eligible = metadata_fetch_eligible, + "status metadata fetch eligibility returned" + ); + if !metadata_fetch_eligible { + tracing::info!( + target: "codex_tui::status_metadata", + "skipping status metadata fetch: ineligible" + ); + return Ok(None); + } + + tracing::info!( + target: "codex_tui::status_metadata", + "starting status metadata fetch" + ); + fetch_account_metadata(metadata_request_handle) + .await + .map(Some) + } + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::StatusMetadataLoaded { request_id, result }); + }); + } + + fn on_status_metadata_loaded( + &mut self, + request_id: u64, + result: Result, String>, + ) { + let account_metadata = match result { + Ok(Some(metadata)) => { + let group_count = metadata.account_group_names.as_ref().map_or(0, Vec::len); + tracing::info!( + target: "codex_tui::status_metadata", + display_name_present = metadata.account_display_name.is_some(), + group_count, + "status metadata loaded" + ); + Some(StatusAccountMetadata { + account_display_name: metadata.account_display_name, + account_group_names: metadata.account_group_names, + }) + } + Ok(None) => { + tracing::info!( + target: "codex_tui::status_metadata", + "status metadata unavailable; rendering status without metadata" + ); + None + } + Err(err) => { + tracing::warn!( + target: "codex_tui::status_metadata", + "account/metadata/read failed before /status render: {err}" + ); + None + } + }; + self.chat_widget + .finish_status_metadata_fetch(request_id, account_metadata); + } + 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 +4657,12 @@ impl App { AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } + AppEvent::RefreshStatusMetadata { request_id } => { + self.refresh_status_metadata(app_server, request_id); + } + AppEvent::StatusMetadataLoaded { request_id, result } => { + self.on_status_metadata_loaded(request_id, result); + } AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(snapshots) => { for snapshot in snapshots { @@ -6229,6 +6315,40 @@ async fn fetch_account_rate_limits( Ok(app_server_rate_limit_snapshots_to_core(response)) } +async fn fetch_account_metadata( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("account-metadata-{}", Uuid::new_v4())); + let response: GetAccountMetadataResponse = request_handle + .request_typed(ClientRequest::GetAccountMetadata { + request_id, + params: GetAccountMetadataParams { + eligibility_only: false, + }, + }) + .await + .wrap_err("account/metadata/read failed in TUI")?; + + Ok(response) +} + +async fn fetch_account_metadata_eligibility( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("account-metadata-eligibility-{}", Uuid::new_v4())); + let response: GetAccountMetadataResponse = request_handle + .request_typed(ClientRequest::GetAccountMetadata { + request_id, + params: GetAccountMetadataParams { + eligibility_only: true, + }, + }) + .await + .wrap_err("account/metadata/read eligibility check failed in TUI")?; + + Ok(response.metadata_fetch_eligible) +} + 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 91f00b99b0..1a756ef24c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use codex_app_server_protocol::GetAccountMetadataResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; @@ -171,6 +172,17 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Refresh ChatGPT account metadata for a `/status` output. + RefreshStatusMetadata { + request_id: u64, + }, + + /// Result of loading ChatGPT account metadata for `/status`. + StatusMetadataLoaded { + request_id: u64, + result: Result, String>, + }, + /// Result of prefetching connectors. ConnectorsLoaded { result: Result, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1334b6e2f2..1e6ed68e7a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -69,6 +69,7 @@ use crate::model_catalog::ModelCatalog; use crate::multi_agents; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; +use crate::status::StatusAccountMetadata; use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; @@ -768,7 +769,9 @@ pub(crate) struct ChatWidget { token_info: Option, rate_limit_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, + loading_status_metadata_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, + next_status_metadata_request_id: u64, plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, @@ -4801,7 +4804,9 @@ impl ChatWidget { token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), + loading_status_metadata_outputs: Vec::new(), next_status_refresh_request_id: 0, + next_status_metadata_request_id: 0, plan_type: initial_plan_type, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -7065,7 +7070,9 @@ impl ChatWidget { pub(crate) fn add_status_output( &mut self, refreshing_rate_limits: bool, - request_id: Option, + rate_limit_request_id: Option, + metadata_request_id: Option, + account_metadata: Option, ) { let default_usage = TokenUsage::default(); let token_info = self.token_info.as_ref(); @@ -7081,6 +7088,7 @@ impl ChatWidget { .collect(); let agents_summary = crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); + let loading_account_metadata = metadata_request_id.is_some(); let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( &self.config, self.status_account_display.as_ref(), @@ -7097,35 +7105,85 @@ impl ChatWidget { reasoning_effort_override, agents_summary, refreshing_rate_limits, + account_metadata, + loading_account_metadata, ); - if let Some(request_id) = request_id { - self.refreshing_status_outputs.push((request_id, handle)); + if let Some(request_id) = rate_limit_request_id { + self.refreshing_status_outputs + .push((request_id, handle.clone())); } - self.add_to_history(cell); + if let Some(request_id) = metadata_request_id { + self.loading_status_metadata_outputs + .push((request_id, handle.clone())); + } + if loading_account_metadata { + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } else { + self.add_to_history(cell); + } + } + + fn finish_pending_status_output( + pending_outputs: &mut Vec<(u64, StatusHistoryHandle)>, + request_id: u64, + finish: impl FnOnce(&StatusHistoryHandle), + ) -> bool { + if pending_outputs.is_empty() { + return false; + } + + let mut remaining = Vec::with_capacity(pending_outputs.len()); + let mut finish = Some(finish); + let mut updated_any = false; + for (pending_request_id, handle) in pending_outputs.drain(..) { + if pending_request_id == request_id { + updated_any = true; + if let Some(finish) = finish.take() { + finish(&handle); + } + } else { + remaining.push((pending_request_id, handle)); + } + } + *pending_outputs = remaining; + updated_any } pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { - if self.refreshing_status_outputs.is_empty() { - return; - } - let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() .cloned() .collect(); let now = Local::now(); - let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); - let mut updated_any = false; - for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { - if pending_request_id == request_id { - updated_any = true; + let updated_any = Self::finish_pending_status_output( + &mut self.refreshing_status_outputs, + request_id, + |handle| { handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); - } else { - remaining.push((pending_request_id, handle)); - } + }, + ); + if updated_any { + self.request_redraw(); } - self.refreshing_status_outputs = remaining; + } + + pub(crate) fn finish_status_metadata_fetch( + &mut self, + request_id: u64, + account_metadata: Option, + ) { + let updated_any = Self::finish_pending_status_output( + &mut self.loading_status_metadata_outputs, + request_id, + |handle| { + handle.finish_account_metadata_fetch(account_metadata); + }, + ); if updated_any { self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index e6e93d17fb..6d9dd67f7b 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -270,19 +270,38 @@ impl ChatWidget { self.open_skills_menu(); } SlashCommand::Status => { + let metadata_request_id = self.has_chatgpt_account().then(|| { + let request_id = self.next_status_metadata_request_id; + self.next_status_metadata_request_id = + self.next_status_metadata_request_id.wrapping_add(1); + request_id + }); if self.should_prefetch_rate_limits() { let request_id = self.next_status_refresh_request_id; self.next_status_refresh_request_id = self.next_status_refresh_request_id.wrapping_add(1); - self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + self.add_status_output( + /*refreshing_rate_limits*/ true, + Some(request_id), + metadata_request_id, + /*account_metadata*/ None, + ); self.app_event_tx.send(AppEvent::RefreshRateLimits { origin: RateLimitRefreshOrigin::StatusCommand { request_id }, }); } else { self.add_status_output( - /*refreshing_rate_limits*/ false, /*request_id*/ None, + /*refreshing_rate_limits*/ false, + /*rate_limit_request_id*/ None, + metadata_request_id, + /*account_metadata*/ None, ); } + + if let Some(request_id) = metadata_request_id { + self.app_event_tx + .send(AppEvent::RefreshStatusMetadata { request_id }); + } } SlashCommand::DebugConfig => { self.add_debug_config_output(); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0ae51c4ff8..a98c942262 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -197,7 +197,9 @@ pub(super) async fn make_chatwidget_manual( token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), + loading_status_metadata_outputs: Vec::new(), next_status_refresh_request_id: 0, + next_status_metadata_request_id: 0, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), 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 e59928ff1c..f421b985f2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -86,7 +86,8 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { assert_eq!(chat.bottom_pane.context_window_percent(), Some(100)); chat.add_status_output( - /*refreshing_rate_limits*/ false, /*request_id*/ None, + /*refreshing_rate_limits*/ false, /*rate_limit_request_id*/ None, + /*metadata_request_id*/ None, /*account_metadata*/ None, ); let cells = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 51f1c8da45..fa3fe778ad 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -1,12 +1,34 @@ use super::*; +use crate::app_event::RateLimitRefreshOrigin; use assert_matches::assert_matches; +fn render_status_without_metadata(chat: &mut ChatWidget) { + if chat.should_prefetch_rate_limits() { + let request_id = chat.next_status_refresh_request_id; + chat.next_status_refresh_request_id = chat.next_status_refresh_request_id.wrapping_add(1); + chat.add_status_output( + /*refreshing_rate_limits*/ true, + Some(request_id), + /*metadata_request_id*/ None, + /*account_metadata*/ None, + ); + chat.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }); + } else { + chat.add_status_output( + /*refreshing_rate_limits*/ false, /*rate_limit_request_id*/ None, + /*metadata_request_id*/ None, /*account_metadata*/ None, + ); + } +} + #[tokio::test] async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); let rendered = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { @@ -32,7 +54,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(_)) => {} @@ -49,7 +71,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( chat.finish_status_rate_limit_refresh(first_request_id); drain_insert_history(&mut rx); - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); let refreshed = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { lines_to_single_string(&cell.display_lines(/*width*/ 80)) @@ -66,7 +88,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( async fn status_command_renders_immediately_without_rate_limit_refresh() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); assert!( @@ -81,7 +103,7 @@ async fn status_command_renders_instruction_sources_from_thread_session() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md")]; - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); let rendered = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { @@ -104,7 +126,7 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(_)) => {} other => panic!("expected first status output, got {other:?}"), @@ -116,7 +138,7 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { other => panic!("expected first refresh request, got {other:?}"), }; - chat.dispatch_command(SlashCommand::Status); + render_status_without_metadata(&mut chat); let second_rendered = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { lines_to_single_string(&cell.display_lines(/*width*/ 80)) diff --git a/codex-rs/tui/src/status/account.rs b/codex-rs/tui/src/status/account.rs index 23255f4379..397e882482 100644 --- a/codex-rs/tui/src/status/account.rs +++ b/codex-rs/tui/src/status/account.rs @@ -6,3 +6,9 @@ pub(crate) enum StatusAccountDisplay { }, ApiKey, } + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusAccountMetadata { + pub(crate) account_display_name: Option, + pub(crate) account_group_names: Option>, +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 20b4c6ed8d..bc7594effd 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -23,6 +23,7 @@ use std::path::PathBuf; use url::Url; use super::account::StatusAccountDisplay; +use super::account::StatusAccountMetadata; use super::format::FieldFormatter; use super::format::line_display_width; use super::format::push_label; @@ -65,9 +66,16 @@ struct StatusRateLimitState { refreshing_rate_limits: bool, } +#[derive(Debug, Clone)] +struct StatusAccountMetadataState { + account_metadata: Option, + loading_account_metadata: bool, +} + #[derive(Debug, Clone)] pub(crate) struct StatusHistoryHandle { rate_limit_state: Arc>, + account_metadata_state: Arc>, } impl StatusHistoryHandle { @@ -89,6 +97,19 @@ impl StatusHistoryHandle { state.rate_limits = rate_limits; state.refreshing_rate_limits = false; } + + pub(crate) fn finish_account_metadata_fetch( + &self, + account_metadata: Option, + ) { + #[expect(clippy::expect_used)] + let mut state = self + .account_metadata_state + .write() + .expect("status history account metadata state poisoned"); + state.account_metadata = account_metadata; + state.loading_account_metadata = false; + } } #[derive(Debug)] @@ -101,6 +122,7 @@ struct StatusHistoryCell { collaboration_mode: Option, model_provider: Option, account: Option, + account_metadata_state: Arc>, thread_name: Option, session_id: Option, forked_from: Option, @@ -178,6 +200,8 @@ pub(crate) fn new_status_output_with_rate_limits( reasoning_effort_override, "".to_string(), refreshing_rate_limits, + /*account_metadata*/ None, + /*loading_account_metadata*/ false, ) .0 } @@ -199,6 +223,8 @@ pub(crate) fn new_status_output_with_rate_limits_handle( reasoning_effort_override: Option>, agents_summary: String, refreshing_rate_limits: bool, + account_metadata: Option, + loading_account_metadata: bool, ) -> (CompositeHistoryCell, StatusHistoryHandle) { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let (card, handle) = StatusHistoryCell::new( @@ -217,6 +243,8 @@ pub(crate) fn new_status_output_with_rate_limits_handle( reasoning_effort_override, agents_summary, refreshing_rate_limits, + account_metadata, + loading_account_metadata, ); ( @@ -243,6 +271,8 @@ impl StatusHistoryCell { reasoning_effort_override: Option>, agents_summary: String, refreshing_rate_limits: bool, + account_metadata: Option, + loading_account_metadata: bool, ) -> (Self, StatusHistoryHandle) { let mut config_entries = vec![ ("workdir", config.cwd.display().to_string()), @@ -335,6 +365,10 @@ impl StatusHistoryCell { rate_limits, refreshing_rate_limits, })); + let account_metadata_state = Arc::new(RwLock::new(StatusAccountMetadataState { + account_metadata, + loading_account_metadata, + })); let agents_summary = Arc::new(RwLock::new(agents_summary)); ( @@ -346,6 +380,7 @@ impl StatusHistoryCell { collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, + account_metadata_state: account_metadata_state.clone(), thread_name, session_id, forked_from, @@ -353,7 +388,10 @@ impl StatusHistoryCell { agents_summary, rate_limit_state: rate_limit_state.clone(), }, - StatusHistoryHandle { rate_limit_state }, + StatusHistoryHandle { + rate_limit_state, + account_metadata_state, + }, ) } @@ -592,6 +630,13 @@ impl HistoryCell for StatusHistoryCell { .read() .expect("status history agents summary state poisoned") .clone(); + #[expect(clippy::expect_used)] + let account_metadata_state = self + .account_metadata_state + .read() + .expect("status history account metadata state poisoned") + .clone(); + let account_metadata = account_metadata_state.account_metadata.clone(); if self.model_provider.is_some() { push_label(&mut labels, &mut seen, "Model provider"); @@ -599,6 +644,22 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + if account_metadata_state.loading_account_metadata + || account_metadata + .as_ref() + .and_then(|metadata| metadata.account_display_name.as_ref()) + .is_some() + { + push_label(&mut labels, &mut seen, "Organization"); + } + if account_metadata_state.loading_account_metadata + || account_metadata + .as_ref() + .and_then(|metadata| metadata.account_group_names.as_ref()) + .is_some_and(|group_names| !group_names.is_empty()) + { + push_label(&mut labels, &mut seen, "Groups"); + } if thread_name.is_some() { push_label(&mut labels, &mut seen, "Thread name"); } @@ -658,6 +719,23 @@ impl HistoryCell for StatusHistoryCell { if let Some(account_value) = account_value { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if account_metadata_state.loading_account_metadata { + lines.push(formatter.line( + "Organization", + vec![Span::from("Retrieving account information...").dim()], + )); + } else if let Some(metadata) = account_metadata { + if let Some(account_display_name) = metadata.account_display_name { + lines.push(formatter.line("Organization", vec![Span::from(account_display_name)])); + } + if let Some(account_group_names) = metadata.account_group_names + && !account_group_names.is_empty() + { + lines.push( + formatter.line("Groups", vec![Span::from(account_group_names.join(", "))]), + ); + } + } if let Some(thread_name) = thread_name { lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index d618863384..9516c20006 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -13,6 +13,7 @@ mod helpers; mod rate_limits; pub(crate) use account::StatusAccountDisplay; +pub(crate) use account::StatusAccountMetadata; pub(crate) use card::StatusHistoryHandle; #[cfg(test)] pub(crate) use card::new_status_output; diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_account_metadata.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_account_metadata.snap new file mode 100644 index 0000000000..8b84eb1770 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_account_metadata.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ Account: dev@example.com (Business) │ +│ Organization: Acme Corp │ +│ Groups: Engineering, Codex Pilots │ +│ │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 12b23a38f6..7531dd2e80 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,10 +1,12 @@ use super::new_status_output; use super::new_status_output_with_rate_limits; +use super::new_status_output_with_rate_limits_handle; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::status::StatusAccountDisplay; +use crate::status::StatusAccountMetadata; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use chrono::Duration as ChronoDuration; @@ -289,6 +291,58 @@ async fn status_snapshot_includes_forked_from() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_includes_account_metadata() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = test_path_buf("/workspace/tests").abs(); + + let account_display = Some(StatusAccountDisplay::ChatGpt { + email: Some("dev@example.com".to_string()), + plan: Some("Business".to_string()), + }); + let account_metadata = StatusAccountMetadata { + account_display_name: Some("Acme Corp".to_string()), + account_group_names: Some(vec!["Engineering".to_string(), "Codex Pilots".to_string()]), + }; + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let (composite, _) = new_status_output_with_rate_limits_handle( + &config, + account_display.as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ false, + Some(account_metadata), + /*loading_account_metadata*/ false, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home");